不如本身写一个 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 提出更多的建议


本文同时也发布在个人 知乎前端专栏,欢迎你们关注

相关文章
相关标签/搜索