猛火Fierflame

ECMAScript 装饰器(阶段3)

不知不觉 ECMAScript 装饰器提案(tc39/proposal-decorators)已经进入阶段3了,这也就意味着此提案已经基本敲定,不会再发生大的变化了(除非出现重大问题)。

装饰器是在定义期间在类、类元素上调用的函数,例如:

@defineElement("my-element")
class MyMlement extends HTMLElement {
    @reactive accessor clicked = false;
    @enumerable(false)
    m(arg) {}
    @foo
    get x() {
        // ...
    }

    @foo
    set x(val) {
        // ...
    }

    @logged x = 1
}

如何使用装饰器

目前只能通过 babel 的插件 @babel/plugin-proposal-decorators 实现对装饰器的支持。因为此插件目前支持不同阶段的提案,所以还需要将插件选项中的 version 设置为 "2021-12",具体配置可以参见插件文档

装饰器的类型

装饰器是一个接收两个参数的函数,

为了更为简明得描述类型,下面使用 typescript 进行定义:

interface DecoratorContext {
    /**
     * 装饰器用途的类型
     * @description 目前支持的值有
     * - "class" 表示当前用于类
     * - "method" 表示当前用于类方法
     * - "getter" 表示当前用于类getter
     * - "setter" 表示当前用于类setter
     * - "field" 表示当前用于类属性
     * - "accessor" 表示当前用于类自动访问器(新增)
     */
    kind: string;
    /**
     * 值的名称
     * @description 对于类,是类名,对于类成员,则为成员名,特别的。对于私有成员,其仅为易读名称,例如 #attr
     */
    name: string | symbol;
    /**
     * 包含访问值的方法的对象。
     * @description 这些方法可以解决类外方位私有成员的问题
     * @description 这些方法还获取实例上元素的最终值,而不是传递给装饰器的当前值。
     * @description 这对于大多数涉及访问的用例很重要,例如类型验证器或序列化器。
     * @description 根据 babel 生成的代码,此属性只存在与私有类成员的上下文中,但根据提案,不论是否是私有成员(类装饰器除外),都存在此属性
     */
    access?: {
        /**
         * 用于获取属性值
         * @description 仅当 kind 为 "method"、"getter"、"field"、"accessor" 时有效
         */
         get?(): unknown;
        /**
         * 用于设置属性值
         * @description 仅当 kind 为 "setter"、"field"、"accessor" 时有效
         */
        set?(value: unknown): void;
    };
    /**
     * 表示此成员是否为私有成员
     * @description 仅当 kind 为 "method"、"getter"、"setter"、"field"、"accessor" 时有效
     */
    private?: boolean;
    /**
     * 表示此成员是否为静态成员
     * @description 仅当 kind 为 "method"、"getter"、"setter"、"field"、"accessor" 时有效
     */
    static?: boolean;
    /**
     * 允许用户添加额外的初始化逻辑
     * @description 仅当 kind 为 "class"、"method"、"getter"、"setter"、"accessor" 时有效
     */
    addInitializer?(initializer: () => void): void;

}
interface Decorator{
    /**
     * 装饰器函数
     * @param value 传递给装饰器的值,其中 类型 `Input` 仅表示传递给给定装饰器的类型
     * @param context 包含有关被装饰的值的信息的上下文对象
     * @return 不同上下文的装饰器,要求的返回值的类型不同 `Output` 仅代表返回值的类型
     */
    (value: Input, context: DecoratorContext): Output | void;
}

注: 根据 babel 生成的代码,装饰器上下文中还存在getMetadata(key)setMetadata(key,value) 方法,其与元数据有关。

装饰器的用法

装饰器被作为表达式,与计算的属性名称一起排序。其计算是从左到右,从上到下的。装饰器的结果存储在等价的局部变量中,稍后在类定义最初完成执行后调用。因其为表达式,所以也可以用在类表达式中,例如:

@f1
@f2
@f3
class MyClass1 {}

@f1 @f2 @f3
class MyClass2 {}

@f1 @f2
@f3 class MyClass3 {}

const MyClass4 = @f1 @f2 @f3 class {}

其中,在三种写法中,三个装饰器执行的顺序均为 @f3@f2@f1

在使用装饰器时,默认情况下只支持 . 的变量链及可选一次的函数调用,如:

@a

@a.b

@a()

@a.b.c.d()

对于一下几种用法,则是错误的

@a[1]

@f().d

@a.b.c()()

如果确实想使用如上类似的用法,则需要使用 @(expression) 的形式,如:

@(a[1])

@(f().d)

@(a.b.c()())

类装饰器 kind == 'class'

/**
 * 类装饰器定义
 * context 参数说明见上文
 **/
interface ClassDecorator  {
    (value: Function, context: {
        kind: "class";
        name: string | undefined;
        addInitializer(initializer: () => void): void;
    }): Function | void;
}

类装饰器接收被装饰的类作为第一个参数,并且可以选择返回一个新类来替换它。如果返回不可构造的值,则会引发错误。

先创建一个用来当作装饰器的 logged 函数,@logged 实现为每当创建一个类的实例,就打印一条日志:

function logged(value, { kind, name }) {
    if (kind === "class") {
        return class extends value {
            constructor(...args) {
                super(...args);
                console.log(`创建一个 ${name} 实例,参数为 ${args.join(", ")}`);
            }
        }
    }
    // ...
}

现在来应用这个装饰器:

@logged
class C {}

new C(1);
// 创建一个 C 实例,参数为 1

对其脱糖,其定义大致逻辑为:

class C {}

C = logged(C, {
    kind: "class",
    name: "C",
}) ?? C;

new C(1);

类方法装饰器 kind == 'method'

/**
 * 类方法装饰器定义
 * context 参数说明见上文
 **/
interface ClassMethodDecorator {
    (method: (...args: unkonwn[]) => unkonwn, context: {
        kind: "method";
        name: string | symbol;
        access: { get(): unknown };
        static: boolean;
        private: boolean;
        addInitializer(initializer: () => void): void;
    }): ((...args: unkonwn[]) => unkonwn) | void;
}

类访问器装饰器接收被装饰的方法作为第一个值,并且可以返回一个新方法来替换原始方法。如果返回一个新方法,将会用作替换原始方法(非静态方法替换原型上的原始方法,静态方法替换在类本身上的原始方法)。如果返回任何其他类型的值,则会引发错误。

@logged装饰器进行扩展,以实现在调用方法之前和之后分别打印一条日志:

function logged(value, { kind, name }) {
    if (kind === "method") {
        return function (...args) {
            console.log(`方法 ${name} 接收到的参数:${args.join(", ")}`);
            const ret = value.call(this, ...args);
            console.log(`方法 ${name} 执行结束`);
            return ret;
        };
    }
    // ...
}

@logged 应用在类方法上:

class C {
    @logged
    m(arg) {}
}

new C().m(1);
// 方法 m 接收到的参数:1
// 方法 m 执行结束

对其脱糖,其大致逻辑为:

class C {
    m(arg) {}
}

C.prototype.m = logged(C.prototype.m, {
    kind: "method",
    name: "m",
    static: false,
    private: false,
}) ?? C.prototype.m;


new C().m(1);

类访问器装饰器 kind == 'getter' kind == 'setter'

/**
 * 类访问器(获取器 getter)装饰器定义
 * context 参数说明见上文
 **/
interface ClassGetterDecorator {
    (getter: ()=> unknow, context: {
        kind: "getter";
        name: string | symbol;
        access: { get(): unknown };
        static: boolean;
        private: boolean;
        addInitializer(initializer: () => void): void;
    }): (()=> unknow) | void;
}
/**
 * 类访问器(设置器 setter)装饰器定义
 * context 参数说明见上文
 **/
interface ClassSetterDecorator {
    (setter: (value: unknow)=> void, context: {
        kind: "setter";
        name: string | symbol;
        access: { set(value: unknown): void };
        static: boolean;
        private: boolean;
        addInitializer(initializer: () => void): void;
    }): (v: unknow)=> void | void;
}

类访问器装饰器接收原始底层 getter/setter 函数作为第一个值,并且可以选择返回一个新的 getter/setter 函数来替换它。与方法装饰器一样,这个新函数用于替换原有的 getter/setter,如果返回任何其他类型的值,则会抛出错误。

继续扩展@logged装饰器,以实现在获取值及设置值时分别打印一条日志:

function logged(value, { kind, name }) {
    if (kind === "getter") {
        return function () {
            const ret = value.call(this);
            console.log(`属性 ${name} 的值为:${ret}`);
            return ret;
        };
    }
        if (kind === "setter") {
            return function (val) {
            console.log(`属性 ${name} 被设置为:${val}`);
            const ret = value.call(this, value);
            return ret;
        };
    }
    // ...
}

@logged 应用在类访问器上:

class C {
    #x = 1
    @logged
    get x() { return this.#x }
    @logged
    set x(val) { this.#x = val }
}

const c = new C();
c.x = c.x + 10
// 属性 x 的值为:1
// 属性 x 被设置为:11

对其脱糖,其大致逻辑为:

class C {
    #x = 1
    @logged
    get x() { return this.#x }
    @logged
    set x(val) { this.#x = val }
}

let { set, get } = Object.getOwnPropertyDescriptor(C.prototype, "x");
set = logged(set, {
    kind: "setter",
    name: "x",
    static: false,
    private: false,
}) ?? set;

get = logged(get, {
    kind: "getter",
    name: "x",
    static: false,
    private: false,
}) ?? get;

Object.defineProperty(C.prototype, "x", { set, get });

类字段装饰器 kind == 'field'

/**
 * 类字段装饰器装饰器定义
 * context 参数说明见上文
 **/
interface ClassFieldDecorator {
    (value: undefined, context: {
    kind: "field";
    name: string | symbol;
    access: { get(): unknown, set(value: unknown): void };
    static: boolean;
    private: boolean;
    }): ((initialValue: unknown) => unknown) | void;
}

与其他几种装饰器不同,类字段在装饰时没有直接输入值。相反,用户可以选择返回一个初始化函数,该函数在分配字段时运行,接收字段的初始值并返回一个新的初始值。如果返回函数以外的任何其他类型的值,则会抛出错误。

继续对@logged装饰器进行扩展,以实现类字段在初始化时打印一条日志:

function logged(value, { kind, name }) {
    if (kind === "field") {
        return function (initialValue) {
            console.log(`将 ${name} 初始化为:${initialValue}`);
            return initialValue;
        };
    }
    // ...
}

@logged 应用在类字段上:

class C {
  @logged x = 1;
}

new C();
// 将 x 初始化为:1

对其脱糖,其大致逻辑为:

let initializeX = logged(undefined, {
    kind: "field",
    name: "x",
    static: false,
    private: false,
}) ?? (initialValue) => initialValue;

class C {
    x = initializeX.call(this, 1);
}

new C();

类自动访问器

类自动访问器是一种新语法,通过在类字段前添加 accessor 关键字来定义:

class C {
    accessor x = 1;
}

与常规字段不同,自动访问器在类原型上定义了 getter 和 setter。其大致等价于:

class C {
    #x = 1;

    get x() {
        return this.#x;
    }

    set x(val) {
        this.#x = val;
    }
}

也可以定义静态和私有自动访问器:

class C {
    static accessor x = 1;
    accessor #y = 2;
}

类自动访问器装饰器 kind == 'accessor'

interface ClassAutoAccessorDecorator {
    (value: { get: () => unknown; set(value: unknown) => void; }, context: {
        kind: "accessor";
        name: string | symbol;
        access: { get(): unknown, set(value: unknown): void };
        static: boolean;
        private: boolean;
        addInitializer(initializer: () => void): void;
    }): {
        get?: () => unknown;
        set?: (value: unknown) => void;
        initialize?: (initialValue: unknown) => unknown;
    } | void;
}

与字段装饰器不同,自动访问器装饰器接收的值是一个对象,其中包含定义在类原型上的 getset。装饰器可以包装这些并返回一个新 get和/或 set,允许对属性的访问被装饰器拦截。这是类字段无法实现的功能,但类自动访问器却可以。此外,自动访问器可以返回一个初始化函数 initialize,用于更改私有槽中支持值的初始值,类似于类字段装饰器。如果返回一个对象但省略了任何值,则省略值的默认行为是使用原始行为。如果返回包含这些属性的对象之外的任何其他类型的值,则会引发错误。

进一步扩展 @logged 装饰器,我们可以让它也处理自动访问器,在自动访问器初始化和访问时记录:

function logged(value, { kind, name }) {
    if (kind === "accessor") {
        let { get, set } = value;
        return {
            get() {
                console.log(`获取 ${name}`);
                return get.call(this);
            },
            set(val) {
                console.log(`设置 ${name} 为 ${val}`);
                return set.call(this, val);
            },
            init(initialValue) {
                console.log(`初始化 ${name} 为 ${initialValue}`);
                return initialValue;
            }
        };
    }
    // ...
}

@logged 应用在类自动访问器上:

class C {
    @logged accessor x = 1;
}

let c = new C();
// 初始化 x 为 1
c.x = c.x + 10;
// 获取 x
// 设置 x 为 11

其近似等价为:

class C {
    #x = initializeX.call(this, 1);
    get x() {
        return this.#x;
    }
    set x(val) {
        this.#x = val;
    }
}

let { get: oldGet, set: oldSet } = Object.getOwnPropertyDescriptor(C.prototype, "x");

let {
    get: newGet = oldGet,
    set: newSet = oldSet,
    init: initializeX = (initialValue) => initialValue
} = logged(
    { get: oldGet, set: oldSet },
    {
        kind: "accessor",
        name: "x",
        static: false,
        private: false,
    }
) ?? {};

Object.defineProperty(C.prototype, "x", { get: newGet, set: newSet });


let c = new C();
c.x;
c.x = 123;

添加初始化逻辑 addInitializer

可以调用此方法将初始化函数与类或类元素相关联,该方法可用于在定义值后运行任意代码以完成设置。这些初始化器的时间取决于装饰器的类型:

例子1:@customElement

创建一个在浏览器中注册 Web 组件的装饰器。

function customElement(name) {
    return (value, { addInitializer }) => {
        addInitializer(function() {
            customElements.define(name, this);
        });
    }
}

@customElement('my-element')
class MyElement extends HTMLElement {
}

Q: 为何要用初始化器,而不是在调用装饰器时就时注册?

A: 同一个类可能使用多个类装饰器,且每个装饰器都可能将类进行替换。如果在调用时就注册,可能会造成注册的类并非类注册器最终返回的类。

其近似等价为:

class MyElement {
    static get observedAttributes() {
        return ['some', 'attrs'];
    }
}

const initializers = [];

MyElement = customElement('my-element')(MyElement, {
    kind: "class",
    name: "MyElement",
    addInitializer(fn) {
        initializers.push(fn);
    },
}) ?? MyElement;

for (let initializer of initializers) {
    initializer.call(MyElement);
}

例子2:@bound

创建一个@bound装饰器,它将方法绑定到类实例上:

function bound(value, { name, addInitializer }) {
    addInitializer(function () {
        this[name] = this[name].bind(this);
    });
}

class C {
    message = "你好!";

    @bound
    m() {
        console.log(this.message);
    }
}

const { m } = new C();

m(); // hello!

其近似等价为:

const initializers = []

class C {
    constructor() {
        for (let initializer of initializers) {
            initializer.call(this);
        }
        this.message = "你好!";
    }

    m() {
        console.log(this.message);
    }
}


C.prototype.m = bound(
    C.prototype.m,
    {
        kind: "method",
        name: "m",
        static: false,
        private: false,
        addInitializer(fn) {
            initializersForM.push(fn);
        },
    }
) ?? C.prototype.m;


const { m } = new C();

m();
上一篇:已经是第一篇
下一篇:文件系统访问API