這篇文章裡沒有過多的技巧和經驗,記錄的是一個想法從誕生到實現的過程
背景需求
在上一篇文章 構建大型 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;
},複製程式碼
同理,我們也實現了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 提出更多的建議
本文同時也釋出在我的 知乎前端專欄,歡迎大家關注