Zhai Xuguang, joined the front end team of Qunar ticket in November 2019, and is currently working in the domestic basic platform. I like engineering and typescript, and I like various tools to improve development efficiency. I have a strong interest in this area and have many ideas to implement. Like reading source code, keen on exploring the fun of source code, like deep sea exploration, because of the accidental discovery of a rare knowledge point or skills and excited.

Technical Point Introduction

  • Declaration of complex types of utility functions (difficult)
  • Unit tests with TS-Mocha + CHAI
  • Use TS + rollup to type packages for different module specifications

preface

Let’s take a look at some code

const {name = 'xxx', age} = { name: null, age: 18}
console.log(name);
Copy the code

The name output is null, because the default value of the destruct assignment is only valid if the value is undefined, which can cause bugs if not noticed. Our group recently encountered a bug caused by this, in which the server returned data that was not assigned because the default value for destructed assignment was used, causing a problem because the value was null.

So how can you avoid this problem?

We have two final solutions. The first one is to recursively set the default value after the server returns the data. After that, we don’t need to make judgment and just deal with it directly. The second method is to make a judgment when fetching an attribute, setting the default value if null or undefined. To support both scenarios, we have encapsulated a utility function package @qnpm/flight-common-utils.

The toolkit first includes setDefaults and getProperty functions. The first is to recursively setDefaults, and the second is to take properties and setDefaults. Other utility functions can also be included to encapsulate common logic for reuse across projects. For example, I’m going to declare isEmpty, I’m going to recursively determine whether an object isEqual to an attribute isEqual, and so on.

Because of Typscript, general-purpose functions are considered a lot, and the type logic is written more complex than the code that implements the logic for more precise type hints.

use

NPM install @qnpm/flight-common-utils --save --registry= company NPM repositoryCopy the code

or

Yarn add @qnpm/flight-common-utils --registry= Company NPM repositoryCopy the code

Implement utility functions

Only setDefaults and getProperty of complex types are introduced here.

setDefaults

This function takes one object to be processed, several default objects, and the last parameter can be passed to a function with custom processing logic.

function setDefaults(obj, ... defaultObjs) { }Copy the code

You want to call it like this:

setDefaults({a: {b: 2}}, {a: {c: 3}} ); // {a: {b: 2, c: 3}}Copy the code

This type is characterized by the fact that the return value of the function is a combination of the original object and some default objects, with an indefinite number of arguments. So we’re using an overload of the function type, plus the bottom of any.

type SetDefaultsCustomizer = (objectValue: any, sourceValue: any, key? : string, object? : {}, source? : {}) => any;Copy the code

SetDefaultsCustomizer is a custom handler type that takes two values to be processed, the name of the key, and two objects.

And then there’s the type of setDefauts, which overrides a lot of cases.

function setDefaults<TObject>(object: TObject): TObject;
Copy the code

If there is only one argument, the object is returned.

function setDefaults<TObject, TSource>(object: TObject, source: TSource, customizer: SetDefaultsCustomizer): TObject & TSource;
Copy the code

When a source object is passed in, the object returned is the combined tobjject & TSource of two objects.

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;
Copy the code

Enumerating 1, 2, 3, and 4, and adding any to the list, will prompt users for 4 or less arguments, but will prompt users for any if the number of arguments exceeds 4, covering most usage scenarios.

Implement this function:

type AnyObject = Record<string | number | symbol, any>; function setDefaults(obj: any, ... DefaultObjs: any []) : any {/ / the assignment of an Array is a onst defaultObjsArr = Array. The prototype. Slice. The call (defaultObjs); Const customizer = (function() {if (defaultObjsArr && Typeof defaultObjsArr[defaultobjs.length - 1]) const customizer = (function() {if (defaultObjsArr && Typeof defaultObjsArr[defaultobjs.length - 1]  === "function") { return defaultObjsArr.splice(-1)[0]; }}) (); Return defaultobJsarr.reduce ((curObj: AnyObject, defaultObj: AnyObject) => { return assignObjectDeep(curObj, defaultObj, customizer); }, Object(obj)); }Copy the code

Record is a built-in type, which is implemented as follows:

type Record<K extends string | number | symbol, T> = { [P in K]: T; }
Copy the code

So AnyObject is an object of type Any.

After assigning a copy of the parameter array, extract the custom handler and set the default value through the Reduce loop. AssignObjectDeep implements logic to recursively set default values for an object.

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; };Copy the code

The type is limited only to an object which is TObj extends AnyObject, and the key must be the index key extends Keyof TObj.

If it is an array, then recursively, otherwise merge the two objects, call this function when there is a customizer, otherwise check whether the value of the object is null or undefined, and use the default. Void 0 undefeind == void 0 undefeind

getProperty

GetProperty takes three arguments, the object, the property path, and a default value.

function getProperty(object, path, defaultValue){}
Copy the code

You want to call it like this:

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'
Copy the code

Because there are many overloads, the type is more complex, which is the characteristic of utility class functions. We’ll start by declaring a few types to use:

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;
}
Copy the code

AnyObject is the object type whose value is any. Record and ReadonlyArray are built-in types. PropertyName is the index type of the object. There are only three types: String, number, symbol. PropertyPath is the type of path, which can be a single name or an array of them. A NumericDictionary is a fixed object with a name of type number, similar to an array.

Object is null and undefined:

function getProperty(
    object: null | undefined,
    path: PropertyPath
): undefined;

function getProperty<TDefault>(
    object: null | undefined,
    path: PropertyPath,
    defaultValue: TDefault
): TDefault;
Copy the code

Then the type of object is an array:

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;
Copy the code

Now object is an object, and it’s the same thing as setDefaults, where path can be any array of elements, and we declare their order, so we’re just saying 1, 2, 3, 4, and any.

When path has only one element:

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;
Copy the code

When you pass in a default value, the return value might be the default value TDefault, or it might be the value of the object TObject[TKey], but TObject[TKey] is definitely not undefined, so let’s say that.

Exclude<TObject[TKey], undefined> | TDefault
Copy the code

Then there is path with 2 elements:

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;
Copy the code

It’s the same with 3 and 4, so I won’t list them.

Bottom type:

function getProperty( object: any, path: PropertyPath, defaultValue? : any ): any;Copy the code

The idea is to first handle null and undefined, and then loop through the value of the property, returning the default value if undefined, otherwise returning the value. The LoDash implementation is referenced here.

function getProperty(object: any, path: PropertyPath, defaultValue? {// handle null and undefined const result = object == null? Undefined: baseGet(object, path) // If the value is undefined or null, return the default value (according to our requirements, null also needs to return the default value) return result == undefined? defaultValue : result } function baseGet (object: any, path: PropertyPath): Any {path = castPath(path, object) let index = 0 const length = path.length = null && index < length) {object = object[toKey(path[index++])]} Undefined return (index && index === length)? object : undefined }Copy the code

test

The test uses TS-Mocha to organize test cases, using CHAI to make assertions.

The getProperty test tests the logic when object is an invalid value, object, array, and path is incorrectly written.

describe('getProperty', () => { const obj = { a: { b: { c: 1, d: Null}}} const arr = [1, 2, 3, {obj}] it Return default value ', () => {assert.strictEqual(getProperty(undefined, 'A.B.C ', 1), 1) assert.strictEqual(getProperty(null,' A.B.C ', 2)) 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(' wrong attribute path returns default value ', () => {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')) [1]), 2) assert.strictEqual(getProperty(arr, [3, 'obj', 'a', 'b', 'c']), 1) }) })Copy the code

The test pass

Compile the package

The utility function package needs to be packaged into CMD, ESM, and UMD specifications and typescript support, so export the declaration file.

The typescript compiler compiles to CMD and ESM versions, and supports exporting. D. ts declarations. Umd packaging uses rollup.

Tsconfig. json is:

{ "compilerOptions": { "noImplicitAny": true, "removeComments": true, "preserveConstEnums": false, "allowSyntheticDefaultImports": true, "sourceMap": false, "types": ["node", "mocha"], "lib": "], "[" es5 downlevelIteration" : true} / / support set of iteration, "include:" [". / SRC / / * * *. Ts "]}Copy the code

Esm and CJS and types then inherit this configuration file and override the module type.

{
    "extends": "./tsconfig.json",
    "compilerOptions": {
      "module": "commonjs",
      "target": "es5",
      "outDir": "./dist/cjs"
    }
}
Copy the code
{
    "extends": "./tsconfig.json",
    "compilerOptions": {
      "module": "esnext",
      "target": "es5",
      "removeComments": false,
      "outDir": "./dist/esm"
    },
}
Copy the code

Also, the types is configured with a declaration of true, and the output directory of the type file is specified through declarationDir.

{
    "extends": "./tsconfig.json",
    "compilerOptions": {
      "module": "es2015",
      "removeComments": false,
      "declaration": true,
      "declarationMap": false,
      "declarationDir": "./dist/types",
      "emitDeclarationOnly": true,
      "rootDir": "./src"
    }
}
Copy the code

The ROLLup ts configuration file also needs to be separate, module type is ESM, rollup will do the following processing.

{
    "extends": "./tsconfig.json",
    "compilerOptions": {
      "module": "esnext",
      "target": "es5"
    }
}
Copy the code

Then there is the rollup configuration, which is used for umD packaging.

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
      }
    })
  )
}
Copy the code

PeerDependencies is an external declaration that identifies CJS modules through CommonJS, nodeResolve for Node module lookup, and typescript for TS compilation. Replace is used to set global variables, and Terser is used for compression in production.

PeerDependencies is an external declaration that identifies CJS modules through CommonJS, nodeResolve for Node module lookup, and typescript for TS compilation. Replace is used to set global variables, and Terser is used for compression in production.

Register scripts in package.json:

{
  "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"
  }
}
Copy the code

Next, declare the different module type files in package.json.

Main is the field that Node looks for, is a CJS package, module is read by Webpack and rollup, is an ESM package, types is read by TSC and contains type declarations. The UMD field is just an identifier.

conclusion

This article details the reasons for wrapping this package, as well as the implementation logic for some generic functions, especially how complex types are written. Ts-mocha + CHAI for testing, rollup + typescript for compiling and packaging. A library of utility functions is encapsulated in this way. Typescript type declarations are one of the more difficult parts of typescript. It’s not easy to write types correctly, especially for utility functions.

I hope you found that interesting.