Доброго времени суток, друзья!
Представляю вашему вниманию адаптированный перевод нового варианта предложения (сентябрь 2020 г.), касающегося использования декораторов в JavaScript, с небольшими пояснениями относительно характера происходящего.
Впервые данное предложение прозвучало около 5 лет назад и с тех пор претерпело несколько значительных изменений. В настоящее время оно (по-прежнему) находится на второй стадии рассмотрения.
Если вы раньше не слышали о декораторах или хотите освежить свои знания, рекомендую ознакомиться со следующими статьями:
- Декораторы в JavaScript
- Минимальное руководство по декораторам ECMAScript
- Декораторы JavaScript с нуля
Итак, что такое декоратор? Декоратор (decorator) — это функция, вызываемая на элементе класса (поле или методе) или на самом классе в процессе его определения, оборачивающая или заменяющая элемент (или класс) новым значением (возвращаемым декоратором).
Декорированное поле класса обрабатывается как обертка из геттера/сеттера, позволяющая извлекать/присваивать (изменять) значение этому полю.
Декораторы также могут аннотировать элемент класса метаданными (metadata). Метаданные — это коллекция простых свойств объекта, добавленных декораторами. Они доступны как набор вложенных объектов в свойстве [Symbol.metadata].
Синтаксис
Синтаксис декораторов, помимо префикса @ (@decoratorName), предполагает следующее:
- Выражения декораторов ограничены цепочкой переменных (можно использовать несколько декораторов), доступом к свойству с помощью ., но не c помощью [], и вызовом посредством ()
- Декорироваться могут не только определения классов, но и их элементы (поля и методы)
- Декораторы классов указываются после export и default
Для определения декораторов не существует каких-либо специальных правил; любая функция может быть использована в качестве такового.
Детали семантики
Декоратор оценивается в три этапа:
- Выражение декоратора (все, что следует после @) оценивается вместе с вычисляемыми названиями свойств
- Декоратор вызывается (как функция) в процессе определения класса, после оценки методов, но до объединения конструктора и прототипа
- Декоратор применяется (изменяет конструктор и прототип) только один раз после вызова
1. Вычисление декораторов
Декораторы оцениваются как выражения вместе с вычисляемыми именами свойств. Это происходит слева направо и сверху вниз. Результат декоратора сохраняется в своего рода локальную переменную, которая вызывается (используется) после завершения определения класса.
2. Вызов декораторов
Декоратор вызывается с двумя аргументами: оборачиваемым элементом и, опционально, объектом контекста.
Оборачиваемый элемент: первый параметр
Первый аргумент, который оборачивается декоратором, это то, что мы декорируем (извиняюсь за тавтологию):
- Если речь идет о простом методе, методе инициализации, геттере или сеттере: соответствующая функция
- Если о классе: сам класс
- Если о поле: объект с двумя свойствами:
- get: функция без параметров, которая вызывается с получателем (receiver), представляющим собой объект, возвращающий содержащееся в нем значение
- set: функция, принимающая один параметр (новое значение), которая вызывается с получателем, представляющим собой переданный объект, и возвращает undefined
Объект контекста: второй параметр
Объект контекста — объект, передаваемый декоратору в качестве второго аргумента — содержит следующие свойства:
- kind: имеет одно из следующих значений:
- «class»
- «method»
- «init-method»
- «getter»
- «setter»
- «field»
- name:
- публичное поле или метод: name — строковый или символьный ключ свойства
- частное поле или метод: отсутствует
- класс: отсутствует
- isStatic:
- статическое поле или метод: true
- поле или метод экземпляра: false
- класс: отсутствует
«Target» (конструктор или прототип) не передается декораторам полей или методов по той причине, что она («цель») еще не сконструирована в момент вызова декоратора.
Возвращаемое значение
Возвращаемое значение зависит от типа декоратора:
- класс: новый класс
- метод, геттер или сеттер: новая функция
- поле: объект с тремя свойствами:
- get
- set
- initialize: функция, вызываемая с тем же аргументом, что и set, возвращающая значение, которое используется для инициализации переменной. Данная функция вызывается, когда настройка низлежащего (внутреннего) хранилища (underlying storage) зависит от инициализатора поля или определения метода
- метод init: объект с двумя свойствами:
- method: функция, заменяющая метод
- initialize: функция без аргументов, возвращаемое значение которой игнорируется, и которая вызывается с вновь созданным объектом в качестве получателя
3. Применение декораторов
Декораторы применяются после их вызова. Промежуточные этапы алгоритма работы декораторов не могут быть зафиксированы — вновь созданный класс является недоступным до тех пор, пока не будут применены все декораторы методов и полей экземпляров.
Декораторы классов вызываются после применения декораторов полей и методов.
Наконец, применяются декораторы статических полей.
Семантика декраторов полей
Декоратор поля класса представляет собой пару геттер/сеттер — упаковку для частного поля. Поэтому код:
function id(v) { return v }
class C {
@id x = y
}
имеет такую семантику:
class C {
// префикс # указывает на приватность переменной-поля
#x = y
get x() { return this.#x }
set x(v) { this.#x = v }
}
Декораторы полей ведут себя подобно частным полям. Следующий код выбросит исключение TypeError из-за того, что мы пытаемся получить доступ к «y» до ее добавления в экземпляр:
class C {
@id x = this.y
@id y
}
new C // TypeError
Пара геттер/сеттер — это обычные методы объекта, которые являются неперечислимыми (неперечисляемыми, если угодно), как и другие методы. Содержащиеся в ней приватные поля добавляются один за другим, вместе с инициализаторами, как обычные частные поля.
Цели проектирования
- Должно быть одинаково легко как использовать встроенные декораторы, так и писать собственные
- Декораторы должны применяться только к декорируемым объектам без побочных эффектов
Случаи применения
- Хранение метаданных в классах и методах
- Преобразование поля в аксессор
- Оборачивание метода или класса (такое использование декораторов чем-то напоминает проксирование объектов)
Примеры
Примеры реализации и использования декораторов.
@logged
Декоратор @logged выводит в консоль сообщения о начале и завершении выполнения метода. Существуют другие популярные декораторы, оборачивающие функции, например: @deprecated, debounce, @memoize и т.д.
Использование:
// расширение .mjs указывает на файл-модуль
import { logged } from './logged.mjs'
class C {
@logged
m(arg) {
this.#x = arg
}
@logged
set #x(value) { }
}
new C().m(1)
// запуск m с аргументами 1
// запуск set #x с аргументами 1
// завершение set #x
// завершение m
@logged может быть реализован в JavaScript в качестве декоратора. Декоратор — это функция, которая вызывается с аргументом, содержащим декорируемый элемент. Таким элементом может быть метод, геттер или сеттер. Декораторы могут вызываться со вторым аргументом — контекстом, однако, в данном случае он нам не нужен.
Значение, возвращаемое декоратором, заменяет оборачиваемый элемент. Для методов, геттеров и сеттеров, возвращаемое значение — это заменяющая их функция.
// logged.mjs
export function logged(f) {
// получаем название функции
const name = f.name
function wrapped(...args) {
// сообщаем о запуске функции
console.log(`запуск ${name} с аргументами ${args.join(', ')}`)
// получаем результат выполнения функции
const ret = f.call(this, ...args)
// сообщаем о завершении функции
console.log(`завершение ${name}`)
// возвращаем результат
return ret
}
// Object.defineProperty() определяет новое или изменяет существующее свойство объекта
// https://developer.mozilla.org/ru/docs/Web/JavaScript/Reference/Global_Objects/Object/defineProperty
Object.defineProperty(wrapped, 'name', { value: name, configurable: true })
// возвращаем обертку
return wrapped
}
Результат транспиляции приведенного примера может выглядеть следующим образом:
let x_setter
class C {
m(arg) {
this.#x = arg
}
static #x_setter(value) { }
// предложение - статические блоки инициализации класса (class static initialization blocks)
// https://github.com/tc39/proposal-class-static-block
static { x_setter = C.#x_setter }
set #x(value) { return x_setter.call(this, value) }
}
C.prototype.m = logged(C.prototype.m, { kind: "method", name: "m", isStatic: false })
x_setter = logged(x_setter, {kind: "setter", isStatic: false})
Обратите внимание, что геттеры и сеттеры декорируются раздельно. Аксессоры (вычисляемые свойства) не объединяются, как в предыдущих предложениях.
@defineElement
HTML Custom Elements (пользовательские элементы, часть веб-компонентов) позволяют создавать свои собственные HTML-элементы. Регистрация элементов осуществляется с помощью customElements.define. Вот как можно выполнить регистрацию элемента с помощью декораторов:
import { defineElement } from './defineElement.js'
@defineElement('my-class')
class MyClass extends HTMLElement { }
Классы могут декорироваться наравне с методами и аксессорами.
// defineElement.mjs
export function defineElement(name, options) {
return klass => {
customElements.define(name, klass, options); return klass
}
}
Декоратор принимает аргументы, которые сам же использует, поэтому он реализуется как функция, возвращающая другую функцию. Вы можете думать об этом как о «фабрике декораторов»: после передачи аргументов, вы получаете другой декоратор.
Декораторы, добавляющие метаданные
Декораторы могут снабжать элементы класса метаданными путем добавления свойства metadata к передаваемому им объекту-контексту. Все объекты, содержащие метаданные, объединяются с помощью Object.assign и помещаются в свойство класса [Symbol.metadata]. Например:
// добавление метаданных к классу
@annotate({x: 'y'}) @annotate({v: 'w'}) class C {
// добавление метаданных к методу
@annotate({a: 'b'}) method() { }
// добавление метаданных к полю
@annotate({c: 'd'}) field
}
C[Symbol.metadata].class.x // 'y'
C[Symbol.metadata].class.v // 'w'
// методы, предоставляемые классом, являются распределенными или совместными,
C[Symbol.metadata].prototype.methods.method.a // 'b'
// а поля собственными
C[Symbol.metadata].instance.fields.field.c // 'd'
Обратите внимание, что формат представления объекта с аннотацией является примерным и впоследствии может быть уточнен. Основная задача примера — показать, что аннотация — это всего лишь объект, не требующий использования библиотек для чтения или записи данных в него, он создается системой автоматически.
Рассматриваемый декоратор может быть реализован так:
function annotate(metadata) {
return (_, context) => {
context.metadata = metadata
return _
}
}
При каждом вызове декоратора ему передается новый контекст, затем свойство metadata, при условии, что оно не равняется undefined, включается в [Symbol.metadata].
Обратите внимание, что метаданные, добавляемые к самому классу, а не к его методу, недоступны для декораторов, объявленных в классе. Добавление метаданных в класс происходит в конструкторе после вызова всех «внутренних» декораторов во избежание потери данных.
@tracked
Декоратор @tracked наблюдает за полем класса и вызывает метод render при вызове сеттера. Данный паттерн и похожие на него паттерны широко используются различными фреймворками для решения проблемы повторного рендеринга.
Семантика декорирумых полей предполагает обертку из геттера/сеттера вокруг некоторого приватного хранилища данных. @tracked может обернуть пару геттер/сеттер для реализации логики повторного рендеринга:
import {tracked} from './tracked.mjs'
class Element {
@tracked counter = 0
increment() { this.counter++ }
render() { console.log(counter) }
}
const e = new Element()
e.increment() // в консоль выводится 1
e.increment() // 2
При декорировании поля, «обернутое» значение представляет собой объект с двумя свойствами: функциями get и set, предназначенными для управления внутренним хранилищем. Они сконструированы таким образом, чтобы автоматически привязываться к экземпляру (с помощью call()).
// tracked.mjs
export function tracked({ get, set }) {
return {
get,
set(value) {
if (get.call(this) !== value) {
set.call(this, value)
this.render()
}
}
}
}
Ограниченный доступ к приватным полям и методам
Порой некоторому коду, находящемуся за пределами класса, может потребоваться доступ к приватным полям или методам. Например, для обеспечения взаимодействия между двумя классами или в целях тестирования кода внутри класса.
Декораторы делают возможным доступ к приватным полям и методам. Эта логика может быть инкапсулирована в объекте с приватными ключами-ссылками, предоставляемыми по необходимости.
import { PrivateKey } from './private-key.mjs'
let key = new PrivateKey()
export class Box {
@key.show #contents
}
export function setBox(box, contents) {
return key.set(box, contents)
}
export function getBox(box) {
return key.get(box)
}
Обратите внимание, что приведенный пример — это своего рода хак, который легче реализовать с помощью конструкций наподобие ссылок на приватные имена с помощью private.name или расширения области видимости приватных имен с помощью private/with. Однако он показывает, как данное предложение органично расширяет существующий функционал.
// private-key.mjs
export class PrivateKey {
#get
#set
show({ get, set }) {
assert(this.#get === undefined && this.#set === undefined)
this.#get = get
this.#set = set
return { get, set }
}
get(obj) {
return this.#get.call(obj)
}
set(obj, value) {
return this.#set.call(obj, value)
}
}
@deprecated
Декоратор @deprecated выводит в консоль предупреждение об использовании устаревших полей, методов или аксессоров. Пример использования:
import { deprecated } from './deprecated.mjs'
export class MyClass {
@deprecated field
@deprecated method() { }
otherMethod() { }
}
Для того, чтобы обеспечить возможность работы декоратора с разными элементами класса, поле kind контекста сообщает декоратору тип синтаксической конструкции, признаваемой устаревшей. Данная техника также позволяет выбрасывать исключения, когда декоратор используется в недопустимом контексте, например: внутренний класс не может быть помечен как устаревший, поскольку доступ к нему запретить невозможно.
function wrapDeprecated(fn) {
let name = fn.name
function method(...args) {
console.warn(`код ${name} признан устаревшим`)
return fn.call(this, ...args)
}
Object.defineProperty(method, 'name', { value: name, configurable: true })
return method
}
export function deprecated(element, { kind }) {
switch (kind) {
case 'method':
case 'getter':
case 'setter':
return wrapDeprecated(element)
case 'field': {
let { get, set } = element
return { get: wrapDeprecated(get), set: wrapDeprecated(set) }
}
default:
// включая 'class'
throw new Error(`${kind} является недопустимой целью @deprecated`)
}
}
Декораторы методов, требующие предварительной настройки
Некоторые декораторы методов основаны на выполнении кода при создании экземпляра класса. Например:
- Декоратор @on('event') для методов класса расширяет HTMLElement, который регистрирует этот метод как обработчик события в конструкторе
- Декоратор @bound является эквивалентом this.method = this.method.bind(this) в конструкторе
Существуют разные способы использования названных декораторов.
Вариант 1: конструкторы и метаданные
Эти декораторы представляют собой комбинацию метаданных и примеси (mixin), содержащей операции по инициализации, которые используются в конструкторе.
@on с примесью
class MyClass extends WithActions(HTMLElement) {
@on('click') clickHandler() {}
}
Указанный декоратор может быть определен следующим образом:
// у нас может быть несколько обработчиков с одинаковыми именами,
// поэтому используется Symbol
// https://developer.mozilla.org/ru/docs/Web/JavaScript/Reference/Global_Objects/Symbol
const handler = Symbol('handler')
function on(eventName) {
return (method, context) => {
context.metadata = { [handler]: eventName }
return method
}
}
class MetadataLookupCache {
// в качестве ключей используются объекты,
// во избежание утечек памяти используется WeakMap
// https://developer.mozilla.org/ru/docs/Web/JavaScript/Reference/Global_Objects/WeakMap
#map = new WeakMap()
#name
constructor(name) { this.#name = name }
get(newTarget) {
let data = this.#map.get(newTarget)
if (data === undefined) {
data = []
let klass = newTarget
while (klass !== null && !(this.#name in klass)) {
for (const [name, { [this.#name]: eventName }] of Object.entries(klass[Symbol.metadata].instance.methods)) {
if (eventName !== undefined) {
data.push({ name, eventName })
}
}
klass = klass.__proto__
}
this.#map.set(newTarget, data)
}
return data
}
}
const handlersMap = new MetadataLookupCache(handler)
function WithActions(superClass) {
return class C extends superClass {
constructor(...args) {
super(...args)
const handlers = handlersMap.get(new.target, C)
for (const { name, eventName } of handlers) {
this.addEventListener(eventName, this[name].bind(this))
}
}
}
}
@bound c примесью
@bound может быть использован следующим образом:
class C extends WithBoundMethod(Object) {
#x = 1
@bound method() { return this.#x }
}
const c = new C()
const m = c.method
m() // 1, а не TypeError
Реализация декоратора может выглядеть так:
const boundName = Symbol('boundName')
function bound(method, context) {
context.metadata = { [boundName]: true }
return method
}
const boundMap = new MetadataLookupCache(boundName)
function WithBoundMethods(superClass) {
return class C extends superClass {
constructor(...args) {
super(...args)
const names = boundMap.get(new.target, C)
for (const { name } of names) {
this[name] = this[name].bind(this)
}
}
}
}
Обратите внимание, что MetadataLookupCache используется в обоих примерах. Также имейте ввиду, что это и следующее предложение предполагают использование некой стандартной библиотеки для добавления метаданных.
Вариант 2: декораторы метода init
Декоратор init: предназначен для случаев, когда требуется выполнить операцию по инициализации, но невозможно вызвать суперкласс/примесь. Он позволяет добавлять такие операции при выполнении конструктора.
@on c init
Использование:
class MyElement extends HTMLElement {
@init: on('click') clickHandler()
}
Декоратор init: вызывается также, как декораторы методов, но возвращает пару { method, initialize }, где initialize вызывается с новым экземпляром в качестве значения this, без аргументов, и ничего не возвращает.
function on(eventName) {
return (method, context) => {
assert(context.kind === 'init-method')
return { method, initialize() { this.addEventListener(eventName, method) } }
}
}
@bound с init
init: также может использоваться для построения декоратора init: bound:
class C {
#x = 1
@init: bound method() { return this.#x }
}
const c = new C()
const m = c.method
m() // 1, а не TypeError
Декоратор @bound может быть реализован следующим образом:
function bound(method, { kind, name }) {
assert(kind === 'init-method')
return { method, initialize() { this[name] = this[name].bind(this) } }
}
Для более подробной информации об ограничениях использования, а также об открытых вопросах, которые разработчикам предстоит решить перед стандартизацией декораторов в JavaScript, обратитесь к тексту предложения по ссылке, приведенной в начале статьи.
На этом позвольте откланяться. Благодарю за внимание.
gigimon
почему декораторы в javascript это такая большая проблема? То добавляют, то убирают, то будут, то не будут, то используйте, то нет. Во многих языках давно есть и отлично работают
Lodin
Первый вариант (2015 года) имел слишком малые возможности, а две последующих версии забраковали из-за проблем с оптимизацией в JS-движках. Из-за того, что декораторы про факту меняют базовую структуру класса, все оптимизации, построенные на статическом анализе структуры класса, рассыпаются, и в результате получается, что класс с декоратором работает в несколько раз медленнее, чем класс без декоратора. Ещё важно учитывать возможность транспайлинга декораторов: например, третья версия внесла очень большую сложность для Babel, и в конечном итоге была отклонена в том числе и по этой причине.
Нынешняя (четвёртая) редакция учитывает все проблемы предыдущих, так что, будем надеяться, в этот раз всё пройдёт хорошо, и мы, наконец, увидим декораторы в JS.