typescript设置默认值_用 typescript 写一个工具函数库

82627e6410242c81278944c53ea651f0.gif

点击上方蓝字关注我们!

f5a9bc8c075dc5fff3d582dc3fc3f062.png

作者介绍

翟旭光,2019 年 11 月加入 Qunar 机票前端团队,目前在国内基础平台。喜欢工程化和 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 等。

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

使用

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)

})

})

测试通过。

3241df8e8fc4e262a5bf9b5130939722.png

编译打包

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

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

784d19bcbfe4a5dfc771230cf1d3958b.png

其中,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 typescript from '@rollup/plugin-typescript'

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

}),

typescript({

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

}

})

)

}

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

1afad875a521f03668aff74d3cf289c1.png

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

总结

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

希望大家能有所收获。

【END】

5b648887287cdc5e75700998b01544e6.png

af475bf75ed17b319a80f3acf5b1fbca.png

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值