写点什么

用 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:441

评论

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

话题讨论 | go、php 、java、python、cpp谁才能成为后端的主流

sinsy

Java c++ php go 话题讨论

话题讨论 | 2020年你有什么推荐的书

soolaugust

话题讨论

花火交易所APP软件系统开发(现成)

开發I852946OIIO

系统开发

突破容量极限:TiDB 的海量数据“无感扩容”秘籍

京东科技开发者

分布式数据库 #TiDB

《写给大忙人看的JAVA核心技术》.pdf

田维常

电子书

CSS flex 排版与动画 — 重学 CSS

三钻

CSS 排版 前端进阶训练营 Flex

Spring 源码学习 08:register 注册配置类

程序员小航

Java spring 源码 源码阅读

App自动化《元素定位方式、元素操作、混合应用、分层设计、代码方式执行Pytest 命令》

清菡

App

第十一周 安全稳定作业

钟杰

极客大学架构师训练营

第十一周 安全稳定总结

钟杰

极客大学架构师训练营

线程上下文切换,这些是你需要掌握的

田维常

系统上下文

架构师训练营 11 周作业

郎哲158

源码深度解析 Handler 机制及应用

vivo互联网技术

android 客户端开发

探秘密码学:深入了解对称加密与密钥协商技术

京东科技开发者

网络安全 密码学

智慧公安情报指挥合成作战管控平台开发

t13823115967

智慧公安情报研判系统开发 智慧公安 合成作战管控平台

基于区块链技术落地应用开发-食品溯源

13828808769

话题讨论 | 程序员摸鱼的时候都喜欢干些什么

soolaugust

话题讨论

低成本快速上链 智臻链开放联盟网络正式对外开放

京东科技开发者

区块链 京东

Seata是什么?一文了解其实现原理

vivo互联网技术

分布式 分布式事务 分布式架构

公安情报研判管控分析平台建设解决方案

t13823115967

智慧公安情报研判系统开发 智慧公安 情报研判管控分析平台

我是程序员,我用这种方式铭记历史

kokohuang

Hexo GitHub Pages python 爬虫 中国历史 铭记历史

谁还不是凡尔赛了,LEARUN.NET框架,实力不容低调

力软.net/java开发平台

.net .net core learun

第七周总结

小兵

阿里云Lindorm与Intel、OSIsoft共建IT & OT超融合工业数据云

许力

数据库 大数据 IoT 工业互联网 工业物联网

KMP —— 字符串分析算法

三钻

算法 前端 前端进阶训练营 KMP

什么是工作流?工作流有什么作用?怎样配置工作流程?

Marilyn

敏捷开发 工作流

话题讨论 | 作为开发你是如何阅读源码的?

程序员小航

话题讨论

架构师训练营第 1 期第 11 周总结

du tiezheng

极客大学架构师训练营

架构师训练营第 1 期第 11 周作业

du tiezheng

极客大学架构师训练营

手撸一个在线css三角形生成器

徐小夕

CSS css3 前端 前端工程 CSS小技巧

话题讨论 | 深入浅出Linux内存管理,图解物理内存和虚拟内存

程序员柠檬

话题讨论

微服务架构下如何保证事务的一致性

微服务架构下如何保证事务的一致性

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