Будущее JavaScript: декораторы



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

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

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

    Похожие публикации

    Реклама

    Комментарии 3

      0
      почему декораторы в javascript это такая большая проблема? То добавляют, то убирают, то будут, то не будут, то используйте, то нет. Во многих языках давно есть и отлично работают
        +5

        Первый вариант (2015 года) имел слишком малые возможности, а две последующих версии забраковали из-за проблем с оптимизацией в JS-движках. Из-за того, что декораторы про факту меняют базовую структуру класса, все оптимизации, построенные на статическом анализе структуры класса, рассыпаются, и в результате получается, что класс с декоратором работает в несколько раз медленнее, чем класс без декоратора. Ещё важно учитывать возможность транспайлинга декораторов: например, третья версия внесла очень большую сложность для Babel, и в конечном итоге была отклонена в том числе и по этой причине.


        Нынешняя (четвёртая) редакция учитывает все проблемы предыдущих, так что, будем надеяться, в этот раз всё пройдёт хорошо, и мы, наконец, увидим декораторы в JS.

        –2
        У меня уже слово «декораторы» ассоциируются с Бугаенко, как все таки работает на отлично многочисленное повторение на человеческое подсознание :)

        Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

        Самое читаемое