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

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

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


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *