不如本身寫一個 schema 類庫吧

這篇文章裏沒有過多的技巧和經驗,記錄的是一個想法從誕生到實現的過程javascript

背景需求

在上一篇文章 構建大型 Mobx 應用的幾個建議 中,我提到過使用 schema 來約定數據結構。但遺憾的事情是,在瀏覽器端,我一直沒有能找到合適的 schmea 類庫,因此只能用 Immutable.js 中的 Record 代替。前端

若是你還不瞭解什麼是 schema,在這裏簡單解釋一下: 在應用內部的不一樣組件之間,應用端與服務端之間,都須要使用消息進行通訊,而隨着應用複雜度增加,消息的數據結構也變得複雜和龐大。對每一類須要使用的消息或者對象提早定義 schema,有利於確保通訊的正確性,防止傳入不存在的字段,或者傳入字段的類型不正確;同時也具備自解釋的文檔的做用,有利於從此的維護。咱們以 joi 類庫爲例java

const Joi = require('joi');

const schema = Joi.object().keys({
    username: Joi.string().alphanum().min(3).max(30).required(),
    password: Joi.string().regex(/^[a-zA-Z0-9]{3,30}$/),
    access_token: [Joi.string(), Joi.number()],
    birthyear: Joi.number().integer().min(1900).max(2013),
    email: Joi.string().email({ minDomainAtoms: 2 })
}).with('username', 'birthyear').without('password', 'access_token');

// Return result.
const result = Joi.validate({ username: 'abc', birthyear: 1994 }, schema);
// result.error === null -> valid

// You can also pass a callback which will be called synchronously with the validation result.
Joi.validate({ username: 'abc', birthyear: 1994 }, schema, function (err, value) { });  // err === null -> valid
複製代碼

就像能在 npm 上能找到的全部 schema 類庫相似,它們始終在採起一種「過後驗證」機制,即事先定義 schema 以後,再將須要驗證的對象交給 schema 進行驗證,這是讓我不滿意的。我更但願採起 Reacord 的方式:git

const Person = Record({
  name: '',
  age: ''
})
const person = new Person({
  name: 'Lee',
  age: 22,
})
const team = new List(jsonData).map(Person) // => List<Person>
複製代碼

在上面的例子中,schema 儼然擁有了相似於「類」的功能,你可以使用它建立指定數據結構的實例。若是你在建立實例時傳入的屬性沒有事先定義便會報錯。可是美中不足的是,Record 不支持更進一步的對每一個字段進行約束:指定類型、最大值和最小值等,就像在 joi 裏看到的那樣。github

介於找不到滿意的 schema 類庫,不如咱們本身編寫一個。綜上它須要具有如下兩種能力:npm

  • 可以根據 schema 建立實例,而不是過後驗證
  • 支持對 schema 定義時字段的約束

設計 API

在開發以前,咱們須要考慮而且約定未來如何使用它。關於這一點在上一小節中已經得出初步的結論了。json

假設類庫名爲 Schemaapi

  • 建立 Schema:
const PersonSchema = Schema({
  name: '',
  age: ''
})
複製代碼

雖然咱們支持對字段約束,可是你能夠不須要約束。那麼採用以上的方式便可,僅僅約定了 schema 的字段名詞,以及默認值數組

  • 實例化 Schema:
const person = PersonSchema({
  name: 'Lee',
  age: 22
})
複製代碼
  • 對字段進行約束:
const PersonSchema = Schema({
  name: Types().string().default('').required(),
  age: Types().number().required()
})
複製代碼

解釋一下,理想狀態下應該使用 React 中PropTypes的方式對字段進行約束,例如PropTypes.func.isRequired,可是一時想不到如何實現,因而提供Types類輔佐以鏈式調用的方式曲線救國,能夠約束的條件以下:瀏覽器

  • 數據類型約束
    • string(): 僅限字符串類型
    • number(): 僅限數字類型
    • boolean(): 僅限布爾類型
    • array(): 僅限數組類型
    • object(): 僅限對象類型
  • 其餘約束
    • required(): 該字段建立實例時必傳
    • default(value): 該字段的默認值
    • valueof(value1, value2, value3): 該字段值必須是 value1, value2, value3 值之一

固然還能夠添加其餘種類的約束,好比min()max()regex()等等,這些二期再實現,以上纔是目前來講看來是最重要

  • 支持 schema 嵌套
const PersonSchema = Schema({
  name: Types().string().default('').required(),
  age: Types().number().required(),
  job: Schema({
    title: '',
    company: ''
  })
})
複製代碼

實現

Types

關於 Types 的鏈式調用 Types().string().required() 讓我想到了什麼?jQuery. jQuery 是如何實現鏈式調用的?函數調用的結束始終返回對 jQuery 的引用。

Types是一個類,Types()用於生成一個實例。你可能注意到沒有使用關鍵詞new,由於我認爲使用關鍵詞new是很雞肋很累贅的事情。技術上不使用new關鍵詞生成實例也很容易,只要 1) 使用函數而不是 class 定義類; 2) 在構造函數中添加對實例的判斷:

function Types() {
  if (!(this instanceof Types)) {
    return new Types();
  }
}
複製代碼

而至於對各類數據類型的驗證,咱們藉助而且封裝lodash的方法進行實現。用戶每執行一個約束(.string())函數,咱們會生成一個內部的驗證函數,存儲在 Types 實例的 validators 變量中,用於未來對該字段值的判斷

import _ from 'lodash'

const lodashWrap = fn => {
  return value => {
    return fn.call(this, value);
  };
};

function Types() {
  if (!(this instanceof Types)) {
    return new Types();
  }
  this.validators = []
}

Types.prototype = {
  string: function() {
    this.validators.push(lodashWrap(_.isString));
    return this;
  },
複製代碼

同理,咱們也實現了defaultrequiredvalueof

function Types() {
  if (!(this instanceof Types)) {
    return new Types();
  }
  this.validators = [];
  this.isRequired = false;
  this.defaultValue = void 0;
  this.possibleValues = [];
}


Types.prototype = {
  default: function(defaultValue) {
    this.defaultValue = defaultValue;
    return this;
  },
  required: function() {
    this.isRequired = true;
    return this;
  },
  valueOf: function() {
    this.possibleValues = _.flattenDeep(Array.from(arguments));
    return this
複製代碼

Schema

經過咱們以前約定的 Schema() 的用法不難判斷出 Schema 的基本結構應該以下:

export const Schema = definition => {
  return function(inputObj = {}) {
    return {}
  }
}
複製代碼

Schema 的代碼實現中絕大部分並無什麼特別的,基本上就是經過遍歷 definition 來得到不一樣字段的各類約束信息:

export const Schema = definition => {
  const fieldValidator = {};
  const fieldDefaults = {};
  const fieldPossibleValues = {};
  const fieldSchemas = {};
複製代碼

上述代碼中的fieldValidatorfieldDefaults都是「詞典」,用於歸類存儲不一樣字段的各類約束信息

definition 中咱們獲取到了 schema 的定義,即對每一個字段(key)的約束。經過對字段值的各類判斷,就能獲得用於想表達的約束信息:

  • 若是值不是 Types 的實例,表示用戶只是定義了字段,但並無對它進行約束,同時當前值也是默認值。在建立實例或者對實例進行寫操做時不須要任何校驗
  • 若是值是 Types 實例,那麼咱們就能從實例的屬性裏取得各類約束信息,就是以前Types定義裏的意義validatorsdefaultValueisRequiredpossibleValues
  • 若是值是函數,表示用戶定義了一個嵌套的 Schema,在校驗時須要使用這個定義的 Schema 進行校驗

承接以上代碼:

const fields = Object.keys(definition);
fields.forEach(field => {
  const fieldValue = definition[field];
  if (_.isFunction(fieldValue)) {
    fieldSchemas[field] = fieldValue;
    return;
  }
  if (!(fieldValue instanceof Types)) {
    fieldDefaults[field] = fieldValue;
    return;
  }
  if (fieldValue.validators.length) {
    fieldValidator[field] = fieldValue.validators;
  }
  if (typeof fieldValue.defaultValue !== "undefined") {
    fieldDefaults[field] = fieldValue.defaultValue;
  }
  if (fieldValue.possibleValues && fieldValue.possibleValues.length) {
    fieldPossibleValues[field] = fieldValue.possibleValues;
  }
});
複製代碼

Schema類的實現關鍵在於如何實現set訪問器,即如何在用戶給字段賦值時進行校驗,校驗經過以後才容許賦值成功。關於如何實現訪問器,咱們有兩種方案進行選擇:

  • 使用 Object.defineProperty 定義對象的訪問器
  • 使用 Proxy 機制

Object.defineProperty的本質是對對象進行修改(固然你也可以深度拷貝一份原對象再進行修改,以免污染);而 Proxy 從「語義」上來講更適合這個場景,也不存在污染的問題。而且在同時嘗試了兩個方案以後,使用 Proxy 的成本更低。因而決定使用 Proxy 機制,那麼代碼結構大體變爲:

export const Schema = definition => {
  return function(inputObj = {}) {
    const proxyHandler = {
      get: (target, prop) => {
        return target[prop];
      },
      set: (target, prop, value) => {
        // LOTS OF TODO
      }
    }
    return new Proxy(Object.assign({}, inputObj), proxyHandler);
  }
}
複製代碼

set 方法中省略的則是循序漸進的各類判斷代碼了

結束語

本文的源碼在 github.com/hh54188/sch…

你能夠拷貝它,和它玩耍,測試它,修改它。但千萬不要將它用在生產環境中,它尚未通過充分的測試,以及還有不少細枝末節和邊界狀況須要處理

歡迎經過 pull requestissues 提出更多的建議


本文同時也發佈在個人 知乎前端專欄,歡迎你們關注

相關文章
相關標籤/搜索