不如自己寫一個 schema 類庫吧
這篇文章裡沒有過多的技巧和經驗,記錄的是一個想法從誕生到實現的過程
背景需求
在上一篇文章 構建大型 Mobx 應用的幾個建議 中,我提到過使用 schema 來約定資料結構。但遺憾的事情是,在瀏覽器端,我一直沒有能找到合適的 schmea 類庫,所以只能用 Immutable.js 中的 Record 代替。
如果你還不瞭解什麼是 schema,在這裡簡單解釋一下: 在應用內部的不同元件之間,應用端與服務端之間,都需要使用訊息進行通訊,而隨著應用複雜度增長,訊息的資料結構也變得複雜和龐大。對每一類需要使用的訊息或者物件提前定義 schema,有利於確保通訊的正確性,防止傳入不存在的欄位,或者傳入欄位的型別不正確;同時也具有自解釋的文件的作用,有利於今後的維護。我們以
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;
},
複製程式碼
同理,我們也實現了default
、required
和valueof
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 = {};
複製程式碼
上述程式碼中的fieldValidator
、fieldDefaults
都是“詞典”,用於歸類儲存不同欄位的各種約束資訊
在 definition
中我們獲取到了 schema 的定義,即對每個欄位(key)的約束。通過對欄位值的各種判斷,就能得到用於想表達的約束資訊:
- 如果值不是
Types
的例項,表示使用者只是定義了欄位,但並沒有對它進行約束,同時當前值也是預設值。在建立例項或者對例項進行寫操作時不需要任何校驗 - 如果值是
Types
例項,那麼我們就能從例項的屬性裡取得各種約束資訊,就是之前Types
定義裡的意義validators
、defaultValue
、isRequired
、possibleValues
- 如果值是函式,表示使用者定義了一個巢狀的 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 request 和 issues 提出更多的建議
本文同時也釋出在我的 知乎前端專欄,歡迎大家關注