TySheMo 前端数据建模

使数据变化可预测,前后端数据耦合规范化

前言

适用版本:2.6.x

我在当前的业务中非常挣扎,因为非常明显的数据问题给我带来非常大的麻烦。是怎么样的数据问题?举个例子,在一个表单的编辑时,有一个文件上传的位置,我认为文件这种类型,具有通用性,所以文件都是以数组的形式存在,个数由程序控制;但后台同学认为这个位置的文件只会有一个,所以返回给我一个对象即可。这样的数据问题每天都会发生。如果你也像我一样,在业务中深陷数据本身的问题,那么 TySheMo 可能能帮到你。

TySheMo 主要为具有复杂的前端数据模型场景准备。它不是一个典型的新一代项目,并不追求性能和特性上的多厉害,相反,它是在性能有损的前提下,提升前端数据管理的高可用性。这是 TySheMo 的使用文档,你将会在这里了解到 TySheMo 的思想、实现逻辑、使用方法。

以往,前端是一个消费数据的地方。后台 API 返回数据,前端拿到数据之后,在界面上进行渲染。但随着现代应用越来越复杂,开发场景也在随时代进步,例如同一个功能的多端开发,这些都使得简单的拿数据进行渲染不再适用。最最最常见的一个问题是,前端对后端数据具有较强的依赖,包括数据类型和结构。前端总希望拿到数据之后直接渲染,但是后端返回的数据并不一定可靠,很多隐藏的细节会给前端带来麻烦,例如数据类型、数据结构、字段缺失等等。这些看上去都是小问题,但是在真实的开发场景中,会给开发者留下非常深刻的负面印象,因为你会需要花大量的时间去进行调试。

如何解决这个问题?

我在腾讯的导师 @zawa 提出了“前端数据建模”的概念,并付诸实践。我在去年,发布了 HelloType 这个库,可以有效解决运行时数据检查的问题。这些经验给了我新的冲动,基于 HelloType 的成果,复制一个新的“前端数据建模”库。之所以是复制,是因为 zawa 所实现的库,仅在腾讯内可用。而且,由于使用习惯不同,我希望更多使用 HelloType 的语法去构建这个库。最终,TySheMo 发布了。

TySheMo 的主要目标,是为那些需要进行结构化数据进行前端管理的场景提供一套解决方案。结构化数据主要是指在 Restful 风格下,后端 API 返回的数据结构,提交到 API 的数据结构,有非常强的前后端耦合的场景。前端在获取 API 数据之后,总是会需要经过一定的条件判断之后才用来展示。比如判断某个值是否存在,判断某个值是否是某种数据类型,实际上,这些判断是没有必要的非业务代码。这些判断不仅增加了代码量,同时,也给业务逻辑本身带来了干扰,让后来新加入团队的成员看到时一脸懵逼,不知所措。而使用 TySheMo 很大程度上,不需要去进行数据判断,它的目标是,在从后端获取数据之后,restore 到 model 中,无论后端返回的数据格式或结构是否能正确解析,都可以让对应的数据拥有符合业务代码中的需要,这样,你就可以不用再在业务代码中写一大堆的条件判断。

但是更底层的,TySheMo 希望探索科学抽象的前端数据建模方案。它把一个数据模型进行拆分,从顶层模型到底层数据规则,它的拆分结果是:

从理想状态上看,他们之间具有层层递进的关系。原型是一切的基础,是一个值最原子的类型。类型由原型,以及特定的数据结构组成。规则则是针对复杂的 Type 结构,对每个属性的逻辑规则予以处理。模式则是由对不同的元数据组合而成,元数据中就包含了类型。模型的基础就是 Schema,并且提供了操作数据的接口。

概念

TySheMo 是 Type Schema Model 这三个单词的合体。也就是说,这个库,将包含这三个部分。但是,并不要急,它会包含 5 个方面的内容:原型、类型、规则、模式、模型。

原型

原型(Prototype)是用来描述数据的原子属性或特征方式。什么是“原子属性”,就是作为一个数据,在计算机中,它是怎么被对待的,即一个变量是如何对待的,以什么方式存储和运算它。

举一个例子,我们在代码中常会写 var a = 10 这样的代码,这时候,我们知道 a 是一个数值,在浏览器或其他环境中,js 引擎会按照数值型数据对它进行存储和运算。但是,你怎么知道 a 是一个数值呢?我的意思是,计算机怎么知道它是一个数值呢?这是由 a 的原子属性决定的。一个 js 中的数值,它具备数值模型的特征,并且拥有数值模型上的一些接口。数值可以进行精度求值,可以进行小数位计算等,这些特定的特征使得程序运行时,a 按照数值进行存储和运算。

在 js 中,基本的数据类型有 6 种:number, string, boolean, null, undefined, symbol. 未来 js 还会出现 bigint,甚至还会有 bigfloat 这样的新数据类型。这些基础的数据类型构成了 js 世界的原子属性。同样的道理,在 TySheMo 的体系里,对一个数据的原型进行判定,是一切的基础。问题在于,怎么去判定一个原型呢?实际上,我们要找到一种方法,可以判断某个变量是否符合某种原型,比如,我们常见的 typeof 就是一种判断方法。但在 TySheMo 的体系里,typeof 这种方法不适用。

TySheMo 判定一个变量是否符合某个原型主要通过创建一个判定逻辑,返回 true 或者 false。这个过程主要依托 TySheMo 内建的接口,你可以使用 js 的原生接口作为原型,也可以使用 TySheMo 内置的原型。

在学习下面的 Prototype 的使用方法之后你可以创建自己的原型,用来作为数据类型判定的原子属性判定方法。

类型

数据类型(Type,本文简称“类型”),是指一个变量的本质特征,主要描述的是数据结构,以及结构上每个节点的类型。什么是“变量的本质特征”呢?就是你需要知道这个变量,它的内在结构,以及嵌套在它结构节点上的更深层级的类型。

例如,你称呼一个小男孩是一个“人”,这里的“人”就是“小男孩”的本质特征,“人”所拥有的结构体系,“小男孩”也应该拥有,否则“小男孩”就不是“人”。明白这一点之后,“人”拥有什么结构,“一个小男孩”就会拥有什么结构,而且还会在“人”的共性上多出一些特征。如果将“小男孩”视为一个变量,那么“人”就是他的数据类型。他具备“人”的结构,以及人“可以说话”“可以走路”等方法。

从某种意义上讲,原型也是一种类型,但是由于原型是原子层面的,它无法描述有结构的数据,只能描述无结构的数据。举个例子,原型可以指出某个变量应该是一个数组,但是,它无法描述这个数据的内部结构,无法描述数组内每个元素的类型。

现在我们要反过来思考,作为“人”这个概念,怎么去检测这个小男孩是不是“人”。一个数据类型,如何去判别一个变量是不是该数据类型?在 TySheMo 中,我们需要借助“类型容器”来实现这个目的。数据类型本身无法用代码来表述,但是,我们可以通过一个类型容器,使得数据类型是可以被表达的。而要得到这个类型容器,就是 TySheMo 提供的 Type。

TySheMo 将抽象的“数据类型”进行形而上的提炼,用“数据容器”这个可以用代码表达的东西代替。

基于 Type,我们可以定义任意的数据类型。等你学了 Type 的使用方法之后,你也可以定义自己的数据类型了。

规则

属性规则(Rule,本文简称“规则”)是在一个对象中,属性的存在形式和值的逻辑的描述。属性规则用于对象属性的类型进行逻辑判定,它的主要效果是根据对象本身的特征或人为逻辑,决定当前属性是否必须存在,或者当前属性应该以怎样的方式进行类型判定。

属性规则只能用于 Dict 和 Tuple 这两种类型。

模式

模式(Schema)是指用于描述数据每个属性的详细描述的集合。说的简单点,就是元数据的集合,有点类似 sql 数据库的结构描述,“包含了那些字段,每个字段的结构是怎样的”。模式开始针对业务场景进行抽象。在业务代码中,我们对一个对象的每一个属性(类似字段)进行描述(类似字段结构),得到的一个完整的数据描述就是 Schema。

TySheMo 的模式定义了一个对象数据包含了那些属性字段,每个属性的具体逻辑是怎样的。它并不关心实际的数据是怎样的,它关心的是数据的抽象逻辑。当数据还不存在时,这个数据的每个字段要符合哪些逻辑。

在 TySheMo 中,通过 Schema 这个类来创建一个 schema 实例。一个 schema 实例是无状态的,它不保存数据,它就像一个管道一样,接收数据,并处理数据,输出一个完全符合校验逻辑,不会报错的数据。

模型

数据模型是一个数据管理器,它通过内部状态管理着数据,在你需要使用的时候从中取出数据,也可以更新数据。TySheMo 的 Model 是基于 Schema 的,也就是说,一个 Model 它必须有一个 schema 实例,用来作为模型提供数据的抽象结构的基础。除此之外,TySheMo Model 还提供数据观察和响应能力,在调用模型的更新方法之后,你所设置的观察回调会被执行。

Model 将会是和业务代码结合紧密的一个东西,因此,当我在设计 Model 时,所站在的角度就不再是纯粹的技术抽象层面去思考问题,而是更多的要考虑实际的业务场景。我们可以想象一种业务场景,就是从后端拿到数据后,通过数据模型,将后端给的数据回写到模型中,这样,我可以在不做任何多余操作的情况下,让数据高可用。

在设计时,我将一个模型设计为 Model 的继承类,也就是如下的写法:

import { Model } from 'tyshemo'

class SomeModel extends Model {
  schema() {
    return { ... }
  }
}

这样,创建一个模型之后,你需要实例化这个模型为一个具体的对象,才能使用这个模型。这主要是考虑到:1. 模型不应该在应用一运行时就消耗内存,而是在使用的时候再去实例化出来。2. 同一个模型可能在同一个页面需要实例化多个。

这样的设计也是经过了实践验证的。这有利于我们不断的去扩展模型,根据不同的业务场景和实际的代码逻辑来进行控制。

总而言之,模型是业务中,对数据进行管理、使用的对象。也是 TySheMo 的最顶层设计。

小结

TySheMo 从微观的数据规则到宏观的数据模型,给出了一个完成的前端数据建模的实现。它大致为一个层层递进的设计:

TySheMo 体系结构,它们处于数据的:Prototype 原子级别;Type 校验级别;Rule 逻辑级别;Schema 结构级别;Model 应用级别。

这样的设计不仅将每一个层级的概念提炼后仍然具有可行性,同时还符合我们对建模的认知。经过抽象之后的概念,完全脱离了业务逻辑,形成一个可以脱离实际需求,完全服务于建模的 js 模块。这有利于在任何业务中,如果有需要,都可以把 TySheMo 拿过来用,而不会受到限制。

快速入门

本章主要教会你如何快速的在你的项目中使用 TySheMo。

安装

npm i tyshemo

引入到项目

tyshemo 这个 npm 包提供了多种被引用的方式。

Webpack 中使用

 import { Model } from 'tyshemo'

ES6 的方式引入

import { Model } from '../node_modules/tyshemo/src/index.js'

CommonJS 的方式引入

const { Model } = require('tyshemo')

tyshemo 导出的默认方式是 commonjs,支持 nodejs 和通过 webpack 打包。

Bundle 的方式引入

为了方便使用,tyshemo 输出一个完整的 bundle 文件,以 umd 规范导出接口,可以直接在页面引入。

<script src="/node_modules/tyshemo/dist/bundle.js"></script>
<script>
const { Model } = window['tyshemo']
</script>

因为遵循 umd 规范,所以你可以在 require.js 等 amd 规范的框架中使用 bundle 方式。

使用

TySheMo 有很多接口。我们在业务中最常用的是两个,一个是利用类型校验能力,对拿到的数据进行检验。另一个则是使用 Model 在业务中管理数据,确保数据的可靠性。

数据类型校验

// person.type.js

import { Dict, List, Range } from 'tyshemo'

export PersonDict = new Dict({
  name: String,
  age: Number,
})

export PersonList = new List([PersonDict])

export PersonResponse = new Dict({
  code: new Range({ min: 0, max: 100000, minBound: true }),
  data: PersonList,
})
// 业务代码 person.service.js

import { PersonResponse } from './person.type.js'

export getPersons(params) {
  // 当从用户接口返回的数据不符合 PersonResponse 定义时,会报错
  return fetch('xxx', params).then(res => res.json()).then((data) => {
    PersonResponse.assert(data)
    return data
  })
}

数据规整化

在你的业务代码中使用后台返回的数据直接渲染?No!你应该建立自己的数据模型,该数据模型拥有你渲染需要的字段,后台返回的数据作为源输入到模型中,经过解析和调整,由模型输出统一规格的数据,这样,未来你的接口数据发生变化,你只需要在模型的解析方法中进行调整,而无需修改渲染层面的数据来适应。

import { Model } from 'tyshemo'

export class PersonModel extends Model {
  schema() {
    return {
      name: {
        type: String,
        default: 'unknown',
      },
      age: {
        type: Number,
        default: 0,
      },
    }
  }
}
export class PersonPage extends React.Component {
  constructor(props) {
    super(props)

    const person = new PersonModel()
    this.person = person

    person.watch('*', () => this.forceUpdate())
  }
  grow() {
    this.person.state.age ++
  }
  render() {
    const data = this.person.data
    return (
      <div>
        <span>{data.name}</span>
        <span onClick={() => this.grow()}>{data.age}</span>
      </div>
    )
  }
}

在下文中,我会详细的去讲解 Model 的接口,学习完之后,你就可以理解上面这段代码是怎么工作的了。

数据原型

使用 js 原生接口(标准库或特定结构)作为原型:

上面的规则利用了 js 原生的能力来进行判断,虽然上面列举了不多,但是,任何一个实例对象,都可以用它的类来作为原型,例如 let some = new Some() 中 Some 作为 some 的原型。因此,基于这种内置逻辑,其实原型是有无穷多的。

使用 TySheMo 内置原型:

在具体写代码的时候,这些原型总是位于数据校验链的最末端。

数据类型

本章详细讲解类型容器的使用。前文讲到,类型容器是 TySheMo 对数据的结构类型的具体化,帮助开发者判断某个变量是否符合某种结构类型。

TySheMo 中内置了 5 种常见的结构类型。Dict, List, Tuple, Enum, Range, 其中最常用的莫过于 Dict 和 List。我们在 TySheMo 体系下使用类型容器,不是去定义某个数据结构,而是用来判断某个变量是否符合某个结构类型。

TySheMo 中创建类型容器的基础是 Type 类,Dict, List, Tuple, Enum, Range 全都是基于Type 扩展出来的。

Type

作为类型容器的基础,Type 类创建基于 Prototype 的类型容器。也就是说,在进行实例化的时候,你应该传入 prototype 作为参数。

const SomeType = new Type(String)

但是在实际编程的时候,你不会这样去做,我们 100% 只会用到 Dict, List, Tuple, Enum, Range. 在这些扩展出来的类型中,每个节点,可以直接接收 prototy,而无需自己写一个 new Type。

得到一个类型容器之后,你就可以使用类型容器的方法进行类型校验了。下方的方法是 Dict, List, Tuple, Enum, Range都具备的。

assert

断言某个值是否符合当前类型。

import { Dict } from 'tyshemo'

const SomeDict = new Dict({
  name: String,
  age: Number,
})

SomeDict.assert({
  name: 'tomy',
  age: 10,
})

assert 方法不返回任何内容,但是它会在断言失败时使用 throw 抛出错误,中断程序执行。

catch

catch 方法用于抓取类型校验过程中的错误,如果错误发生,返回错误(一个 TypeError 实例),如果没有错误,返回 null。

const error = SomeDict.catch({
  name: 'tomy',
  age: 10,
})

test

test 方法用于判定校验是否通过,通过返回 true,不通过返回false。

const data = {
  name: 'some',
  age: 0,
}
if (SomeDict.test(data)) {
  // do something
}

track

track 方法返回一个 Promise 实例,在校验过程中会抓取错误,如果错误发生,会 reject 抛出错误,如果通过,则 resolve null。

SomeDict.track({
  name: null,
}).catch(error => console.log(error))

trace

trace 方法和 track 方法的使用是一模一样的,但是 trace 在进行数据校验的时间点上不同。track 会在你输入数据的时候进行校验,而 trace 会在一个异步进程中去校验数据,也就是延后校验。这样做的好处是,在一些数据类型不影响业务的场景中,你仅想知道数据是否符合类型,而不想让校验过程阻塞界面渲染,就可以使用 trace 替代 track。

但是,你需要注意的是,由于是异步校验,如果你在异步任务没有执行之前修改了传入的数据,就会发生不可预测的问题,因为当异步校验开始时,已经不是要校验原始数据了。

Strict/strict/toBeStrict

严格模式主要是为规则设定的。在严格模式下,某些规则会失效,例如 ifexist。严格模式会带来如下影响:

其中 Strict 和 strict 是属性,它们是完全一样的,只是为了给开发者提供不同大小写使用习惯的选择。

const SomeStrictDict = SomeDict.Strict

你会得到一个全新的 Dict 实例,这个实例复用了原来实例的配置参数,但是和原来的实例没有任何关系。

toBeStrict 是一个方法。调用它,你会将当前这个实例转化为一个严格模式的实例。

SomeDict.toBeStrict(true)

在使用时,应该小心。因为一般情况下,我们经常复用定义好的类型容器,如果你在业务中修改了严格模式,可能导致在另外的地方的校验会失败。一般仅仅在一次性使用一个类型容器时,可能用到 toBeStrict.

Dict

Dict 用于校验字典结构,对应 js 中的对象,和 python 中的 dict。在实例化时,你必须传入一个对象,这个对象规定了所有内部结构,你可以通过阅读内部结构就可以了解后台 api 应该返回什么数据给你。基于这种想法,TySheMo 可以作为前后端确定返回数据结构和类型的一个协商工具。

const SomeDict = new Dict({
  name: String,
  age: 10,
})
const OtherDict = new Dict({
  some: SomeDict, // 复用定义好的类型
  other: ifexist(SomeDict), // 使用规则
  total: ifnotmatch(Number, 0), // 通过 ifnotmatch 可以在类型校验失败时设置默认值
})

可以说,Dict 是我们用到最多的一种类型。它的使用场景及其广泛,而且有非常高的复用需求,因此,在 Type 的基础上,Dict 还拥有另外 2 个方法。

extend

扩展当前 dict 得到一个新 dict。新 dict 天生拥有当前 dict 的全部属性,但是你可以通过传入新的属性值进行覆盖,同时,你可以传入原本没有的属性来添加属性。

const Some2Dict = SomeDict.extend({
  age: Numeric, // 修改一个原有属性
  height: Number, // 添加一个新属性
})

extract

从当前 dict 中提炼部分属性得到一个新 dict。新 dict 的属性一定是原 dict 属性的子集。如果你需要该属性被提炼,只需要设置为 true 即可。

const Some3Dict = Some2Dict.extract({
  // some3 将只有 name 和 height 这两个属性
  name: true,
  height: true,
})

你可以将 extract 和 extend 连起来用,起到一定的效果。

const Some4Dict = Some2Dict.extract({
  name: true,
  height: true,
}).extend({
  weight: Number,
})

这在一些必要的场景下非常有用,可以减少代码量,但同时又可以非常明晰的知道一个 dict 都必须拥有那些校验规则。

List

List 用于校验列表结构,对数组进行检查,对应 python 中的 list。在 js 中,一个数组的内部结构是随意的,但是,在真实的业务场景中,一个列表总是要求内部元素是具备结构化特征的。一个列表中的元素,往往只会有一种类型,而不会像 js 的数组中一样,任意类型都可以。

const SomeList = new List([SomeDict])

这样规定之后,要求被检查的数组的每一个元素必须符合 SomeDict 类型。

const SomeList = new List([SomeDict, Some2Dict])

这样规定之后,要求被检查的数组的元素,必须是 SomeDict 或 Some2Dict 中任意一种类型,适合列表中有多种结构类型的适合用。

List 结构往往对应后台输出的数组,因此,如果这个数组中一个元素都没有,也不会引发错误。因为正常业务中,数组中不存在数据是常有的事。

Tuple

Tuple 用于校验元组结构,对数组进行检查,对应 python 中的 tuple。在业务场景中,我们有时需要一个数组的元素个数是确定的,每一个位置上元素的类型也是确定的。例如对函数的参数列表进行检查的时候,Tuple 则是最佳的选择。

const SomeTupe = new Tuple([String, Number])
function some(...args) {
  SomeTuple.assert(args)
}

Tuple 的末尾元素可以使用 ifexist,非末尾元素使用 ifexist 无意义。

Enum

Enum 表示枚举,被校验的值必须符合被枚举的值中的某一个。在业务场景中,实际上我们可能经常用到 Enum,因为我们要检验的数据,经常只要符合两种类型的其中之一即可。

const SomeEnum = new Enum([Number, Numeric]) // 数字或则数字形式的字符串都行

Range

Range 表示区间,被校验的数字必须在规定的区间内。这个类型比较少用到。

const SomePercent = new Range({
  min: 0,
  max: 100,
  minBound: true, // 是否包含最小值,默认 false
  maxBound: false, // 是否包含最大值,默认 false
})

Mapping

Mapping 表示映射关系表,可用于规定结构单一的对象类型结构。例如:

const SomeMapping = new Mapping([Numeric, Number])

可用于检查类似如下数据:

{
  201908: 12,
  201909: 20,
  201910: 19
}

这种 key-value 类型规律且单一的对象,且最重要的是 key 是在规律性变化的,可以用 Mapping 来检查。

辅助器 Ty

TySheMo 提供了一个便捷的操作工具用来帮助开发者进行数据类型校验:Ty。Ty 这个工具集合了 TySheMo 数据校验的大部分功能,并且进行了封装,让校验接口更形象化,而且,在我们写代码过程中,有的时候你需要通过编辑器搜索功能,一次性搜索出某些特定的校验逻辑,直接使用 Type 实例进行校验会让这种操作很麻烦,而如果有一个基于 Ty 开头的标识,搜索就会方便很多。

if (Ty.is(10).of(Number)) {
  // do something
}

Ty 上提供了如下的便捷方法。这些方法,大部分都是链式的,理解起来会比较舒服。

expect.to.be

类似于 assert 的功能。

Ty.expect(10).to.be(Number)

你也可以使用它的别名 expect.to.match 这样看上去更符合词语的意思。

Ty.expect(10).to.match(Number)

两则之间仅仅是一个别名而已,作用完全相同。

catch.by

类似于 type 实例的 catch 功能。

const error = Ty.catch(10).by(Numeric)

track.by

类似于 track 功能。

Ty.track(10).by(Numeric).catch(error => console.log(error))

trace.by

Ty.tracc(10).by(Numeric).catch(error => console.log(error))

is.typeof

用于判断一个类型是否是某值的类型。

if (Ty.is(Number).typeof(10)) {
  ...
}

is.of

用于判断一个值是否符合一个类型。

if (Ty.is(10).of(Number)) {
  ...
}

decorate.with

这是一个高级功能,用于对 ES6+ 的 class 进行修饰。你需要了解“装饰器”这种操作符之后再来使用。而且,就目前而言,使用装饰器要求你的编译工具支持。

它支持对属性、方法的输入和输出进行装饰。

@Ty.decorate().with(SomeDict) // 修饰 constructor
class Some {
  constructor(some) {
    this.data = some
  }
  
  @Ty.decorate().with(String) // 修饰属性
  name = ''

  @Ty.decorate().with(Function) // 修饰方法
  @Ty.decorate('input').with(SongTupleType) // 修饰方法参数
  @Ty.decorate('output').with(SingType) // 修饰方法返回值
  sing(song) {
    // ...
  }
}

create

在某些情况下,你并不知道你应该创建一个 Dict 还是一个 List,Ty.create 帮助你完成这个任务。

const SomeType = Ty.create(unknown)

监听功能

Ty 提供一个监听错误的功能。在一些特殊的情况下,而要获得监听能力,你必须实例化一个 Ty 对象:

const ty = new Ty()

此时,实例对象 ty 才拥有监听能力。

bind

绑定一个函数,用于在监听到错误发生时执行。这个函数接收一个 Error 实例,即错误发生时的错误实例。

const watch = error => record(error) // 创建一个函数,用于和你的监控系统对接,将错误信息记录到监控系统中
ty.bind(watch)

unbind

用于解绑一个监听函数。

ty.unbind(watch)

silent

是否开启安静模式。开启安静模式后,不会在阻断程序运行,不在控制台输出错误。一旦开启安静模式,Ty 中使用 throw 抛出错误的地方将受到影响,例如 expect.to.be, track.by, trace.by。

一般来说,不建议开启安静模式,但是在监听了错误的时候,你可以通过开启安静模式减少报错。

属性规则

TySheMo 体系中的属性规则,仅适用于 Dict 和 Tuple。为什么呢?只有 Dict 和 Tuple 中我们可以确定属性,List 虽然也是数组,但是它的元素个数是不定的,是一个弹性结构,无法运用属性规则。

你必须在属性的位置上使用这些规则,而不能直接传给 type 构造器。

const SomeDict = new Dict({
  some: ifexist(String),
})
const SomeTuple = new Tuple([
  Number,
  ifexist(String),
])

Tuple 相当于有确定的索引号,因此,可以使用规则。但是在使用一些规则的时候,要考虑它存在的意义,例如 ifexist 仅适用末尾的元素,非末尾元素使用无意义。

TySheMo 的内置规则全部是函数,规则函数一般接收原型、类型和规则作为参数。

asynchronous

异步规则。当一个类型需要异步加载的时候,可以使用该规则。它接收一个函数,用于返回最终的类型。在最终的类型返回之前,所有的校验都会被直接通过。

const SomeType = new Dict({
  some: asynchronous(fetchType), // 定义一个 fetchType 函数,用于从服务端抓取数据类型
})

在 fetchType 没有完成之前,some 属性相当于遵循 Any。

shouldmatch

必须满足传入的类型,如果不满足,则会抛出传入的 message 作为错误信息。

const SomeType = new Dict({
  some: shouldmatch(String, '{keyPath} 必须是一个字符串'),
})

shouldnotmatch

和 shouldmatch 相反,检查时,不能是传入的类型,如果符合传入的类型,反而会抛出错误,并用传入的 message 作为错误信息。

const SomeType = new Dict({
  some: shouldnotmatch(String, '{keyPath} 不能是一个字符串'),
})

match

同时满足多条规则。match 接收一个数组,但比较常用的场景是搭配 shouldmatch 和 shouldnotmatch 这两个规则来用。

const SomeType = new Dict({
  some: match([
    shouldmatch(String, '必须是一个字符串'),
    shouldmatch(Numeric, '必须是一个数字'),
  ]),
})

determine

判定规则。用于根据不同的情况使用不同的规则。例如,你想先判定数据中是否存在某个字段,如果存在的话,使用某个类型,如果不存在的话使用另外一个类型。

const SomeType = new Dict({
  some: determine(({ data }) => {
    if ('type' in data) {
      return String
    }
    else {
      return Any
    }
  }),
})

它接收两个参数:

types 传和不传效果不同,传了的情况下,返回索引号对应的 type,不传的时候,直接使用 fn 的结果作为 type。

ifexist

根据属性存在情况进行校验。当属性不存在时,不进行任何校验。当属性存在时,必须符合传入的类型。ifexist 是一个非常重要的属性,它可以让属性规则更加灵活。而且由于属性规则本身是可以嵌套的,你还可以在 ifexist 里面嵌套另外一个规则,例如 ifnotmatch。

const SomeType = new Dict({
  some: ifexist(String),
})

ifnotmatch

如果被校验的属性不符合类型,则用传入的参数作为默认值进行替代。

const SomeType = new Dict({
  some: ifnotmatch(Number, 0),
})

它的第二个参数可以是一个函数,如果是一个函数的话,会在校验失败时,执行该函数,并把该函数的返回结果作为默认值。

ifmatch

和 ifnotmatch 相反,当接收到到值和传入到类型一致时,第二个参数会被使用。

shouldexist

判定该属性是否必须存在。它接收一个函数用来进行判定如,如果返回 true,当被检查的数据没有该属性,就会报错。如果返回 false,当被检查的数据没有该属性,不会报错。但无论返回 true 或 false,只要该属性存在,都应该满足传入的第二个参数的类型。

const SomeType = new Dict({
  some: shouldexist(({ data }) => !!data.should, String),
})

shouldnotexist

和 shuldexist 相反,当函数返回 true 时,不允许该属性存在,当函数返回 fasle 时,该属性可存在也可不存在,存在时必须满足传入的类型。

const SomeType = new Dict({
  some: shouldnotexist(({ data }) => !!data.shouldnot, String),
})

这个规则有点绕,需要你在实践中多尝试。

beof

校验值是否为传入的类的实例。在 TySheMo 中,类似 String, Boolean 这些系统自带的变量都被作为校验使用的原型去了。但是,如果真的想去校验一个 new String('xx') 是否是 String 的实例时,就需要用到 beof。

const SomeType = new Dict({
  some: beof(String),
})

equal

用于对对象等需要进行深度比较的值。

const SomeType = new Dict({
  some: equal({ ok: true }),
})

nullor

允许被检查到值为 null。

const SomeRule = nullor(String)
const SomeType = dict({
  some: SomeRule,
})

当 some 属性值为 null 时,不会返回错误。

lambda

用于对属性方法进行重写,以此让该方法拥有参数和返回值校验的能力。lambda 规则接收两个参数,第一个参数必须是一个 Tuple 类型容器,用于对方法函数对传入参数进行校验,第二个参数是用于对函数返回值进行校验的类型容器。

const SomeTuple = new Tuple([Number, Number])
const SomeType = new Dict({
  do: lambda(SomeTuple, Number),
})

Rule

在学习了前面的所有内置规则之后,我们来学习这些规则背后的 Rule 这个类。

const SomeRule = new Rule({
  validate: v => v !== 0,
  message: 'value should not be 0.',
})

所有上述规则生成器函数的最后,都是返回一个 Rule 的实例。因此,你实际上可以使用这些 Rule 实例来做一些事。

validate

validate 方法用于对数据进行校验,并返回一个 error 或 null。

bind

bind 方法用于绑定一个函数,当 validate 校验中返回了错误,这个函数一定会执行,接收 error 实例。

在一些规则中,例如 ifnotmatch, ifmatch 中,数据会被校验和改写。但是,如果你这个时候想要对被校验的原始所抛出的错误进行监控时,可以使用 bind 方法:

const SomeRule = ifnotmatch(Number, 0)
const SomeType = new Dict({ some: SomeRule, })

SomeRule.bind(error => console.log(error))

这样,无论任何情况下,你都可以抓取到 SomeRule 校验失败时抛出的错误。

unbind

解除 bind 的函数。

错误

当我们使用 TySheMo 的类型检查系统去检查一个数据时,在检查到数据不符合类型结构时,会有错误抛出。这个错误 error 是一个错误实例,即 Error 的实例。Type 的错误是一个 Error 的子集,具有特殊的属性。

错误收集和展示

TySheMo 内部检查时会收集所有错误,而非遇到检查失败就立即停止后续检查。也就是说,它是遍历整个被检查对象后才把错误返回的。在返回的错误中,你可以通过打印错误 message 查看所有错误。例如:

const SomeType = dict({
  a: ifexist(tuple([
    new Enum([String, Boolean]),
    Number,
  ])),
  b: {
    title: String,
    count: Number,
  },
  c: shouldmatch(dict({
    name: String,
    age: Number,
  }), '{keyPath} 不是一个用户。'),
})
test(() => SomeType.assert({
  a: [1],
  b: 'null',
  c: null,
}))

TySheMo 抛出的错误将是一个包含了该对象所有错误的信息。比如上面这个校验,会抛出如下的错误:

它有4条信息,a[0], a[1], b, c,告诉了你四个位置上具体是什么错误。

错误信息模板

你可以看到,上面的错误信息,前 3 条使用了内置的错误信息模板。你可以通过修改模板来调整内置信息模板。

import { TyError } from 'tyshemo'

TyError.defaultMessages = {
  ...TyError.defaultMessages,
  exception: '{keyPath} 位置错误。',
}

模板以键值对形式存在,包括如下:

在消息值里面,你可以使用字符串模板例如上面示例代码里面的 {keyPath},可以用到的有:

错误信息序列化

当你的 should 或 receive 过长的适合,你阅读起来非常麻烦,TySheMo 提供了可以换行展示的能力,这样可以方便你阅读,要开启这个功能,你只需要:

import { TyError } from 'tyshemo'

TyError.shouldBreakLongMessage = true

开启这个功能之后,你的消息里面的对象、数组,会自动进行换行,这样可以方便你阅读,可以更好的知道错误的详情。

你可以看到,开启之后,b 那条错误里面的对象换行了。

错误信息脱敏

在一些系统中,你希望你收到的错误信息进行脱敏,而非把错误内容完整的展示出来。你需要关闭对应的功能:

import { TyError } from 'tyshemo'

TyError.shouldUseSensitiveData = false

关闭之后,最终打印出来的值会被脱敏。字符串显示为 "***" 数字显示为 ***。

错误信息格式化

针对单条错误,你希望对这一条错误信息进行单独格式化。你可以通过 error.format 方法实现。

const error = SomeType.catch(some)
error.format(templates, jointag, shouldbreak, shouldusesensitive)

Parser(试验)

作为一项试验性功能,你现在还无法直接通过 tyshemo 引入该功能。

描述语言

Parser 是用以解析基于字符串描述的 Type 构造器。它的目标是,一个类型容器的配置,由服务端返回。它类似于一些描述语言,用字符串描述类型结构。

例如,服务端返回了如下的 json 作为类型结构描述:

{
  "__def__": [
    {
      "name": "book",
      "def": {
        "name": "string",
        "price": "float"
      }
    }
  ],
  "name": "string",
  "age": "number",
  "has_football": "?boolean",
  "sex": "F|M",
  "dot": "=xxxxx",
  "belong": "?=animal",
  "vioce": "!number",
  "num": "string,numeric",
  "parents": "[string,string]",
  "books": [
    {
      "name": "string",
      "age": "number"
    }
  ],
  "body": {
    "head": "boolean",
    "neck": "boolean"
  }
}

它完全是基于字符串的描述的。但是很明显,你可以大致读懂它的描述内容。只是有些符号代表什么意义,你还需要学一下而已。前端在拿到这样一段描述 json 之后,怎么把它解析为一个类型容器呢?

import Parser from 'tyshemo/dist/parser'

const parser = new Parser()
const SomeType = parser.parse(json)

这样你就可以将描述文本转化为可运行的类型容器。

语法

目前仅支持 Dict。它的语法非常简单:

{
  属性名:规则 类型,
}

规则表达式

仅支持 4 种规则:

前三种规则必须置于描述最前面,? 可以和其他规则混用,例如 "?=tomy" 表示如果存在该属性,必须等于字符串 "tomy"。

目前不支持复杂的规则表达。如果不遵循上述语法,解析器可能报错,或返回错误的结果,因此必须非常注重描述语言的写法。

类型表达式

Parser 仅支持如下表达式用以简写类型:

类型表达式的优先级高于规则表达式,例如 "string,number|numeric" 解释之后是 match([String, new Enum([Number, Numeric])])

原型

内置支持的类型如下:

Parser.defaultTypes = {
  'string': String,
  'number': Number,
  'boolean': Boolean,
  'null': Null,
  'undefined': Undefined,
  'symbol': Symbol,
  'function': Function,
  'array': Array,
  'object': Object,
  'numeric': Numeric,
  'int': Int,
  'float': Float,
  'negative': Negative,
  'positive': Positive,
  'zero': Zero,
  'any': Any,
  'nn': NaN,
  'infinity': Infinity,
  'finity': Finity,
  'date': Date,
  'promise': Promise,
  'error': Error,
  'regexp': RegExp,
}

你可以通过修改 Parser.defaultTypes 来扩展或修改支持的类型。

自定义类型

由于 TySheMo 仅支持非常简单的描述语言,它不支持在描述文本中嵌套,因此,如果你需要有多层的嵌套规则,例如某个字段是对象,然后你需要对对象内部进行约束,你必须使用自定义规则,例如最前面代码里面的 books 那个属性一样。

自定义类型逻辑如下:

TySheMo Parser 会首先去读取 __def__ 中的内容,它必须是一个数组,Parser 会去遍历这个数组,这个数组的元素结构如 { name, def },每遍历一个元素,这个元素的解析结果就会加入到 Parser 中,成为一个定义,在后面的任何描述中,都可以使用这个元素。遍历完 __def__ 之后,再对整个 json 进行解析。

以前面的 books 为例。我们首先在 __def__ 中定义了一个 name 为 book 的类型,这个类型的具体描述在 def 中,它实际上也是一个对象结构。在 books 的描述中,我们使用了 "book[]",表示 books 是一个 book 的数组。

独立作用域

在一些场景下,你需要使用预设好的类型。你可以这样做:

// 预设类型
const types = {
  some: new Tuple([String, Number]),
}

// 将预设类型加入到该解析器,仅该解析器可以读懂预设类型
const parser = new Parser(types)

// 使用预设类型
parser.parse({
  list: "some", // some 为预设类型中对应的属性名
})

在 Parser 实例化的时候传入预设类型,这些预设类型只会在该解析器解析的时候生效,而不会影响其他解析器。

接口

parse(description)

将描述文本 json 对象解析为 tyshemo 的类型系统实例。

describe(type)

将 tyshemo 的类型系统实例(Type 实例)抽象为描述文本 json。

define(type, description)

定义一个解析对组。

parser.define(SomeType, 'some')

经过 define 之后,你就可以在描述文本中使用 some 代表 SomeType 了。

技巧

一般而言,只会在一些特殊场景下去使用解析器。基于性能的考虑,不应该在前端大面积使用这种方案,因为解析器本身在解析的时候,就会消耗一定的性能。

前后端通信时

由于前后端通信时,你无法直接传递运行时变量,不可能把 String, Number 这种传给前端,因此,只能选择 json 字符串等通用描述方式。

前后端同构时

使用同一套类型校验时,前后端使用相同的预设类型,可以减少传输成本。例如上面的一段代码,其中的 some 类型,我们可以提前预设好,这样,在发送的描述文件时,就不需要再在描述文件中使用 __def__ 去定义 some。

Mocker(试验)

上面,我们利用 Parser.describe 可以生成描述文件。差不多同样的道理,我们可以生成 mock 数据。问题的关键在于,我们如何生成每种原型对应的随机值。用法和 Parser 很像,但随机值的产生不同。

const = loaders = [
  [String, function() {
    return 'a random string'
  }],
]
const mocker = new Mocker(loaders)

const dict = new Dict({
  name: String,
})

const mock = mocker.mock(dict)

mock 方法可以提炼出一个 mock 数据,loaders 中的函数会返回一个经过随机算法返回的字符串。最终,mock 方法会返回一个符合 dict 的假数据。

Schema

数据模式(Schema,本文简称“模式”)是一种抽象结构。它的作用更多是描述,描述一个结构应该是怎么构成的。Schema 是 TySheMo 提供的一个类,它用于生成一个模式描述。而这个描述有一些方法,去保证你所提供的数据符合这个结构。

基于业务场景的思考,目前 Schema 仅做了一层数据结构的描述。也就是说,Schema 是不嵌套的。它只能描述当前这一层的数据,深层结构数据是无法进行描述的(虽然你可以用 object 来表示)。

import { Schema } from 'tyshemo'

const PersonSchema = new Schema({
  name: {
    type: String,
    default: 'unknown',
  },
  age: {
    type: Number,
    default: 0,
  },
})

这就是一个 schema,创建一个 schema,你需要传入配置对象。对象的属性,就是所描述的数据的属性,属性值,则是该属性的描述。一个属性描述包含如下

setter 和 getter 是两个格式化函数,例如你要求当前属性必须是数字,但是在表单中会使用字符串输入,因此,在 setter 中将字符串转化为数字,就可以解决问题。setter 具有比较高的优先级,它会在 validate, ensure 之前发挥作用,这样你就不用担心本来想用 '12' 得到 12 时由于类型校验不符合要求而得到 default 了。

以上就是一个属性描述的所有配置项。在接下来的接口详解时,可能会涉及到,你可以继续往下读。一个 schema 实例上面的方法有如下这些。

has

用于判定是否需要某个属性。

if (PersonSchema.has('body')) {
  // ...
}

set

基于 setter 配置的格式化。

const formatted = PersonSchema.set('age', '12')

get

基于 getter 配置的格式化。

const formatted = PersonSchema.get('age', 12)

validate

用于校验某个数据是否符合 schema 的描述。它会调用配置中的 type 和 validators 选项进行数据校验。

const error = PersonSchema.validate(somedata, this)

注意它的第二个参数,当你的 validators 中的 validate 函数里面使用了 this,那么这个 this 将指向你这里传的第二个参数。例如:

const SomeSchema = new Schema({
  some: {
    type: String,
    default: '',
    validators: [
      {
        validate(value) { return this.hasChildren(value) },
        message: '"some" should have children',
      },
    ],
  },
})
const some = {
  hasChildren(value) { ... },
}
const data = { ... }
const error = SomeSchema.validate(data, some)

它也支持对单独一个属性及其值进行校验:

const error = SomeSchema.validate('some', value, some)

ensure

确保你提供的数据是符合校验的。它会调用上面的 validate 进行每个 key 的数据校验,校验失败时,会使用配置中的 default 作为默认值替换掉原来的属性值,并且使用 catch 配置选项抛出错误。

const data = {
  age: null,
}
const ensured = PersonSchema.ensure(data, some)
// 没有 name 属性,会使用 default 补充,age 属性校验失败,会用 0 作为默认值,所以,最后得到的是
// { name: 'unknown', age: 0 }

ensure 也支持第二个参数,目的是传递给 validate 校验函数使用。另外,ensure 也支持单独对一个属性进行处理。

const ensured = PersonSchema.ensure('age', age, some)

需要注意的是,ensure 方法虽然能保证你的数据是完全符合校验规则的,但这也依赖于你所提供的是合理的。例如,你的 type 为 String,但是你的 default 给的是 0,那么这明显不符合要求,但这没有在代码层面进行强制规定,而需要人为自觉遵守,除了 default,prepare 的返回值,compute 的返回值也是如此。

rebuild

用于重建数据。它会利用配置中的 prepare 来预处理数据,输出经过处理后的结果。

const rebuilt = PersonSchema.rebuild(data, some)

rebuild 只会利用 prepare 处理数据,不做其余的事。因此,执行完 rebuild 之后,你可能还需要执行 ensure 来确保那些不存在于传入的数据中的属性。另外,第二个参数是为了让 prepare 函数像 validate 函数一样使用 this。

formulate

用于规整化数据。它利用 drop 和 map 输出格式化后的数据。同时也支持第二个参数给 map 使用。

const formulated = PersonSchema.formulate(data, some)

digest

计算属性重新计算。TySheMo 采用脏检查机制完成计算属性。digest 会运行所有 compute 配置,计算新的属性值。

const computed = PersonSchema.digest(data, some, fn)

它的前两个参数和前面的方法一致,第二个参数是给 compute 配置函数使用的。难点在于,digest 支持第三个函数。这也是这些方法中唯一一个支持第三个参数的方法。

fn 的作用是,在每次该属性计算之后,重新调用的 hook。既然是 hook,说明它是为了在一些特定场景下才使用。不过,大多数情况下,你都需要传入 fn 来解决问题。为什么会有第三个参数呢?首先,我们为了让 digest 不对原始输入对数据有副作用,我们在 digest 过程中,不会对 data 做任何修改。但是这就带来了一个问题,如果计算属性中,依赖了 data 上的另外一个属性,而如果 data 不变动的话,compute 就得不到想要的结果。因此,这种情况下,我们需要传入第三个参数 fn 来修改 data。我们用代码来演示一下:

const SomeSchema = new Schema({
  name: {
    type: String,
    default: '',
  },
  age: {
    type: Number,
    default: 0,
  },
  height: {
    type: Number,
    default: 0,
    compute() {
      return this.age * 1.75
    },
  },
  weight: {
    type: Number,
    default: 0,
    compute() {
      return this.height * 8 / 13
    },
  },
})

上面定义了个 schema,其中 height 属性依赖 age 属性,weight 属性依赖 height 属性。接下来:

const some = {
  name: 'tomy',
  age: 10,
}
const computed = SomeSchema.digest(some, some)

试想一下会发生什么?在计算 weight 属性时会出错,从而使用了默认值 0. 为什么?因为对于 weight 的计算函数而言,this.height 永远都是 undefined,因为在 digest 内部,不会去修改 some。这时候怎么办呢?这时就要用到第三个参数了。

const computed = SomeSchema.digest(some, some, (key, value) => {
  some[key] = value
})

第三个函数在每次计算结束后得到新值时执行。也就是说,计算完 height 之后,重新执行了 some['height'] = value,那么在进行 weight 的计算时,height 就有了值。只有当 compute 配置函数中,不需要依赖其他动态值时,才完全可以不用考虑传入 fn,但是这种情况极少,因为如果不依赖动态值,那么在一开始的时候,就会把值塞到对象上。

如果你去阅读 Model 的源码,你还会发现,digest 的第二个参数和第一个参数没有绝对的一致性,因为 compute 中的 this 不一定指向第一个参数。

extend

扩展当前的 schema,返回一个全新的 schema。具体逻辑和 Dict 的 extend 相同,只不过这里传入的参数符合 Schema 的要求。

extract

从当前 schema 中提取出部分属性,返回一个全新的 schema。具体逻辑和用法都和 Dict 的 extract 相同。

Model

终于进入到真正的数据建模讲解中。数据模型(Model,本文简称“模型”)是关于业务的数据管理工具。把前端的数据想象成需要一个数据库来进行管理。当你的数据需要自己把控的时候,你需要怎么入手?你会涉及到哪些操作?在我看来,比较复杂的场景是:编辑表单。你需要从 api 读取数据,并且恢复到表单模型中,表单本身会需要自己的状态来维护,提交表单时需要校验数据,提交结果需要进行规整化(根据后天 api 的接收要求)。在这个过程中,对于本质其实只是一份数据,却要考虑 3 种状态,这种时候,通过 TySheMo 模型进行管理,会非常方便。

TySheMo 的模型具备如下功能:

Model 和 Schema 不同,Schema 不关心真实的数据,而 Model 管理真实数据。Model 基于 Schema 来做数据的模式,也就是说 Model 内管理的数据遵循 Schema 的描述。

创建模型

一个模型是对数据的定义,但 Model 类不是数据本身,而是数据的规定。我们创建模型并不是直接 new Model,而是通过 extends 关键字扩展 Model 类。这是一个需要稍微思考一下的过程,但是一旦理解其中的奥秘,就非常有意思。

Schema

创建模型需要重写 schema 方法:

import { Model } from 'tyshemo'

export class SomeModel extends Model {
  schema() {
    return { ... }
  }
}

创建一个继承自 Model 的类,并在 schema 方法中返回一个 schema 实例。有了一个模型之后,我们就可以实例化得到一个模型的实例,这个实例则是数据的容器。

const model = new SomeModel()

实例化的时候,你可以传入一个初始数据,如果不传,则会使用 schema 中的 default 打造一个初始数据。也就是说,模型实例化之后,立即拥有了一个符合 schema 规定的数据。我们可以用 model.data 读取模型实例的数据。

将 Schema 引入到 Model 中是 TySheMo(或者说 zawa 思想)最重要的一个点。因为有了 Schema,Model 中的数据不再是不可预测的。

创建一个 Model 原来如此简单。

数据校验

在 TySheMo 模型中,进行模型的数据校验非常方便。因为我们在 schema 中已经定义好了所有的校验规则,所以实际上,你只需要对上文中关于 Schema 的 validate 的部分进行了解,就可以知道 Model 的校验逻辑是怎样的。

validate

直接调用 Model 的 validate 方法进行数据校验。

const error = model.validate()

它不接收任何参数,直接对 model 的数据进行全量校验。

message

有的时候,你想知道某个字段的当前值是否不符合要求,你可以使用 message 方法:

const message = model.message('name')

特别是在一些表单的错误提示时,非常有用。

数据恢复

前文提到,围绕 Model 实际上有三种状态的数据。我们现在要对第一种数据进行处理,然后让数据变为第二种状态的数据。即我们像 model 中注入新数据,使 model 重新拥有一个全新的状态数据。

restore

恢复数据调用 restore 方法即可。这个方法接收新的数据。

model.restore(data)

它实际上用到了 schema 上的多个方法,保证了恢复的数据的完整性和可靠性。

parse

在后台 api 返回的数据和 model 进行数据恢复之间,有一个 hook 方法会被调用,即 parse 方法。你可以在创建模型时重写该方法,在 restore 一开始时,该方法会被调用。

class SomeModel extends Model {
  schema() {
    return { ... }
  }
  parse(data) {
    return {
      ...data,
      user: localStoreage.getItem('USER'),
    }
  }
}

parse 方法只能在创建模型时进行规定。在 restore 执行之前,parse 会被调用,得到一个新的数据之后在 restore。因此,你可以在 parse 里面做任何事情,特别是你知道你的 api 接口返回的数据需要进行结构或字段名调整的时候。

数据规整

将数据输出为另外一个个数,用于表单提交之类的。这是我在工作中积累出来的经验。在提交表单之前,你所需要的数据格式跟你实际在模型中管理的状态格式大部分情况下不同,前端和后段对数据格式的要求非常不一致,这很常见。通过 schema 中的配置选项,你都不需要动脑筋就可以得到一个规整后的数据。

jsondata

通过 jsondata 方法,你可以得到一个基于规整规则后输出的 js 对象。

const json = model.jsondata()

plaindata

通过 plaindata 方法,你可以得到一个将 jsondata 打平的数据。打平后的数据是只有一层的 key=>value 结构。

const plain = model.plaindata()

formdata

通过 formdata 方法,得到一个 FormData 的实例。

const formdata = model.formdata()

serialize

在利用 schema 中的规整规则格式化数据之后,你可能还需要再对数据进行一次转化,这时我们提供了一个 hook 方法,即 serialize 方法。它和 parse 有异曲同工之妙。
class SomeModel extends Model {
  schema() {
    return { ... }
  }
  serialize(data) {
    return {
      ...data,
      user: localStoreage.getItem('USER'),
    }
  }
}

这能保证你提交的数据,一定是按照后台 api 需要的。

parse 和 serialize 这两个 hook 的设计,让模型具有可调整性。当后台 api 返回的数据结构发生变化,或对你提交的数据提出要求时,你完全不必去修改业务中的代码,而是直接修改模型这两个 hook 中的逻辑即可。

使用数据

在你的业务中使用模型上的数据,实际就是要从模型读取数据。由于模型上数据是完全的 js 对象,是一种引用型数据,因此你获得数据之后,还能获得它的变化结果。虽然这和流行的 immutable 不同,但是很明显,有的时候我们需要这种效果。

data

你可以读取 model 上的 data 属性来获取数据。

const data = model.data

state

你可以读取 model 上的 state 属性来获取数据。

const state = model.state

在单纯使用数据这个点上,date 和 state 几乎没有差别。state 基于 Proxy,因此,需要你的浏览器支持 Proxy。

get

你可以使用 get 方法读取数据。

const data = model.get()
const some = model.get('some')

get 方法有一个优势,它可以使用连字符读取更深层级的属性。例如

const some = model.get('body.some')

而且,不会因为 body 不存在而报错。

在 schema 的各种配置函数里面,都推荐使用 this.get 获取模型上的数据。

更新数据

更新 model 的数据是一个比较复杂的操作。看上去不就是修改一个数据吗?但是,你要是考虑到具体的业务场景,你就会发现,太复杂了。我们不可能考虑到所有的场景,所以,我提供了以下方式。

update

类似 react 的 setState,update 是一个接收一个对象,批量更新,带事务管理的,异步的操作。完成数据更新之后,compute 和 watch 回调都会被执行。在短时间内多次调用 update,多次传入的对象会被合并,并且一次性更新,并且返回一个 Promise 实例。如果是 resolved 状态,表示没有问题,可以更新。但是如果是 rejected 状态,则表示更新的数据不符合校验规则,整个更新都不会被执行,数据不会发生任何变化。

model.update({
  name: 'lily',
  age: 12,
})

set

通过 set 方法更新和 update 完全不同。set 方法一次只能更新一个属性,且是同步的操作。在短时间内多次执行 set 会反复调用 compute 和 watch 回调,因此,可能消耗比较大的性能。所以,建议养成使用 update 的习惯。

model.set('name', 'lily')

set 支持第三个参数,表示是否要进行数据检查。在 set 的时候,如果发生校验失败,会通过 throw 抛出错误,程序会被中断。(默认不检查,也就是说,set 可以写入错误的不符合校验的数据。写入之后,你可以通过 message 方法得到错误。)

model.set('name', 'lily', true)

state

从 model 上读取 state 属性,得到 state 之后,直接更新 state 上的数据,可以起到和 set 一样的效果。它利用 Proxy,实现了和 vue 一样的操作习惯。

model.state.name = 'lily'

注意,state 修改数据调用 set 默认行为,即不会进行数据检查。

data

从 model 上读取 data 属性,然后直接修改 data 上的字段值。但是和前三种不同,data 属性被修改时,compute 和 watch 回调不会自动执行。此时,你需要手动调用空的 update 来触发。

model.data.name = 'lily'
model.data.age = 10
model.update() // 此时 update,是同步执行的,可能造成阻塞

当这样操作的时候,update 变成来同步执行的了。在进行 compute 和 watch 回调之前,会进行数据检查,如果检查失败,会抛出错误,并中断更新。

数据监听

和 angular 一样,你可以通过一个方法来监听某个属性值的变化。而且基于脏检查机制,你可以在 watcher 中更新数据而不用担心死循环问题。

watch

使用 watch 监听某个属性的变化,并且在监听函数中,可以更新另外一个属性。在监听函数中,推荐使用 get 和 set 操作数据。

model.watch('name', (oldValue, newValue) => {}, 1)

watch 支持第三个参数,代表这个监听函数的执行顺序,数字越小,越靠前执行。

unwatch

取消 watch 绑定的回调函数。

监听任何变动

使用 * 可以监听 model 数据的所有变动,只要数据变动,回调函数都会被执行。

model.watch('*', () => {})

结语

TySheMo 是一款全新的思考前端数据管理的类库。它主要解决的是在前端不同的使用场景下,如何抽象数据类型和规则逻辑,特别是在一些前后端数据耦合比较严重的系统中。使用 TySheMo 不仅可以解决数据校验问题,还可以解决数据的输入输出结构调整问题。当然,它还会有一些不足,如果你觉得这样的想法对你有一些借鉴意义,或者任何想法,欢迎在下方留言和我讨论。

2019-06-04

已有1条评论
  1. 高清壁纸 2019-06-22 15:11

    学习了