1. 程式人生 > 實用技巧 >JS 中的裝飾器模式

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 也是有相同場景的考慮的。