ECMAScript 装饰器(阶段3)

310 阅读7分钟

不知不觉 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;
}

与字段装饰器不同,自动访问器装饰器接收的值是一个对象,其中包含定义在类原型上的 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();

参考文献


原文链接:www.fierflame.com/ECMAScript/…