HelloType使用手册

完全符合ES语法的前端js运行时数据类型检查工具,从此对api返回的数据格式心中有数

前言

HelloType是一款我想强推的前端数据类型检查工具,因为越来越多的数据类型问题引起了前端的bug。即使已经有TypeScript、Flow等工具,但是还是无法解决运行时的数据类型检查,它们都是在编译时进行数据类型检查的工具。特别是在前端请求后端API,用返回的数据进行下一步操作时,经常遇到由于前端默认认为后端返回的数据格式是正确的,就直接进行一些数据遍历之类的操作,于是bug就出现了。

传统解决这类问题的办法是,在用到后端API返回的数据之前,对要用到的地方进行数据类型判断,在业务代码里面反复的去写,去判断数据类型是否符合我的需要,只有在数据类型满足操作之后,才进行下一步操作。但是,这样反复的在业务代码中写逻辑判断,会让大部分时间都浪费掉,而且最要命的是,你无法完全做到不遗漏的判断,总有时候会疏忽某个检查而导致bug。

我实在不能忍受这种情况的继续发生,所以,我写了HelloType,一方面是解决这一现实问题,另一方面也算是自己在js弱数据类型下的探索。因此,我希望写一个手册,不仅把怎么使用HelloType说清楚,也希望能把自己在这个过程中的一些思想讲清楚,这也可以为其他开发者提供一些思维上的借鉴。

基础概念

HelloType中的“类型”和编程语言里面的“类型”是有区别的,在HelloType里面的“类型”更多的是指一个容器,本质也就是一个js对象实例,这个实例拥有一些方法,利用这些方法,就可以判断一个变量所存储的数据是不是符合这个类型的要求。

在js语言中,基本数据类型只有number, boolean, string, null, undefined, symbol这几种,其他所有复杂的数据类型,都是由object这种类型扩展出来的,因此,js里面只有7种数据类型。但是,js里面却没有一种方法可以区分出这7种类型,使用最多的typeof除了这几种类型外,还会给出function,而null无法得到。还有坑是,NaN的数据类型是number。也就是说,在js里面没有绝对的办法确定某个变量的值一定是某个类型。为了解决这个问题,在HelloType中我重新对js的数据类型进行规整。

原型 prototype

和prototype概念一样,所谓原型,就是一个数据的本质。但是正如前面所说,js只有7种,因此我们进行扩展:

上面红色标记的是我扩展出的原型,需要通过import { Null, Undefined, Any } from 'hello-type'引入。除了上面列出来的一些原生的数据原型,js中其他没有列出来的原型也可以在这个列表中,只不过我这里列出了最常见的,其他任何原型都是可以用的,甚至包括Error,Promise等等。另外,我们还可以自己创建原型,就跟js里面创建一个带prototype的function,或者直接用class声明一个类一样,自己写一个类,然后以这个类为原型,那么基于这个原型的容器,在断言类的实例的时候,是可以通过的。

容器 Type

我们有了原型,并不能直接使用原型和具体的某个值进行对比,然后就得出这个值是不是符合这个原型对应的数据类型。我们需要借助一个数据类型容器。所谓数据类型容器,其实是说将原型本身包裹起来,这样,利用容器的统一方法,就可以进行类型判别(断言)。HelloType是怎么做的呢?就是提供了一个Type构造器,构造一个容器,例如:

const NumberType = new Type(Number)

这样,就用原型Number构造了一个容器NumberType,接下来就可以用这个容器对某个具体的值进行断言:

NumberType.assert(1) // 不会抛出错误
NumberType.assert('1') // 会抛出错误

我这里用了“断言”作为类型校验的动词,主要是因为我认为数据类型校验其实是一个非必须的动作,它不是业务逻辑的必须部分,而是我们附加的逻辑,但是在写代码时又不得不加入而已。数据类型校验先于所有业务逻辑,期望被校验的数据符合数据类型,当校验不通过时,容器可以发出阻断指令,因此,用“断言”也比较形象。

当然,容器还有其他方法,用以满足不同的数据类型检查需要。下文我会详细介绍容器的各个方法,这里就暂时做概念的介绍。

型式 pattern

如果仅仅有原型和容器,还不足以满足我们对数据类型的检查。因为我们在实际开发中会出现多种嵌套的数据形式,因此我们要有一种方式可以满足这种需要。

const SomeType = new Type({
  name: String,
  books: List([Object]),
})

上面红色部分不是原型,但为什么可以传给Type构造器呢?因为它被我称为型式(pattern),它是形式化的数据结构,这个结构将对数据进行约束。也就是说,如果你用SomeType去校验一个对象,那么对象必须有name、books两个属性,而且books属性值必须是数组。实际上,任何被传入Type构造器的都是一个型式,包括前面直接传Number,“Number”本身也被认为是一个型式。

规则 Rule

所谓规则,就是指一个值是否是我们需要的数据类型的确定方式。它跟在某个具体的属性名后面,作为该属性值的校验方式,当然,它也可以作为型式传入new Type:

const SomeType = new Type({
  name: String,
  age: IfExists(Number),
})
// String和IfExists(Number)就是name和age的规则

HelloType支持如下规则:

HelloType内置了几种基于Rule的规则:

比如,我们可以这样使用某个内置规则:

const SomeType = new Type({
 name: String,
 age: IfExists(Number), // 当obj.age存在时,它的值必须是一个数字,如果obj.age不存在,则忽略这个检查
})

我们可以利用HelloType提供的构造器创建自己的规则:

import { Rule } from 'hello-type'

const SomeRule = new Rule(function(value) {
  if (typeof value !== 'object') {
    return new Error('your passed value is not an object')
  }
})

这样你就可以像Number,String这样使用SomeRule了,Null,Undefined,Any这三个扩展原型都是这样出来的。

小结

在实际开发中,你根本不需要去思考这些概念,所有使用都凭着感觉走,只要记住几个禁忌即可,例如记住Lambda仅能用于对象的属性值,IfExists只能用于对象属性值、数组元素、Tuple的末尾参数。这些禁忌如果可能的话,我会在最后列出来。

快速入门

这一章基于对一个api返回数据的类型检查的场景,教你快速上手HelloType。

包的安装和使用

HelloType是一个npm包,你需要通过npm进行安装:

npm install --save hello-type

接下来在你的项目中使用这个包,它支持ES6、AMD的模块引入,也支持直接联入到HTML文档中使用。

// ES6
import { Type } from 'hello-type'
// CommonJS
const { Type } = require('hello-type')
// AMD or CMD
define(['hello-type'], function(hello_type) {
  const { Type } = hello_type
})

在HTML中直接使用时,你需要把hello-type/dist目录下的hello-type.js文件拷贝到你的本地目录:

<script src="hello-type/dist/hello-type.js"></script>
<script>
const { Type } = window['hello-type'] // 注意这里的使用方法
</script>

创建类型检查容器

在使用HelloType进行数据校验之前,你需要先进行数据类型的规定,通过创建一个容器,把规定固定下来。

const BookType = new Type({
  name: String,
  price: Number,
})

一般而言,一个项目里面会有很多这样的初始化工作,所以,为了方便项目管理,最好把所有这种形式的代码集中起来。目录结构如下:

|-types
|  |-person
|  |-book
|  |  |-book.type.js
|  |-benifit
|-controllers
|  |-shop.controller.js

如上,创建了一个types目录,用来保管所有的type相关文件,甚至为了区分,文件命名时使用了.type.js后缀。

// book.type.js
import { Type } from 'hello-type'

export const BookType = new Type({
  name: String,
  author: String,
})
// shop.controller.js
import HelloType from 'hello-type'
import { BookType } from '../types/book/book.type.js

export default class ShopController {
  sell(book) {
    // 在所有业务代码运行之前,用HelloType对方法函数的参数类型进行检查
    // 如果别的地方调用sell方法时,传入的book不符合BookType的规定,那么这里会直接抛出错误,方法无法继续执行
    HelloType.expect(book).toMatch(BookType)

    // 具体的业务逻辑代码
  }
}

对API数据进行检查

当我们通过ajax到服务器api接口拿到数据之后,并不能确保数据接口返回的结果一定是符合预期的,我们得在使用接口数据之前进行类型和结构断言:

import { Dict, List, IfExists, Type, HelloType } from 'hello-type'

export const NumberType = new Type(Number)
export const BooksType = List([
  IfExists({
    name: String,
    age: Number,
  })
])
export const BooksResType = Dict({
  code: Number,
  data: BooksType,
})
async function getBookDetail(id) {
  HelloType.expect(id).toMatch(NumberType) // 对函数的输入进行类型断言
  
  let res = await fetch(apiUrlBase + '/books/' + id)
  let data = await res.json()
  HelloType.expect(data).toMatch(BooksResType)
  
  // 由于对api接口返回的数据进行了断言,如果程序能够执行到这里,那么说明api返回的数据符合要求,下面的操作不必有任何担心

  let books = data.data
  books.forEach((book) => {
    // ...
  })

  HelloType.expect(books).toMatch(BooksType) // 对函数的返回值进行类型断言
  return books
}

这样就完成了对API返回数据的类型断言,由于这是一个async函数,因此,当断言中报错时,会阻止程序继续往下执行,并抛出错误,在使用函数时,可以通过catch获取到抛出的错误内容,并对错误内容进行解读(例如上传到监控系统)。

看上去是不是很简单?看不懂?没关系,关于Dict、List,以及Type里面套用等,会在后文一一详解,只要继续阅读就能理解了。

Type

利用Type可以创建一个容器,它是一个构造器,也是一个ES6的class,因此在使用时要用new去实例化。它没有任何配置项,每一个传入的值,都是一个型式,型式可以是任意内容,它会按照一定的规则进行型式的格式化,形成规则体现。

constructor

要使用Type,只能通过new关键字去实例化一个实例出来,得到的就是一个类型容器。

const NumberType = new Type(Number)

上面这段代码就创建了一个基于Number的类型容器,它用来检查一个值是不是一个数字。由于js的一些坑,例如NaN的typeof也是number,所以我对这些情况都进行统一:

上面这四种类型都做了强制规定,因此,你在用它们的时候,真的就跟它们的字面意思一样,不会产生副作用。在HelloType的体系里,所有的原型在上一章已经说过了,这里就不再赘述。

HelloType希望用js里面最简单直接的思维解决数据类型问题,对一个数据进行类型断言时,我没有发明一种新形式来表示类型,因为类型是枚举不完的,我看到有一个和hello-type目的相同的库,它通过字符串来表达类型,然而,在它的issue列表里,很多都是对这种字符串来表示类型的一些疑问或希望修改类型名等。而HelloType用原生的类型原型作为类型的基础,几乎不需要做任何扩展,就可以把js里面所有类型囊括进来,这就是所谓以不变应万变吧。

new Type支持同时传入一组型式,这样可以一次性对多个数据进行校验。这对于有多个参数的函数的参数校验非常有用:

const ParamsType = new Type(String, Number, Boolean)
ParamsType.assert('tomy', 10, true)

这种一次打包一组数据,但又不是数组的数据形式,我把它扩展为了Tuple(元组),下文会详细介绍。

传入new Type的型式是你最直观的一种书写方式,比如一个对象结构,或者一个数组结构:

const SomeType = new Type({
  name: String,
  detail: {
    title: String,
    content: String,
  },
})

虽然内部而言,它其实是一个Dict嵌套的结构,但是实际上,在外表上看,它就像一个普通的对象一样。Dict是字典结构,下文会详细解释。在构建容器的时候,容器本身,只能对object的第一层数据进行校验,当有嵌套数据的时候怎么办呢?把被嵌套的型式转化为一个Dict容器,这样,虽然容器本身只校验第一层,但是当校验到规则为一个容器的时候,它会利用容器,对该属性的值再次进行校验,而这个时候,这个二级容器,又可以去校验下一级的对象。正是因为实现了这个机制,所以HelloType几乎可以实现无限层的校验,虽然这是不允许的。虽然我这里不能把原理讲明白,但是你只要按照你以为的方式去用就好了,并不会有问题。

assert

assert方法是Type容器的最重要方法,也是所有HelloType方法的基础。当你完成Type实例化,创建了一个容器之后,这个容器实例就自动拥有了一些方法,assert方法就是需要介绍的第一个方法。

assert方法用来断言接收到的变量数据是否符合当前容器所对应的数据类型和数据格式:

const person = {
  name: 'tomy',
  age: 10,
}

PersonType.assert(person) // 断言person符合PersonType的定义

如果person不符合PersonType的定义,assert会throw TypeError,而在js里面,被抛出的错误,可以被window.onerror监听到,这非常有用,对于前端异常的监控,或者一些特别的逻辑非常有帮助,因此,我才选择throw error的形式抛出错误。Error还能记录代码的stack信息,可以帮助我们快速定位错误位置。

assert接收的参数个数,和new Type时接收的参数个数必须相同,如果不同,也会抛出错误。因此,对参数个数具有严格的要求,你不能用assert一次性校验多个数据,只能反复调用assert来校验。

另外有一点需要注意,HelloType只会在遇到第一个断言失败时跳出断言,而不会遍历所有断言。这也就是说,当你assert抛出错误时,并不代表该数据只有一个地方不符合类型,在你修复了bug之后,需要继续关注,在后面的断言里,还可能出现不符合类型。例如:

const SomeType = new Type({
  name: String,
  age: Number,
})
const some = {}
SomeType.assert(some) // 在对name属性值进行断言的时候抛出错误,并终止断言。但是你会发现,其实age属性也不符合要求。

test

assert方法会通过throw TypeError抛出错误,这可能不是你想要的,你可能只想看看这个数据是否符合规定,如果符合返回一个true,不符合返回一个false。test方法就是做这件事的。

let bool = PersonType.test(person) // true
if (!bool) {
  alert('person does not match PersonType')
}

catch

test方法虽然可以让你判断,但是你希望还能获取到具体的错误信息,catch方法就可以帮助你捕获错误而不抛出任何错误。

let error = PersonType.catch(person)
if (error) {
  console.log(error.stack)
}

catch方法会把捕获到的错误返回,如果没有错误发生,就返回null。

track.with

不过有的时候,你并不希望HelloType终止你的程序,但你又不想用catch,多加一层判断,这个时候,你可以使用track.with,异步记录错误。

PersonType.track(person).with(fn)

使用这个语句,本身不会让程序终止,它在断言之后,如果发现不符合类型,就会执行fn,并把error传给fn。不过,如果在fn中抛出错误就看你自己了。利用track.with,你就可以在不影响函数的正常执行的情况下,还可以抓取到数据类型错误信息。

trace.with

track和trace的区别,在英文里面是这样的,track追踪的是从起点到当前点的线索,而trace是从当前点开始往后跟踪。假如你是一个侦探,正在怀疑一个人是凶手,你在不停的找他的犯罪证据或者线索就是track,而你在他身上偷偷安装跟踪器进行跟踪就是trace。在HelloType体系里,trace.with和track.with的用法是一模一样的,只不过执行的进程不同。track的前半部分是同步的,会阻塞进程。而trace会完全异步执行的,也就是说它会在异步执行时才会去检查数据的类型错误。这也就是说,如果你在函数的执行过程中如果改动了数据,trace是先不管的,只有当函数中的同步进程执行完了,trace才会对数据进行检查,而这个时候,trace检查的是修改过的数据,因此,在使用trace.with的时候一定要注意这点。

之所以提供trace和track,主要是从性能上考虑,track是同步执行的,对性能有所影响,而trace是完全异步执行的,因此不仅不会由于抛出错误而终止进程,而且还把对数据对判断放到末尾去做,因此对界面的渲染一点影响都没有。不过最惨的地方在于,如果你在代码中修改了数据,那trace得到的结果可能就跟你预期的不同了。因此使用的时候一定要注意。

严格模式

在进行断言的时候,默认情况下,HelloType会使用兼容模式。当断言一个对象的时候,当一个对象里面有一个属性是type规定的对象里没有规定的时候,不会产生任何问题,(type里有的一定会校验,)也就是忽略不校验。而如果是严格模式,则要求对象的属性和type规定的一模一样,如果被检查的对象多了一些属性是type里面没有规定的,那么也会报错。

之所以提供严格模式,也是想要提供一种选项,帮助数据类型检查。

那么怎么开启严格模式呢?有两种方式:

PersonType.toBeStrict()

将PersonType切换为严格模式。

let StrictPersonType = PersonType.Strict

返回一个拷贝,而这个拷贝是严格模式的。strct和Strict是一模一样的,你也可以用someType.strict,只是为了让使用的时候,根据不同开发者的习惯,提供大小写区别的方式。

为什么要提供两种方式呢?因为切换模式是直接让当前容器进行改变,也就是说,如果你在其他地方也使用到了该容器,那就会有影响,而strict则不会改变当前容器,而是返回一个新容器,只不过这个容器是严格模式的。大部分情况下,推荐使用strict来获取一个新容器,防止对其他实例造成影响。

严格模式会自动继承给被嵌套的容器,例如:

const SubType = new Type({
  name: String,
})
const ParentType = new Type({
  name: String,
  child: SubType,
})

ParentType.toBeStrict()
PerentType.assert({
  name: 'jhon',
  child: {
    name: 'tomy',
    age: 10,
  },
})

上面的校验会抛出错误,因为child.age设置了值,而严格模式下,被嵌套的容器也会转化为严格模式后再使用,因此,你不用自己手动嵌套一个严格模式的容器。而且,这种情况,SubType自身不会被切换为严格模式,内部使用的是SubType.Strict,因此你可以放心使用。

扩展数据类型

js的基础数据类型显然不够用,所有的复杂数据都基于object进行扩展,所以到最后instanceof Object都是true。我希望通过HelloType,对js的数据类型扩展,在进行数据类型检查的时候,让数据类型有所归属。基于这种想法,我扩展出了5种,但实际上,基于已有的原材料,你可以扩展出自己的数据类型(容器)。

Dict 字典

实际上,我在HelloType里面对一个对象进行检查时,只会对真正的对象进行检查,也就是说,一个function、一个类的实例,虽然它们在js里面都是对象,但是实际上,在HelloType里面它们会被认为是自己真实的本质,而不是一个对象。而Dict就代表对象本身。

在Type的基础上,所有扩展类型都做了强规定,这样保证这些类型的特殊性。

const DictType = Dict({
  nage: String,
  age: Number,
  children: [IfExists(Object)], // IfExists是一个内置规则,下文会更详细的讲
})

这样就得到一个Dict的容器。它和一个new Type的普通容器是一样的,只是在它的基础上加了一些强制而已,其他没有区别。

List 列表

List对应数组,它规定一个数组的每个位置上元素的类型。

const ListType = List([String, Number])

List的校验规则如下:

上述最后一点,当超出长度时,比如用上面的ListType去校验['tomy', 10, true]就会不通过,第二个元素后面的元素必须是String或Number。

Enum 枚举

枚举在js里面本身是没有的,我扩展出来,在HelloType体系里非常有用。它很简单,要求要校验的值,会用规定的几个规则中的一个去校验:

const EnumType = Enum(String, Number, Boolean)

那么在校验的时候,被校验值必须是这三种类型之一。

不过一般而言,很少会这样用,因为我们并不希望一个值是多类型的,我们更多的用法是:

const ColorType = Enum('blue', 'red', 'yellow')

前面讲过,具体的某个值也是一种规则,要求被校验值必须与该值相等。在Enum中就非常明显,ColorType去校验的时候,值必须是这三个值中的一个,否则就会不通过。

枚举是一个超级好用的类型,它可以帮助你实现各种花式操作,例如:

function link(a, b) {
  return a + b
}

这个函数,乍一看是个加法运算,但是在js里面,字符串连接也是这样,所以,这就比较麻烦了,两种数据类型都是允许的,但是假如,你只想它是其中的一种,你可以这样:

const ParamsType = Enum(Tuple(Number, Number), Tuple(String, String))

这样可以防止用户在用的时候,传入一个字符串和一个数字。

Tuple 元组

元组是指一组散列的数据,按照一定的顺序组成一组。它非常适合用来校验函数的参数。

const TupleType = Tuple(String, Number)

它非常使用函数参数校验,因为有的时候,你并不知道函数参数会传多少个,你会这样做:

function fun(...args) {
  TupleType.assert(...args)
}

因为Tuple可以校验一组值,而非一个值,所以它很适合这种校验。它的校验规则如下:

如果Tuple只接收一个参数的话,和new Type看上去没两样,但是,在兼容模式下,Tuple支持IfExists,而new Type不支持。这时,如果Tuple末尾的IfExists会被兼容,也就是说在参数个数校验时,会特殊处理,而new Type必须是强对应。下文会更详细介绍IfExists。

const SomeType = Tuple(String, Number, IfExists(Boolean))

注意,如果不是尾部的IfExists,它会被强制校验。

Range 域值

Range代表一个数字区间,要校验的值首先得是数字,其次得是出于这个区间的值,区间包含传入的起始点和结束点。

const RangeType = Range(0, 100)

上面的RangeType要求被校验的是数字,且必须处于[0, 100]之间(包含0和100)。

自定义类型

你也可以写出自己的自定义类型,其实构建一个自定义类型非常简单,你可以查看Dict的源码,它干了两件事,一是规定了函数传入参数的限制,另一件事是创建一个容器,这个容器具有特殊的校验规则,并给它赋一个name。例如,我现在要扩展出一个只允许包含两个元素,且都是数字的数组的类型:

function Position() {
  let type = List([Number, Number]).toBeStrict()
  type.name = 'Position'
}
const PositionType = Postion()

PositionType.assert([10, 100]) // ok
PositionType.assert([1, 1, 1]) // throw TypeError
PositionType.assert(['tomy', 10]) // throw TypeError

利用已有的各种素材,可以创建任何类型。

Rule

规则是HelloType里面对一个值进行校验时,真正用来作为对比基础的东西。也正因为这样,规则是最千变万化的,前文已经说了哪些东西可以作为规则。不过显然,有穷的列举还是无法满足所有需求,有的时候你需要自定义规则,这个时候你就需要用到Rule。

constructor

Rule是一个构造器,使用时你需要先引入:

import { Rule } from 'hello-type'

它接收一个参数,这个参数是一个函数,函数可以包含一个参数,这个参数就是要校验的值。这个函数可以返回任意内容,但是,当你校验失败时,必须返回一个Error/TypeError实例,当HelloType发现你返回的是Error的实例的时候,就认为校验失败,并抛出错误:

const MyRule = new Rule(function(value) {
  if (typeof value !== 'object') {
    return new TypeError('%arg is not an object')
  }
})

非常简单。不过它也可以接收两个参数,第一个参数可以是一个字符串,用来给这个Rule命名:

const MyRule = new Rule('ObjectRule', function(value) {
  if (typeof value !== 'object') {
    return new TypeError('%arg is not an object')
  }
})
MyRule.name // ObjectRule

=== 注意:以下内容有点危险 ===

Rule构造器还可以接收第三个参数,但是这个参数有点危险,请谨慎使用。第三个参数用来覆盖当前属性的值。它仅对对象属性、数组元素生效。

const MyRule = new Rule('ObjectRule', function(value) {
  if (typeof value !== 'object') {
    return new TypeError('%arg is not an object')
  }
}, function(error, target, prop) {
  if (error) {
    target[prop] = {}
  }
})

上面这段代码创建了一个rule,它会检查一个值是否typeof为object,如果不是就抛出错误,但是如果当前检查的值是一个对象的属性,或者一个数组的元素,它还会继执行第三个参数的函数,而不会立即终止进程,执行完第三个参数的函数之后,再回到第二个函数参数重新进行校验,如果校验通过了,则不会报任何错。

第三个函数有三个参数

但是你应该注意到,在第三个函数中,我使用了target[prop] = {},直接修改了原始数据中的内容,这一点你要千万小心,这个操作修改了原始值,因此你一定要在知道发生了什么的情况下使用。

Null、Undefined、Any

在最前面的章节里我已经介绍到了这Null、Undefined、Any这三个被扩展出来的原型。

Null

要校验的值必须是null。

Undefined

要校验的值必须是undefined。

Any

要校验的值可以是任何类型。

这三个扩展原型实际上是通过Rule扩展出来的,它们的源码非常简单:

export const Null = new Rule(value => value === null)
export const Undefined = new Rule(value => value === undefined)
export const Any = new Rule(() => null)

你再结合上面Rule的使用方法去看,就非常容易理解了。

内置规则

我在HelloType中内置了几种规则,把常见的一些情况考虑进去,当然,考虑的不够全面,如果你有认为需要改进的地方,可以给我提出需求,我可以根据情况,把你提出的规则内置进去。

IfExists

这一些情况下,你希望有些值是可选的,比如一个对象的某个属性,你希望如果这个属性存在,就校验它,如果不存在,就直接略过。数组也是一样,你希望一个数组如果是空数组,就直接略过,如果这个数组有元素,那么要求它每个元素都是一个字符串。这样的需求比较常见。

那么怎么做到呢?就要借助IfExists。

const SomeType = Dict({
  name: String,
  age: IfExists(Number)
})

严格模式下的时候,要特别注意,IfExists会失效,因此,如果你用SomeType的严格模式,age必须是一个数字。

在Tuple中使用IfExists有特殊性,Tuple只会让末尾的IfExists生效:

const SomeType = Tuple(String, IfExists(String), Number, IfExists(Boolean), IfExists(Boolean))

上面有三个IfExists,但只有末尾两个会生效,第一个IfExists会被严格校验。

InstanceOf

这个规则用于校验被校验值是否是传入值的一个实例。

在HelloType中String,Number,Boolean,Object都做了强制,因此,当你想要判断一个值是否是new String出来的怎么办呢?这个时候就得借助InstanceOf:

const StringType = new Type(InstanceOf(String))
StringType.assert('xxx') // throw TypeError
StringType.assert(new String('xxx')) // ok

不过InstanceOf其实在其他类、实例之间没有多大用,你可以直接对其他实例做检查,而不需要借助InstanceOf。

Equal

在HelloType的校验中,虽然会先校验一个值是否与规定的值相等,然后再考虑其他校验规则。而String,Number,Boolean,Object都被做了强制,因此,如果你这样做:

const SomeType = new Type(String)
SomeType.assert(String) // throw TypeError

只会得到一个错误,所以,在这种情况下,默认的相等规则失效了,这个时候就需要借助Equal:

const SomeType = new Type(Equal(String))
SomeType.assert(String) // ok

IfNotMatch

当一个值不符合你的预期的时候,你想给它一个默认值怎么办呢?例如,你希望api返回的children是一个数组,但是api返回给你null,你想把它调整为一个空数组,怎么办呢?用IfNotMatch可以办到。

const SomeType = Dict({
  code: Number,
  data: {
    name: String,
    children: IfNotMatch(List([Object]), [])
  },
})

虽然上面的这句话很像IfExists,但是并不是,IfExists是允许children不存在,而IfNotMatch要求children必须存在,如果不存在,或者说它不满足List([Object]),就会用一个空数组去代替。

前面说过Rule的第三个参数,IfNotMatch就用了这第三个危险的参数,它会修改你的原始值,因此在使用的时候要特别小心。

Lambda

这个规则非常微妙,虽然我不推荐你使用它,但是我希望你知道它的思想,这样你可以更好的使用HelloType。Lambda会检查当前值是否是一个函数,如果是函数的话,会重写这个函数,并且用输入和输出规则进行校验。

const SomeType = Dict({
  fn: Lambda(Tuple(String, Number), Object),
})

这个不能多讲,毕竟它是一个比较危险容易出bug的规则,你应该学习它的思想,去github上读它的源码。

Validate:自定义消息

这个规则更适合用来做自定义消息,它非常有利于降级HelloType的概念,让基础开发者不必深入了解HelloType的理念,从业务中逐渐过度HelloType的使用方法,我们来看一个例子:

let SomeType = Dict({
  name: Validate(String, '名字必须为字符串'),
  child: Validate(Any, 'child字段必须填写'),
  age: Validate(value => typeof value === 'number' && value > 10, '年龄必须大于10'),
})

上面规定了一个对象的三个字段的类型,在具体业务中,这个对象可能是表单要提交的对象,利用这种方式,可以很好的做表单检查:

function onSubmit() {
  let data = this.formdata
  let error = SomeType.catch(data)
  if (error) {
    this.toast.error(error.message)
  }
  // ...
}

上面这段绿色的代码进行了数据检查,当用户点击提交按钮的时候,SomeType检查数据,并返回错误提示(只能提示到出错的第一条数据,当用户修改之后,再点提交,如果还有错误,又会提示)。

Validate函数的参数如下:

HelloType辅助器

前面的内容基本上已经囊括了HelloType类型检查的所有内容,你可以利用上面的内容编程了。但是有一个问题,上面的使用方法太过分散,如果你要进行代码升级的话,非常麻烦。因此,我提供了一堆辅助器方便统一代码风格,这样,在用编辑器进行全局搜索时,就很有用,可以快速定位到代码位置。

所有辅助器都以HelloType打头,例如:

HelloType.expect(obj).toMatch(SomeType)

这样,你搜索HelloType.expect,就可以找到所有断言代码了。

expect.to.match

它提供对某个目标值进行校验的方法。HelloType.expect.to.match是assert方法的别称,例如:

HelloType.expect(obj).to.match(SomeType)
// 等同于:
SomeType.assert(obj)

这两者是一样的。虽然前面一句的句子长很多,但是这主要是为了代码管理的方便。而且还有好处,先卖关子,后面会讲到。

catch.by

HelloType.catch.by是catch方法的别称:

var error = HelloType.catch(obj).by(SomeType)
// 等同于:
var error = SomeType.catch(obj)

track.by.with

HelloType.track.by.with是track.with的别称:

var error = HelloType.track(obj).by(SomeType).with(fn)
// 等同于:
var error = SomeType.tracked(obj).with(fn)

trace.by.with

HelloType.trace.by.with是trace.with的别称:

var error = HelloType.trace(obj).by(SomeType).with(fn)
// 等同于:
var error = SomeType.trace(obj).with(fn)

is.typeof

HelloType.is.typeof是test的别称:

var bool = HelloType.is(SomeType).typeof(obj)
// 等同于:
var bool = SomeType.test(obj)

decorate

ES7提供了装饰器的能力,用以对一个类及其成员进行装饰。这部分内容,对于比较熟悉的开发者应该不陌生,如果你不熟悉,不使用也是没关系的。

with

HelloType.decorate.with可以直接装饰类,也可以装饰属性成员:

@HelloType.decorate.with(ParamsType)
class A {
  @HelloType.decorate.with(ObjectType)
  obj = {}
}

上面这段代码,会在new A的时候,对constructor的参数进行校验。

input.with

HelloType.decorate.input.with可以装饰方法成员,校验它的输入值:

class A {
  @HelloType.decorate.with(FunctionType)
  @HelloType.decorate.input.with(ParamsType)
  say(msg) {}
}

output.with

HelloType.decorate.output.with可以装饰方法成员,校验它的返回值:

class A {
  @HelloType.decorate.with(FunctionType)
  @HelloType.decorate.input.with(ParamsType)
  @HelloType.decorate.output.with(ReturnType)
  say(msg) {}
}

小结

with后面除了可以直接传Type实例之外,还可以传一个函数,因为有的时候你不想直接用容器去校验,而是有一定逻辑的去校验。函数怎么用呢?

@HelloType.decorate.input.with((...args) => {
  ParamsType.assert(...args)
})

总之,在你知道装饰器的情况下使用HelloType.decorate,会让你的代码减少侵入性。

define

HelloType.define的功能是,将一个对象转化为规定的类型对应的对象,在修改对象值的时候,会进行值的类型检查。例如:

const ObjType = Dict({
  name: String,
  age: Number,
  sub: {
    key: Object,
  },
})
let obj = HelloType.define({}).by(ObjType)

这需要注意,define不会修改原始值,而是先克隆一个备份之后再赋值给obj。define的结果会自带规定的那些属性,例如obj.sub.key已经有了,虽然是undefined,但是不会报错。

经过define之后,如果你进行赋值obj.sub.key = 10就会报错,因为obj.sub.key必须是一个object。这个时候,你可能还会用上Lambda,可以往上翻看它的具体方法。

其他方法

slient/Slient

安静模式,仅对HelloType.expect.to.match有效,直接使用容器不会生效。所谓安静模式,意思是不通过throw TypeError终止进程,但是抛出错误。

开启安静模式下,你还可以通过bind绑定一个函数,用以收集错误。先看下开启安静模式:

HelloType.slient = true
HelloType.expect(obj).to.match(SomeType) // 如果不匹配,不会报错中断进程,而是console.error打印错误,且不会阻断程序

从这点上讲,只有expect.to.match、define里面的不通过情况下可以使用slient模式。因此,你在想使用安静模式的时候,要选对方法。当安静模式开启时,expect.to.match只会返回true/false,而不会阻断进程,因此,你也可以利用这点做些动作。

Slient和slient是一模一样的,只是为了编程习惯,提供大小写区分而已。

注意:安静模式是全局的,一旦你设置了HelloType.slient为true,其他任何你使用HelloType.expect.to.match的地方都会被改变为安静模式,因此,你必须确定是不是要全局开启安静模式。如果不是,建议你从正确的HelloType方法中挑选使用。

bind

当你使用辅助器的时候,有的时候,你想传入一个函数,用来捕获任何可能出现的报错。这个时候,你可以使用bind来绑定一个函数:

const fn = (error) => {
  saveToCacheStore(error)
}
HelloType.bind(fn)

当你在使用expect.to.match或者使用define的时候,运行过程中由于数据和类型不匹配抛出错误时,但fn会运行。这样,无论你是否在安静模式下,都可以获得错误信息,并把它记录在本地,以备之后做分析。

另外,trace和track辅助器原本是要传入一个fn到with中,用来抓取错误,而如果你使用bind绑定了函数,就可以不用传with也可以收集到错误信息。

unbind

unbind是bind的反函数,用以直接解除fn的绑定。解绑之后,抛出错误时,这个函数就不会执行了。

HelloTypeError

这是整个HelloType里面最隐性的一个部分,开发者在默认情况下不会用到HelloTypeError,但是实际上,每次接收到HelloType默认抛出的错误时,错误的类型都是它。HelloTypeError是基于TypeError的错误对象扩展,它具备非常高级的功能,因此,在自定义一个Rule的时候,建议返回HelloTypeError的实例,这样才能发挥HelloType的最大功能。

引入和使用

想要使用HelloTypeError很简单,通过包引入即可:

import { HelloTypeError } from 'hello-type'

这样之后,你就可以像使用js原生的Error一样使用HelloTypeError。

let err = new HelloTypeError(message)

秘密

HelloTypeError比任何Error类型都更加强化,它拥有更为复杂的结构,但仍然保持Error的基本结构。那么接下来,让我们来看看它都拥有哪些神秘的东东。

traces

和普通Error不同,HelloTypeError是为HelloType服务的,在HelloType中,必须追踪到错误发生的具体路径,因此,当一个错误产生时,追踪信息就至关重要。而这些追踪信息,不仅能得到错误发生时的基本信息,而且具体是什么值发生了错误,都可以通过检查error.traces得到。有兴趣的你,可以打印出来看看。

addtrace

但是,如果一个普通用户,想要独立使用HelloTypeError时,就可以使用error.addtrace来在traces链中添加一个追踪记录,添加使用的是unshift,因此新增的记录被加在整个链条的最前面。这是由于我们在使用traces的时候,其实它是一个自下而上冒泡的过程。总之,最后,traces这个数组记录了从错误发生的位置,到最外层的追踪,以此可以知道到底是型式的哪个位置发生了问题。

addtrace的参数是任意的对象,它只是用来作为记录的,如果是脱离HelloType独立使用,开发者可以自己规定一种对象模式。

err.addtrace({
  key: 'name',
  value: 'tomy',
})

summary

而将所有的traces整理之后,就是summary。你可以通过直接读取err.summary来得到整理后traces的完整信息,这些信息是从下往上冒泡,用上层信息覆盖下层信息后得到的。另外,它有两个非常有用的属性,keyPath和stack。keyPath用来获取相对于被验证对象,发生错误的位置。stack是最底层到最顶层的错误堆栈信息。

console.log(err.message + '\n' + err.summary.stack)

message

而message和普通的Error不同,普通的Error传入的message就是最后Error抛出的错误信息。但是HelloTypeError的message高级很多,它支持{插值}。

let err = new HelloTypeError('{name} is wrong.', {
  name: 'tomy',
})

而最终message内容展示出什么内容,还要看后面的addtrace传入的值。也就是说,最终的summary才是message的值的来源。

这就非常好玩了,有了这样的功能,那么message的内容就可以根据trace信息发生变化。

因此,在创建一个error的时候,最好是可以提前通过插值来安排你的消息。

translate

不过,一般情况下,HelloType抛出的错误,都是提前预设好了message的。下文会讲怎么修改默认的message,但是,如果你在得到一个error的时候,又不想用它默认的message,想在特殊情况下,提供特殊的message信息。这个时候,可以使用error的translate方法:

let msg = err.translate('{keyPath}不在{target}中。')

这样,你就可以利用error的summary来得到一个新的message信息,在特殊情况下,返回特殊文本体现。

具体的案例,你可以看下我github上examples目录下的例子。

messages

修改HelloType内置的错误提示语。HelloType的所有错误提示语位于error.js里面,你可以阅读源码,查阅所有可以修改的提示语信息。通过messages方法,传入一个对象,用来覆盖默认提示语。提示语里面支持{插值},但是每个提示语的插值是固定的,请查看源码了解。

HelloTypeError.messages.enum = '{args}不符合枚举类型{name}({rules}),请从枚举列表中选择。'

你也可以打印出HelloTypeError.messages来看看messages里面到底都有些什么,以此决定都要修改什么。

结束语

以上就是有关HelloType的所有使用方法,当然,有一些隐性的东西我不会透露出来,因为有些方法并不常用,或者不应该用,有兴趣的读者可以自己去阅读源码是怎么实现的。

HelloType是我花了一个周末时间写完的,但后面陆续又花了几周把最终的形态确定下来,可以说是这段时间在api数据检查过程中拼命挣扎的结果。

我在自己的博客中提到过,开源的真正意义,不是在于提供一款免费的产品,而是在于提供一种问题的解决思路,给其他人去参考。都说开发中不要重复造轮子,但是由于开源协议,或者具体某个需求的不确定性,改造轮子是很正常的事,我觉得“不要重复造轮子”是说,不应该为了解决前人已经解决过的问题,从零开始思考,构建一套自己的实现,而应该站在巨人肩膀上,直接把前人解决问题的思维方式甚至思路借鉴过来,在此基础上,再去实现具体需求。一味的拒绝使用别人的代码(思路)或一味的否定拒绝使用别人的代码我认为都是不合理的。最近红芯浏览器内核的事闹的沸沸扬扬,我觉得可能是整个业界的一种自卑心态。实际上,任何一项技术,都是站在前人的肩膀上获得成功,chrome内核也用了很多别人的东西,做一款基于Chrome内核的浏览器是没有什么错的,只不过在推广的时候,打上一些“首款自主研发”这样的词汇,不免让人觉得是在骗国家的钱。好了,这种吐槽也没有太大意义,只希望读者能够从HelloType项目中得到一点启发,根据自己的需要获取需要的部分。

2018-08-26