原标题:用 typescript 写一个工具函数库
作者介绍
翟旭光,2019 年 11 月加入 Qunar 机票前端团队,目前在国内基础平台。喜欢工程化和 type,喜欢各种提升开发效率的工具,对这方面有浓厚的兴趣,有很多想法待实现。喜欢阅读源码,热衷于探索源码的乐趣,就像在深海探险,会因为偶然发现的一个冷僻的知识点或技巧而兴奋。
技术点介绍
工具函数的复杂类型的声明(难点)
用 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 等。
因为用了 typ,通用函数考虑的情况很多,为了更精准的类型提示,类型的逻辑写的很复杂,比实现逻辑的代码都多。
使用
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' )
// => 3
getProperty ( object , [ 'a' , '0' , 'b' , 'c' ])
// => 3
getProperty ( 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 来兜底。
当 path 的元素只有一个的时候:
function getProperty < TObject extends object , TKey extends keyof TObject >(
object : TObject ,
path : TKey | [ TKey ]
): TObject [ TKey ];
function getProperty < TObject extends object , TKey extends keyof TObject >(
object : TObject | null | undefined ,
path : TKey | [ TKey ]
): TObject [ TKey ] | undefined ;
function getProperty < TObject extends object , TKey extends keyof TObject , TDefault >(
object : TObject | null | undefined ,
path : TKey | [ TKey ],
defaultValue : TDefault
): Exclude < TObject [ TKey ], undefined > | TDefault ;
当传入默认值时,返回值可能是默认值 TDefault,也可能是对象的值 TObject[TKey],但 TObject[TKey]一定不是 undefined,所以这里这么写。
Exclude < TObject [ TKey ], undefined > | TDefault
然后是 path 有 2 个元素的时候:
function getProperty < TObject extends object , TKey1 extends keyof TObject , TKey2 extends keyof TObject [ TKey1 ]>(
object : TObject | null | undefined ,
path : [ TKey1 , TKey2 ]
): TObject [ TKey1 ][ TKey2 ] | undefined ;
function getProperty < TObject extends object , TKey1 extends keyof TObject , TKey2 extends keyof TObject [ TKey1 ], TDefault >(
object : TObject | null | undefined ,
path : [ TKey1 , TKey2 ],
defaultValue : TDefault
): Exclude < TObject [ TKey1 ][ TKey2 ], undefined > | TDefault ;
3 个、4 个也是一样,就不列了。
兜底类型:
function getProperty (
object : any ,
path : PropertyPath ,
defaultValue ?: any
): any ;
实现思路是先处理 null 和 undefined 的情况,然后循环取属性值,如果值为 undefined 则返回默认值,否则返回取到的值。这里参考了 lodash 的实现。
function getProperty ( object : any , path : PropertyPath , defaultValue ?: any ): any {
//处理null 和undefined
const result = object == null ? undefined : baseGet ( object , path )
//如果取到的值是undefined或null则返回默认值(根据我们的需求,null时也需要返回默认值)
return result == undefined ? defaultValue : result
}
function baseGet ( object : any , path : PropertyPath ): any {
path = castPath ( path , object )
let index = 0
const length = path . length
// 循环取path对象的属性值
while ( object != null && index < length ) {
object = object [ toKey ( path [ index ++])]
}
// 如果取到了最后一个元素,则返回该值,否则返回undefined
return ( index && index === length ) ? object : undefined
}
测试
测试使用的 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 三种规范的包,同时要支持 type,所以要导出声明文件。
通过 type 编译器可以分别编译成 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"
}
}
然后是 rollup 的配置,rollup 用来做 umd 的打包。
import nodeResolve from '@rollup/plugin-node-resolve'
import commonjs from '@rollup/plugin-commonjs'
import type from '@rollup/plugin-type'
import replace from 'rollup-plugin-replace'
import { terser } from 'rollup-plugin-terser'
import pkg from './package.json'
const env = process . env . NODE_ENV
const config = {
input : 'src/index.ts' ,
output : {
format : 'umd' ,
name : 'FlightCommonUtils'
},
external : Object . keys ( pkg . peerDependencies || {}),
plugins : [
commonjs ,
nodeResolve ({
jsnext : true
}),
type ({
tsconfig : './tsconfig.esm.rollup.json'
}),
replace ({
'process.env.NODE_ENV' : JSON . stringify ( env )
})
]
}
if ( env === 'production' ) {
config . plugins . push (
terser ({
compress : {
pure_getters : true ,
unsafe : true ,
unsafe_comps : true ,
warnings : false
}
})
)
}
package.json 中注册 s:
{
"s" : {
"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 + type 做编译打包。一个工具函数库就这么封装的。其中 type 的类型声明算是比较难的部分吧,想写出类型简单,把类型写的准确就不简单了,特别是工具函数,情况特别的多。
希望大家能有所收获。返回搜狐,查看更多
责任编辑: