不如自己寫一個 schema 類庫吧

李熠發表於2019-01-14

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

背景需求

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

如果你還不瞭解什麼是 schema,在這裡簡單解釋一下: 在應用內部的不同元件之間,應用端與服務端之間,都需要使用訊息進行通訊,而隨著應用複雜度增長,訊息的資料結構也變得複雜和龐大。對每一類需要使用的訊息或者物件提前定義 schema,有利於確保通訊的正確性,防止傳入不存在的欄位,或者傳入欄位的型別不正確;同時也具有自解釋的文件的作用,有利於今後的維護。我們以 joi 類庫為例

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 的方式:

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 裡看到的那樣。

介於找不到滿意的 schema 類庫,不如我們自己編寫一個。綜上它需要具備以下兩種能力:

  • 能夠根據 schema 建立例項,而不是事後驗證
  • 支援對 schema 定義時欄位的約束

設計 API

在開發之前,我們需要考慮並且約定將來如何使用它。關於這一點在上一小節中已經得出初步的結論了。

假設類庫名為 Schema

  • 建立 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 提出更多的建議


本文同時也釋出在我的 知乎前端專欄,歡迎大家關注

來源:https://juejin.im/post/5c3c8aed518825259278e95d

相關文章