TypeScript 前端工程最佳实践

作者:王春雨

前言

随着前端工程化的快速发展, TypeScript 变得越来越受欢迎,它已经成为前端开发人员必备技能。 TypeScript 最初是由微软开发并开源的一种编程语言,自2012年10月发布首个公开版本以来,它已得到了人们的广泛认可。TypeScript 发展至今,已经成为很多大型项目的标配,其提供的静态类型系统,大大增强了代码的可读性、可维护性和代码质量。同时,它提供最新的JavaScript特性,能让我们构建更加健壮的组件,新版本不断迭代更新,编写前端代码也越来越香。

TypeScript 前端工程最佳实践插图

typescript 下载量变化趋势(来自于 npm trends)

1 为什么使用 TypeScript

微软提出 TypeScript 主要是为了实现两个目标:为 JavaScript 提供可选的类型系统,兼容当前及未来的 JavaScript 特性。首先类型系统能够提高代码的质量和可维护性,国内外大型团队经过不断实践后得出一些结论:

  • 类型有利于代码的重构,它有利于编译器在编译时而不是运行时发现错误;
  • 类型是出色的文档形式之一,良好的函数声明胜过冗长的代码注释,通过声明即可知道具体的实现;

像其他语言都有类型的存在,如果强加于 JavaScript 之上,类型可能会有一些不必要的复杂性,而 TypeScript 在两者之间做了折中处理尽可能地降低了入门门槛,它使 JavaScript 即 TypeScript ,为 JavaScript 提供了编译时的类型安全。TypeScript 类型完全是可选的,原来的 .js 文件可以直接被重命名为 .ts ,ts 文件可以被编译成标准的 JavaScript 代码,并保证编译后的代码全部兼容,它也被成为 JavaScript 的 “超集”。没有类型的 JavaScript 语法虽然简单灵活,使用的变量是弱类型,但是比较难以掌握,TypeScript 提供的静态类型检查,很好的弥补了 JavaScript 的不足。

TypeScript 类型可以是隐式的也可以是显式的,它会尽可能安全地推断类型,以便在代码开发过程中以极小的成本为你提供类型安全,也可以使用显式的声明类型注解让编译器编译出我们想要的内容,更重要的是为下一个必须阅读代码的开发人员理解代码逻辑。

类型错误也不会阻止JavaScript 的正常运行,为了方便把 JavaScript 代码迁移到 TypeScript,即使存在编译错误,TypeScript 也会被编译出完整的 JavaScript 代码,这与其他语言的编译器工作方式有很大不同,这也正是 TypeScript 被青睐的另一个原因。

TypeScript 的特点还有很多比如下面这些:

  1. 免费开源,使用 Apache 授权协议;
  2. 基于ECMAScript 标准进行拓展,是 JavaScript 的超集;
  3. 添加了可选静态类型、类和模块;
  4. 可以编译为可读的、符合ECMAScript 规范的 JavaScript;
  5. 成为一款跨平台的工具,支持所有的浏览器、主机和操作系统;
  6. 保证可以与 JavaScript 代码一起使用,无须修改(这一点保证了 JavaScript 项目可以向 TypeScript 平滑迁移);
  7. 文件扩展名是 ts/tsx;
  8. 编译时检查,不污染运行时;

总的来说我们没有理由不使用 TypeScript, 因为 JavaScript 就是 TypeScript,TypeScript 可以让 JavaScript 更美好。

TypeScript 前端工程最佳实践插图1

2 开始使用 TypeScript

2.1 安装 TypeScript 依赖环境

TypeScript 开发环境搭建非常简单,大部分前端工程都集成了 TypeScript 只需安装依赖增加配置即可。所有前端项目都离不开 NodeJS 和 npm 工具,npm 命令安装 TypeScript,通常TypeScript 自带的 tsc 并不能直接运行TypeScript 代码,因此我们还会安装 TypeScript 的运行时 ts-node:

npm install --save-dev typescript ts-node

TypeScript 前端工程最佳实践插图2TypeScript 前端工程最佳实践插图3

2.1.1 集成 Babel

前端工程大都离不开 Babel ,我们需要将 TypScript 和 Babel 结合使用,TypeScript 编译器负责对代码进行静态类型检查,Babel 负责将TypeScript 代码转译为可以执行的 JavaScript 代码:

TypeScript 前端工程最佳实践插图4

Babel 与 TypeScript 结合的关键依赖 @babel/preset-typescript,它提供了从 TypeScript 代码中移除类型相关代码(如,类型注解,接口,类型文件等),并在 babel.config.js 文件添加配置选项:

npm install -D @babel/preset-typescript

// babel.config.js
{
"presets": [
// ...
"@babel/preset-typescript"
]
}

TypeScript 前端工程最佳实践插图2TypeScript 前端工程最佳实践插图3

2.1.2 集成 ESlint

代码检查是项目的重要组成部分,TypeScript 自身的约束相对简单只可以发现一些代码错误并不会帮助我们统一代码风格,当项目越来越庞大,开发人员越来越多时,代码风格的约束还是必不可少的。我们可以借助 ESLint对代码风格进行约束,为了让 eslint 来解析 TypeScript 代码我们需要安装解析器 @typescript-eslint/parser 和 插件 @typescript-eslint/eslint-plugin:

npm install --save-dev @typescript-eslint/parser @typescript-eslint/eslint-plugin

TypeScript 前端工程最佳实践插图2TypeScript 前端工程最佳实践插图3

注意: @typescript-eslint/parser 和 @typescript-eslint/eslint-plugin 必须使用相同的版本
在 .eslintrc.js 配置文件中添加选项:

 "parser": "@typescript-eslint/parser",
      "plugins": ["@typescript-eslint"],

// 可以直接启用推荐的规则
  "extends": [
    "eslint:recommended",
    "plugin:@typescript-eslint/recommended"
]
// 也可以选择自定义规则
"rules": {
"@typescript-eslint/no-use-before-define": "error",
// ...
}

TypeScript 前端工程最佳实践插图2TypeScript 前端工程最佳实践插图3

自定义规则选项具体解读:

TypeScript 前端工程最佳实践插图5

2.2 配置 TypeScript

TypeScript 本身提供了只使用参数在命令行编译 TypeScript 文件,但是在实际项目开发时我们都会使用 tsconfig.json ,如果项目中没有此文件,可以手动创建也可以使用命令行创建(tsc —init)。使用 TypeScript 初期仅需要一份默认的 tsconfig.json 即可,它包含了一下基本的编译选项相关信息,当我们需要定制编译选项时就需要去了解每一项具体的含义,编译选项解读如下:

TypeScript 前端工程最佳实践插图6

2.严格的类型检查选项:

  • strict: 是否启用严格类型检查选项,可选 ture | false
  • allowUnreachableCode: 是否允许不可达的代码出现,可选 ture | false
  • allowUnusedLabels: 是否报告未使用的标签错误,可选 ture | false
  • noImplicitAny: 当在表达式和声明上有隐式的 any 时是否报错,可选 ture | false
  • strictNullChecks: 是否启用严格的 null 检查,可选 ture | false
  • noImplicitThis: 当 this 表达式的值为 any 时,生成一个错误,可选 ture | false
  • alwaysStrict: 是否以严格模式检查每个模块,并在每个文件里加入 use strict,可选 ture | false
  • noImplicitReturns: 当函数有的分支没有返回值时是否会报错,可选 ture | false
  • noFallthroughCasesInSwitch: 表示是否报告 switch 语句的 case 分支落空(fallthrough)错误;

3.模块解析选项:

  • moduleResolution: 模块解析策略默认为 node 比较通用的一种方式基
  • commonjs 模块标准,另一种是 classic 适用于其他 module 标准,如 amd、 umd、 esnext 等等
  • baseUrl: “./“ 用于解析非相对模块名称的根目录
  • paths: 模块名到基于 baseUrl 的路径映射的列表,格式 {}
  • rootDirs: 根文件夹列表,其做好内容表示项目运行时的结果内容,格式 []
  • typeRoots: 包含类型声明的文件列表,格式 [“./types”] ,相对于配置文件的路径解析;
  • allowSyntheticDefaultImports: 是否允许从没有设置默认导出的模块中默认导入

4.Source Map 选项:

  • sourceRoot: ./ 指定调试器应该找到 TypeScript 文件而不是源文件的位置
  • mapRoot: ./ 指定调试器应该找到映射文件而不是生成文件的位置
  • inlineSourceMap: 是否生成单个 sourceMap 文件,不是将 sourceMap 生成不同的文件
  • inlineSources: 是否将代码与 sourceMap 生成到一个文件中,要求同时设置 inlineSourceMap 和 sourceMap 属性

5.其它选项:

  • experimentalDecorators: 是否启用装饰器
  • emitDecoratorMetadata: 是否为装饰器提供元数据的支持

6.还可以使用include 和 exclude 选项来指定编译器需要和不需要编译的文件,一般增加必要的 exclude 文件会提升编译性能:

 "exclude": [
    "node_modules",
    "dist"
...
  ],

TypeScript 前端工程最佳实践插图2TypeScript 前端工程最佳实践插图3

2.3 TypeScript 类型注解

熟悉了 TypeScript 的相关配置,再来看一看 TypeScript 提供的基本类型,下图是与 ES6 类型的对比:

TypeScript 前端工程最佳实践插图7
图中蓝色的为基本类型,红色为 TypeScript 支持的特殊类型

TypeScript 的类型注解相当于其它语言的类型声明,可以使用 let 和 const 声明一个变量,语法如下:

// let 或 const 变量名:数据类型 = 初始值;
//例如:
let varName: string = 'hello typescript'

TypeScript 前端工程最佳实践插图2TypeScript 前端工程最佳实践插图3

函数声明,推荐使用函数表达式,也可以使用箭头函数显得更简洁一下:

let 或 const 函数表达式名 = function(参数1:类型,参数2:类型):类型{
// 执行代码
// return xx;
}
// 例如
let sum = function(num1: number, num2: number): number {
return num1 + num2;
}

TypeScript 前端工程最佳实践插图2TypeScript 前端工程最佳实践插图3

2.4 TypeScript 特殊类型介绍

typescript 基本类型的用法和其它后端语言类似在这里不进行详细介绍,TypeScript 还提供了一些其它语言没有的特殊类型在使用过程中有很多需要注意的地方。

2.4.1 any 任意值

any 在 TypeScript 类型系统中占有特殊的地位。它为我们提供了一个类型系统的“后门”,TypeScript 会把类型检查关闭,它能够兼容所有的类型,因此所有类型都能被赋值给它。但我们必须减少对它的依赖,因为需要确保类型安全,除非必须使用它才能解决问题,当使用 any 时,基本上是在告诉 TypeScript 编译器不用进行任何类型检查。
任意值类型和 Object 有相似的作用,但是 Object 类型的变量只允许给它赋值不同类型的值,但是却不能在它上面调用方法,即便真有这些方法:

2.4.2 void、null 和 undefined

空值(void)、null 和 undefined 这几个值类似,在使用的过程中很容易混淆,以下依次进行说明:

  • 空值 void 表示不返回任何值,一般用于函数定义返回类型时使用,用 void 关键字表示没有任何返回值的函数,void 类型的变量只能赋值为 null 和 undefined,不能赋值给其他类型上(除了 any 类型以外);
  • null 表示不存在的对象值,一般只当作值来用,而不是当作类型使用;
  • undefined 表示变量已经声明但是尚未初始化的变量的值,undefined 通常也是当作值来使用;
    null 和 undefined 是所有类型的子类型,我们可以把 null 和 undefined 赋值给任何类型的变量。如果开启了 strictNullChecks 配置,那么 null 和 undefined 只能赋值给 void 和它们自身,这能避免很多常见的问题。

2.4.3 枚举

TypeScript 语言支持枚举类型,它是对JavaScript 标准数据类型的一个补充。枚举取值被限定在一定范围内的场景,在实际开发中有很多场景都适合用枚举来表示,枚举类型可以为一组数据赋予更加友好的名称,从而提升代码的可读性,使用 enum 关键字来定义:

enum SendType {
SEND_NORMAL,
SEND_BATCH,
SEND_FRESH,
...
}
console.log(SendType.SEND_NORMAL === 0) // true
console.log(SendType.SEND_BATCH === 1) // true
console.log(SendType.SEND_FRESH === 2) // true

TypeScript 前端工程最佳实践插图2TypeScript 前端工程最佳实践插图3

一般枚举的声明都采用首字母大写或者全部大写的方式,默认枚举值是从 0 开始编号。也可以手动编号为数值型或者字符串类型:

// 数值枚举
enum SendType {
SEND_NORMAL = 1,
SEND_BATCH = 2,
SEND_FRESH,  // 按以上规则自动赋值为 3
...
}
const sendtypeVal =  SendType.SEND_BATCH; 

// 编译后输出代码
var SendType;
(function (SendType) {
    SendType[SendType["SEND_NORMAL"] = 1] = "SEND_NORMAL";
    SendType[SendType["SEND_BATCH"] = 2] = "SEND_BATCH";
    SendType[SendType["SEND_FRESH"] = 3] = "SEND_FRESH"; // 按以上规则自动赋值为 3
})(SendType || (SendType = {}));

var sendtypeVal =  SendType.SEND_BATCH; 

// 字符串枚举
enum PRODUCT_CODE {
  P1 = 'ed-m-0001', // 特惠送
  P2 = 'ed-m-0002', // 特快送
  P4 = 'ed-m-0003', // 同城即日
  P5 = 'ed-m-0006', // 特瞬送城际
}

TypeScript 前端工程最佳实践插图2TypeScript 前端工程最佳实践插图3

这样写法编译后的常量代码比较冗长,而且在运行时 sendtypeVal 的取值不变,将会查找变量 SendType 和 SendType.SEND_BATCH。我们还有一个可以使代码更简洁且能获得性能提升的小技巧那就是使用常量枚举(const enum)。

// 使用常量枚举编译前
const enum SendType {
    SEND_NORMAL = 1,
    SEND_BATCH = 2,
    SEND_FRESH  // 按以上规则自动赋值为 3
}

const sendtypeVal =  SendType.SEND_BATCH;

// 编译后
var sendtypeVal = 2 /* SendType.SEND_BATCH */;

TypeScript 前端工程最佳实践插图2TypeScript 前端工程最佳实践插图3

2.4.4 never 类型

大多数情况我们并不需要手动定义 never 类型,只有在写一些非常复杂的类型和类型工具方法,或者为一个库定义类型等情况下才需要用到它,never 类型一般出现在函数抛出异常或存在无法正常结束的情况下。

2.4.5 元组类型

元组类型的声明和数组比较类似,只是元组中的各个元素类型可以不同。简单示例如下:

// 元祖示例
let row: [number, string, number] = [1, 'hello', 88];

TypeScript 前端工程最佳实践插图2TypeScript 前端工程最佳实践插图3

2.4.6 接口 interface

接口是 TypeScript 的一个核心概念,它能将多个类型声明组合成一个类型注解:

interface CountDown {
readonly uuid: string // 只读属性
  time: number
  autoStart: boolean
  format: string
value: string | number // 联合类型,支持字符串和数值型
[key: string]: number // 字符串的键,数值型的值
}
interface CountDown {
  finish?: () => void // 可选类型
  millisecond?: boolean // 可选方法
}
// 接口可以重复声明,多次声明可以合并为一个接口

TypeScript 前端工程最佳实践插图2TypeScript 前端工程最佳实践插图3

接口可以继承其它类型对象,相当于将继承的对象类型复制到当前接口:

interface Style {
color: string
}
interface: Shape {
name: string
}
interface: Circle extends Style, Shape {
radius: number

// 还会包含继承的属性
// color: string
// name: string
}
const circle: Circle = { // 包含 3 个属性
radius: 1,
color: 'red',
name: 'circle'
}

TypeScript 前端工程最佳实践插图2TypeScript 前端工程最佳实践插图3

如果子接口与父接口之间存在同名的类型成员,那么子接口中的类型成员具有更高优先级。

2.4.7 类型别名 type

TypeScript 提供了为类型注解设置别名的便捷方法——类型别名,类型别名就是可以给一个类型起一个新名字。在 TypeScript 中使用关键字 type 来描述类型变量:

type StrOrNum = string | number
// 用法和其它基本类型一样
let sample: StrOrNum
sample = 123
sample = '123'
sample = true // 错误

TypeScript 前端工程最佳实践插图2TypeScript 前端工程最佳实践插图3

与接口区别,我们可以为任意类型注解设置别名,这在联合类型和交叉类型中比较实用,下面是一些常用方法

type Text = string | { text: string } // 联合类型

type Coordinates = [number, number] // 元组类型
type Callback = (data: string) => void // 函数类型

type Shape = { name: string } // 对象类型
type Circle = Shape & { radius: number} // 交叉类型,包含了 name 和 radius 属性

TypeScript 前端工程最佳实践插图2TypeScript 前端工程最佳实践插图3

如果需要使用类型注解的层次结构,请使用接口,它能使用implements 和 extends。为一个简单的对象类型使用类型别名,只需要给它一个语义化的名字即可。另外,想给联合类型和交叉类型提供一个语义化的别名时,使用类型别名更加合适而不是用接口。类型别名与接口的区别如下:

  1. 类型别名能够表示非对象类型,接口则只能表示对象类型,因此我们想要表示原始类型、联合类型和交叉类型时只能使用类型别名;
  2. 类型别名不支持继承,接口可以继承其它接口、类等对象类型,类型别名可以借助交叉类型来实现继承的效果;
  3. 接口名总是会显示在编译器的诊断信息和代码编辑器的智能提示信息中,而类型别名的名字只在特定情况下显示;
  4. 接口具有声明合并的行为,而类型别名不会进行声明合并;

2.4.8 命名空间 namespace

随着项目越来越复杂,我们需要一种手段来组织代码,以便于在记录它们类型的同时还不用担心与其它对象产生命名冲突。因此我们把一些代码放到一个命名空间内,而不是把它们放到全局命名空间下。现实生活中,一个学校里经常会出现同名同姓的同学,如果在不同班里,就可以用班级名+姓名来区分。其实命名空间与班级名的作用一样,可以防止同名的函数和变量相互影响。
TypeScript 中命名空间使用 namespace 关键字来定义,基本语法格式:

namespace 命名空间名 {
const 私有变量; 
export interface 接口名;
export class 类名;
}
// 如果需要在命名空间外部调用需要添加 export 关键字
命名空间名.接口名;
命名空间名.类名;
命名空间名.私有变量; // 错误,私有变量不允许访问

TypeScript 前端工程最佳实践插图2TypeScript 前端工程最佳实践插图3

在构建比较复杂的应用时,往往需要将代码分离到不同的文件中,以便进行维护,同一个命名空间可以出现在多个文件中。尽管是不同的文件,但是它们依然是同一个命名空间,使用时就如同它们在一个文件中定义的一样。

// 多文件命名空间
// Validation.ts
namespace Validation {
export interface StringValidator {
isAcceptable(s: string): boolean;
}
}

// NumberValidator.ts
namespace Validation { // 相同命名空间
export interface NumberValidator {
isAcceptable(num: number): boolean;
}
}

TypeScript 前端工程最佳实践插图2TypeScript 前端工程最佳实践插图3

2.4.9 泛型

TypeScript 设计泛型的关键动机是在成员之间提供有意义的类型约束,这些成员可以是类的实例成员、类的方法、函数的参数、函数的返回值。使用泛型,可以将相同的代码用于不同的类型(语法:一般在类名、方法名的后面加上 ),一个队列的简单实现与泛型的示例:

class Queue {
private data = []
push = item => this.data.push(item)
pop = () => this.data.shift()
}

const queue = new Queue()
// 在没有约束的情况下,开发人员很可能进入误区,导致运行时错误(或潜在问题)
queue.push(0) // 最初是数值类型
queue.push('1') // 有人添加了字符串类型

// 使用过程中,走入了误区
console.log(queue.pop().toPrecision(1));
console.log(queue.pop().toPrecision(1)); // 运行时错误

TypeScript 前端工程最佳实践插图2TypeScript 前端工程最佳实践插图3

一个解决办法可以解决以上问题:

class QueueOfNumber {
private data: number[] = []
push = (item: number) => this.data.push(item)
pop = (): number => this.data.shift()
}
const queue = new Queue()

queue.push(0) 
queue.push('1') // 错误,不能放入一个 字符串类型 的数据

TypeScript 前端工程最佳实践插图2TypeScript 前端工程最佳实践插图3

这么做如果需要一个字符串的队列,怎么办?需要重写一遍类似的代码?这时就可以用到泛型,可以让放入的类型和取出的类型一样:

class Queue {
private data: T[] = []
push = (item: T) => this.data.push(item)
pop = (): T | undefined => this.data.shift()
}
// 数值类型
const queue = new Queue()
queue.push(0) 
queue.push(1) 
// 或者 字符串类型
const queue = new Queue()
queue.push('0')
queue.push('1')

TypeScript 前端工程最佳实践插图2TypeScript 前端工程最佳实践插图3

我们可以随意指定泛型的参数类型,一般使用简单的泛型时,常用 T、U、V 表示。如果在我们的参数里,拥有不止一个泛型,就应该使用更加语义化的名称,如 TKey 和 TValue。依照惯例,以 T 作为泛型的前缀,在其它语言已经是约定俗成的方式了。

2.4.10 类型断言

TypeScript 程序中的每一个表达式都具有某种类型,编译器可以通过类型注解或类型推导来确定表达式类型,但有时,开发者比编译器更清楚某个表达式的类型,因此就需要用到类型断言,类型断言(Type Assertion) 可以用来手动指定一个值的类型,告诉编译器应该是什么类型,具体语法如下:

  • expr(值、对象或者表达式);
  • expr as T (值或者对象 as 类型);
  • expr as const 或 expr 可以将某类型强制转换成不可变类型;
  • expr!(!类型断言):非空类型断言运算符 “!” 是 TypeScript 特有的类型运算符;
type AddressVO = { address: string }
(sendAddress).address //  类型断言
(sendAddress as AddressVO).address // as 类型断言

let val = true as const // 等于 const val = true
function getParams(router: { params: Array } | undefined) {
if(!router) return ''

return router!.params // 告诉编译器 router 是非空的
}

TypeScript 前端工程最佳实践插图2TypeScript 前端工程最佳实践插图3

3 深入 TypeScript 泛型编程

泛型编程是一种编程风格或者编程范式,它允许在程序中定义形式类型参数,然后在泛型实例化时使用实际类型参数来替换形式类型参数。刚开始进行 TypeScript 开发时,我们很容易重复的编写代码,通过泛型,我们能够定义更加通用的数据结构和类型。许多编程语言都很流行面向对象编程,可以创建公共接口的类并隐藏实现细节,让类之间进行交互,可以有效管理复杂度对复杂领域分而治之。但是对于前端来说泛型编程可以更好的解耦、组件化和可复用。接下来使用泛型处理一种常见的需求:通过示例创建独立的、可重用的组件。

3.1 解耦关注点

我们需要一个 getNumbers 函数返回一个数字数组,允许在返回数组之前对每一项数字应用一个变换处理函数,该函数接收一个数字返回一个新数字。如果调用者不需要任何处理,可以将只返回其结果的函数作为默认值。

type TransformFunction = (value: number) => number

function doNothing(value: number): number ( // doNothing() 只返回原数据,不进行任何处理
  return value
)

function getNumbers(transform: TransformFunction = doNothing): number[] {
    /** */
}

TypeScript 前端工程最佳实践插图2TypeScript 前端工程最佳实践插图3

又出现另一种业务场景,有一个 Widget 对象数组,可以从 WidgetWidget 对象创建一个 AssembledWidget 对象。assembleWidgets() 函数处理一个 Widget 对象数组,并返回一个 AssembledWidget 对象数组。因为我们不想做不必要的封装,所以 assembleWidgets() 将一个 pluck() 函数作为实参,给定一个 Widget 对象数组时,pluck() 返回该数组的一个子集。允许调用者告诉函数需要哪些字段,从而忽略其余字段。

type PluckFunction = (widgets: Widget) => Widget[]

function pluckAll(widgets:  Widget[]):  Widget[] ( 
// pluckAll() 返回全部,不进行任何处理
  return widgets
)

// 如果用户没有提供 pluck() 函数,则返回 pluckAll 作为实参的默认值
function assembleWidgets(pluck: PluckFunction = pluckAll): AssembledWidget[] {
    /** */
}

TypeScript 前端工程最佳实践插图2TypeScript 前端工程最佳实践插图3

仔细观察可以两处代码都有相似之处,doNothing() 和 pluckAll() 它们都接收一个参数,并不做处理就返回。它们的区别只是接收和返回的值类型不同:doNothing 使用数字,pluckAll 使用 Widget 对象数字,两个函数都是恒等函数。在代数中恒等函数指的是 f(x) = x。在实际开发中这种恒等函数会有很多,出现在各处,我们需要编写一个可重用的恒等函数来简化代码,使用 any 类型是不安全的它会绕过正常的类型检查,这时我们就可以使用泛型恒等函数:

function identity(value: T):  T ( // 有一个类型参数 T 的泛型恒等函数
  return value
)
// 可以使用 identity 代替 doNothing 和 pluckAll

TypeScript 前端工程最佳实践插图2TypeScript 前端工程最佳实践插图3

采用这种实现方式,可以将恒等逻辑与实际业务逻辑问题进行更好的解耦,恒等逻辑可以完全独立出来。这个恒等函数的类型参数是 T,当为 T 指定了实际类型时,就创建了具体的函数。

TypeScript 前端工程最佳实践插图8

泛型类型:是指参数化一个或多个类型的泛型函数、类、接口等。泛型类型允许我们编写能够支持不同类型的通用代码,从而实现高度的代码重用。使用泛型让代码的组件化程度更高,我们可以把这些泛型组件用作基本模块,通过组合它们实现期望的行为,同时在组件之间只保留下最小限度的依赖。

3.2 泛型数据结构

假如我们要实现一个数值二叉树和字符串链表。把二叉树实现为一个或多个结点,每个结点存储一个数值,并引用其左侧和右侧的子结点,这些引用指向结点,如果没有子结点,可以指向 undefined。

class NumberBinaryTreeNode {
  value: number
  left: NumberBinaryTreeNode | undefined
  right: NumberBinaryTreeNode | undefined

  constructor(value: number) {
    this.value = value
  }
}

TypeScript 前端工程最佳实践插图2TypeScript 前端工程最佳实践插图3

类似地,我们实现链表为一个或多个结点,每个结点存储一个 string 和对下一个结点的引用,如果没有下一个结点,引用就指向 undefined。

class StringLinkedListNode {
  value: string
  next: StringLinkedListNode | undefined

  constructor(value: string) {
    this.value = value
  }
}

TypeScript 前端工程最佳实践插图2TypeScript 前端工程最佳实践插图3

如果工程的其它部分需要一个字符串二叉树或者数值列表我们可以简单的复制代码,然后替换几个地方,复制从来不是一个好选择,如果原来的代码有Bug,很可能会忘记在复制的版本中修复 Bug。我们可以使用泛型来避免复制代码。
我们可以实现一个泛型的 NumberTreeNode,使其可用于任何类型:

class BinaryTreeNode {
  value: T
  left: BinaryTreeNode | undefined
  right: BinaryTreeNode | undefined

  constructor(value: T) {
    this.value = value
  }
}

TypeScript 前端工程最佳实践插图2TypeScript 前端工程最佳实践插图3

实际我们不应该等待有字符串二叉树的新需求才创建泛型二叉树:原始的 NumberBinaryTreeNode 实现在二叉树数据结构和类型 number 之间产生了不必要的耦合。同样,我们也可以把字符串链表替换成泛型的 LinkedListNode:

class LinkedListNode {
  value: string
  next: LinkedListNode | undefined

  constructor(value: string) {
    this.value = value
  }
}

TypeScript 前端工程最佳实践插图2TypeScript 前端工程最佳实践插图3

我们要知道,有很成熟的库已经提供了所需的大部分数据结构(如列表、队列、栈、集合、字典等)。介绍实现,只是为了更好的理解泛型,在真实项目中最好不要自己编写代码,可以从库中选择泛型数据结构,去阅读库中泛型数据结构的代码更有助于提升我们的编码能力。一个可以迭代的泛型链表完整实现供参考如下:

type IteratorResult = {
  done: boolean
  value: T
}

interface Iterator {
  next(): IteratorResult
}

interface IterableIterator extends Iterator {
    [Symbol.iterator](): IterableIterator;
}

function* linkedListIterator(head: LinkedListNode): IterableIterator {
  let current: LinkedListNode | undefined = head
  while (current) {
    yield current.value // 在遍历链表过程中,交出每个值
    current = current.next
  }
}

class LinkedListNode implements Iterable {
  value: T
  next: LinkedListNode | undefined

  constructor(value: T) {
    this.value = value
  }

  // Symbol.iterator 是 TypeScript 特有语法,预示着当前对象可以使用 for ... of 遍历
  [Symbol.iterator](): Iterator { 
    return linkedListIterator(this)
  }
}

TypeScript 前端工程最佳实践插图2TypeScript 前端工程最佳实践插图3

我们使用了生成器在遍历数据结构的过程中会交出值,所以使用它能够简化遍历代码。生成器返回一个 IterableIterator,所以我们可以直接在 for … of 循环中使用。
以上对泛型编程的介绍只是凤毛菱角,其实泛型编程支持极为强大的抽象和代码可重用性,使用正确的抽象时,我们可以写出简洁、高性能、容易阅读且优雅的代码。

4 TypeScript 注释指令

4.1 常用注释指令

TypeScript 编译器可以通过编译选项设置对所有 .ts 和 .tsx 文件进行类型检查。但是在实际开发中有些代码可能无法避免检查错误,因此 TypeScript 提供了一些注释指令来忽略或者检查某个JavaScript 文件或者代码片段:

  • // @ts-nocheck: 为某个文件添加这个注释,就相当于告诉编译器不对该文件进行类型检查。即使存在错误,编译器也不会报错;
  • // @ts-check: 与上个注释相反,可以在某个特定的文件添加这个注释指令,告诉编译器对该文件进行类型检查;
  • // @ts-ignore: 注释指令的作用是忽略对某一行代码进行类型检查,编译器进行类型检查时会跳过指令相邻的下一行代码;4.2 JSDoc 与类型JSDoc 是一款知名的为 JavaScript 代码添加文档注释的工具,JSDoc 利用 JavaScript 语言中的多行注释结合特殊的“JSDoc 标签”来为代码添加丰富的描述信息。
    TypeScript 编译器可以自动推断出大部分代码的类型信息,也能从 JSDoc 中提取类型信息,以下是TypeScript 编译器支持的部分 JSDoc 标签:
  • @typedef 标签能够创建自定义类型;
  • @type 标签能够定义变量类型;
  • @param 标签用于定义函数参数类型;
  • @return 和 @returns 标签作用相同,都用于定义函数返回值类型;
  • @extends 标签定义继承的基类;
  • @public @protected @private 标签分别定义类的公共成员、受保护成员和私有成员;
  • @readonly 标签定义只读成员;

4.3 三斜线指令

三斜线指令是一系列指令的统称,它是从 TypeScript 早期版本就开始支持的编译指令。目前,已经不推荐继续使用三斜线指令了,因为可以使用模块来取代它的大部分功能。简单了解一下即可,它以三条斜线开始,并包含一个XML标签,有几种不同的语法:

TypeScript 前端工程最佳实践插图9

5 TypeScript 内置工具类型

TypeScript 提供了很多内置的工具类型根据不同的应用场景选择合适的工具可以减轻很多工作,减少冗余代码提升代码质量,下面列举了一些常用的工具:

  • Partial:构造一个新类型,并将类型 T 的所有属性变为可选属性;
  • Required:构造一个新类型,并将类型 T 的所有属性变为必选属性;
  • Readonly: 构造一个新类型,并将类型 T 的所有属性变为只读属性;
  • Pick: 已有对象类型中选取给定的属性名,返回一个新的对象类型;
  • Omit: 从已有对象类型中剔除给定的属性名,返回一个新的对象类型;
    示例代码:
interface A {
  x: number
  y: number
  z?: string
}
type T0 = Partial
// 等价于 
type T0 = {
    x?: number | undefined;
    y?: number | undefined;
    z?: string | undefined;
}

type T1 = Required
// 等价于
type T1 = {
    x: number;
    y: number;
    z: string;
}

type T2 = Readonly
// 等价于
type T2 = {
    readonly x: number;
    readonly y: number;
    readonly z?: string | undefined;
}

type T3 = Pick
// 等价于
type T3 = {
    x: number;
}

type T4 = Omit
// 等价于
type T4 = {
    y: number;
    z?: string | undefined;
}

TypeScript 前端工程最佳实践插图2TypeScript 前端工程最佳实践插图3

6 TypeScript 提效工具

6.1 TypeScript 演练场

TypeScript 开发团队提供了一款非常实用的在线代码编辑工具——TypeScript 演练场
地址:https://www.typescriptlang.org/zh/play

TypeScript 前端工程最佳实践插图10

  • 左侧编写 TS 代码,右侧自动生成编译后的代码;
  • 可以自主选择 TypeScript 编译版本;
  • 版本列表最后一项是一个特殊版本 “Nightly” 即 “每日构建版本”,想尝试最新功能可以试试;
  • 支持 TypeScript 大部分配置项和编译选项,可以模拟本地环境,查看代码片段的输出结果;

6.2 JSDoc Generator 插件

如果使用的是 vscode 编辑器直接搜索( JSDoc Generator 插件)插件地址:https://marketplace.visualstudio.com/items?itemName=crystal-spider.jsdoc-generator 安装成功后,使用 Ctrl + Shift + P 打开命令面板,可以进行如下操作可以自动生成带有 TypeScript 声明类型的文档注释:

  • 选择 Generate JSDoc 为当前光标处代码生成文档注释;
  • 选择Generate JSDoc for the current file 为当前文件生成文档注释;

    6.3 代码格式化工具
    VSCode 仅提供了基本的格式化功能,如果需要定制更加详细的格式化规则可以安装专用的插件来实现。我们使用 Prettier 功能非常强大(推荐使用),它是目前最流行的格式化工具: https://prettier.io/,同时也提供了一个在线编辑器:https://prettier.io/playground/6.4 模块导入自动归类和排序在多人协作开发时代码越来越复杂,一个文件需要导入很多模块,每个人都会加加着加着就有点乱了,绝对路径的、相对路径的,自定义模块、公用模块顺序和类别都是混乱的,模块导入过多还会出现重复的。引入 TypeScript 之后检查更加严格,导入的不规范会有错误提示,如果只靠手动优化工作量大且容易出错。VSCode 编辑器提供了按字母顺序自动排序和归类导入语句的功能,直接按下快捷键“Shift + Alt + O”即可优化。也可以通过右键菜单“Source Action” 下的 “Organize Imports” 选项来进行优化导入语句。6.5 启用 CodeLens

CodeLens 是一项特别好用的功能,它能够在代码的位置显示一些可操作项,例如:

  • 显示函数、类、方法和接口等被引用的次数以及被哪些代码引用;
  • 显示接口被实现的次数以及谁实现了该接口;

VSCode 已经内置了 CodeLens 功能,只需要在设置面板开启,找到TypeScript 对应的 Code Lens 两个相关选项并勾选上:

TypeScript 前端工程最佳实践插图11

开启后的效果,出现引用次数,点击 references 位置可以查看哪里引用了:

TypeScript 前端工程最佳实践插图12

6.6 接口自动生成 TypeScript 类型

对于前端业务开发来说,最频繁的工作之一就是和接口打交道,前端和接口之间经常出现出入参不一致的情况,后端的接口定义也需要在前端定义相同的类型,大量的类型定义如果都靠手写不仅工作量大而且容易出错。因此,我们需要能够自动生成这些接口类型定义的 TypeScript 代码。VSCode 插件市场就有这样一款插件——Paste JSON as Code 。
插件地址:https://marketplace.visualstudio.com/items?itemName=quicktype.quicktype
安装这个 VSCode 插件可以将接口返回的数据,自动转换成类型定义接口文件。
1.剪贴板转换成类型定义:首先将 JSON 串复制到剪贴板, Ctrl + Shift + P 找到命令:Paste JSON to Types -> 输入接口名称

{"a":1,"b":"2","c":3} // 复制这段 JSON 代码

// Generated by https://quicktype.io
export interface Obj {
  a: number;
  b: string;
  c: number;
}

TypeScript 前端工程最佳实践插图2TypeScript 前端工程最佳实践插图3

2.JSON 文件转换类型定义(这个更常用一些):打开 JSON 文件使用Ctrl + Shift + P 找到命令: Open quicktype for JSON。下图为 package.json 文件生成类型定义的示例:

TypeScript 前端工程最佳实践插图13

对应大量且冗长的接口字段一键生成是不是很方便呢!希望这些工具能给每一位研发带来帮助提升研发效率。

7 总结

TypeScript 是一个比较复杂的类型系统,本文只是对其基本用法进行了简要说明和工作中用到的知识点,适合刚开始使用 TypeScript 或者准备使用的研发人员,对于更深层次的架构设计和技术原理并未提及,如果感兴趣的可以线下交流。用好 TypeScript 可以编写出更好、更安全的代码希望对读到本文的有所帮助并能在实际工作中运用。希望本文作为 TypeScript 入门级为读者做一个良好的开端。感谢阅读!!

文章来源于互联网:TypeScript 前端工程最佳实践

THE END
分享
二维码