L'avenir de JavaScript: les décorateurs





Bonne journée, mes amis!



Je présente à votre attention une traduction adaptée de la nouvelle proposition (septembre 2020) concernant l'utilisation des décorateurs en JavaScript, avec une petite explication sur ce qui se passe.



Cette proposition a été faite pour la première fois il y a environ 5 ans et a subi plusieurs changements importants depuis. Il en est actuellement (encore) à la deuxième étape de l'examen.



Si vous n'avez jamais entendu parler de décorateurs auparavant ou si vous souhaitez rafraîchir vos connaissances, je vous recommande de lire les articles suivants:





Alors qu'est-ce qu'un décorateur? Un décorateur est une fonction appelée sur un élément d'une classe (champ ou méthode) ou sur la classe elle-même lors de sa définition, qui encapsule ou remplace l'élément (ou la classe) par une nouvelle valeur (retournée par le décorateur).



Un champ de classe décoré est traité comme un wrapper d'un getter / setter, vous permettant de récupérer / assigner (changer) une valeur à ce champ.



Les décorateurs peuvent également annoter un membre de classe avec des métadonnées. Les métadonnées sont une collection de propriétés d'objets simples ajoutées par les décorateurs. Ils sont disponibles sous la forme d'un ensemble d'objets imbriqués dans la propriété [Symbol.metadata].



Syntaxe



La syntaxe du décorateur, en plus du préfixe @ (@decoratorName), suppose ce qui suit:



  • Les expressions de décorateur sont limitées au chaînage de variables (plusieurs décorateurs peuvent être utilisés), à l'accès à la propriété avec., Mais pas avec [], et à l'appel avec ()
  • Non seulement les définitions de classe peuvent être décorées, mais aussi leurs éléments (champs et méthodes)
  • Les décorateurs de classe sont spécifiés après l'exportation et par défaut


Il n'y a pas de règles spéciales pour définir les décorateurs; toute fonction peut être utilisée comme telle.



Détails de la sémantique



Le décorateur est évalué en trois étapes:



  1. L'expression de décorateur (ce qui suit @) est évaluée avec les noms de propriété calculés
  2. Le décorateur est appelé (en tant que fonction) lors de la définition de la classe, après l'évaluation des méthodes, mais avant la combinaison du constructeur et du prototype
  3. Le décorateur est appliqué (modifie le constructeur et le prototype) une seule fois après l'appel


1. Décorateurs informatiques



Les décorateurs sont évalués comme des expressions avec les noms de propriétés calculés. Cela se produit de gauche à droite et de haut en bas. Le résultat du décorateur est stocké dans une sorte de variable locale qui est appelée (utilisée) une fois la définition de classe terminée.



2. Appel aux décorateurs



Le décorateur est appelé avec deux arguments: l'élément enveloppé et, éventuellement, l'objet context.



Élément enveloppé: premier paramètre



Le premier argument que le décorateur enroule est ce que nous décorons (désolé pour la tautologie):



  • Quand il s'agit d'une méthode simple, méthode d'initialisation, getter ou setter: la fonction correspondante
  • Si à propos de la classe: la classe elle-même
  • Si sur le champ: un objet avec deux propriétés:



    • get: une fonction sans paramètres qui est appelée avec un récepteur, qui est un objet qui renvoie la valeur qu'il contient
    • set: une fonction qui prend un paramètre (nouvelle valeur), qui est appelée avec un récepteur qui est l'objet passé, et retourne undefined


Objet de contexte: deuxième paramètre



L'objet de contexte - l'objet passé au décorateur comme deuxième argument - contient les propriétés suivantes:



  • kind: a l'une des valeurs suivantes:



    • "Classe"
    • "Méthode"
    • "Méthode Init"
    • "Getter"
    • "Setter"
    • "Champ"
  • Nom:



    • champ public ou méthode: nom - clé de propriété de chaîne ou de caractère
    • champ ou méthode privé: aucun
    • classe: absent
  • isStatic:



    • champ statique ou méthode: true
    • champ ou méthode d'instance: false
    • classe: absent


La "cible" (constructeur ou prototype) n'est pas passée aux décorateurs de champ ou de méthode car elle (la "cible") n'a pas encore été construite au moment où le décorateur est appelé.



Valeur de retour



La valeur de retour dépend du type du décorateur:



  • classe: nouvelle classe
  • méthode, getter ou setter: nouvelle fonction
  • field: un objet avec trois propriétés:



    • avoir
    • ensemble
    • initialize: une fonction appelée avec le même argument que set, retournant la valeur utilisée pour initialiser la variable. Cette fonction est appelée lorsque le paramétrage du stockage sous-jacent dépend de l'initialiseur de champ ou de la définition de méthode
  • méthode init: un objet avec deux propriétés:



    • méthode: une fonction qui remplace une méthode
    • initialize: une fonction sans arguments, dont la valeur de retour est ignorée et qui est appelée avec l'objet nouvellement créé comme récepteur


3. Utilisation de décorateurs



Les décorateurs sont appliqués après leur appel. Les étapes intermédiaires de l'algorithme du décorateur ne peuvent pas être fixées - la classe nouvellement créée est inaccessible tant que tous les décorateurs des méthodes et des champs d'instance ne sont pas appliqués.



Les décorateurs de classe sont appelés après l'application des décorateurs de champ et de méthode.



Enfin, des décorateurs de champ statiques sont appliqués.



Sémantique des décorateurs de terrain



Un décorateur de champ de classe est une paire getter / setter pour un champ privé. Par conséquent, le code:



function id(v) { return v }

class C {
  @id x = y
}

      
      





a la sémantique suivante:



class C {
  //  #    -
  #x = y
  get x() { return this.#x }
  set x(v) { this.#x = v }
}

      
      





Les décorateurs de champ se comportent comme des champs privés. Le code suivant lèvera une exception TypeError car nous essayons d'accéder à "y" avant de l'ajouter à l'instance:



class C {
  @id x = this.y
  @id y
}
new C // TypeError

      
      





La paire getter / setter sont des méthodes ordinaires sur un objet, qui ne sont pas énumérables (non énumérables, si vous voulez) comme les autres méthodes. Les champs privés qu'il contient sont ajoutés un par un, avec les initialiseurs, comme les champs privés ordinaires.



Objectifs de conception



  • Il devrait être aussi facile d'utiliser les décorateurs intégrés que d'écrire les vôtres
  • Les décorateurs ne doivent être appliqués qu'aux objets décorés sans effets secondaires.


Cas d'application



  • Stockage des métadonnées dans les classes et les méthodes
  • Conversion d'un champ en accesseur
  • Emballage d'une méthode ou d'une classe (cette utilisation des décorateurs est quelque peu similaire au proxy d'objet)


Exemples de



Exemples de mise en œuvre et d'utilisation de décorateurs.



@logged



Le décorateur @logged imprime des messages sur la console concernant le début et la fin de l'exécution de la méthode. Il existe d'autres décorateurs populaires qui encapsulent des fonctions telles que: @deprecated. rebondir, @memoize, etc.



En utilisant:



//  .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 peut être implémenté en JavaScript en tant que décorateur. Un décorateur est une fonction appelée avec un argument contenant l'élément à décorer. Cet élément peut être une méthode, un getter ou un setter. Les décorateurs peuvent être appelés avec un deuxième argument, le contexte, cependant, dans ce cas, nous n'en avons pas besoin.



La valeur renvoyée par le décorateur remplace l'élément encapsulé. Pour les méthodes, les getters et les setters, la valeur de retour est la fonction qui les remplace.



// 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
}

      
      





Le résultat de la transpilation de l'exemple donné peut ressembler à ceci:



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})

      
      





Notez que les getters et les setters sont décorés séparément. Les accesseurs (propriétés calculées) ne sont pas combinés comme dans les clauses précédentes.



@defineElement



Les éléments personnalisés HTML (éléments personnalisés, faisant partie des composants Web) vous permettent de créer vos propres éléments HTML. Les éléments sont enregistrés à l' aide de customElements.define . Voici comment enregistrer un élément à l'aide de décorateurs:



import { defineElement } from './defineElement.js'

@defineElement('my-class')
class MyClass extends HTMLElement { }

      
      





Les cours peuvent être décorés avec des méthodes et des accesseurs.



// defineElement.mjs
export function defineElement(name, options) {
  return klass => {
    customElements.define(name, klass, options); return klass
  }
}

      
      





Le décorateur prend des arguments qu'il utilise lui-même, il est donc implémenté comme une fonction qui renvoie une autre fonction. Vous pouvez considérer cela comme une "usine de décorateurs": après avoir passé des arguments, vous obtenez un décorateur différent.



Décorateurs ajoutant des métadonnées



Les décorateurs peuvent fournir des métadonnées aux membres de la classe en ajoutant une propriété de métadonnées à l'objet de contexte qui leur est passé. Tous les objets contenant des métadonnées sont concaténés à l'aide d' Object.assign et placés dans la propriété de classe [Symbol.metadata]. Par exemple:



//    
@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'

      
      





Veuillez noter que le format de présentation de l'objet annoté est approximatif et peut être affiné davantage. La tâche principale de l'exemple est de montrer qu'une annotation est simplement un objet qui ne nécessite pas l'utilisation de bibliothèques pour y lire ou y écrire des données; elle est créée automatiquement par le système.



Le décorateur en question peut être implémenté comme ceci:



function annotate(metadata) {
  return (_, context) => {
    context.metadata = metadata
    return _
  }
}

      
      





Chaque fois que le décorateur est appelé, un nouveau contexte lui est passé, puis la propriété de métadonnées, à condition qu'elle ne soit pas indéfinie, est incluse dans [Symbol.metadata].



Notez que les métadonnées ajoutées à la classe elle-même, et non à sa méthode, ne sont pas disponibles pour les décorateurs déclarés dans la classe. L'ajout de métadonnées à une classe se produit dans le constructeur après avoir appelé tous les décorateurs "internes" afin d'éviter la perte de données.



@tracked



Le décorateur @tracked observe le champ de classe et appelle la méthode de rendu lorsque le setter est appelé. Ce modèle et des modèles similaires sont largement utilisés par divers frameworks pour résoudre le problème du re-rendu.



La sémantique des champs décorés suggère un wrapper getter / setter autour d'un magasin de données privé. @tracked peut encapsuler une paire getter / setter pour implémenter une logique de re-rendu:



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

      
      





Lors de la décoration d'un champ, la valeur «encapsulée» est un objet avec deux propriétés: obtenir et définir des fonctions de gestion du stockage interne. Ils sont conçus pour se lier automatiquement à une instance (en utilisant call ()).



// tracked.mjs
export function tracked({ get, set }) {
  return {
    get,
    set(value) {
      if (get.call(this) !== value) {
        set.call(this, value)
        this.render()
      }
    }
  }
}

      
      





Accès limité aux champs et méthodes privés



Parfois, certains codes en dehors de la classe peuvent avoir besoin d'accéder à des champs ou des méthodes privés. Par exemple, pour assurer l'interopérabilité entre deux classes ou pour tester le code au sein d'une classe.



Les décorateurs permettent d'accéder aux champs et méthodes privés. Cette logique peut être encapsulée dans un objet avec des clés de référence privées fournies selon les besoins.



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)
}

      
      





Notez que l'exemple ci-dessus est une sorte de piratage plus facile à implémenter avec des constructions comme référencer des noms privés avec private.name ou étendre la portée des noms privés avec private / with . Cependant, il montre comment cette proposition étend de manière organique la fonctionnalité existante.



// 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)
}
}

      
      





@ déprécié



Le décorateur @deprecated imprime un avertissement sur la console concernant l'utilisation de champs, méthodes ou accesseurs obsolètes. Exemple d'utilisation:



import { deprecated } from './deprecated.mjs'

export class MyClass {
  @deprecated field

  @deprecated method() { }

  otherMethod() { }
}

      
      





Afin de permettre au décorateur de travailler avec différents éléments de la classe, le champ kind du contexte informe le décorateur du type de la construction syntaxique reconnue comme obsolète. Cette technique vous permet également de lever des exceptions lorsque le décorateur est utilisé dans un contexte non valide, par exemple: une classe interne ne peut pas être marquée comme obsolète car l'accès ne peut pas être refusé.



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`)
  }
}

      
      





Décorateurs de méthode nécessitant une préconfiguration



Certains décorateurs de méthodes reposent sur l'exécution de code lorsque la classe est instanciée. Par exemple:



  • Le décorateur @on ('event') pour les méthodes de classe étend HTMLElement, qui enregistre cette méthode en tant que gestionnaire d'événements dans le constructeur
  • Le décorateur @bound est équivalent à this.method = this.method.bind (this) dans le constructeur


Il existe différentes manières d'utiliser les décorateurs nommés.



Option 1: constructeurs et métadonnées



Ces décorateurs sont une combinaison de métadonnées et d'un mixin contenant des opérations d'initialisation qui sont utilisées dans le constructeur.



@on avec une touche



class MyClass extends WithActions(HTMLElement) {
  @on('click') clickHandler() {}
}

      
      





Le décorateur spécifié peut être défini comme ceci:



//         ,
//   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 avec un mixin



@bound peut être utilisé comme ceci:



class C extends WithBoundMethod(Object) {
  #x = 1
  @bound method() { return this.#x }
}

const c = new C()
const m = c.method
m() // 1,   TypeError

      
      





L'implémentation du décorateur pourrait ressembler à ceci:



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)
      }
    }
  }
}

      
      





Notez que MetadataLookupCache est utilisé dans les deux exemples. Gardez également à l'esprit que cette phrase et la suivante supposent l'utilisation d'une sorte de bibliothèque standard pour ajouter des métadonnées.



Option 2: décorateurs de méthode init



Décorateur init: destiné aux cas où une opération d'initialisation est requise, mais il n'est pas possible d'appeler la superclasse / mixin. Il permet d'ajouter de telles opérations lorsque le constructeur est exécuté.



@on c init



En utilisant:



class MyElement extends HTMLElement {
  @init: on('click') clickHandler()
}

      
      





Décorateur init: Appelé comme les décorateurs de méthode, mais retourne une paire {method, initialize}, où initialize est appelé avec une nouvelle instance comme valeur this, aucun argument et ne renvoie rien.



function on(eventName) {
  return (method, context) => {
    assert(context.kind === 'init-method')
    return { method, initialize() { this.addEventListener(eventName, method) } }
  }
}

      
      





@bound avec init



init: peut également être utilisé pour construire un décorateur init: lié:



class C {
  #x = 1
  @init: bound method() { return this.#x }
}

const c = new C()
const m = c.method
m() // 1,   TypeError

      
      





Le décorateur @bound peut être implémenté comme ceci:



function bound(method, { kind, name }) {
  assert(kind === 'init-method')
  return { method, initialize() { this[name] = this[name].bind(this) } }
}

      
      





Pour plus d'informations sur les limites d'utilisation, ainsi que pour les questions ouvertes que les développeurs doivent résoudre avant de standardiser les décorateurs en JavaScript, reportez-vous au texte de la proposition sur le lien fourni au début de l'article.



Sur ce, laissez-moi prendre congé. Merci de votre attention.



All Articles