JS 中的装饰器模式
背景
使用过 mobx + mobx-react 的同学对于 ES 的新特性装饰器肯定不陌生。我在第一次使用装饰器的时候,我就对它爱不释手,书写起来简单优雅,太适合我这种爱装 X 且懒的同学了。今天我就带着大家深入浅出这个优雅的语法特性:装饰器。
预备知识
- 全球统一为 ECMAScript 新特性、语法制定统一标准的组织委员会是 TC39;
- 对于单个的新特性,TC39 有专门的标准和阶段去跟进该特性,也就是我们常说的 stage-0 到 stage-4,其中的新特性的成熟完备性从低到高;
普及完一些必要的知识点后,我们继续进入到我们的主题:装饰器。
演变过程
装饰器的制定过程也不是一帆风顺的,而且就算是2020年初的现在,这个备受争议的语法特性官方标准还在讨论制定当中,目前仍处于 stage-2: 草稿状态。
但目前市面上 Babel、TypeScript 编译支持的装饰器语法主要包括两种方式,一个是 传统方式(legacy) 和目前标准方式。
由于目前标准还不是很成熟,编译器的支持并不全面,所以市面上大部分的装饰器库,大都只是兼容 legacy 方式,如 Mobx,如下为 Mobx 官网中的一段话:
Note that the legacy mode is important (as is putting the decorators proposal first). Non-legacy mode is WIP.
下面我就从实际场景出发,来使用装饰器模式来实现我们常见的一些业务场景。
注意:由于新版标准可以说是在 legacy 的方式下改造出来的,legacy 更加灵活,标准方式则主张静态配置去扩展实现装饰器功能
实际场景
需求
我希望实现一个 validate 修饰器,用于定义成员变量的校验规则,使用如下
import {validate, check} from 'validate'
class Person {
@validate(val => !['M', 'W'].includes(val) && '需要为 M 或者 W')
gender = 'M'
}
const person = new Person();
person.gender = null;
check(person); // => [{ name: 'gender', error: '需要为 M 或者 W' }]
以上这种方式,相比于运行时 validate,如下
const check = (person) => {
const errors = [];
if (!['M', 'W'].includes(person.gender)) {
errors.push({name: 'gender', error: '需要为 M 或者 W'});
}
return errors;
}
装饰器的方式能够更快捷的维护校验逻辑,更加具有表驱动程序的优势,只需要改配置即可。但是对于没有接触过装饰器模式模式的同学,深入改造装饰器内部的逻辑就有一定门坎了(但是不怕,这篇文章帮助大家降低门坎)。
实现
由于目前 Babel 编译对于新版标准支持不是很完全,对于标准的装饰器模式实现有一定程度的影响,所以本文主要介绍 legacy 方式的实现,相信对于大家后续实现标准的装饰器也是有帮助的!
思路整理
按照 api 的使用用例,我们可以知道,对于 person 实例是已经注入了 validate 校验逻辑的,然后在 check
方法中提取校验逻辑并执行即可。
@validate // 注入校验逻辑
|
check // 提取校验逻辑并执行
|
返回校验结果
首先我们在 babel 配置中需要如下配置:
"plugins": [
[
"@babel/proposal-decorators",
{
"legacy": true
}
],
["@babel/proposal-class-properties", { "loose": true }]
]
对于我们需要实现的 @validate
装饰器结构如下:
// rule 为外界自定义校验逻辑
function validate(rule) {
// target 为原型,也就是 Person.prototype
// keyName 为修饰的成员名,如 `gender`
// descriptor 为该成员的是修饰实体
return (target, keyName, descriptor) => {
// 注入 rule
target['check'] = target['check'] || {};
target['check'][keyName] = rule;
return descriptor;
}
}
根据上述逻辑,执行完 @validate
之后,在 Person.prototype
中会注入 'check'
属性,同时我们在 check
方法中拿到该属性即可进行校验。
那么我们是不是完成了该方法呢?其实还远远不够:
- 首先,对于隐式注入的
check
属性需要足够隐藏,同时属性名check
未免太容易被实例属性覆盖,从而不能通过原型链找到该属性 - 在类继承模式下,
check
属性可能会丢失,甚至会污染校验规则
首先我们来看第一个问题:改造我们的代码
const getInjectPropName =
typeof Symbol === 'function' ? name => Symbol.for(`[[${name}]]`) : name => `[[${name}]]`
const addHideProps = (target, name, value) => {
Object.defineProperty(target, name, {
enumerable: false,
configurable: true,
writable: true,
value
})
}
function validate(rule) {
return (target, keyName, descriptor) => {
const name = getInjectPropName('check');
addHideProps(target, name, target[name] || {});
target[name][keyName] = rule;
return descriptor;
}
}
相比于之前的代码实现,这样 Object.keys(Person.prototype)
不会包含 check
属性,同时也大大降低了属性命名冲突的问题。
对于第二个问题,类继承模式下的装饰器书写。如下例子:
class Person {
@validate(val => !['M', 'W'].includes(val) && '需要为 M 或者 W')
gender = 'M'
@validate(a => !(a > 10) && '需要大于10')
age = 12
}
class Man extends Person {
@validate(val => !['M'].includes(val) && '需要为 M')
gender = 'M'
}
其中的原型链模型图如下
person instance +-------------------+
+----------+ | Person.prototype |
|__proto___+------>------------------+
| |+ | rules |
+----------+ +-------+--+-+------+
| | ^ ^ ^
| | | | |
| | | |
+----------+ | |
| rules +- -- -- -- -- | |
+----------+ |
| |
| |
person instance+
+----------+ |
|__proto___| |
man instance | |+
+-----------+ +----------+ |
|__proto__ | | | |
| +---->+ |
+-----------+ | | |
| | +----------+
| | | rules | |
| | +---^------+
| | |
| | |
+-----------+
| rules | - - - - - -- - - -+
+-----------+
可以看到 man instance 和 person instance 共享同一份 rules,同时 Man
中的 validate
已经污染了共享的这份 rules,导致 person instance
校验逻辑
所以我们需要把原型模型修改为如下模式:
person instance +-------------------+
+----------+ | Person.prototype |
|__proto___+------>------------------+
| |+ | rules |
+----------+ +-------+-----------+
| | ^
| | |
| |
+----------+ |
| rules +- -- -- -- --
+----------+
person instance2
Man.prototype
+----------+
|__proto___|
man instance | |
+-----------+ +----------+
|__proto__ | | |
| +---->+ |
+-----------+ | |
| | +----------+
| | | rules |
| | +---+------+
| | ^
| | |
+-----------+ |
| rules | - - - - +
+-----------+
可以看到 man instance
和 person instance
都有一份 rules
在其原型链上,这样就不会有污染的问题,同时也不会丢失校验规则
修改我们的代码:
const getInjectPropName =
typeof Symbol === 'function' ? name => Symbol.for(`[[${name}]]`) : name => `[[${name}]]`
const addHideProps = (target, name, value) => {
Object.defineProperty(target, name, {
enumerable: false,
configurable: true,
writable: true,
value
})
}
function validate(rule) {
return (target, keyName, descriptor) => {
const name = getInjectPropName('check');
// 没有注入过 rules
if (!target[name]) {
addHideProps(target, name, {});
} else {
// 已经注入,但是是注入在 target.__proto__ 中
// 也就是继承模式
if (!target.hasOwnProperty(name)) {
// 浅拷贝一份至 own
addHideProps(target, name, {...target[name]})
}
}
target[name][keyName] = rule;
return descriptor;
}
}
如上,才算是我们完备的代码!而且 mobx 也是有相同场景的考虑的。
总结
总结是把以上模式沉淀为 decorate-utils 方便我们自定义自己的修饰器