Прежде чем перейти к статье, хочу вам представить, экономическую онлайн игру Brave Knights, в которой вы можете играть и зарабатывать. Регистируйтесь, играйте и зарабатывайте!
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'
}
Текущие имплементации полифиллов этот пример полностью переварить еще не могут но, думаю, в скором времени это исправится.
Синтаксис для применения декораторов в целом не слишком отличается от привычного, есть только пара деталей:
Декоратор класса должен идти после export (если он есть) - это наверное главное отличие от статус-кво.
Для "обычного" применения декоратора можно использовать идентификатор, точку и вызов функции -
@dotted.form.with('some-call')
Для "сложного" применения можно использовать синтаксис со скобками:
@(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
- эта функция вызывается при инициализации класса для вычисления начального значения поля. Именно из-за этого для поля класса не передается addInitializer
- Output
его заменяет.
Пример декоратора 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 декораторов.
Дождались, в общем.