Typescript複雜型別的宣告:寫一個工具函式庫

一個人的光發表於2020-01-01

通過這篇文章,可以學到

  • 工具函式的複雜型別的宣告(難點)
  • 用ts-mocha + chai做單元測試
  • 用ts + rollup打不同模組規範的包
  • 使用jsdoc生成文件

前言

先看一段程式碼

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,通用函式考慮的情況很多,為了更精準的型別提示,型別的邏輯寫的很複雜,比實現邏輯的程式碼都多。。

實現工具函式

這裡只介紹型別較為複雜的setDefaults、getProperty。

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(object: any, ...defaultObjs: any[]): any;

複製程式碼

因為引數數量不固定,所以需要列舉引數為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){}
複製程式碼

因為過載情況較多,型別比較複雜,這是工具類函式的特點。 首先宣告幾個用到的型別

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時也需要返回預設值)
  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)
  })

})
複製程式碼

測試通過

Typescript複雜型別的宣告:寫一個工具函式庫

編譯打包

工具函式包需要打包成cmd、esm、umd三種規範的包,同時要支援typescript,所以要匯出宣告檔案。

通過typescript編譯器可以分別編譯成 cmd、esm版本,也支援匯出 .d.ts宣告檔案,umd的打包使用rollup。

Typescript複雜型別的宣告:寫一個工具函式庫

其中,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中對不同的模組型別的檔案做宣告

Typescript複雜型別的宣告:寫一個工具函式庫

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的型別宣告算是比較難的部分吧,想寫出型別簡單,把型別寫的準確就不簡單了,特別是工具函式,除了準確,還要求通用,所以情況特別的多。

希望大家能有所收穫。

歡迎關注我的公眾號,會持續分享一些原始碼類的或者我做的工具。

Typescript複雜型別的宣告:寫一個工具函式庫

相關文章