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 isWIP.
下面我就從實際場景出發,來使用裝飾器模式來實現我們常見的一些業務場景。
注意:由於新版標準可以說是在 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方法中拿到該屬性即可進行校驗。
那麼我們是不是完成了該方法呢?其實還遠遠不夠:
佛山vi設計https://www.houdianzi.com/fsvi/ 豌豆資源搜尋大全https://55wd.com
首先,對於隱式注入的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 也是有相同場景的考慮的。