17. TypeScript 装饰器
简介
装饰器(Decorator)是一种语法结构,用来在定义时修改类(class)的行为。
在语法上,装饰器有如下几个特征。
(1)第一个字符(或者说前缀)是@
,后面是一个表达式。
(2)@
后面的表达式,必须是一个函数(或者执行后可以得到一个函数)。
(3)这个函数接受所修饰对象的一些相关值作为参数。
(4)这个函数要么不返回值,要么返回一个新对象取代所修饰的目标对象。
举例来说,有一个函数Injectable()
当作装饰器使用,那么需要写成@Injectable
,然后放在某个类的前面。
上面示例中,由于有了装饰器@Injectable
,类A
的行为在运行时就会发生改变。
下面就是一个最简单的装饰器。
上面示例中,函数simpleDecorator()
用作装饰器,附加在类A
之上,后者在代码解析时就会打印一行日志。
编译上面的代码会报错,提示没有用到装饰器的参数。现在就为装饰器加上参数,让它更像正式运行的代码。
function simpleDecorator(
value:any,
context:any
) {
console.log(`hi, this is ${context.kind} ${context.name}`);
return value;
}
@simpleDecorator
class A {} // "hi, this is class A"
上面的代码就可以顺利通过编译了,代码含义这里先不解释。大家只要理解,类A
在执行前会先执行装饰器simpleDecorator()
,并且会向装饰器自动传入参数就可以了。
装饰器有多种形式,基本上只要在@
符号后面添加表达式都是可以的。下面都是合法的装饰器。
注意,@
后面的表达式,最终执行后得到的应该是一个函数。
相比使用子类改变父类,装饰器更加简洁优雅,缺点是不那么直观,功能也受到一些限制。所以,装饰器一般只用来为类添加某种特定行为。
@frozen class Foo {
@configurable(false)
@enumerable(true)
method() {}
@throttle(500)
expensiveMethod() {}
}
上面示例中,一共有四个装饰器,一个用在类本身(@frozen
),另外三个用在类的方法(@configurable
、@enumerable
、@throttle
)。它们不仅增加了代码的可读性,清晰地表达了意图,而且提供一种方便的手段,增加或修改类的功能。
装饰器的版本
TypeScript 从早期开始,就支持装饰器。但是,装饰器的语法后来发生了变化。ECMAScript 标准委员会最终通过的语法标准,与 TypeScript 早期使用的语法有很大差异。
目前,TypeScript 5.0 同时支持两种装饰器语法。标准语法可以直接使用,传统语法需要打开--experimentalDecorators
编译参数。
本章介绍装饰器的标准语法,下一章介绍传统语法。
装饰器的结构
装饰器函数的类型定义如下。
type Decorator = (
value: DecoratedValue,
context: {
kind: string;
name: string | symbol;
addInitializer?(initializer: () => void): void;
static?: boolean;
private?: boolean;
access: {
get?(): unknown;
set?(value: unknown): void;
};
}
) => void | ReplacementValue;
上面代码中,Decorator
是装饰器的类型定义。它是一个函数,使用时会接收到value
和context
两个参数。
value
:所装饰的对象。context
:上下文对象,TypeScript 提供一个原生接口ClassMethodDecoratorContext
,描述这个对象。
上面是一个装饰器函数,其中第二个参数context
的类型就可以写成ClassMethodDecoratorContext
。
context
对象的属性,根据所装饰对象的不同而不同,其中只有两个属性(kind
和name
)是必有的,其他都是可选的。
(1)kind
:字符串,表示所装饰对象的类型,可能取以下的值。
- 'class'
- 'method'
- 'getter'
- 'setter'
- 'field'
- 'accessor'
这表示一共有六种类型的装饰器。
(2)name
:字符串或者 Symbol 值,所装饰对象的名字,比如类名、属性名等。
(3)addInitializer()
:函数,用来添加类的初始化逻辑。以前,这些逻辑通常放在构造函数里面,对方法进行初始化,现在改成以函数形式传入addInitializer()
方法。注意,addInitializer()
没有返回值。
(4)private
:布尔值,表示所装饰的对象是否为类的私有成员。
(5)static
:布尔值,表示所装饰的对象是否为类的静态成员。
(6)access
:一个对象,包含了某个值的 get 和 set 方法。
类装饰器
类装饰器的类型描述如下。
type ClassDecorator = (
value: Function,
context: {
kind: 'class';
name: string | undefined;
addInitializer(initializer: () => void): void;
}
) => Function | void;
类装饰器接受两个参数:value
(当前类本身)和context
(上下文对象)。其中,context
对象的kind
属性固定为字符串class
。
类装饰器一般用来对类进行操作,可以不返回任何值,请看下面的例子。
function Greeter(value, context) {
if (context.kind === 'class') {
value.prototype.greet = function () {
console.log('你好');
};
}
}
@Greeter
class User {}
let u = new User();
u.greet(); // "你好"
上面示例中,类装饰器@Greeter
在类User
的原型对象上,添加了一个greet()
方法,实例就可以直接使用该方法。
类装饰器可以返回一个函数,替代当前类的构造方法。
function countInstances(value:any, context:any) {
let instanceCount = 0;
const wrapper = function (...args:any[]) {
instanceCount++;
const instance = new value(...args);
instance.count = instanceCount;
return instance;
} as unknown as typeof MyClass;
wrapper.prototype = value.prototype; // A
return wrapper;
}
@countInstances
class MyClass {}
const inst1 = new MyClass();
inst1 instanceof MyClass // true
inst1.count // 1
上面示例中,类装饰器@countInstances
返回一个函数,替换了类MyClass
的构造方法。新的构造方法实现了实例的计数,每新建一个实例,计数器就会加一,并且对实例添加count
属性,表示当前实例的编号。
注意,上例为了确保新构造方法继承定义在MyClass
的原型之上的成员,特别加入A
行,确保两者的原型对象是一致的。否则,新的构造函数wrapper
的原型对象,与MyClass
不同,通不过instanceof
运算符。
类装饰器也可以返回一个新的类,替代原来所装饰的类。
function countInstances(value:any, context:any) {
let instanceCount = 0;
return class extends value {
constructor(...args:any[]) {
super(...args);
instanceCount++;
this.count = instanceCount;
}
};
}
@countInstances
class MyClass {}
const inst1 = new MyClass();
inst1 instanceof MyClass // true
inst1.count // 1
上面示例中,@countInstances
返回一个MyClass
的子类。
下面的例子是通过类装饰器,禁止使用new
命令新建类的实例。
function functionCallable(
value:any, {kind}:any
):any {
if (kind === 'class') {
return function (...args:any) {
if (new.target !== undefined) {
throw new TypeError('This function can’t be new-invoked');
}
return new value(...args);
}
}
}
@functionCallable
class Person {
name:string;
constructor(name:string) {
this.name = name;
}
}
// @ts-ignore
const robin = Person('Robin');
robin.name // 'Robin'
上面示例中,类装饰器@functionCallable
返回一个新的构造方法,里面判断new.target
是否不为空,如果是的,就表示通过new
命令调用,从而报错。
类装饰器的上下文对象context
的addInitializer()
方法,用来定义一个类的初始化函数,在类完全定义结束后执行。
function customElement(name: string) {
return <Input extends new (...args: any) => any>(
value: Input,
context: ClassDecoratorContext
) => {
context.addInitializer(function () {
customElements.define(name, value);
});
};
}
@customElement("hello-world")
class MyComponent extends HTMLElement {
constructor() {
super();
}
connectedCallback() {
this.innerHTML = `<h1>Hello World</h1>`;
}
}
上面示例中,类MyComponent
定义完成后,会自动执行类装饰器@customElement()
给出的初始化函数,该函数会将当前类注册为指定名称(本例为<hello-world>
)的自定义 HTML 元素。
方法装饰器
方法装饰器用来装饰类的方法(method)。它的类型描述如下。
type ClassMethodDecorator = (
value: Function,
context: {
kind: 'method';
name: string | symbol;
static: boolean;
private: boolean;
access: { get: () => unknown };
addInitializer(initializer: () => void): void;
}
) => Function | void;
根据上面的类型,方法装饰器是一个函数,接受两个参数:value
和context
。
参数value
是方法本身,参数context
是上下文对象,有以下属性。
kind
:值固定为字符串method
,表示当前为方法装饰器。name
:所装饰的方法名,类型为字符串或 Symbol 值。static
:布尔值,表示是否为静态方法。该属性为只读属性。private
:布尔值,表示是否为私有方法。该属性为只读属性。access
:对象,包含了方法的存取器,但是只有get()
方法用来取值,没有set()
方法进行赋值。addInitializer()
:为方法增加初始化函数。
方法装饰器会改写类的原始方法,实质等同于下面的操作。
function trace(decoratedMethod) {
// ...
}
class C {
@trace
toString() {
return 'C';
}
}
// `@trace` 等同于
// C.prototype.toString = trace(C.prototype.toString);
上面示例中,@trace
是方法toString()
的装饰器,它的效果等同于最后一行对toString()
的改写。
如果方法装饰器返回一个新的函数,就会替代所装饰的原始函数。
function replaceMethod() {
return function () {
return `How are you, ${this.name}?`;
}
}
class Person {
constructor(name) {
this.name = name;
}
@replaceMethod
hello() {
return `Hi ${this.name}!`;
}
}
const robin = new Person('Robin');
robin.hello() // 'How are you, Robin?'
上面示例中,装饰器@replaceMethod
返回的函数,就成为了新的hello()
方法。
下面是另一个例子。
class Person {
name: string;
constructor(name: string) {
this.name = name;
}
@log
greet() {
console.log(`Hello, my name is ${this.name}.`);
}
}
function log(originalMethod:any, context:ClassMethodDecoratorContext) {
const methodName = String(context.name);
function replacementMethod(this: any, ...args: any[]) {
console.log(`LOG: Entering method '${methodName}'.`)
const result = originalMethod.call(this, ...args);
console.log(`LOG: Exiting method '${methodName}'.`)
return result;
}
return replacementMethod;
}
const person = new Person('张三');
person.greet()
// "LOG: Entering method 'greet'."
// "Hello, my name is 张三."
// "LOG: Exiting method 'greet'."
上面示例中,装饰器@log
的返回值是一个函数replacementMethod
,替代了原始方法greet()
。在replacementMethod()
内部,通过执行originalMethod.call()
完成了对原始方法的调用。
利用方法装饰器,可以将类的方法变成延迟执行。
function delay(milliseconds: number = 0) {
return function (value, context) {
if (context.kind === "method") {
return function (...args: any[]) {
setTimeout(() => {
value.apply(this, args);
}, milliseconds);
};
}
};
}
class Logger {
@delay(1000)
log(msg: string) {
console.log(`${msg}`);
}
}
let logger = new Logger();
logger.log("Hello World");
上面示例中,方法装饰器@delay(1000)
将方法log()
的执行推迟了1秒(1000毫秒)。这里真正的方法装饰器,是delay()
执行后返回的函数,delay()
的作用是接收参数,用来设置推迟执行的时间。这种通过高阶函数返回装饰器的做法,称为“工厂模式”,即可以像工厂那样生产出一个模子的装饰器。
方法装饰器的参数context
对象里面,有一个addInitializer()
方法。它是一个钩子方法,用来在类的初始化阶段,添加回调函数。这个回调函数就是作为addInitializer()
的参数传入的,它会在构造方法执行期间执行,早于属性(field)的初始化。
下面是addInitializer()
方法的一个例子。我们知道,类的方法往往需要在构造方法里面,进行this
的绑定。
class Person {
name: string;
constructor(name: string) {
this.name = name;
// greet() 绑定 this
this.greet = this.greet.bind(this);
}
greet() {
console.log(`Hello, my name is ${this.name}.`);
}
}
const g = new Person('张三').greet;
g() // "Hello, my name is 张三."
上面例子中,类Person
的构造方法内部,将this
与greet()
方法进行了绑定。如果没有这一行,将greet()
赋值给变量g
进行调用,就会报错了。
this
的绑定必须放在构造方法里面,因为这必须在类的初始化阶段完成。现在,它可以移到方法装饰器的addInitializer()
里面。
function bound(
originalMethod:any, context:ClassMethodDecoratorContext
) {
const methodName = context.name;
if (context.private) {
throw new Error(`不能绑定私有方法 ${methodName as string}`);
}
context.addInitializer(function () {
this[methodName] = this[methodName].bind(this);
});
}
上面示例中,绑定this
转移到了addInitializer()
方法里面。
下面再看一个例子,通过addInitializer()
将选定的方法名,放入一个集合。
function collect(
value,
{name, addInitializer}
) {
addInitializer(function () {
if (!this.collectedMethodKeys) {
this.collectedMethodKeys = new Set();
}
this.collectedMethodKeys.add(name);
});
}
class C {
@collect
toString() {}
@collect
[Symbol.iterator]() {}
}
const inst = new C();
inst.collectedMethodKeys // new Set(['toString', Symbol.iterator])
上面示例中,方法装饰器@collect
会将所装饰的成员名字,加入一个 Set 集合collectedMethodKeys
。
属性装饰器
属性装饰器用来装饰定义在类顶部的属性(field)。它的类型描述如下。
type ClassFieldDecorator = (
value: undefined,
context: {
kind: 'field';
name: string | symbol;
static: boolean;
private: boolean;
access: { get: () => unknown, set: (value: unknown) => void };
addInitializer(initializer: () => void): void;
}
) => (initialValue: unknown) => unknown | void;
注意,装饰器的第一个参数value
的类型是undefined
,这意味着这个参数实际上没用的,装饰器不能从value
获取所装饰属性的值。另外,第二个参数context
对象的kind
属性的值为字符串field
,而不是“property”或“attribute”,这一点是需要注意的。
属性装饰器要么不返回值,要么返回一个函数,该函数会自动执行,用来对所装饰属性进行初始化。该函数的参数是所装饰属性的初始值,该函数的返回值是该属性的最终值。
function logged(value, context) {
const { kind, name } = context;
if (kind === 'field') {
return function (initialValue) {
console.log(`initializing ${name} with value ${initialValue}`);
return initialValue;
};
}
}
class Color {
@logged name = 'green';
}
const color = new Color();
// "initializing name with value green"
上面示例中,属性装饰器@logged
装饰属性name
。@logged
的返回值是一个函数,该函数用来对属性name
进行初始化,它的参数initialValue
就是属性name
的初始值green
。新建实例对象color
时,该函数会自动执行。
属性装饰器的返回值函数,可以用来更改属性的初始值。
function twice() {
return initialValue => initialValue * 2;
}
class C {
@twice
field = 3;
}
const inst = new C();
inst.field // 6
上面示例中,属性装饰器@twice
返回一个函数,该函数的返回值是属性field
的初始值乘以2,所以属性field
的最终值是6。
属性装饰器的上下文对象context
的access
属性,提供所装饰属性的存取器,请看下面的例子。
let acc;
function exposeAccess(
value, {access}
) {
acc = access;
}
class Color {
@exposeAccess
name = 'green'
}
const green = new Color();
green.name // 'green'
acc.get(green) // 'green'
acc.set(green, 'red');
green.name // 'red'
上面示例中,access
包含了属性name
的存取器,可以对该属性进行取值和赋值。
getter 装饰器,setter 装饰器
getter 装饰器和 setter 装饰器,是分别针对类的取值器(getter)和存值器(setter)的装饰器。它们的类型描述如下。
type ClassGetterDecorator = (
value: Function,
context: {
kind: 'getter';
name: string | symbol;
static: boolean;
private: boolean;
access: { get: () => unknown };
addInitializer(initializer: () => void): void;
}
) => Function | void;
type ClassSetterDecorator = (
value: Function,
context: {
kind: 'setter';
name: string | symbol;
static: boolean;
private: boolean;
access: { set: (value: unknown) => void };
addInitializer(initializer: () => void): void;
}
) => Function | void;
注意,getter 装饰器的上下文对象context
的access
属性,只包含get()
方法;setter 装饰器的access
属性,只包含set()
方法。
这两个装饰器要么不返回值,要么返回一个函数,取代原来的取值器或存值器。
下面的例子是将取值器的结果,保存为一个属性,加快后面的读取。
class C {
@lazy
get value() {
console.log('正在计算……');
return '开销大的计算结果';
}
}
function lazy(
value:any,
{kind, name}:any
) {
if (kind === 'getter') {
return function (this:any) {
const result = value.call(this);
Object.defineProperty(
this, name,
{
value: result,
writable: false,
}
);
return result;
};
}
return;
}
const inst = new C();
inst.value
// 正在计算……
// '开销大的计算结果'
inst.value
// '开销大的计算结果'
上面示例中,第一次读取inst.value
,会进行计算,然后装饰器@lazy
将结果存入只读属性value
,后面再读取这个属性,就不会进行计算了。
accessor 装饰器
装饰器语法引入了一个新的属性修饰符accessor
。
上面示例中,accessor
修饰符等同于为公开属性x
自动生成取值器和存值器,它们作用于私有属性x
。(注意,公开的x
与私有的x
不是同一个属性。)也就是说,上面的代码等同于下面的代码。
accessor
也可以与静态属性和私有属性一起使用。
accessor 装饰器的类型如下。
type 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;
init?: (initialValue: unknown) => unknown;
} | void;
accessor 装饰器的value
参数,是一个包含get()
方法和set()
方法的对象。该装饰器可以不返回值,或者返回一个新的对象,用来取代原来的get()
方法和set()
方法。此外,装饰器返回的对象还可以包括一个init()
方法,用来改变私有属性的初始值。
下面是一个例子。
class C {
@logged accessor x = 1;
}
function logged(value, { kind, name }) {
if (kind === "accessor") {
let { get, set } = value;
return {
get() {
console.log(`getting ${name}`);
return get.call(this);
},
set(val) {
console.log(`setting ${name} to ${val}`);
return set.call(this, val);
},
init(initialValue) {
console.log(`initializing ${name} with value ${initialValue}`);
return initialValue;
}
};
}
}
let c = new C();
c.x;
// getting x
c.x = 123;
// setting x to 123
上面示例中,装饰器@logged
为属性x
的存值器和取值器,加上了日志输出。
装饰器的执行顺序
装饰器的执行分为两个阶段。
(1)评估(evaluation):计算@
符号后面的表达式的值,得到的应该是函数。
(2)应用(application):将评估装饰器后得到的函数,应用于所装饰对象。
也就是说,装饰器的执行顺序是,先评估所有装饰器表达式的值,再将其应用于当前类。
应用装饰器时,顺序依次为方法装饰器和属性装饰器,然后是类装饰器。
请看下面的例子。
function d(str:string) {
console.log(`评估 @d(): ${str}`);
return (
value:any, context:any
) => console.log(`应用 @d(): ${str}`);
}
function log(str:string) {
console.log(str);
return str;
}
@d('类装饰器')
class T {
@d('静态属性装饰器')
static staticField = log('静态属性值');
@d('原型方法')
[log('计算方法名')]() {}
@d('实例属性')
instanceField = log('实例属性值');
@d('静态方法装饰器')
static fn(){}
}
上面示例中,类T
有四种装饰器:类装饰器、静态属性装饰器、方法装饰器、属性装饰器。
它的运行结果如下。
评估 @d(): 类装饰器
评估 @d(): 静态属性装饰器
评估 @d(): 原型方法
计算方法名
评估 @d(): 实例属性
评估 @d(): 静态方法装饰器
应用 @d(): 静态方法装饰器
应用 @d(): 原型方法
应用 @d(): 静态属性装饰器
应用 @d(): 实例属性
应用 @d(): 类装饰器
静态属性值
可以看到,类载入的时候,代码按照以下顺序执行。
(1)装饰器评估:这一步计算装饰器的值,首先是类装饰器,然后是类内部的装饰器,按照它们出现的顺序。
注意,如果属性名或方法名是计算值(本例是“计算方法名”),则它们在对应的装饰器评估之后,也会进行自身的评估。
(2)装饰器应用:实际执行装饰器函数,将它们与对应的方法和属性进行结合。
静态方法装饰器首先应用,然后是原型方法的装饰器和静态属性装饰器,接下来是实例属性装饰器,最后是类装饰器。
注意,“实例属性值”在类初始化的阶段并不执行,直到类实例化时才会执行。
如果一个方法或属性有多个装饰器,则内层的装饰器先执行,外层的装饰器后执行。
class Person {
name: string;
constructor(name: string) {
this.name = name;
}
@bound
@log
greet() {
console.log(`Hello, my name is ${this.name}.`);
}
}
上面示例中,greet()
有两个装饰器,内层的@log
先执行,外层的@bound
针对得到的结果再执行。