4

用 typescript 写一个工具函数库

 3 years ago
source link: https://www.infoq.cn/article/r58umq8iJZGLA1wW3teU
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

技术点介绍

  • 工具函数的复杂类型的声明(难点)

  • 用 ts-mocha + chai 做单元测试

  • 用 ts + rollup 打不同模块规范的包

前言

先看一段代码

const {name = 'xxx', age} = { name: null, age: 18}console.log(name);

复制代码

name 输出的是 null,因为解构赋值的默认值只有当值为 undefined 时才会生效,这点如果不注意就会引起 bug。我们组内最近就遇到了因为这点而引起的一个 bug,服务端返回的数据,因为使用了解构赋值的默认值,结果因为值为 null 没有被赋值,而导致了问题。

那么如何能避免这种问题呢?

我们最终的方案有两种,第一种服务端返回数据之后递归的设置默认值,之后就不需要再做判断,直接处理就行。第二种是当取属性的时候去做判断,如果为 null 或 undefined 就设置默认值。为了支持这两种方案,我们封装了一个工具函数包 @qnpm/flight-common-utils。

这个工具包首先要包含 setDefaults、getProperty 这两个函数,第一个是递归设置默认值的,第二个是取属性并设置默认值的。除此之外还可以包含一些别的工具函数,把一些通用逻辑封装进来以跨项目复用。比如判空 isEmpty,递归判断对象和属性是否相等 isEqual 等。

因为用了 typescript,通用函数考虑的情况很多,为了更精准的类型提示,类型的逻辑写的很复杂,比实现逻辑的代码都多。

使用

npm install @qnpm/flight-common-utils --save --registry=公司npm仓库

复制代码

或者

yarn add @qnpm/flight-common-utils --registry=公司npm仓库

复制代码

实现工具函数

这里只介绍类型较为复杂的 setDefaults、getProperty。

setDefaults

这个函数的参数是一个待处理对象,若干个默认对象,最后一个参数可以传入一个函数自定义处理逻辑。

这个函数的参数是一个待处理对象,若干个默认对象,最后一个参数可以传入一个函数自定义处理逻辑。

function setDefaults(obj, ...defaultObjs) {}

复制代码

希望使用时这样调用:

setDefaults({a: {b: 2}}, {a: {c: 3}} );// {a:  {b: 2, c: 3}}

复制代码

这里的类型的特点是函数返回值是原对象和一些默认对象的合并,并且参数个数不确定。所以用到了函数类型的重载,加上 any 的兜底。

type SetDefaultsCustomizer = (objectValue: any, sourceValue: any, key?: string, object?: {}, source?: {}) => any;

复制代码

SetDefaultsCustomizer 是自定义处理函数的类型,接受两个需要处理的值,和 key 的名字,还有两个对象。

然后是 setDefauts 的类型,这里重载了很多情况的类型。

function setDefaults<TObject>(object: TObject): TObject;

复制代码

如果只有一个参数,那么直接返回这个对象。

function setDefaults<TObject, TSource>(object: TObject, source: TSource, customizer: SetDefaultsCustomizer): TObject & TSource;

复制代码

当传入一个 source 对象时,返回的对象为两个对象的合并 TObject & TSource。

function setDefaults<TObject, TSource1, TSource2>(object: TObject, source1: TSource1, source2: TSource2, customizer: SetDefaultsCustomizer): TObject & TSource1 & TSource2;function setDefaults<TObject, TSource1, TSource2, TSource3>(object: TObject, source1: TSource1, source2: TSource2, source3: TSource3, customizer: SetDefaultsCustomizer): TObject & TSource1 & TSource2 & TSource3;function setDefaults<TObject, TSource1, TSource2, TSource3, TSource4>(object: TObject,source1: TSource1,source2: TSource2,source3: TSource3,source4: TSource4,customizer: SetDefaultsCustomizer): TObject & TSource1 & TSource2 & TSource3 & TSource4;

复制代码

因为参数数量不固定,所以需要枚举参数为 1,2,3,4 的情况,同时加一个 any 的情况来兜底,这样声明当用户写 4 个和以下参数的时候都是有提示的,但超过 4 个就只能提示 any 了,能覆盖大多数使用场景。

实现这个函数:

type AnyObject = Record<string | number | symbol, any>;function setDefaults(obj: any, ...defaultObjs: any[]): any {  // 把数组赋值一份  const defaultObjsArr = Array.prototype.slice.call(defaultObjs); // 取出自定义处理函数  const customizer = (function() {    if (defaultObjsArr.length && typeof defaultObjsArr[defaultObjs.length - 1] === "function") {      return defaultObjsArr.splice(-1)[0];    }  })(); // 通过reduce循环设置默认值  return defaultObjsArr.reduce((curObj: AnyObject, defaultObj: AnyObject) => {    return assignObjectDeep(curObj, defaultObj, customizer);  }, Object(obj));}

复制代码

Record 是内置类型,具体实现是:

type Record<K extends string | number | symbol, T> = { [P in K]: T; }

复制代码

所以,AnyObject 其实就是一个值为 any 类型的对象。

把参数数组赋值一份后,取出自定义处理函数,通过 reduce 循环设置默认值。assignObjectDeep 实现的是给一个对象递归设置默认值的逻辑。

const assignObjectDeep = <TObj extends AnyObject, Key extends keyof TObj>(  obj: TObj,  srcObj: TObj,  customizer: SetDefaultsCustomizer): TObj => {  for (const key in Object(srcObj)) {    if (      typeof obj[key] === "object" &&      typeof srcObj[key] === "object" &&      getTag(srcObj[key]) !== "[object Array]"    ) {      obj[key as Key] = assignObjectDeep(obj[key], srcObj[key], customizer);    } else {      obj[key as Key] = customizer        ? customizer(obj[key], srcObj[key],key, obj, srcObj)        : obj[key] == void 0        ? srcObj[key]        : obj[key];    }  }  return obj;};

复制代码

类型只限制了必须是一个对象也就是 TObj extends AnyObject,同时 key 必须是这个对象的索引 Key extends keyof TObj。

通过 for in 遍历这个对象,如果是数组,那么就递归,否则合并两个对象,当有 customizer 时,调用该函数处理,否则判断该对象的值是否为 null 或 undefined,是则用默认值。(void 0 是 undefeind,== void 0 就是判断是否为 null 或 undefeind)

getProperty

getProperty 有三个参数,对象,属性路径和默认值。

function getProperty(object, path, defaultValue){}

复制代码

希望使用时这样调用:

const object = { 'a': [{ 'b': { 'c': 3 } }] }getProperty(object, 'a[0].b.c')// => 3getProperty(object, ['a', '0', 'b', 'c'])// => 3getProperty(object, 'a.b.c', 'default')// => 'default'

复制代码

因为重载情况较多,类型比较复杂,这是工具类函数的特点。首先声明几个用到的类型:

type AnyObject = Record<string | number | symbol, any>;type Many<T> = T | ReadonlyArray<T>;type PropertyName = string | number | symbol;type PropertyPath = Many<PropertyName>;interface NumericDictionary<T> {    [index: number]: T;}

复制代码

AnyObject 为值为 any 的对象类型。Record 和 ReadonlyArray 是内置类型。PropertyName 为对象的索引类型,只有三种,string、number、symbol,PropertyPath 是 path 的类型,可以是单个的 name,也可以是他们的数组,所以写了一个工具类型 Many 来生成这个类型。NumericDictionary 是一个 name 类型为 number,值类型固定的对象,类似数组。

首先是 object 为 null 和 undefined 的情况:

function getProperty(    object: null | undefined,    path: PropertyPath): undefined;function getProperty<TDefault>(    object: null | undefined,    path: PropertyPath,    defaultValue: TDefault): TDefault;

复制代码

然后是 object 为数组时的类型:

function getProperty<T>(    object: NumericDictionary<T>,    path: number): T;function getProperty<T>(    object: NumericDictionary<T> | null | undefined,    path: number): T | undefined;function getProperty<T, TDefault>(    object: NumericDictionary<T> | null | undefined,    path: number,    defaultValue: TDefault): T | TDefault;

复制代码

接下来是 object 为对象的情况,这里的特点和 setDefaults 一样,path 可能为元素任意个的数组,又要声明他们的顺序,这里只是写了参数分别为 1 个 、2 个 、3 个、 4 个的类型,然后加上 any 来兜底。

测试

测试使用的 ts-mocha 组织测试用例,使用 chai 做断言。

getProperty 的测试,测试了 object 为无效值、对象、数组,还有 path 写错的时候的逻辑。

describe('getProperty', () => {  const obj = { a: { b: { c: 1, d: null } } }  const arr = [ 1, 2, 3, {      obj  }]  it('对象为无效值时,返回默认值', () => {    assert.strictEqual(getProperty(undefined, 'a.b.c', 1), 1)    assert.strictEqual(getProperty(null, 'a.b.c', 1), 1)    assert.strictEqual(getProperty('', 'a.b.c', 1), 1)  })  it('能拿到对象的属性path的值', () => {    assert.strictEqual(getProperty(obj, 'a.b.c'), 1)    assert.strictEqual(getProperty(obj, 'a[b][c]'), 1)    assert.strictEqual(getProperty(obj, ['a', 'b', 'c']), 1)    assert.strictEqual(getProperty(obj, 'a.b.d.e', 1), 1)  })  it('错误的属性path的值会返回默认值', () => {    assert.strictEqual(getProperty(obj, 'c.b.a', 100), 100)    assert.strictEqual(getProperty(obj, 'a[c]', 100), 100)    assert.strictEqual(getProperty(obj, [], 100), 100)  })  it('数组能取到属性path的值', () => {    assert.strictEqual(getProperty(arr, '1'), 2)    assert.strictEqual(getProperty(arr, [1]), 2)    assert.strictEqual(getProperty(arr, [3, 'obj', 'a', 'b', 'c']), 1)  })})

复制代码

测试通过。

ZFRBbqI.jpg!mobile

编译打包

工具函数包需要打包成 cmd、esm、umd 三种规范的包,同时要支持 typescript,所以要导出声明文件。

通过 typescript 编译器可以分别编译成 cmd、esm 版本,也支持导出.d.ts 声明文件,umd 的打包使用 rollup。

BVVji2N.jpg!mobile

其中,tsconfig.json 为:

{  "compilerOptions": {    "noImplicitAny": true,    "removeComments": true,    "preserveConstEnums": false,    "allowSyntheticDefaultImports": true,    "sourceMap": false,    "types": [      "node",      "mocha"    ],    "lib": [      "es5"    ],    "downlevelIteration": true    //支持set等的迭代  },  "include": [    "./src/**/*.ts"  ]}

复制代码

然后 esm 和 cjs 还有 types 都继承了这个配置文件,重写了 module 的类型。

{  "extends": "./tsconfig.json",  "compilerOptions": {    "module": "commonjs",    "target": "es5",    "outDir": "./dist/cjs"  }}

复制代码

{  "extends": "./tsconfig.json",  "compilerOptions": {    "module": "esnext",    "target": "es5",    "removeComments": false,    "outDir": "./dist/esm"  },}

复制代码

同时,types 的配置要加上 declaration 为 true,并通过 declarationDir 指定类型文件的输出目录。

{  "extends": "./tsconfig.json",  "compilerOptions": {    "module": "es2015",    "removeComments": false,    "declaration": true,    "declarationMap": false,    "declarationDir": "./dist/types",    "emitDeclarationOnly": true,    "rootDir": "./src"  }}

复制代码

还有 rollup 的 ts 配置文件也需要单独出来,module 类型为 esm,rollup 会做接下来的处理。

{  "extends": "./tsconfig.json",  "compilerOptions": {    "module": "esnext",    "target": "es5"  }}

复制代码

其中 peerDependencies 作为 external 外部声明,通过 commonjs 识别 cjs 模块,通过 nodeResolve 做 node 模块查找,然后 typescript 做 ts 编译,通过 replace 做全局变量的设置,生产环境下使用 terser 来做压缩。

package.json 中注册 scripts:

{  "scripts": {    "build:cjs": "tsc -b ./tsconfig.cjs.json",    "build:es": "tsc -b ./tsconfig.esm.json",    "build:test": "tsc -b ./tsconfig.test.json",    "build:types": "tsc -b ./tsconfig.types.json",    "build:umd": "cross-env NODE_ENV=development rollup -c -o dist/umd/flight-common-utils.js",    "build:umd:min": "cross-env NODE_ENV=production rollup -c -o dist/umd/flight-common-utils.min.js",    "build": "npm run clean && npm-run-all build:cjs build:es build:types build:umd build:umd:min",    "clean": "rimraf lib dist es"  }}

复制代码

接下来,在 package.json 中对不同的模块类型的文件做声明。

iMNzA3e.jpg!mobile

main 是 node 会查找的字段,是 cjs 规范的包,module 是 webpack 和 rollup 会读取的,是 esm 规范的包,types 是 tsc 读取的,包含类型声明。umd 字段只是一个标识。

总结

本文详细讲述了封装这个包的原因,以及一些通用函数的实现逻辑,特别是复杂的类型如何去写。然后介绍了 ts-mocha + chai 来做测试,rollup + typescript 做编译打包。一个工具函数库就这么封装的。其中 typescript 的类型声明算是比较难的部分吧,想写出类型简单,把类型写的准确就不简单了,特别是工具函数,情况特别的多。

希望大家能有所收获。

头图:Unsplash

作者:翟旭光

原文: https://mp.weixin.qq.com/s/PRCpMc9TPzglrEUo2BiDog

原文:用 typescript 写一个工具函数库

来源:Qunar 技术沙龙 - 微信公众号 [ID:QunarTL]

转载:著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK