JavaScript декораторы наконец-то в Stage 3

от автора

18 апреля 2022 года, после 5 лет доработки (первый коммит от 30 апреля 2017 года), proposal по декораторам наконец-то достиг 3 стадии, что означает что у него есть спецификация, тестовая имплементация и осталась только полировка на основе фидбека от разработчиков. Учитывая что это уже четвертая (!) итерация декораторов, их переход в стадию принятия это эпохальное событие для JS — не припомню ни одной другой фичи, которая прошла такой длинный и тернистый путь, с диаметрально разными подходами и аж двумя разными legacy-имплементациями, в Babel и TypeScript. Давайте же посмотрим на неё повнимательней.

Ссылки

https://github.com/tc39/proposal-decorators — репозиторий самого предложения, включая все предыдущие версии (в истории коммитов).

http://senocular.github.io/articles/js_history_of_decorators.html — история предложений, включая ссылки на все четыре основные версии.

https://javascriptdecorators.org/ — независимая имплементация

https://babeljs.io/docs/en/babel-plugin-proposal-decorators — плагин для Babel

Кстати, новая версия датируется в Babel как 2021-12 — потому что была представлена на саммите TC39 в декабре 2021 года.

Чем отличается от предыдущих версий

Во-первых, новые декораторы пока работают только с классами и их элементами. Впрочем, предложения по расширению той же логики на функции/параметры/объекты/переменные/аннотации/блоки/инициализаторы есть, но в текущую спеку не входят (что неудивительно, вряд ли кто-то хочет потратить еще 5 лет на достижение Stage 4).

Во-вторых, главное отличие новых декораторов: они работают только с сущностью которую декорируют (класс, поле класса, метод, геттер/сеттер и аксессор — новая сущность, о которой далее), а не с дескрипторами свойств и/или прототипами классов, как легаси подходы.

То есть они не способны добавить новые сущности в прототип/инстанс класса или хотя бы изменить их вид (с поля на геттер/сеттер, например), а могут только преобразовать ту сущность, которая описана в исходном коде — обернуть её в дополнительную логику или полностью заменить на другую, но того же вида.

Это было сделано в первую очередь под давлением разработчиков V8 основных движков, так как чрезмерная гибкость предыдущих декораторов крайне плохо подходила для оптимизации кода в рантайме — именно поэтому принятие декораторов так затянулось.

Демо и синтаксис применения декораторов

Ну и сразу полный пример со всеми возможными комбинациями синтаксиса:

//export должен быть перед декоратором export default //декоратор класса, может изменять сам класс @defineElement("some-element") class SomeElement extends HTMLElement {   //декоратор поля - может заменить значение поля при инициализации класса   //все дальнейшие чтения/записи он не отслеживает   @inject('some-dep')   dep    //новый синтаксис - аксессор   //по факту просто сахар для пары геттер/сеттер   //похож на автоматически реализуемые свойства в C#   //могут быть и приватными и статическими   //декоратор может отслеживать чтение/запись   @reactive accessor clicked = false    //ну с методами и прочим все как обычно   @logged   someMethod() {     return 42   }    //да, с приватными элементами тоже работает, как и со статическими   //название декоратора может быть через точку   @random.int(0, 42)   #val    @logged   get val() {     return this.#val   }    @logged   set val(value) {     this.#val = value   }    //апофеоз:   //статический приватный аксессор c декоратором со сложным доступом   @(someArr[3].someFunc('param'))   static acсessor #name = 'some-element' }

Текущие имплементации полифиллов этот пример полностью переварить еще не могут но, думаю, в скором времени это исправится.

Синтаксис для применения декораторов в целом не слишком отличается от привычного, есть только пара деталей:

  1. Декоратор класса должен идти после export (если он есть) — это наверное главное отличие от статус-кво.

  2. Для «обычного» применения декоратора можно использовать идентификатор, точку и вызов функции — @dotted.form.with('some-call')

  3. Для «сложного» применения можно использовать синтаксис со скобками: @(complex[1])

Написание декораторов

Тут никаких особых сюрпризов — декоратор это обычная функция с таким типом:

context предоставляет, как ни странно, контекст, сведения о месте применения декоратора, где:

  • kind — вид элемента, на который применяется декоратор

  • name — название элемента

  • access — объект который позволяет в произвольный момент времени получить/установить значение элемента, может пригодиться, например, для DI. Разрешен только для элементов класса, но не для самих классов (то есть get или set есть только когда kind != 'class')

  • private и static — есть ли у элемента класса соответствующие модификаторы

  • addInitializer позволяет выполнить код после того как сам класс (не инстанс!) или элемент класса полностью определен — например, в нем можно зарегистрировать класс в DI или забиндить метод. Не применим только для поля класса (то есть определен когда kind != 'field' — об этом далее)

Input и Output зависят от kind, но в целом Input — это значение элемента как оно написано в коде, а Output — значение на которое оно будет заменено в рантайме.

Важный нюанс — для полей класса (когда kind == 'field') Input всегда undefined, а Output может быть функцией вида (initValue: unknown) => any — эта функция вызывается при инициализации класса для вычисления начального значения поля. Именно из-за этого для поля класса не передается addInitializerOutput его заменяет.

Пример декоратора logged:

function logged(value, { kind, name }) {   if (kind === "method") {     return function (...args) {       console.log(`starting ${name} with arguments ${args.join(", ")}`);       const ret = value.call(this, ...args);       console.log(`ending ${name}`);       return ret;     };   }   if (kind === "field") {     return function (initialValue) {       console.log(`initializing ${name} with value ${initialValue}`);       return initialValue;     };   }   if (kind === "class") {     return class extends value {       constructor(...args) {         super(...args);         console.log(`constructing an instance of ${name} with arguments ${args.join(", ")}`);       }     } }

Ну или вот customElement с использованием addInitializer:

function customElement(name) {   (value, { addInitializer }) => {     addInitializer(function() {       customElements.define(name, this);     });   } }  @customElement('my-element') class MyElement extends HTMLElement {   static get observedAttributes() {     return ['some', 'attrs'];   } }

Больше примеров (в том числе и с применением access для DI) смотрите на гитхабе.

Аксессоры

Ограничения новых декораторов в виде запрета на изменение вида элемента в целом логичны, но они убивают один крайне важный юзкейс декораторов — когда поле превращается в пару геттер/сеттер с дополнительной логикой вокруг. Это может быть, например, логгирование изменений поля для отладки, а может быть полноценная система реактивности, как в MobX, который, по сути, основан на этом хаке:

import {computed, observable, autorun} from 'mobx'  class Counter {   //вот здесь поле превращается в геттер/сеттер     @observable num = 1   //а будет так     @observable accessor num = 1          @computed     get double() {         return this.num * 2     } }  const counter = new Counter()  //выведет 2 autorun(() => console.log(counter.double))   //когда изменяем num, изменится и double counter.num = 2 //autorun выполняется снова и выводит 4

С новыми декораторами все такие поля придется помечать как accessor что, конечно, не слишком весело, но в целом терпимо и может отслеживаться, например, тайпскриптом. Под капотом работать это будет примерно так:

class C {   accessor x = 1; }  //Раскрывается в...  class C {   #x = 1;    get x() {     return this.#x;   }    set x(val) {     this.#x = val;   } }

Имплементации

Пока ждем реализации в основных тулзах — в первую очередь это, конечно, поддержка аксессоров как нового синтаксиса. Когда IDE, TypeScript и Babel (esbuild и т.д.) смогут их корректно обрабатывать, сделать полифиллы будет не так и сложно.

И я крайне надеюсь что TypeScript будет корректно обрабатывать типы декораторов при замене значений — сейчас декоратор никак не может повлиять на тип декорируемого значения.

Ссылки для отслеживания внедрения:

https://github.com/microsoft/TypeScript/issues/48885 — TypeScript — фича включена в планы на версию 4.8.

https://github.com/evanw/esbuild/issues/104 — esbuild — ждут реализации в TS/node/браузерах.

Ну а потом последует волна переезда на новую реализацию со стороны экосистемы. К счастью, декораторы в JS не так и распространены, и при этом новые декораторы могут быть реализованы в библиотеках вместе со старыми — их сигнатура отличается от Babel/TS декораторов.

Дождались, в общем.

Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.
Пользуетесь декораторами в JS?
30.65% Да! 19
69.35% Нет… 43
Проголосовали 62 пользователя. Воздержались 7 пользователей.

ссылка на оригинал статьи https://habr.com/ru/post/666688/