不知不觉 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"
,具体配置可以参见插件文档
装饰器的类型
装饰器是一个接收两个参数的函数,
- 第一个参数是被装饰的值,在特殊情况下的类字段的情况下,也可能是
undefined
- 第二个参数是包含有关被装饰的值的信息的上下文对象
为了更为简明得描述类型,下面使用 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;
}
与字段装饰器不同,自动访问器装饰器接收的值是一个对象,其中包含定义在类原型上的 get
和 set
。装饰器可以包装这些并返回一个新 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();