Typescript超复杂类型的声明:写一个工具函数库

经过这篇文章,能够学到node

  • 工具函数的复杂类型的声明(难点)
  • 用ts-mocha + chai作单元测试
  • 用ts + rollup打不一样模块规范的包
  • 使用jsdoc生成文档

前言

先看一段代码webpack

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

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

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

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

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

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

实现工具函数

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

setDefaults

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

function setDefaults(obj, ...defaultObjs) {
}
复制代码

这里的类型的特色是函数返回值是原对象和一些默认对象的合并,而且参数个数不肯定。因此用到了函数类型的重载,加上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;

function setDefaults<TResult>(object: any, ...defaultObjs: any[]): TResult;

复制代码

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

实现这个函数:

type AnyObject = Record<string | number | symbol, any>;

function setDefaults<TResult>(obj: any, ...defaultObjs: any[]): TResult {
  // 把数组赋值一份
  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){}
复制代码

由于重载状况较多,类型比较复杂,这是工具类函数的特色。 首先声明几个用到的类型

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则返回默认值
  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三种规范的包,同时要支持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"]
    },
    "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中对不一样的模块类型的文件作声明

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

文档

文档经过jsdoc生成,能够根据注释生成文档,可是并不支持ts,因此我是经过打包完以后在基于打包结果作jsdoc生成。

而且我但愿文档直接拼接在README.md里面,因此写了一个小脚本。

const { execSync } = require('child_process')
const fs = require('fs')
const path = require('path')

const binPath = path.resolve(__dirname, '../node_modules/.bin/jsdoc2md')
const srcPath = path.resolve(__dirname, '../dist/esm')

const files = fs.readdirSync(srcPath)

let docStr = ''
files.filter(filename => filename.indexOf('.js') > -1 && filename !== 'index.js').forEach(item => {
  const filePath = path.resolve(srcPath, item)
  docStr += execSync(`${binPath} ${filePath} `).toString('utf-8')
})

let readmeContent = fs.readFileSync(path.resolve(__dirname, './README.md.template')).toString('UTF-8')
readmeContent += docStr

fs.writeFileSync(path.resolve(__dirname, '../README.md'), readmeContent)
复制代码

写了一个模版,而后把生成的jsdoc拼接进去,写入README.md。 一样注册到npm scripts

{
   "scripts": {
    "generateDocs": "npm run build:es && node ./scripts/generateDoc.js",
   }
}
复制代码

#总结

本文详细讲述了封装这个包的缘由,以及一些通用函数的实现逻辑,特别是复杂的类型如何去写。而后介绍了ts-mocha + chai来作测试,rollup + typescript作编译打包,使用jsdoc生成文档。 一个工具函数库就这么封装的。其中typescript的类型声明算是比较难的部分吧,想写出类型简单,把类型写的准确就不简单了,特别是工具函数,状况特别的多。但愿你们能有所收获。

欢迎关注个人公众号,会持续分享一些源码类的或者我作的工具。

相关文章
相关标签/搜索