Доброго времени суток, друзья!

Представляю вашему вниманию адаптированный перевод нового варианта предложения (сентябрь 2020 г.), касающегося использования декораторов в JavaScript, с небольшими пояснениями относительно характера происходящего.

Впервые данное предложение прозвучало около 5 лет назад и с тех пор претерпело несколько значительных изменений. В настоящее время оно (по-прежнему) находится на второй стадии рассмотрения.

Если вы раньше не слышали о декораторах или хотите освежить свои знания, рекомендую ознакомиться со следующими статьями:


Итак, что такое декоратор? Декоратор (decorator) — это функция, вызываемая на элементе класса (поле или методе) или на самом классе в процессе его определения, оборачивающая или заменяющая элемент (или класс) новым значением (возвращаемым декоратором).

Декорированное поле класса обрабатывается как обертка из геттера/сеттера, позволяющая извлекать/присваивать (изменять) значение этому полю.

Декораторы также могут аннотировать элемент класса метаданными (metadata). Метаданные — это коллекция простых свойств объекта, добавленных декораторами. Они доступны как набор вложенных объектов в свойстве [Symbol.metadata].

Синтаксис


Синтаксис декораторов, помимо префикса @ (@decoratorName), предполагает следующее:

  • Выражения декораторов ограничены цепочкой переменных (можно использовать несколько декораторов), доступом к свойству с помощью ., но не c помощью [], и вызовом посредством ()
  • Декорироваться могут не только определения классов, но и их элементы (поля и методы)
  • Декораторы классов указываются после export и default

Для определения декораторов не существует каких-либо специальных правил; любая функция может быть использована в качестве такового.

Детали семантики


Декоратор оценивается в три этапа:

  1. Выражение декоратора (все, что следует после @) оценивается вместе с вычисляемыми названиями свойств
  2. Декоратор вызывается (как функция) в процессе определения класса, после оценки методов, но до объединения конструктора и прототипа
  3. Декоратор применяется (изменяет конструктор и прототип) только один раз после вызова

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, обратитесь к тексту предложения по ссылке, приведенной в начале статьи.

На этом позвольте откланяться. Благодарю за внимание.