10 月,开发者不可错过的开源大数据大会-2021 WeDataSphere 社区大会深圳站 了解详情
写点什么

用 typescript 写一个工具函数库

2021 年 2 月 22 日

用 typescript 写一个工具函数库

技术点介绍


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

  • 用 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)  })})
复制代码


测试通过。



编译打包


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


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



其中,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 中对不同的模块类型的文件做声明。



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]

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


2021 年 2 月 22 日 08:442004

评论

发布
暂无评论
发现更多内容

我们都很忙

Ian哥

28天写作

面对key数量多和区间查询低效问题:Hash索引趴窝,LSM树申请出场

华为云开发者社区

数据库 数据 存储 Hash索引 LSM树

美国大选期间美股迎来大涨,舆情到底有何魔力?

星环科技

人工智能 人工智能大数据

漫谈HTTP协议

架构精进之路

HTTP 七日更 28天写作

机器学习·笔记之:Cost Function - Intuition I

Nydia

淘宝网前期技术架构演进分析

andy

开发质量提升系列:用户体验

罗小龙

最佳实践 方法论 28天写作

你会读书吗?

xcbeyond

读书感悟 读书方式 28天写作

代码 or 指令,浅析ARM架构下的函数的调用过程

华为云开发者社区

函数 任务栈 arm架构

区块链电子票据应用,区块链数字票据平台

135深圳3055源中瑞8032

AI、IoT、区块链、自主系统、下一代计算五大技术引领未来供应链发展

京东科技开发者

区块链 AI IoT 供应链

如何养成一个好习惯

熊斌

读书笔记 28天写作

架构师训练营知识点思维导图

陈浩

架构师训练营第2期

个人隐私之老话重谈

张老蔫

28天写作

记录关于写作的两个小想法

JiangX

28天写作

GNUCash 5: 报表

lidaobing

GNUCash 28天写作

Vue3 中 v-if 和 v-show 指令实现的原理 | 源码解读

五柳

源码分析 前端 Vue3

音视频行业不可或缺的功能-云端录制

anyRTC开发者

音视频 WebRTC 在线教育 直播 RTC

智慧平安小区APP,社区管理一体化平台

135深圳3055源中瑞8032

微服务容错时,这些技术你要立刻想到

华为云开发者社区

微服务 线程 服务雪崩 断路器 服务降级

区块链作用之数字货币的影响

v16629866266

年底跳槽之 如何找工作方向?

一笑

职业规划 28天写作

技术根儿扎得深,不怕“首都”狂风吹!

Arvin

操作系统

工程师思维是什么?能吃吗?

Justin

工程师思维 架构设计 28天写作

Soul 源码阅读 05|Http 长轮询同步数据分析

哼干嘛

前端模拟假数据(json-server光速入门篇)

学习委员

json 前端 Node 28天写作 json-server

Volcano 监控设计解读,一看就懂

华为云开发者社区

Kubernetes 云原生 监控 Volcano 计算

浪漫主义的消亡

石君

28天写作

机器学习应用设计阶段的 10 个陷阱和 11 个最佳实践

浪潮云

机器学习

HTML5中的拖放功能

魔王哪吒

html html5 程序员 面试 前端

区块链落地商品溯源-区块链商品溯源方案

135深圳3055源中瑞8032

用 typescript 写一个工具函数库-InfoQ