React 项目里写 connect(mapStateToProps)(MyComponent) 这种高阶组件包一层,写多了就会觉得别扭:明明想说的是「这个组件连了 store」,写出来却是一堆嵌套的括号,而且要读到文件最后一行才知道这个组件被包了什么。装饰器把这件事挪到了类声明的上方,@connect(...) 一行摆在最显眼的位置,意图和代码位置对齐了。
装饰器的核心机制并不复杂,它就是一个在类定义阶段被调用的函数,拿到目标和属性描述符,改完再交回去。底层依赖的还是 ES5 那个 Object.defineProperty。这篇从 defineProperty 讲起,把类装饰和方法装饰的参数说清楚,然后给出六个常用装饰器的完整实现。
在本篇文章中,我们将从浅入深,和大家一起学习以下知识:
Object.defineProperty和属性描述符,装饰器的地基- 类装饰器的等价写法,以及为什么能带参数
- 方法装饰器的三个参数分别是什么
- 装饰器为什么不能用在函数上
- 六个实用装饰器:注释语义、connect、loading、log、autobind、debounce、time、mixin
- 提案演进与现状:legacy 语义和新语义的差别
# 一、装饰器依赖的地基
装饰器依赖于 ES5 的 Object.defineProperty 方法。搞清楚这个方法,装饰器的行为就有了解释。
# 1.1 Object.defineProperty
Object.defineProperty() 方法会直接在一个对象上定义一个新属性,或者修改一个对象的现有属性,并返回这个对象。
该方法允许精确添加或修改对象的属性。通过赋值来添加的普通属性会创建在属性枚举期间显示的属性(for...in 或 Object.keys 方法),这些值可以被改变,也可以被删除。用 Object.defineProperty() 添加的属性,默认情况下是不可变的。
Object.defineProperty(obj, prop, descriptor)
obj:要在其上定义属性的对象prop:要定义或修改的属性的名称descriptor:将被定义或修改的属性描述符- 返回值:被传递给函数的对象
在 ES6 中,由于 Symbol 类型的特殊性,用 Symbol 类型的值来做对象的 key 与常规的定义或修改不同,而 Object.defineProperty 是定义 key 为 Symbol 的属性的方法之一。
# 1.2 descriptor 属性描述符
对象里目前存在的属性描述符有两种主要形式,数据描述符和存取描述符。
数据描述符是一个具有值的属性,该值可能是可写的,也可能不是可写的。存取描述符是由 getter-setter 函数对描述的属性。这两种形式互斥,同一个描述符里不能既有 value 又有 get,否则直接报错。
configurable
当且仅当该属性的 configurable 为 true 时,该属性描述符才能够被改变,同时该属性也能从对应的对象上被删除。默认为 false。
enumerable
enumerable 定义了对象的属性是否可以在 for...in 循环和 Object.keys() 中被枚举。当且仅当该属性的 enumerable 为 true 时,该属性才能够出现在对象的枚举属性中。默认为 false。
这两个默认值很关键。你用 obj.a = 1 赋值出来的属性,四个特性全是 true;用 Object.defineProperty 定义的,没写的一律是 false 或 undefined。所以定义完发现属性遍历不到、改不动、删不掉,八成是漏了写这几个字段。
方法装饰器拿到的第三个参数就是这个描述符对象,改它就等于改这个方法的行为。这条线索是理解后面所有代码的钥匙。
# 二、Babel 配置
装饰器在原生环境里跑不了,需要编译。
npm install --save-dev @babel/core @babel/cli
npm install --save-dev @babel/plugin-proposal-decorators @babel/plugin-proposal-class-properties
新建 .babelrc 文件:
{
"plugins": [
["@babel/plugin-proposal-decorators", { "legacy": true }],
["@babel/plugin-proposal-class-properties", {"loose": true}]
]
}
再编译指定的文件:
babel decorator.js --out-file decorator-compiled.js
legacy: true 这个开关要单独说一下。它表示按早期的装饰器语义编译,也就是本文所有示例用的那套「三参数 + 改描述符」的写法。这个配置在 2018 年前后是绝大多数项目的选择,Ant Design Pro、dva、mobx-react 那一批脚手架默认都开着它。
原文写于装饰器提案还在早期阶段的时候。这个提案后来经历了好几轮大改,语义和早期版本已经很不一样了,现在阶段更靠后,TypeScript 5 也切换到了新语义并保留了 experimentalDecorators 开关兼容旧写法。具体处在哪个阶段、新旧语义的完整差异,建议以 TC39 提案仓库和 MDN 为准,我这里就不列版本号了,免得写错。
后面所有代码都保留原文的 legacy 写法,因为这是当年真实在跑的东西,也是你在老项目里会读到的形态。第五节会单独讲新语义的差别。
# 三、装饰器的用法
装饰器主要用于两个地方,装饰类,装饰方法或属性。
# 3.1 类的装饰
@testable
class MyTestableClass {
// ...
}
function testable(target) {
target.isTestable = true;
}
MyTestableClass.isTestable // true
上面代码中,@testable 就是一个装饰器。它修改了 MyTestableClass 这个类的行为,为它加上了静态属性 isTestable。testable 函数的参数 target 就是 MyTestableClass 类本身。
装饰器的行为就是下面这样:
@decorator
class A {}
// 等同于
class A {}
A = decorator(A) || A;
装饰器是一个对类进行处理的函数,它的第一个参数就是所要装饰的目标类。注意后面那个 || A,装饰器不返回东西的时候,类保持不变;返回了新的类,就整个替换掉。这一点在实现「返回一个包装类」的装饰器时很重要。
如果觉得一个参数不够用,可以在装饰器外面再封装一层函数:
function testable(isTestable) {
return function(target) {
target.isTestable = isTestable;
}
}
@testable(true)
class MyTestableClass {}
MyTestableClass.isTestable // true
@testable(false)
class MyClass {}
MyClass.isTestable // false
装饰器 testable 可以接受参数,这就等于可以修改装饰器的行为。@testable(true) 这个写法要理解成「先调用 testable(true) 拿到真正的装饰器函数,再用它去装饰类」,多一层柯里化而已。
装饰器对类行为的改变是在代码编译时发生的,而不是在运行时。装饰器就是编译时执行的函数。
这句原文的表述在 legacy 语义下大致成立,但容易引起误解,我补一句更准确的说法:装饰器函数本身是在类定义求值的时候执行的,也就是模块加载、class 语句执行的那一刻,而不是在实例化或者调用方法的时候。它比业务代码早,但仍然是运行时行为,Babel 只是把语法转成了普通函数调用。
前面的例子是为类添加一个静态属性,如果想添加实例属性,可以通过目标类的 prototype 对象操作:
// mixins.js
export function mixins(...list) {
return function (target) {
Object.assign(target.prototype, ...list)
}
}
// main.js
import { mixins } from './mixins'
const Foo = {
foo() { console.log('foo') }
};
@mixins(Foo)
class MyClass {}
let obj = new MyClass();
obj.foo() // 'foo'
上面代码通过装饰器 mixins,把 Foo 对象的方法添加到了 MyClass 的实例上面。注意它改的是 prototype,所以所有实例共享同一份方法,这和在构造函数里 Object.assign(this, Foo) 是两回事。
# 3.2 方法的装饰
装饰器不仅可以装饰类,还可以装饰类的方法和属性。
class Person {
// 装饰器 readonly 用来装饰类的 name 方法
@readonly
name() { return `${this.first} ${this.last}` }
}
原文这里的注释用了中文弯引号包住「类」,在双引擎渲染下容易出问题,已经改成不带引号的表述。
装饰器函数 readonly 一共可以接受三个参数:
function readonly(target, name, descriptor){
// descriptor 对象原来的值如下
// {
// value: specifiedFunction,
// enumerable: false,
// configurable: true,
// writable: true
// };
descriptor.writable = false;
return descriptor;
}
readonly(Person.prototype, 'name', descriptor);
// 类似于
Object.defineProperty(Person.prototype, 'name', descriptor);
- 第一个参数是类的原型对象,上例是
Person.prototype。装饰器的本意是要装饰类的实例,但是这个时候实例还没生成,所以只能去装饰原型(这不同于类的装饰,那种情况下target参数指的是类本身) - 第二个参数是所要装饰的属性名
- 第三个参数是该属性的描述对象
第三个参数是重点。你有两种改法:直接修改传进来的 descriptor 再 return,或者返回一个全新的描述符对象。后面几个实用装饰器基本都是「取出 descriptor.value 这个原函数,包一层,塞回去」这个套路。
# 3.3 为什么不能装饰函数
装饰器只能用于类和类的方法,不能用于函数,因为存在函数提升。
展开说一下这个理由。函数声明会被整体提升到作用域顶部,装饰器代码却在原来的位置执行,两者时机对不上。假设允许这么写:
@log
function foo() {}