JS 中的裝飾器模式

背景

使用過 mobx + mobx-react 的同窗對於 ES 的新特性裝飾器確定不陌生。我在第一次使用裝飾器的時候,我就對它愛不釋手,書寫起來簡單優雅,太適合我這種愛裝 X 且懶的同窗了。今天我就帶着你們深刻淺出這個優雅的語法特性:裝飾器。javascript

預備知識

  • 全球統一爲 ECMAScript 新特性、語法制定統一標準的組織委員會是 TC39;
  • 對於單個的新特性,TC39 有專門的標準和階段去跟進該特性,也就是咱們常說的 stage-0 到 stage-4,其中的新特性的成熟完備性從低到高;

普及完一些必要的知識點後,咱們繼續進入到咱們的主題:裝飾器。java

演變過程

裝飾器的制定過程也不是一路順風的,並且就算是2020年初的如今,這個備受爭議的語法特性官方標準還在討論制定當中,目前仍處於 stage-2: 草稿狀態react

但目前市面上 Babel、TypeScript 編譯支持的裝飾器語法主要包括兩種方式,一個是 傳統方式(legacy) 和目前標準方式git

因爲目前標準還不是很成熟,編譯器的支持並不全面,因此市面上大部分的裝飾器庫,大都只是兼容 legacy 方式,如 Mobx,以下爲 Mobx 官網中的一段話:github

Note that the legacy mode is important (as is putting the decorators proposal first). Non-legacy mode is WIP.

下面我就從實際場景出發,來使用裝飾器模式來實現咱們常見的一些業務場景。api

注意:因爲新版標準能夠說是在 legacy 的方式下改造出來的,legacy 更加靈活,標準方式則主張靜態配置去擴展實現裝飾器功能babel

實際場景

需求

我但願實現一個 validate 修飾器,用於定義成員變量的校驗規則,使用以下ide

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,以下prototype

const check = (person) => {
   const errors = [];
   if (!['M', 'W'].includes(person.gender)) {
      errors.push({name: 'gender', error: '須要爲 M 或者 W'});
   }
   return errors;
}

裝飾器的方式可以更快捷的維護校驗邏輯,更加具備表驅動程序的優點,只須要改配置便可。可是對於沒有接觸過裝飾器模式模式的同窗,深刻改造裝飾器內部的邏輯就有必定門坎了(可是不怕,這篇文章幫助你們下降門坎)。code

實現

因爲目前 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 方法中拿到該屬性便可進行校驗。

那麼咱們是否是完成了該方法呢?其實還遠遠不夠:

  1. 首先,對於隱式注入的 check 屬性須要足夠隱藏,同時屬性名 check 未免太容易被實例屬性覆蓋,從而不能經過原型鏈找到該屬性
  2. 在類繼承模式下,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 instanceperson 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 方便咱們自定義本身的修飾器

相關文章
相關標籤/搜索