diff --git a/style-guid/typescript_style_guid.md b/style-guid/typescript_style_guid.md new file mode 100644 index 0000000..3262a5a --- /dev/null +++ b/style-guid/typescript_style_guid.md @@ -0,0 +1,1674 @@ + + + + +Farris UI TypeScript 编码指南 + +
+

Farris UI TypeScript 编码指南

+ +## 语法风格 + +### 标识符 + +我们规定只能使用ASCII字母、数字、下划线做为标识符。 + +| 代码风格 | 分类 | +|---|---| +| UpperCamelCase | 类 / 接口 / 类型 / 枚举 / 装饰器 / 类型参数 | +| lowerCamelCase | 变量 / 方法参数 / 方法 / 属性 / 模块别名 | +| CONSTANT_CASE | 全局常量 / 全局枚举值 | + +- **缩写**:一般情况下,使用首字母大写方式书写缩写单词,例如:`loadHttpUrl`,除非被第三方平台或者框架要求必须符合其名称,例如:`XMLHttpRequest`。 +- **\$符号**:除非为了与第三方框架的命名规则一致,否则不应该在标识符中使用`$`符号。在Observable values 对象命名中使用`$`符号,请参见下方独立章节。 +- **下划线**:不应用`_`做为`标识符的前缀或者后缀。 +- **常量**:我们用`CONSTANT_CASE`这种命名风格表示这是一个预计不会被修改的值。这种命名方式也可以用于在技术上允许修改,但是希望提醒开发者不应用修改值的场景,例如:没有被深度冻结的值。 +```TypeScript +const UNIT_SUFFIXES = { + 'milliseconds': 'ms', + 'seconds': 's' +}; +// 尽管在JavaScript规则中,UNIT_SUFFIXES是可变化的 +// 但是大写字母命名方式可以提醒开发者,不要改变其内部的值 +``` +常量也可能是类中的某个`static readonly`属性,如下所示: +```TypeScript +class Foo { + private static readonly MY_SPECIAL_NUMBER = 5; + + bar() { + return 2 * Foo.MY_SPECIAL_NUMBER; + } +} +``` +如有一个值可以在应用程序声明周期内,被多次实例化,或者开发者可以任意改变它,这个值必须使用`lowerCamelCase`模式命名。 +如果一个值是实现某接口的箭头函数,这个值可以用`lowerCamelCase`模式声明。 + +### 命名风格 + +- 不要用下划线前缀或者后缀表示私有属性或者方法 +- 不要用`option_`前缀表示可选参数 +- 除非符合上下文环境习惯用法,否则不要在声明接口时特殊标记接口。例如:~~`IMyInterface`~~或者~~`MyFooInterface`~~。命名一个类的接口时,应为其命名一个容易看为什么定义此接口的名字。例如:如果要定义一个标识序列化存储JSON数据的接口,可以为`class TodoItem`定义接口`interface TodoItemStorage`。 +- 为`Observable`对象增加`$`前缀,是区分可观察对象和具体对象的一般约定。这种命名约定是否有助于区分对象行为,取决于每个团队自己的判断,但是应该在当前项目中坚持此命名规则。 + +### 描述性命名 + +命名**必须**是描述性的,并且容易被新人理解。不要使用**含义不明确**或者除你的项目**外对开发者不熟悉**的缩写。**不要**通过在单词中**删减**字母从而缩写命名。 +- 在任何情况下,都不不要使用单字母变量简写命名。 +- 编写的代码主要是为了方便人阅读。 +- 我们可以将精简名称长度的任务交给打包代码环节的压缩代码插件。 + +## 注释风格 + +我们在TypeScript中使用JSDoc`/**...*/`和普通注释`// ... 或者 /* ... */`进行注释代码。 +- 对于展示给用户阅读的注释,使用`/** JSDoc *`格式,例如:通过工具自动提取至API文档 中的注释。 +- 对于注释代码实现细节的注释,使用普通单行注释`// 单行注释`。 +- 省略对于 TypeScript 而言多余的注释:不要再在`@param`、`@return`中注释声明类型了。 +- 不要在TypeScript代码中使用`@override`注释。 +- 如果函数或者参数的类型及名称足以描述自身,则可以省略注释,不要做照抄参数类型和参数名的注释,例如: +```TypeScript + // 这个注释没有任何有意义的内容。 + /** @param fooBarService Foo 应用的 Bar 服务 */ +``` +- 如果通过为构造函数的参数添加访问限定符,声明了类成员,可以使用JSDoc的`@param`添加注释,以便于将参数说明提取的API文档中。 +```TypeScript + /** 这个类演示了如何为参数属性添加文档 */ + class ParamProps { + /** + * @param percolator 煮咖啡所用的咖啡壶。 + * @param beans 煮咖啡所用的咖啡豆。 + */ + constructor( + private readonly percolator: Percolator, + private readonly beans: CoffeeBean[]) {} + } +``` +- 对于在类中声明的属性,可以在属性前使用JSDoc`/** ... */`模式对属性进行注释。 +```TypeScript + /** 这个类演示了如何为普通成员添加文档 */ + class OrdinaryClass { + /** 下次调用 brew() 时所用的咖啡豆。 */ + nextBean: CoffeeBean; + + constructor(initialBean: CoffeeBean) { + this.nextBean = initialBean; + } + } +``` + +## 语言特性 + +### 可见性 + +在TypeScript中,只是使用修饰符描述元素可见性。你在编码时,应该遵循以下规范: +- 应当尽可能严格的限制符合可见性,仅将对外暴露的元素设置为public。 +- 在 TypeScript 中,虽然类成员默认的可见性就为 `public` ,但为与`private`修饰符统一编码风格,明确告知后续编码者这个类成员是公开的,还是应该显示声明`public`修饰符。 + +```TypeScript + class Foo { + // 请使用 public 修饰符,显式声明此变量是公开变量。 + public bar = new Bar(); + // 在构造函数的形参中声明变量,当使用 readonly 修饰符声明变量时,也需要标注public修饰符。 + constructor(public readonly baz: Baz) {} + } +``` + + +### 声明类成员 + +应当使用TypeScript的访问修饰符`private`声明私有成员 + +```TypeScript + class Foo { + private mySpecilNumber = 1; + } +``` + +当声明不会在构造函数外对其赋值的变量时,应该使用 `readonly`修饰符标记变量。此时为精简代码,应该使用TypeScript的[参数属性]( https://www.typescriptlang.org/docs/handbook/classes.html#parameter-properties) 语法,既不要在构造函数外显式声明变量,也不要在构造函数中显式地初始化变量。 +```TypeScript + class Foo { + // 既不要在构造函数外显式地声明变量 + private readonly barService: BarService; + + constructor(barService: BarService) { + // 也不要在构造函数内部显式地初始化变量 + this.barService = barService; + } + } +``` + + +```TypeScript + class Foo { + // 使用TypeScript的「参数属性」简洁明了 + constructor(private readonly barService: BarService) {} + } +``` + +### 初始化类成员 + +如果某个某个类成员并非参数属性,在声明时就应当对其初始化。 + +```TypeScript + class Foo { + private readonly barList: string[]; + constructor() { + // 不要在构造函数内才初始化类成员 + this.barList = []; + } + } +``` + +```TypeScript + class Foo { + // 将声明与初始化代码放在一起,既避免因为失误而忽略初始化,也便于在一处阅读此变量的代码 + private readonly barList: string[] = []; + } +``` + +### 存取器 + +可以在类中使用存取器声明属性成员,其取值器方法必须是纯函数,即不能带来改变对象状态的副作用。存取器也可以用于隐藏类内部的复杂实现细节。 + +```TypeScript + class Foo { + constructor(private readonly barService: BarService) {} + + get bar(): string { + return this.barService.someProperty; + } + + set bar(value: string) { + this.barService.someProperty = value; + } + } +``` + +如果存取器被用于隐藏类内部的某个属性,应当遵循以下规范: +- 应当添加 `internal`前缀,以示这是被隐藏的内部属性。 +- 一旦为私有属性定义了存取器,在类中访问这些私有属性时,就必须通过存取器进行访问,除非为对其定义取值器或赋值器。 +- 取值器和设值器至少有一个承担了取值或者赋值之外的逻辑,否则应该直接使用`public`或者`readonly`标识符标记属性。 +- 非平凡的,也就是说,存取器不能只用于传递属性值,更不能依赖这种存取器对属性进行隐藏。这种情况下,应当直接将属性设为 `public` 。对于只有取值器没有设值器的属性,则应当考虑直接将其设为 `readonly` 。 +```TypeScript + class Foo { + private internalBar = ''; + + get bar() { + return this.internalBar || 'bar'; + } + + set bar(value: string) { + this.internalBar = value.trim(); + } + } +``` + +### 原始类型的封装类 + +不要在TypeScript中用原生类型的封装类,例如 `String` 、 `Boolean` 、 `Number` 等,实例化变量。 + +```TypeScript + // 请不要这样做 + const greeting = new String('hello'); + const hasMetBefore = new Boolean(false); + const metTimes = new Number(5); +``` + +```TypeScript + // 应该这样声明变量 + const greeting = 'hello'; + const hasMetBefore = false; + const metTimes = 5; +``` + +### 数组构造函数 + +不要在 TypeScript 中使用 `Array()` 构造函数构造数组,应该使用字面量方式直接声明数组,以避免许多不合直觉的行为。 + +```TypeScript + // 不要这样做, 传入不同的参数个数,其构造函数会表现不同的构造方式。 + const fooArray = new Array(2); + // 此时参数2被视作数组的长度,构造的结果是 [undefined, undefined] + const barArray = new Array(2, 3); + // 此时参数2, 3又会被视为数组中的元素,返回的结果变成了 [2, 3] +``` + +应当使用方括号对数组进行初始化,或者使用 `from` 构造一个具有确定长度的数组: + +```TypeScript + // 使用字面量直接声明数组,简单明了。 + const fooArray = [2]; + const barArray = [2, 3]; + + // 可以使用length声明数组长度 + const emptyBarArray = []; + emptyBarArray.length = 2; + + // 使用fill方法初始化填充了元素的数组[0, 0, 0, 0, 0] + Array.from({length: 5}).fill(0); +``` + +### 强制类型转换 + +应该在 TypeScript 中,使用 `String()` 和模板字符串强制转换为`string`类型,使用`Boolean()` 和 `!!` 运算符强制转换为`boolean`类型。 + +```TypeScript + const bool = Boolean(false); + const str = String(aNumber); + const bool2 = !!str; + const str2 = `result: ${bool2}`; +``` + +不建议通过字符串连接操作将类型强制转换为 `string` ,这会导致加法运算符两侧的运算对象具有不同的类型。 + +在将其它类型转换为数字时,必须使用 `Number()` 函数,在类型转换有可能失败的场合,必须显式地检查其返回值是否为 `NaN` 。 + +> Number('')、 Number(' ') 和 Number('\t') 返回 0 而不是 NaN。 Number('Infinity') 和 Number('-Infinity') 分别返回 Infinity和 -Infinity。这些情况可能需要特殊处理。 + +```TypeScript + const aNumber = Number('123'); + if (isNaN(aNumber)) { + // 如果输入字符串有可能无法被解析为数字,就需要处理返回 NaN 的情况。 + throw new Error(...); + } + assertFinite(aNumber, ...); + // 如果输入字符串已经保证合法,可以在这里添加断言。 +``` + +禁止使用一元加法运算符 `+` 将字符串强制转换为数字。 +用这种方法做强制类型转换,有可能解析失败,也有可能出现奇怪的边界情况。 +而且,这样的写法往往成为代码中的坏味道, `+` 在代码审核中非常容易被忽略掉。 + +```TypeScript + // 不要使用+将字符串转换为数字 + const x = +y; +``` + +同样地,也禁止在代码中使用 `parseInt` 或 `parseFloat` 进行强制类型转换,因为这两个函数都会忽略字符串中的后缀。 +除非以下场景: +- 需要使用其忽略字符串后缀的特性,解析CSS中像素尺寸,此时应该有备注标明书写意图。 +- 用于解析表示非十进制数字的字符串。 + +```TypeScript + const width = parseInt('100px', 10); // 无论传不传基数, +``` + +对于需要解析非十进制数字的情况,在调用 `parseInt` 进行解析之前必须检查输入是否合法。 + +```TypeScript + if (!/^[a-fA-F0-9]+$/.test(someString)){ + throw new Error(...); + } + // 需要解析 16 进制数。 + // tslint:disable-next-line:ban + const newNumber = parseInt(someString, 16); // 只允许在非十进制的情况下使用 parseInt。 +``` + +应当使用 `Number()` 和 `Math.floor` 或者 `Math.trunc` 解析整数。 + +```TypeScript + let someNumber = Number(someString); + if (isNaN(someNumber)) { + handleError(); + } + someNumber = Math.floor(someNumber); +``` + +不要在 `if` 、 `for` 或者 `while` 的条件语句中显式地将类型转换为 `boolean` ,因为这里原本就会执行隐式的类型转换。 + +```TypeScript + // 不要这样做 + const foo: MyInterface|null = ...; + if (!!foo) {...} + while (!!foo) {...} +``` + +```TypeScript + // 这里原本就会进行隐式类型转换 + const foo: MyInterface|null = ...; + if (foo) {...} + while (foo) {...} +``` + +在代码中使用显式和隐式类型转换均可。 + +```TypeScript + // 显式地和 0 进行比较 + if (arr.length > 0) {...} + + // 依赖隐式类型转换 + if (arr.length) {...} +``` + +### 变量 + +声明变量时应该遵循以下原则: +- 必须使用 `const` 或 `let` 声明变量。 +- 应该尽可能地使用 `const` ,除非这个变量需要被重新赋值才使用`let`。 +- 禁止使用 `var` 。 +- 必须在使用前进行声明。 + +```TypeScript + const foo = otherValue; + // 如果 foo 不可变,就使用 const。 + let bar = someValue; + // 如果 bar 在之后会被重新赋值,就使用 let。 +``` + +### 异常 + +在实例化异常对象时,必须使用 `new Error()` 而不要调用 `Error()` 函数。 + +```TypeScript + // 使用Error构造函数创建一个异常对象,使用new函数与其他实例化对象方式保持一致。 + throw new Error('Foo is not a valid bar.'); + // 不要这样做 + throw Error('Foo is not a valid bar.'); +``` + +### 迭代遍历对象属性 + +使用 `for (... in ...)` 语法迭代访问对象时,必须使用`hasOwnProperty`方法判断属性是对象自身属性,而非从原型链中继承得来的属性。 +禁止使用不加判断的 `for (... in ...)` 语句。 + +```TypeScript + // 不要这样做 + for (const x in someObj) { + // x 可能包括 someObj 从原型中继承得到的属性。 + } +``` + +在对对象进行迭代时,必须使用 `if` 语句对对象的属性进行过滤,或者使用 `for (... of Object.keys(...))` 。 + +```TypeScript + // 应当这样做 + for (const x in someObj) { + if (!someObj.hasOwnProperty(x)) { + continue; + } + // 此时 x 必然是定义在 someObj 上的属性。 + } +``` + +```TypeScript + // 或者这样做 + for (const x of Object.keys(someObj)) { + // 注意:这里使用的是 for _of_ 语法 + // 此时 x 必然是定义在 someObj 上的属性。 + } +``` + +``` + // 这样也可以 + for (const [key, value] of Object.entries(someObj)) { + // 注意:这里使用的是 for _of_ 语法! + // 此时 key 必然是定义在 someObj 上的属性。 + } +``` + +### 迭代访问数组元素 + +不要使用 `for (... in ...)` 迭代访问数组元素,这样做是迭代访问数组下标,并且会将其强制转换为`string`类型,是一个违反直觉的操作。 + +```TypeScript + // 不要这样做 + for (const property in someArray) { + // 这里的 property 是数组的下标,并且被强制类型转换为string类型。 + } +``` + +应当使用 `for (... of someArr)` 语句或者传统的 `for` 循环语句迭代访问数组元素。 + +```TypeScript + // 应当这样做 + for (const element of someArr) { + // 这里的element才是数组元素。 + } +``` + +```TypeScript + // 也可以这样做 + for (let index = 0; index < someArr.length; index++) { + const element = someArr[index]; + // ... + } +``` + +```TypeScript + // 这样也可以 + for (const [index, element] of someArr.entries()) { + + } +``` + +如果在变量数组元素时,要判断元素是否符合某条件,往往会伴随嵌套`for`循环、`if-else`分支判断语句,此时建议先用数组的`filter`方法过滤数据,然后在用`forEach`语句遍历元素。 + +### 展开运算符 + +可以使用展开运算符复制数组或对象,使用展开运算符时,对于同一个键,后出现的值会取代先出现的值。 + +```TypeScript + const foo = { + num: 1, + }; + + const foo2 = { + ...foo, + num: 5, + }; + + const foo3 = { + num: 5, + ...foo, + } + + // foo2中的num晚于展开运算符出现,所有其值为5。 + foo2.num === 5; + + // foo3中的num早于展开运算符出现,所以其值为1。 + foo3.num === 1; +``` + +在使用展开运算符时,被展开的值必须与被创建的值相匹配。也就是说,在创建对象时只能展开对象,在创建数组时只能展开可迭代类型。 +禁止展开原始类型,包括 `null` 和 `undefined` 。 + +```TypeScript + const foo = {num: 7}; + const bar = {num: 5, ...(shouldUseFoo && foo)}; + // 不要这样写,展开运算符有可能作用于 undefined。 + +``` + +```TypeScript + // 不要这样写,展开的值与被作用的值应该是同一类型。 + // 这样写会创建对象 {0: 'a', 1: 'b', 2: 'c'}。 + const fooStrings = ['a', 'b', 'c']; + const ids = {...fooStrings}; +``` + +```TypeScript + // 可以这样做,使用展开运算符创建新对象。 + const foo = shouldUseFoo ? {num: 7} : {}; + const bar = {num: 5, ...foo}; + + // 也可以这样做,在创建新数组时,使用展开元素符,将原有数组元素添加入新数组。 + const fooStrings = ['a', 'b', 'c']; + const ids = [...fooStrings, 'd', 'e']; +``` + +### 语句块 + +对于`for`、`if-else`语句来说,其语句块必须包含在大括号内,即使只有一条语句,也需要换号。 + +```TypeScript + // 应当使用大括号将语句扩起来 + for (let index = 0; index < elementCount; index++) { + doSomethingWith(index); + andSomeMore(); + } + if (foo) { + // 即使只有一条语句,也需要换行,并且用括号将语句包裹起来。 + doSomethingWithALongMethodName(foo); + } +``` + +```TypeScript + // 不要这样做 + if (foo) + foo.doFoo(); + // 也不要这样做 + for (let index = 0; i < elementCount; index++) + doSomethingWithALongMethodName(index); + // 这样也不行! + if (foo) foo.doFoo(); +``` + +### `switch` 语句 + +所有的 `switch` 语句都必须包含一个 `default` 分支,即使这个分支里没有任何代码。 + +```TypeScript + // 应当这样做 + switch (fooType) { + case YFoo: + doSomethingElse(); + break; + default: + // 即使这里什么也没有做,也需要添加default分支。 + } +``` + +非空语句组( `case ...` )不允许越过分支向下执行(编译器会进行检查): + +```TypeScript + // 不能这样做 + switch (fooType) { + case XFoo: + doSomething(); + // 不允许向下执行! + case YFoo: + // ... + default: // 即使这里什么也没有做,也需要要添加default分支。 + } +``` + +空语句组可以这样做: + +```TypeScript + // 可以这样做 + switch (x) { + case X: + case Y: + doSomething(); + break; + default: // 即使这里什么也没有做,也需要要添加default分支。 + } +``` + +### 判断相等 + +必须使用三等号( `===` )和对应的不等号( `!==` )判断相等。两等号会在比较的过程中进行类型转换,这非常容易导致难以理解的错误。并且在 JavaScript 虚拟机上,两等号的运行速度比三等号慢。参见 [JavaScript 相等表]( https://dorey.github.io/JavaScript-Equality-Table/) 。 + +```TypeScript + // 不要这样做 + if (foo == 'bar' || baz != bam) { + // 由于发生了类型转换,会导致难以理解的行为。 + } +``` + +```TypeScript + // 应当这样做 + if (foo === 'bar' || baz !== bam) { + // 一切都很好! + } +``` + +**例外**:但是和 `null` 字面量进行比较时可以使用 `==` 和 `!=` 运算符,这样能够同时覆盖 `null` 和 `undefined` 两种情况。 + +```TypeScript + // 应该这样做 + if (foo == null) { + // 不管 foo 是 null 还是 undefined 都会执行到这里。 + } +``` + +### 声明函数 + +- 应遵循以下原则声明函数: +- 使用 `function foo() { ... }` 的形式声明具名函数,包括嵌套在其它作用域中的函数,例如:函数内部的函数。 +- 不要使用表达式赋值形式声明函数,例如: `const foo = function() {...};` 。 +- TypeScript 本身已不允许重新绑定函数,所以在函数声明中使用 `const` 来阻止重写函数是没有必要的。 +- 如果函数需要访问外层作用域的 `this` ,则应当使用将箭头函数赋值给变量的形式代替函数声明的形式。 + +```TypeScript + // 声明函数 + function foo() { ... } +``` + +```TypeScript + // 不要使用表达式声明函数。 + const foo = function() { ... } +``` + +请注意这里所说的函数声明( `function foo() {}` )和下面要讨论的函数表达式( `doSomethingWith(function() {});` )之间的区别。 + +顶层箭头函数可以用于显式地声明这一函数实现了一个接口。 +```TypeScript + interface SearchFunction { + (source: string, subString: string): boolean; + } + + const fooSearch: SearchFunction = (source, subString) => { ... }; +``` + +### 函数表达式 + +#### 在表达式中使用箭头函数 + +不要使用 `function` 关键字,应当使用箭头函数,定义函数表达式。 + +```TypeScript + // 使用箭头函数定义函数表达式 + bar(() => { this.doSomething(); }) +``` + +```TypeScript + // 不要使用function关键字定义函数表达式 + bar(function() { ... }) +``` + +#### 表达式函数体 和 代码块函数体 + +当使用箭头函数时,应当根据具体情况选择表达式或者代码块作为函数体。 + +```TypeScript + // 使用函数声明的顶层函数。 + function someFunction() { + // 使用代码块函数体的箭头函数 + const receipts = books.map((b: Book) => { + const receipt = payMoney(b.price); + recordTransaction(receipt); + return receipt; + }); + + // 使用表达式函数体的箭头函数 + const longThings = myValues.filter(v => v.length > 1000).map(v => String(v)); + } +``` + +只有在确实需要用到函数返回值的情况下才能使用表达式函数体。 + +```TypeScript + // 如果不需要函数返回值的话,应当使用代码块函数体({ ... })。 + myPromise.then(v => console.log(v)); +``` + +```TypeScript + // 对于没有返回值的情况,应当使用代码块函数体。 + myPromise.then(v => { + console.log(v); + }); + + // 应当这样做!即使需要函数返回值,也可以为了可读性使用代码块函数体。 + const transformed = [1, 2, 3].map(v => { + const intermediate = someComplicatedExpr(v); + const more = acrossManyLines(intermediate); + return worthWrapping(more); + }); + +``` + +#### 重新绑定 `this` + +不要在函数表达式中使用 `this` ,除非它们明确地被用于重新绑定 `this` 指针。 +大多数情况下,使用箭头函数或者显式指定函数参数都能够避免重新绑定 `this` 的需求。 + +```TypeScript + // 不要在函数中访问this + function clickHandler() { + // 这里的this指针被隐式设置为document.body + this.textContent = 'Hello'; + } + + document.body.onclick = clickHandler; +``` + +```TypeScript + // 应当在箭头函数中显式地对对象进行引用。 + document.body.onclick = () => { document.body.textContent = 'hello'; }; + + // 也可以函数显式地接收一个参数。 + const setTextFn = (e: HTMLElement) => { e.textContent = 'hello'; }; + document.body.onclick = setTextFn.bind(null, document.body); +``` + +#### 使用箭头函数作为属性 + +通常情况下,类不应该将任何属性初始化为箭头函数。箭头函数属性需要调用函数意识到被调用函数的 `this` 已经被绑定了,这让 `this` 的指向变得令人费解,也让对应的调用和引用在形式上看着似乎是不正确的,也就是说,需要额外的信息才能确认这样的使用方式是正确的。 +在调用实例方法时,必须使用箭头函数的形式,例如: `const handler = (x) => { this.listener(x); };` 。 +此外,不允许持有或传递实例方法的引用,例如:不要使用 `const handler = this.listener; handler(x);` 的写法。 + +> 在一些特殊的情况下,例如需要将函数绑定到模板时,使用箭头函数作为属性是很有用的做法,同时还能令代码的可读性提高。因此,在这些情况下对于这条规则可视具体情况加以变通。 + +```TypeScript + // 不要这样做 + class DelayHandler { + constructor() { + // 回调函数里的 this 指针不会被保存。 + // 因此回调函数里的 this 不再是 DelayHandler 的实例了。 + setTimeout(this.patienceTracker, 5000); + } + private patienceTracker() { + this.waitedPatiently = true; + } + } +``` + +```TypeScript + // 一般而言不应当使用箭头函数作为属性。 + class DelayHandler { + constructor() { + // 这里看起来就是像是忘记了绑定 this 指针。 + setTimeout(this.patienceTracker, 5000); + } + private patienceTracker = () => { + this.waitedPatiently = true; + } + } +``` + +```TypeScript + // 应当在调用时显式地处理 this 指针的指向问题。 + class DelayHandler { + constructor() { + // 在这种情况下,应尽可能使用匿名函数。 + setTimeout(() => { + this.patienceTracker(); + }, 5000); + } + private patienceTracker() { + this.waitedPatiently = true; + } + } +``` + +#### 事件响应函数 + +如果不需要卸载事件响应函数,可以使用箭头函数的形式,例如:事件是由类自身发送的情况。 +如果必须卸载事件响应函数,则应当使用箭头函数属性,因为箭头函数属性能够自动正确地捕获 `this` 指针,并且能够提供一个用于卸载的稳定引用。 + +```TypeScript + // 可以使用匿名函数或者箭头函数属性做为事件响应函数 + class Component { + onAttached() { + // 事件是由类本身发送的,因此这个事件响应函数不需要卸载。 + this.addEventListener('click', () => { + this.listener(); + }); + // 这里的 this.listener 是一个稳定引用,因此可以在之后被卸载。 + window.addEventListener('onbeforeunload', this.listener); + } + onDetached() { + // 这个事件是由 window 发送的。如果不卸载这个事件响应函数,this.listener + // 会因为绑定了 this 而保存对 this 的引用,从而导致内存泄漏。 + window.removeEventListener('onbeforeunload', this.listener); + } + // 使用箭头函数作为属性能够自动地正确绑定 this 指针。 + private listener = () => { + confirm('Do you want to exit the page?'); + } + } +``` + +不要在注册事件响应函数的表达式中使用 `bind`改变方法的`this`指针 ,这会创建一个无法卸载的临时引用。 + +```TypeScript + // 不要使用bind改变事件响应函数的this指针,这会创建一个无法卸载的临时引用。 + class Component { + onAttached() { + // 这里创建了一个无法卸载的临时引用。 + window.addEventListener('onbeforeunload', this.listener.bind(this)); + } + onDetached() { + // 这里的 bind 创建了listener方法的另一个引用,所以这一行代码实际上没有实现任何功能。 + window.removeEventListener('onbeforeunload', this.listener.bind(this)); + } + private listener() { + confirm('Do you want to exit the page?'); + } + } +``` + +### 自动插入分号 + +不要依赖自动插入分号(ASI),必须显式地使用分号结束每一个语句。 +这能够避免由于不正确的分号插入所导致的 Bug,也能够更好地兼容对 ASI 支持有限的工具,例如: clang-format。 + +### `@ts-ignore`` + +不要使用 `@ts-ignore` ,应直接解决遇到的编译错误,如果使用`@ts-ignore`忽略了一个类型错误,则很难推断其他相关代码最终会接收到什么类型。 + +### 类型断言与非空断言 + +类型断言 `x as SomeType` 和非空断言 `y!` 是不安全的。 +这两种语法只能够绕过编译器,并没有添加任何运行时断言检查,因此有可能导致程序在运行时崩溃。 +因此,除非有明显或确切的理由,否则 **不应** 使用类型断言和非空断言。 + +```TypeScript + // 不要这样做 + (fooInstance as Foo).foo(); + + barInstance!.bar(); +``` + +如果希望对类型和非空条件进行断言,最好的做法是显式地编写运行时检查。 + +```TypeScript + // 可以这样做 + // 这里假定 Foo 是一个类。 + if (fooInstance instanceof Foo) { + fooInstance.foo(); + } + + if (barInstance) { + barInstance.bar(); + } +``` + +有时根据代码中的上下文可以确定某个断言是安全的。在这种情况下, **应当** 添加注释详细地解释为什么这一不安全的行为可以被接受: + +```TypeScript + // 可以这样做 + // x 是一个 Foo 类型的示例,因为…… + (x as Foo).foo(); + + // y 不可能是 null,因为…… + y!.bar(); +``` + +如果使用断言的理由很明显,注释就不是必需的。例如,生成的协议代码总是可空的,但有时根据上下文可以确认其中某些特定的由后端提供的字段必然不为空。在这些情况下应当根据具体场景加以判断和变通。 + +#### 类型断言语法 + +类型断言必须使用 `as` 语法,不要使用尖括号语法,这样能强制保证在断言外必须使用括号。 + +```TypeScript + // 不要这样做 + const fooInstance1 = (z).length; + const fooInstance2 = z.length; +``` + + +```TypeScript + // 应当这样做 + const fooLength = (fooInstance as Foo).length; +``` + +#### 类型断言和对象字面量 + +使用类型标记`: Foo`而非类型断言 `as Foo` 标明对象字面量的类型。 +在日后对接口的字段类型进行修改时,前者能够帮助程序员发现 Bug。 + +```TypeScript + interface Foo { + bar: number; + baz?: string; + // 这个字段曾经的名称是“bam”,后来改名为“baz”。 + } + + const foo = { + bar: 123, + bam: 'abc' + // 如果使用类型断言,改名之后这里并不会报错! + } as Foo; + + function func() { + return { + bar: 123, + bam: 'abc' + // 如果使用类型断言,改名之后这里也不会报错! + } as Foo; + } +``` + +### 声明成员属性 + +接口和类的声明必须使用 `;` 分隔每个成员声明。 + +```TypeScript + // 应当这样做 + interface Foo { + memberA: string; + memberB: number; + } +``` + +为了与类的写法保持一致,不要在接口中使用 `,` 分隔字段。 + +```TypeScript + // 不要这样做 + interface Foo { + memberA: string, + memberB: number, + } +``` + +然而,内联对象类型声明必须使用 `,` 作为分隔符。 + +```TypeScript + // 应当这样做 + type SomeTypeAlias = { + memberA: string, + memberB: number + }; + + let someProperty: {memberC: string, memberD: number}; +``` + +#### 优化访问属性的兼容性 + +不要混用方括号属性访问和句点属性访问两种形式。 + +```TypeScript + // 不要这样做 + // 必须从两种形式中选择其中一种,以保证整个程序的一致性。 + console.log(foo['someField']); + console.log(foo.someField); +``` + +代码应当尽可能为日后的属性重命名需求进行优化,并且为所有程序外部的对象属性声明对应的字段。 + +```TypeScript + // 应当声明一个对应的接口。 + declare interface ServerInfoJson { + appVersion: string; + user: UserJson; + } + const data = JSON.parse(serverResponse) as ServerInfoJson; + console.log(data.appVersion); + // 这里是类型安全的,如果需要重命名也是安全的! +``` + +#### 优化导入模块对象的兼容性 + +导入模块对象时应当直接访问对象上的属性,而不要传递对象本身的引用,以保证模块能够被分析和优化。 +也可以将导入的模块视作命名空间。 + +```TypeScript + // 应当导入模块对象上的属性 + import {method1, method2} from 'utils'; + class A { + readonly utils = {method1, method2}; + } +``` + +```TypeScript + // 而不要导入对象本身 + import * as utils from 'utils'; + class A { + readonly utils = utils; + } +``` + +### 枚举 + +- 必须使用 `enum` 关键字声明枚举类型。 +- TypeScript 的枚举类型本身就是不可变的, 不要使用 `const enum` 。 +- `const enum` 的写法是另一种独立的语言特性,其目的是让枚举对 JavaScript 程序员透明。 + +### `debugger` 语句 + +不允许在生产环境代码中添加 `debugger` 语句。 + +```TypeScript + // 不要在生产代码中使用debugger + function someMethod() { + debugger; + } +``` + +## 类型系统 + +### 类型推导 + +对于所有类型的表达式,包括:变量、字段、返回值等,都可以依赖 TypeScript 编译器所实现的类型推导。 + +```TypeScript + const count = 15; + // 可以通过推导得出count的类型. +``` + +当变量或参数被初始化为 `string` , `number` , `boolean` , `RegExp` 正则表达式字面量或 `new` 表达式时,由于明显能够推导出类型,因此应当省略类型记号。 + +```TypeScript + // 不要添加boolean声明,这不会提高任何可读性。 + const foo: boolean = true; +``` + +```TypeScript + // 不要添加Set类型,TypeScript可以从初始化语句中推导得出。 + const bar: Set = new Set(); +``` + +```TypeScript + // 应当依赖 TypeScript 的类型推导。 + const bar = new Set(); +``` + +> 对于更为复杂的表达式,类型记号有助于提高代码的可读性。此时是否使用类型记号应当由代码审查员决定。 + +#### 返回类型 + +- 代码的作者**可以**自由决定是否在函数和方法中使用类型记号标明返回类型。 +- 代码审查员 **可以** 要求对难以理解的复杂返回类型使用类型记号进行阐明。 +- 项目内部 **可以** 自行规定必须标明返回值。 + +显式地标明函数和方法的返回值有两个优点: +- 能够生成更精确的文档,有助于读者理解代码。 +- 如果未来改变了函数的返回类型的话,可以让因此导致的潜在的错误更快地暴露出来。 + +### Null 还是 Undefined ? + +TypeScript 支持 `null` 和 `undefined` 类型。可空类型可以通过联合类型实现,例如 `string | null` 。对于 `undefined` 也是类似的。对于 `null` 和 `undefined` 的联合类型,并无特殊的语法。 +TypeScript 代码中可以使用 `undefined` 或者 `null` 标记缺少的值,这里并无通用的规则约定应当使用其中的某一种。许多 JavaScript API 使用 `undefined` (例如 `Map.get` ),然而 DOM 和 Google API 中则更多地使用 `null` (例如 `Element.getAttribute` ),因此,对于 `null` 和 `undefined` 的选择取决于当前的上下文。 + +#### 可空/未定义类型别名 + +**不允许** 为包括 `| null` 或 `| undefined` 的联合类型创建类型别名。 +这种可空的别名通常意味着空值在应用中会被层层传递,并且它掩盖了导致空值出现的源头。 +另外,这种别名也让类或接口中的某个值何时有可能为空变得不确定。 +因此,代码 *必须* 在使用别名时才允许添加 `| null` 或者 `| undefined` 。同时,代码 **应当** 在空值出现位置的附近对其进行处理。 + +```TypeScript + // 不要在创建别名的时候包含 undefined + type CoffeeResponse = Latte|Americano|undefined; + + class CoffeeService { + getLatte(): CoffeeResponse { ... }; + } +``` + +```TypeScript + // 应该在使用别名的时候联合 undefined + type CoffeeResponse = Latte|Americano; + + class CoffeeService { + getLatte(): CoffeeResponse|undefined { ... }; + } +``` + +```TypeScript + // 使用断言对可能的空值进行处理更好 + type CoffeeResponse = Latte|Americano; + + class CoffeeService { + getLatte(): CoffeeResponse { + return assert(fetchResponse(), 'Coffee maker is broken, file a ticket'); + }; + } +``` + +#### 可选参数 还是 `undefined` 类型? + +TypeScript 支持使用 `?` 创建可选参数和可选字段,例如: + +```TypeScript + interface CoffeeOrder { + sugarCubes: number; + milk?: Whole|LowFat|HalfHalf; + } + + function pourCoffee(volume?: Milliliter) { ... } +``` + +- 可选参数实际上隐式地向类型中联合了 `| undefined` 。 +- 不同之处在于,在构造类实例或调用方法时,可选参数可以被直接省略。例如:`{sugarCubes: 1}` 是一个合法的 `CoffeeOrder` ,因为 `milk` 字段是可选的。 +- 对于类或者接口,应当使用可选字段和可选参数而非联合 `| undefined` 类型。 +- 对于类,应当尽可能避免使用可选字段,尽可能初始化每一个字段。 + +```TypeScript + class MyClass { + field = ''; + } +``` + +### 结构类型与指名类型 + +TypeScript 的类型系统使用的是结构类型而非指名类型。 +如果一个值拥有某个类型的所有属性,且所有属性的类型能够递归地一一匹配,则这个值与这个类型也是匹配的。 +在代码中,可以在适当的场景使用结构类型。在测试代码之外,应当使用接口而非类对结构类型进行定义。在测试代码中,由于经常要创建 Mock 对象用于测试,此时不引入额外的接口往往较为方便。 +在提供基于结构类型的实现时,应当在符号的声明位置显式地包含其类型,使类型检查和错误检测能够更准确地工作。 + +```TypeScript + // 应当这样做 + const foo: Foo = { + count: 123, + bar: 'abc' + } +``` + +```TypeScript + // 不要这样做 + const badFoo = { + count: 123, + bar: 'abc', + } +``` + +这是因为在上文中, `badFoo` 对象的类型依赖于类型推导。 `badFoo` 对象中可能添加额外的字段,此时类型推导的结果就有可能发生变化。 +如果将 `badFoo` 传给接收 `Foo` 类型参数的函数,错误提示会出现在函数调用的位置,而非对象声明的位置。在大规模的代码仓库中修改接口时,这一点区别会很重要。 + +```TypeScript + interface Animal { + sound: string; + name: string; + } + + function makeSound(animal: Animal) {} + + /** + * 'cat' 的类型会被推导为 '{sound: string}' + */ + const cat = { + sound: 'meow', + }; + + /** + * 'cat' 的类型并不满足函数参数的要求, + * 因此 TypeScript 编译器会在这里报错, + * 而这里有可能离 'cat' 的定义相当远。 + */ + makeSound(cat); + + /** + * Horse 具有结构类型,因此这里会提示类型错误,而函数调用点不会报错。 + * 这是因为 'horse' 不满足接口 'Animal' 的类型约定。 + */ + const horse: Animal = { + sound: 'niegh', + }; + + const dog: Animal = { + sound: 'bark', + name: 'MrPickles', + }; + + makeSound(dog); + makeSound(horse); +``` + +### 使用接口还是类型别名 + +TypeScript 支持使用[类型别名](https://www.typescriptlang.org/docs/handbook/advanced-types.html#type-aliases)为类型命名。 +这一功能可以用于基本类型、联合类型、元组以及其它类型。 +然而,当需要声明用于对象的类型时,应当使用接口,而非对象字面量表达式的类型别名。 + +```TypeScript + // 应当这样做 + interface User { + firstName: string; + lastName: string; + } +``` + +```TypeScript + // 不要这样做 + type User = { + firstName: string, + lastName: string, + } +``` + +这两种形式是几乎等价的,因此,基于从两个形式中只选择其中一种以避免项目中出现变种的原则,这里选择了更常见的接口形式。 + +### `Array` 类型 + +对于名称中只包含字母、数字和点 `.` 的简单类型,应当使用数组的语法糖 `T[]` ,而非更长的 `Array` 形式。 +对于其它复杂的类型,则应当使用较长的 `Array` 。 +这条规则也适用于 `readonly T[]` 和 `ReadonlyArray` 。 + +```TypeScript + // 应当这样做 + const a: string[]; + const b: readonly string[]; + const c: ns.MyObj[]; + const d: Array; + const e: ReadonlyArray; +``` + +```TypeScript + // 不要这样做 + const f: Array; // 语法糖写法更短。 + const g: ReadonlyArray; + const h: {n: number, s: string}[]; // 大括号和中括号让这行代码难以阅读。 + const i: (string|number)[]; + const j: readonly (string|number)[]; +``` + +### 索引类型 `{[key: string]: number}` + +在 JavaScript 中,使用对象作为关联数组(又称“映射表”、“哈希表”或者“字典”)是一种常见的做法: + +```TypeScript + const fileSizes: {[fileName: string]: number} = {}; + fileSizes['readme.txt'] = 541; +``` + +在 TypeScript 中,应当为键提供一个有意义的标签名。 + +```TypeScript + // 不要这样做 + const users: {[key: string]: number} = ...; +``` + +```TypeScript + // 应当这样做 + const users: {[userName: string]: number} = ...; +``` + +然而,相比使用上面的这种形式,在 TypeScript 中应当考虑使用 ES6 新增的 `Map` 与 `Set` 类型。 +ES6 的新增类型能够更明确地表达程序员的设计思路。 +此外, `Map` 类型的键和 `Set` 类型的元素都允许使用 `string` 以外的其他类型。 +TypeScript 内建的 `Record` 允许使用已定义的一组键创建类型。 +它与关联数组的不同之处在于键是静态确定的。 + +### 映射类型与条件类型 + +TypeScript 中的[映射类型](https://www.typescriptlang.org/docs/handbook/advanced-types.html#mapped-types>)与[条件类型](https://www.typescriptlang.org/docs/handbook/advanced-types.html#conditional-types)让程序员能够在已有类型的基础上构建出新的类型。 +在 TypeScript 的标准库中`Record` 、 `Partial` 、 `Readonly` 等许多类型运算符都是基于这一机制。 +TypeScript 类型系统的这一特性让创建新类型变得简洁,还程序员在设计代码抽象时,既能实现强大的功能,同时海能保证类型安全。 +然而,它们也有一些缺点: +- 相较于显式地指定属性与类型间关系(例如使用接口和继承,参见下文中的例子),类型运算符需要读者在头脑中自行对后方的类型表达式进行求值。本质上说,这增加了程序的理解难度,尤其是在类型推导和类型表达式有可能横跨数个文件的情况下。 +- 映射类型与条件类型的求值模型并没有明确的规范,且经常随着 TypeScript 编译器的版本更新而发生变化,因此并不总是易于理解,尤其是与类型推导一同使用时。因此,代码有可能只是碰巧能够通过编译或者给出正确的结果。在这种情况下,使用类型运算符增加了代码未来的维护成本。 +- 映射类型与条件类型最为强大之处在于,它们能够从复杂且/或推导的类型中派生出新的类型。然而从另一方面看,这样做也很容易导致程序难于理解与维护。 +- 有些语法工具并不能够很好地支持类型系统的这一特性。例如,一些 IDE 的“查找引用”功能(以及依赖于它的“重命名重构”)无法发现位于 `Pick` 类型中的属性,因而在查找结果中不会将其设为高亮。 + +因此,推荐的代码规范如下: +- 任何使用都应当使用最简单的类型构造方式进行表达。 +- 一定程度的重复或冗余,往往好过复杂的类型表达式带来的长远维护成本。 +- 映射类型和条件类型必须在符合上述理念的情况下使用。 + +例如,TypeScript 内建的 `Pick` 类型允许以类型 `T` 的子集创建新的类型。然而,使用接口和继承的方式实现往往更易于理解。 + +```TypeScript + interface User { + shoeSize: number; + favoriteIcecream: string; + favoriteChocolate: string; + } + + // FoodPreferences 类型拥有 favoriteIcecream 和 favoriteChocolate,但不包括 shoeSize。 + type FoodPreferences = Pick; +``` + +这种写法等价于显式地写出 `FoodPreferences` 的属性: + +```TypeScript + interface FoodPreferences { + favoriteIcecream: string; + favoriteChocolate: string; + } +``` + +为了减少重复,可以让 `User` 继承 `FoodPreferences` ,或者在 `User` 中嵌套一个类型为 `FoodPrefences` 的字段(这样做可能更好): + +```TypeScript + interface FoodPreferences { /* 同上 */ } + + interface User extends FoodPreferences { + shoeSize: number; + // 这样 User 也包括了 FoodPreferences 的字段。 + } +``` + +使用接口让属性的分类变得清晰,IDE 的支持更完善,方便进一步优化,同时使得代码更易于理解。 + +### `any` 类型 + +TypeScript 的 `any` 类型是所有其它类型的超类,又是所有其它类型的子类,同时还允许解引用一切属性。因此,使用 `any` 十分危险——它会掩盖严重的程序错误,并且它从根本上破坏了对应的值“具有静态属性”的原则。 +尽可能 **不要** 使用 `any` 。 +如果出现了需要使用 `any` 的场景,可以考虑下列的解决方案: + +#### 提供一个更具体的类型 + +使用接口、内联对象类型、或者类型别名: + +```TypeScript + // 声明接口类型以表示服务端发送的 JSON。 + declare interface MyUserJson { + name: string; + email: string; + } + + // 对重复出现的类型使用类型别名。 + type MyType = number|string; + + // 或者对复杂的返回类型使用内联对象类型。 + function getTwoThings(): {something: number, other: string} { + // ... + return {something, other}; + } + + // 使用泛型,有些库在这种情况下可能会使用 any 表示 + // 这里并不考虑函数所作用于的参数类型。 + // 注意,对于这种写法,“只有泛型的返回类型”一节有更详细的规范。 + function nicestElement(items: T[]): T { + // 在 items 中查找最棒的元素。 + // 这里还可以进一步为泛型参数 T 添加限制,例如 。 + } +``` + +#### 使用 `unknown` 而非 `any`` + +`any` 类型的值可以赋给其它任何类型,还可以对其解引用任意属性。一般来说,这个行为不是必需的,也不符合期望,此时代码试图表达的内容其实是“该类型是未知的”。在这种情况下,应当使用内建的 `unknown` 类型。它能够表达相同的语义,并且,因为 `unknown` 不能解引用任意属性,它较 `any` 而言更为安全。 + +```TypeScript + // 应当这样做 + // 可以将任何值(包括 null 和 undefined)赋给 val, + // 但在缩窄类型或者类型转换之前并不能使用它。 + const val: unknown = value; +``` + + +```TypeScript + // 不要这样做 + const danger: any = value /* 这是任意一个表达式的结果 */; + danger.whoops(); // 完全未经检查的访问! +``` + +#### 关闭 Lint 工具对 `any` 的警告 + +有时使用 `any` 是合理的,例如用于在测试中构造 Mock 对象。在这种情况下,应当添加注释关闭 Lint 工具对此的警告,并添加文档对使用 any 的合理性进行说明。 + +```TypeScript + // 这个测试只需要部分地实现 BookService,否则测试会失败。 + // 所以,这里有意地使用了一个不安全的部分实现 Mock 对象。 + // tslint:disable-next-line:no-any + const mockBookService = ({get() { return mockBook; }} as any) as BookService; + // 购物车在这个测试里并未使用。 + // tslint:disable-next-line:no-any + const component = new MyComponent(mockBookService, /* unused ShoppingCart */ null as any); +``` + +### 元组类型 + +应当使用元组类型代替常见的 `Pair` 类型的写法: + +```TypeScript + // 不要这样做 + interface Pair { + first: string; + second: string; + } + + function splitInHalf(input: string): Pair { + // ... + return {first: x, second: y}; + } +``` + +```TypeScript + // 应当这样做 + function splitInHalf(input: string): [string, string] { + // ... + return [x, y]; + } + + // 这样使用: + const [leftHalf, rightHalf] = splitInHalf('my string'); +``` + +然而通常情况下,为属性提供一个有意义的名称往往能让代码更加清晰。 +如果为此声明一个接口过于繁重的话,可以使用内联对象字面量类型: + +```TypeScript + function splitHostPort(address: string): {host: string, port: number} { + // ... + } + + // 这样使用: + const address = splitHostPort(userAddress); + use(address.port); + + // 也可以使用解构进行形如元组的操作: + const {host, port} = splitHostPort(userAddress); +``` + +### 包装类型 + +不要使用如下几种类型,它们是 JavaScript 中基本类型的包装类型: + +- `String` 、 `Boolean` 和 `Number` 。它们的含义和对应的基本类型 `string` 、 `boolean` 和 `number` 略有不同。任何时候,都应当使用后者。 +- `Object` 。它和 `{}` 与 `object` 类似,但包含的范围略微更大。应当使用 `{}` 表示“包括除 `null` 和 `undefined` 之外所有类型”的类型,使用 `object` 表示“所有基本类型以外”的类型(这里的“所有基本类型”包括上文中提到的基本类型, `symbol` 和 `bigint` )。 + +此外,不要将包装类型用作构造函数。 + +### 只有泛型的返回类型 + +不要创建返回类型只有泛型的 API。如果现有的 API 中存在这种情况,使用时应当显式地标明泛型参数类型。 + +## 管理代码 + +### 模块 + +#### 导入路径 + +- TypeScript 代码必须使用路径进行导入。这里的路径既可以是以 `.` 或 `..` 开头的相对路径,也可以是从项目根目录开始的绝对路径,如 `root/path/to/file` 。 +- 引用在逻辑上属于同一项目的文件时,应使用相对路径 `./foo` ,不要使用绝对路径 `path/to/foo` 。 +- 应尽可能地限制父层级的数量,避免出现诸如 `../../../` 的路径,过多的层级会导致模块和路径结构难以理解。 + +```TypeScript + import {Symbol1} from 'farris/path/from/root'; + import {Symbol2} from '../parent-component/file'; + import {Symbol3} from './sibling'; +``` + +#### 命名空间和模块 + +- 在 TypeScript 中有命名空间(namespace)和模块(module)两种组织代码的方式,在编写代码时,必须使用模块,不允许使用命名空间。 +- 在引用其它文件中的代码时必须以 `import {foo} from 'bar'` 的形式进行导入和导出。 +- 不允许使用 `namespace Foo { ... }` 的形式组织代码。 +- 只有在所用的外部第三方库有要求时才能使用命名空间。 +- 如果需要在语义上对代码划分命名空间,应当通过分成不同文件的方式实现。 +- 不允许在导入时使用 `require` 关键字,应当使用 ES6 的模块语法。 + +```TypeScript + // 不要使用命名空间 + namespace Rocket { + function launch() { ... } + } + + // 不要使用 ! + /// + + // 不要使用 require() + import x = require('mydep'); +``` + +### 导出 + +代码中必须使用具名的导出声明。 + +```TypeScript + // 导出时必须命名 + export class Foo { ... } +``` + +不要使用默认导出,这样能保证所有的导入语句都遵循统一的范式: + +```TypeScript + // 不要使用默认导出 + export default class Foo { ... } +``` + +默认导出不会为被导出的符号提供一个标准的名称,这增加了维护的难度和降低可读性的风险。 +默认导出也未带来明显的好处。如下面的例子所示: + +```TypeScript + // 默认导出会造成如下的弊端 + import Foo from './bar'; + // 这个语句是合法的。 + import Bar from './bar'; + // 这个语句也是合法的。 +``` + +具名导出的优势是,当代码中试图导入一个并未被导出的符号时,这段代码会报错。 +例如,假设在 `foo.ts` 中有如下的导出声明: + +```TypeScript + // 不要这样做 + const foo = 'blah'; + export default foo; +``` + +如果在 `bar.ts` 中有如下的导入语句: + +```TypeScript + // 编译错误 + import {fizz} from './foo'; +``` + +会导致编译错误: `error TS2614: Module '"./foo"' has no exported member 'fizz'` 。反之,如果在 `bar.ts` 中的导入语句为: + +```TypeScript + // 不要这样做,这定义了一个多余的变量 fizz! + import fizz from './foo'; +``` + +结果是 `fizz === foo` ,这往往不符合预期,且难以调试。 + +此外,默认导出会鼓励程序员将所有内容全部置于一个巨大的对象当中,这个对象实际上充当了命名空间的角色: + +```TypeScript + // 不要这样做 + export default class Foo { + static SOME_CONSTANT = ... + static someHelpfulFunction() { ... } + ... + } +``` + +显然,这个文件中具有文件作用域,它可以被用做命名空间。但是,这里创建了第二个作用域——类 `Foo` ,这个类在其它文件中具有歧义:它既可以被视为类型,又可以被视为值。 +因此,应当使用文件作用域作为实质上的命名空间,同时使用具名的导出声明: + +```TypeScript + // 应当这样做 + export const SOME_CONSTANT = ... + export function someHelpfulFunction() + export class Foo { + // 只有类 Foo 中的内容 + } +``` + +#### 导出可见性 + +TypeScript 不支持限制导出符号的可见性。因此,不要导出不用于模块以外的符号。一般来说,应当尽量减小模块的外部 API 的规模。 + +#### 可变导出 + +虽然技术上可以实现,但是可变导出会造成难以理解和调试的代码,尤其是对于在多个模块中经过了多次重新导出的符号。这条规则的一个例子是,不允许使用 `export let` 。 + +```TypeScript + // 不要这样做 + export let foo = 3; + // 在纯 ES6 环境中,变量 foo 是一个可变值,导入了 foo 的代码会观察到它的值在一秒钟之后发生了改变。 + // 在 TypeScript 中,如果 foo 被另一个文件重新导出了,导入该文件的代码则不会观察到变化。 + window.setTimeout(() => { + foo = 4; + }, 1000 /* ms */); +``` + +如果确实需要允许外部代码对可变值进行访问,应当提供一个显式的取值器。 + +```TypeScript + // 应当这样做 + let foo = 3; + window.setTimeout(() => { + foo = 4; + }, 1000 /* ms */); + // 使用显式的取值器对可变导出进行访问。 + export function getFoo() { return foo; }; +``` + +有一种常见的编程情景是,要根据某种特定的条件从两个值中选取其中一个进行导出:先检查条件,然后导出。这种情况下,应当保证模块中的代码执行完毕后,导出的结果就是确定的。 + +```TypeScript + function pickApi() { + if (useOtherApi()) return OtherApi; + return RegularApi; + } + export const SomeApi = pickApi(); +``` + +### 导入 + +在 ES6 和 TypeScript 中,导入语句共有四种变体: + +| 导入类型 | 示例 | 用途 | +| --- | --- | --- | +| 模块 | `import * as foo from '...';` | TypeScript 导入方式 | +| 解构 | `import {SomeThing} from '...';` | TypeScript 导入方式 | +| 默认 | `import SomeThing from '...';` | 只用于外部代码的特殊需求 | +| 副作用 | `import '...';` | 只用于加载某些库的副作用(例如自定义元素)| + +```TypeScript + // 应当从这两种变体中选择较合适的一种。 + import * as ng from '@angular/core'; + import {Foo} from './foo'; + + // 只在有需要时使用默认导入。 + import Button from 'Button'; + + // 有时导入某些库是为了其代码执行时的副作用。 + import 'jasmine'; + import '@polymer/paper-button'; +``` + +#### 选择模块导入还是解构导入? + +根据使用场景的不同,模块导入和解构导入分别有其各自的优势。 +虽然模块导入语句中出现了通配符 `*` ,但模块导入并不能因此被视为其它语言中的通配符导入。相反地,模块导入语句为整个模块提供了一个名称,模块中的所有符号都通过这个名称进行访问,这为代码提供了更好的可读性,同时令模块中的所有符号可以进行自动补全。模块导入减少了导入语句的数量(模块中的所有符号都可以使用),降低了命名冲突的出现几率,同时还允许为被导入的模块提供一个简洁的名称。在从一个大型 API 中导入多个不同的符号时,模块导入语句尤其有用。 +解构导入语句则为每一个被导入的符号提供一个局部的名称,这样在使用被导入的符号时,代码可以更简洁。对那些十分常用的符号,例如 Jasmine 的 `describe` 和 `it` 来说,这一点尤其有用。 + +```TypeScript + // 不要无意义地使用命名空间中的名称使得导入语句过于冗长。 + import {TableViewItem, TableViewHeader, TableViewRow, TableViewModel, + TableViewRenderer} from './tableview'; + let item: TableViewItem = ...; +``` + + +```TypeScript + // 应当使用模块作为命名空间。 + import * as tableview from './tableview'; + let item: tableview.Item = ...; +``` + + +```TypeScript + import * as testing from './testing'; + // 所有的测试都只会重复地使用相同的三个函数。 + // 如果只需要导入少数几个符号,而这些符号的使用频率又非常高的话, + // 也可以考虑使用解构导入语句直接导入这几个符号(见下文)。 + testing.describe('foo', () => { + testing.it('bar', () => { + testing.expect(...); + testing.expect(...); + }); + }); +``` + +```TypeScript + // 为这几个常用的函数提供局部变量名更好。 + import {describe, it, expect} from './testing'; + + describe('foo', () => { + it('bar', () => { + expect(...); + expect(...); + }); + }); + ... +``` + +#### 重命名导入 + +在代码中,应当通过使用模块导入或重命名导出解决命名冲突。此外,在需要时,也可以使用重命名导入(例如 `import {SomeThing as SomeOtherThing}` )。 +在以下几种情况下,重命名导入可能较为有用: + +1. 避免与其它导入的符号产生命名冲突。 +2. 被导入符号的名称是自动生成的。 +3. 被导入符号的名称不能清晰地描述其自身,需要通过重命名提高代码的可读性,如将 RxJS 的 `from` 函数重命名为 `observableFrom` 。 + +#### `import type` 和 `export type`` + +不要使用 `import type ... from` 或者 `export type ... from` 。 + +> 这一规则不适用于导出类型定义,如 `export type Foo = ...;` 。 + +```TypeScript + // 不要使用 import type 或者 export type + import type {Foo} from './foo'; + export type {Bar} from './bar'; +``` + +```TypeScript + // 应当使用常规的导入语句 + import {Foo} from './foo'; + export {Bar} from './bar'; +``` + +TypeScript 的工具链会自动区分用作类型的符号和用作值的符号。对于类型引用,工具链不会生成运行时加载的代码。这样做的原因是为了提供更好的开发体验,否则在 `import type` 和 `import` 之间反复切换会非常繁琐。同时, `import type` 并不提供任何保证,因为代码仍然可以通过其它的途径导入同一个依赖。 +如果需要在运行时加载代码以执行其副作用,应使用 `import '...'` 。 +使用 `export type` 似乎可以避免将某个用作值的符号导出为 API。然而,和 `import type` 类似, `export type` 也不提供任何保证,因为外部代码仍然可以通过其它途径导入。如果需要拆分对 API 作为值的使用和作为类型的使用,并保证二者不被混用的话,应当显式地将其拆分成不同的符号,例如 `UserService` 和 `AjaxUserService` ,这样不容易造成错误,同时能更好地表达设计思路。 + +### 根据特征组织代码 + +应当根据特征而非类型组织代码。例如,一个在线商城的代码应当按照 `products` , `checkout` , `backend` 等分类,而不是 `views` , `models` , `controllers` 。 + +## 关于 + +本项目的TypeScript编码指南遵循[CC-By 3.0协议](https://creativecommons.org/licenses/by/3.0/)。 +本指南的内容参考了[Google TypeScript Style Guide](https://google.github.io/styleguide/tsguide.html),结合我们自身的项目实践,融合了本项目自己的规范要求。 + +Creative Commons License