這篇文章裏沒有過多的技巧和經驗,記錄的是一個想法從誕生到實現的過程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
在開發以前,咱們須要考慮而且約定未來如何使用它。關於這一點在上一小節中已經得出初步的結論了。json
假設類庫名爲 Schema
api
const PersonSchema = Schema({
name: '',
age: ''
})
複製代碼
雖然咱們支持對字段約束,可是你能夠不須要約束。那麼採用以上的方式便可,僅僅約定了 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()
等等,這些二期再實現,以上纔是目前來講看來是最重要
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
承接以上代碼:
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
定義對象的訪問器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 提出更多的建議
本文同時也發佈在個人 知乎前端專欄,歡迎你們關注