Chris Hewell Garrett 3f7644823d Adds the 2021-12 transform for decorators
Implements the 2021-12 transform for the decorators proposal, with
support for the `accessor` keyword.
2022-01-16 14:15:08 +01:00

10 KiB

These are notes about the implementation of the 2021-12 decorators transform. The implementation's goals are (in descending order):

  1. Being accurate to the actual proposal (e.g. not defining additional properties unless required, matching semantics exactly, etc.). This includes being able to work properly with private fields and methods.
  2. Transpiling to a very minimal and minifiable output. This transform will affect each and every decorated class, so ensuring that the output is not 10x the size of the original is important.
  3. Having good runtime performance. Decoration output has the potential to drastically impact startup performance, since it runs whenever a decorated class is defined. In addition, every instance of a decorated class may be impacted for certain types of decorators.

All of these goals come somewhat at the expense of readability and can make the implementation difficult to understand, so these notes are meant to document the motivations behind the design.

Overview

Given a simple decorated class like this one:

@dec
class Class {
  @dec a = 123;

  @dec static #b() {
    console.log('foo');
  }

  [someVal]() {}

  @dec
  @dec2
  accessor #c = 456;
}

It's output would be something like the following:

import { applyDecs } from '@babel/helpers';

let initInstance, initClass, initA, callB, computedKey, initC, getC, setC;

const elementDecs = [
  [dec, 0, 'a'],
  [dec, 7, 'x', '#b']
  (computedKey = someVal, null)
  [[dec, dec2], 1, 'y', '#c']
];

const classDecs = [dec];

let Class;
class _Class {
  static {
    let ret = applyDecs(
      this,
      elementDecs,
      [dec]
    );

    initA = ret[0];
    callB = ret[1];
    initC = ret[2];
    getC = ret[3];
    setC = ret[4];
    initInstance = ret[5];
    Class = ret[6];
    initClass = ret[7];
  }

  a = (initInstance(this), initA(this, 123));

  static #b(...args) {
    callB(this, args);
  }
  static x() {
    console.log('foo');
  }

  [computedKey]() {}

  #y = initC(this, 123);
  get y() {
    return this.#y;
  }
  set y(v) {
    this.#y = v;
  }
  get #c() {
    return getC(this);
  }
  set #c(v) {
    setC(this, v);
  }

  static {
    initClass(C);
  }
}

Let's break this output down a bit:

let initInstance, initClass, initA, callB, initC, getC, setC;

First, we need to setup some local variables outside of the class. These are for:

  • Decorated class field/accessor initializers
  • Extra initializer functions added by addInitializers
  • Private class methods

These are essentially all values that cannot be defined on the class itself via Object.defineProperty, so we have to insert them into the class manually, ahead of time and populate them when we run our decorators.

const elementDecs = [
  [dec, 0, 'a'],
  [dec, 7, 'x', '#b']
  (computedKey = someVal, null)
  [[dec, dec2], 1, 'y', '#c']
];

const classDecs = [dec];

Next up, we define and evaluate the decorator member expressions. The reason we do this before defining the class is because we must interleave decorator expressions with computed property key expressions, since computed properties and decorators can run arbitrary code which can modify the runtime of subsequent decorators or computed property keys.

let Class;
class _Class {

This class is being decorated directly, which means that the decorator may replace the class itself. Class bindings are not mutable, so we need to create a new let variable for the decorated class.

  static {
    let ret = applyDecs(
      this,
      elementDecs,
      classDecs
    );

    initA = ret[0];
    callB = ret[1];
    initC = ret[2];
    getC = ret[3];
    setC = ret[4];
    initInstance = ret[5];
    Class = ret[6];
    initClass = ret[7];
  }

Next, we immediately define a static block which actually applies the decorators. This is important because we must apply the decorators after the class prototype has been fully setup, but before static fields are run, since static fields should only see the decorated version of the class.

We apply the decorators to class elements and the class itself, and the application returns an array of values that are used to populate all of the local variables we defined earlier. The array's order is fully deterministic, so we can assign the values based on an index we can calculate ahead of time.

We'll come back to applyDecs in a bit to dig into what its format is exactly, but now let's dig into the new definitions of our class elements.

  a = (initInstance(this), initA(this, 123));

Alright, so previously this was a simple class field. Since it's the first field on the class, we've updated it to immediately call initInstance in its initializer. This calls any initializers added with addInitializer for all of the per-class values (methods and accessors), which should all be setup on the instance before class fields are assigned. Then, it calls initA to get the initial value of the field, which allows initializers returned from the decorator to intercept and decorate it. It's important that the initial value is used/defined within the class body, because initializers can now refer to private class fields, e.g. a = this.#b is a valid field initializer and would become a = initA(this, this.#b), which would also be valid. We cannot extract initializer code, or any other code, from the class body because of this.

Overall, this decoration is pretty straightforward other than the fact that we have to reference initA externally.

  static #b(...args) {
    callB(this, args);
  }
  static x() {
    console.log('foo');
  }

Next up, we have a private static class method #b. This one is a bit more complex, as our definition has been broken out into 2 parts:

  1. static #b: This is the method itself, which being a private method we cannot overwrite with defineProperty. We also can't convert it into a private field because that would change its semantics (would make it writable). So, we instead have it proxy to the locally scoped callB variable, which will be populated with the fully decorated method.
  2. static x: This contains the code of the original method. Once again, this code cannot be removed from the class body because it may reference private identifiers. However, we have moved the code to a public method, which means we can now read its value using Object.getOwnPropertyDescriptor. Decorators use this to get the initial implementation of the method, which can then be wrapped with decorator code/logic. They then delete this temporary property, which is necessary because no additional elements should be added to a class definition.

The name for this method is unimportant, but because public method names cannot be minified and we also need to pass the name into applyDecs, we generate as small of a unique identifier as possible here, starting with 1 char names which are not taken and growing until we find one that is free.

  [computedKey]() {}

Next is the undecorated method with a computed key. This uses the previously calculated and stored computed key.

  #y = initC(this, 123);
  get y() {
    return this.#y;
  }
  set y(v) {
    this.#y = v;
  }
  get #c() {
    return getC(this);
  }
  set #c(v) {
    setC(this, v);
  }

Next up, we have the output for accessor #c. This is the most complicated case, since we have to transpile the decorators, the accessor keyword, and target a private field. Breaking it down piece by piece:

  #y = initC(this, 123);

accessor #c desugars to a getter and setter which are backed by a new private field, #y. Like before, the name of this field doesn't really matter, we'll just generate a short, unique name. We call the decorated initializer for #c and return that value to assign to the field.

  get y() {
    return this.#y;
  }
  set y(v) {
    this.#y = v;
  }

Next we have a getter and setter named y which provide access to the backing storage for the accessor. These are the base getter/setter for the accessor, which the decorator will get via Object.getOwnPropertyDescriptor. They will be deleted from the class fully, so again the name is not important here, just needs to be short.

  get #c() {
    return getC(this);
  }
  set #c(v) {
    setC(this, v);
  }

Next, we have the getter and setter for #c itself. These methods defer to the getC and setC local variables, which will be the decorated versions of the get y and set y methods from the previous step.

  static {
    initClass(C);
  }

Finally, we call initClass in another static block, running any class and static method initializers on the final class. This is done in a static block for convenience with class expressions, but it could run immediately after the class is defined.

Ok, so now that we understand the general output, let's go back to applyDecs:

const elementDecs = [
  [dec, 0, 'a'],
  [dec, 7, 'x', '#b']
  (computedKey = someVal, null)
  [[dec, dec2], 1, 'y', '#c']
];

const classDecs = [dec];

// ...

let ret = applyDecs(
  this,
  elementDecs,
  classDecs
);

applyDecs takes all of the decorators for the class and applies them. It receives the following arguments:

  1. The class itself
  2. Decorators to apply to class elements
  3. Decorators to apply to the class itself

The format of the data is designed to be as minimal as possible. Here's an annotated version of the member descriptors:

[
  // List of decorators to apply to the field. Array if multiple decorators,
  // otherwise just the single decorator itself.
  dec,

  // The type of the decorator, represented as an enum. Static-ness is also
  // encoded by adding 5 to the values
  //   0 === FIELD
  //   1 === ACCESSOR
  //   2 === METHOD
  //   3 === GETTER
  //   4 === SETTER
  //   5 === FIELD + STATIC
  //   6 === ACCESSOR + STATIC
  //   7 === METHOD + STATIC
  //   8 === GETTER + STATIC
  //   9 === SETTER + STATIC
  1,

  // The name of the public property that can be used to access the value.
  // For public members this is the actual name, for private members this is
  // the name of the public property which can be used to access the value
  // (see descriptions of #b and #c above)
  'y',

  // Optional fourth value, this is the spelling of the private element's name,
  // which signals that the element is private to `applyDecs` and is used in the
  // decorator's context object
  '#c'
],

Static and prototype decorators are all described like this. For class decorators, it's just the list of decorators since no other context is necessary.