3. 语言特性
3.1. 可见性
应当限制属性、方法以及类型的可见性有助于代码解耦合。因此:
- 应当尽可能限制符号的可见性。
- 可以将私有方法在同一文件中改写为独立于所有类以外的内部函数,并将私有属性移至单独的内部类中。
- 在
TypeScript
中,符号默认的可见性即为public
,因此,除了在构造函数中声明公开(public
)且非只读(readonly
)的参数属性之外,不要使用public
修饰符。
class Foo {
public bar = new Bar(); // 不要这样做!不需要 public 修饰符!
constructor(public readonly baz: Baz) {
} // 不要这样做!readonly 修饰符已经表明了 baz 是默认 public 的属性,因此不需要 public 修饰符!
}
class Foo {
bar = new Bar(); // 应当这样做!将不需要的 public 修饰符省略!
constructor(public baz: Baz) {
} // 可以这样做!公开且非只读的参数属性允许使用 public 修饰符!
}
3.2. 构造函数
必须调用构造函数时必须使用括号,即使不传递任何参数。
// 坏的
const foo = new Foo;
// 好的
const foo = new Foo();
没有必要提供一个空的或者仅仅调用父类构造函数的构造函数。在 ES2015 标准中,如果没有为类显式地提供构造函数,编译器会提供一个默认的构造函数
。但是,含有参数属性、访问修饰符或参数装饰器的构造函数即使函数体为空也不能省略。
// 不要这样做!没有必要声明一个空的构造函数!
class UnnecessaryConstructor {
constructor() {
}
}
// 不要这样做!没有必要声明一个仅仅调用基类构造函数的构造函数!
class UnnecessaryConstructorOverride extends Base {
constructor(value: number) {
super(value);
}
}
// 应当这样做!默认构造函数由编译器提供即可!
class DefaultConstructor {
}
// 应当这样做!含有参数属性的构造函数不能省略!
class ParameterProperties {
constructor(private myService) {
}
}
// 应当这样做!含有参数装饰器的构造函数不能省略!
class ParameterDecorators {
constructor(@SideEffectDecorator myService) {
}
}
// 应当这样做!私有的构造函数不能省略!
class NoInstantiation {
private constructor() {
}
}
3.3. 类成员
3.3.1. private 替换 #
禁止不要使用 #private
私有字段(又称私有标识符)语法声明私有成员。
必须使用 TypeScript
的访问修饰符(private
)
class FieldName {
private name: string;
#name: string; // 禁止使用
}
私有字段语法会导致 TypeScript
在编译为 JavaScript
时出现体积和性能问题。同时,ES2015 之前的标准都不支持私有字段语法,因此它限制了 TypeScript
最低只能被编译至
ES2015。另外,在进行静态类型和可见性检查时,私有字段语法相比访问修饰符并无明显优势。
3.3.2. 使用 readonly
必须对于不会在构造函数以外进行赋值的属性,应使用 readonly
修饰符标记。这些属性并不需要具有深层不可变性。
class Foo {
readonly name = "foo";
}
const foo = new Foo();
foo.name = 1; // error: readonly
3.3.3. 参数属性
必须不要在构造函数中显式地对类成员进行初始化。应当使用 TypeScript
的参数属性
语法。
// 不要这样做!重复的代码太多了!
class Foo {
private readonly barService: BarService;
constructor(barService: BarService) {
this.barService = barService;
}
}
// 应当这样做!简洁明了!
class Foo {
constructor(private readonly barService: BarService) {
}
}
3.3.4. 字段初始化
必须如果某个成员并非参数属性,应当在声明时就对其进行初始化,这样有时可以完全省略掉构造函数。
// 不要这样做!没有必要单独把初始化语句放在构造函数里!
class Foo {
private readonly userList: string[];
constructor() {
this.userList = [];
}
}
// 应当这样做!省略了构造函数!
class Foo {
private readonly userList: string[] = [];
}
3.3.5. 用于类的词法范围之外的属性
应当在 TypeScript
代码中应当尽量避免使用 obj['foo']
语法绕过可见性限制进行访问。
为什么?
如果一个属性被设为 private
,就相当于向自动化工具和读者声明对这个属性的访问局限于类的内部。例如,用于查找未被使用的代码的工具可能会将一个私有属性标记为未使用,即使在其它文件中有代码设法绕过了可见性限制对其进行访问。
虽然 obj['foo']
可以绕过 TypeScript
编译器对可见性的检查,但是这种访问方法可能会由于调整了构建规则而失效。
3.3.6. 取值器与存取器
必须可以在类中使用存取器,其中取值器方法必须是纯函数
(即结果必须是一致稳定的,且不能有副作用)。存取器还可以用于隐藏内部复杂的实现细节。
class Foo {
constructor(private name: string) {
}
get myName(): string {
return this.name;
}
set myName(newValue: string) {
// 处理一些别的逻辑
this.name += `_${newValue}`;
}
}
const foo = new Foo('parent');
foo.myName = "children";
foo.myName; // parent_children
3.4. 原始类型与封装类
必须在 TypeScript
中,不要实例化原始类型的封装类,例如 String
、 Boolean
、 Number
等。封装类有许多不合直觉的行为,例如 new Boolean(false)
在布尔表达式中会被求值为
true
。
// 不要这样做!
const s = new String('hello');
const b = new Boolean(false);
const n = new Number(5);
// 应当这样做!
const s = 'hello';
const b = false;
const n = 5;
3.5. 数组构造函数
禁止在 TypeScript
中,禁止使用 Array()
构造函数(无论是否使用 new
关键字)。它有许多不合直觉又彼此矛盾的行为,例如:
// 不要这样做!同样的构造函数,其构造方式却却完全不同!
const a = new Array(2); // 参数 2 被视作数组的长度,因此返回的结果是 [undefined, undefined]
const b = new Array(2, 3); // 参数 2, 3 被视为数组中的元素,返回的结果此时变成了 [2, 3]
应当使用方括号对数组进行初始化,或者使用 from
构造一个具有确定长度的数组:
const a = [2];
const b = [2, 3];
// 等价于 Array(2):
const c = [];
c.length = 2;
// 生成 [0, 0, 0, 0, 0]
Array.from<number>({length: 5}).fill(0);
3.6. 强制类型转换
必须在 TypeScript
中,可以使用 String()
和 Boolean()
函数(注意不能和 new
一起使用!)、模板字符串和 !!
运算符进行强制类型转换。
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
。这些情况可能需要特殊处理。
const aNumber = Number('123');
if (isNaN(aNumber)) throw new Error('error'); // 如果输入字符串有可能无法被解析为数字,就需要处理返回 NaN 的情况。
禁止使用一元加法运算符 +
将字符串强制转换为数字。用这种方法进行解析有失败的可能,还有可能出现奇怪的边界情况。而且,这样的写法往往成为代码中的坏味道, +
在代码审核中非常容易被忽略掉。
// 不要这样做!
const x = +y;
不应使用 parseInt
或 parseFloat
进行转换,除非用于解析表示非十进制数字的字符串。因为这两个函数都会忽略字符串中的后缀,这有可能在无意间掩盖了一部分原本会发生错误的情形(例如将 12 dwarves
解析成 12
)。
const n = parseInt(someString, 10); // 无论传不传基数,
const f = parseFloat(someString); // 都很容易造成错误。
对于需要解析非十进制数字的情况,在调用 parseInt
进行解析之前必须检查输入是否合法。
if (!/^[a-fA-F0-9]+$/.test(someString)) throw new Error(...);
// 需要解析 16 进制数。
// tslint:disable-next-line:ban
const n = parseInt(someString, 16); // 只允许在非十进制的情况下使用 parseInt。
应当使用 Number()
和 Math.floor
或者 Math.trunc
(如果支持的话)解析整数。
let f = Number(someString);
if (isNaN(f)) handleError();
f = Math.floor(f);
不要在 if
、 for
或者 while
的条件语句中显式地将类型转换为 boolean
,因为这里原本就会执行隐式的类型转换。
// 不要这样做!
const foo: string | null = 'foo';
if (!!foo) {
// ...
}
while (!!foo) {
// ...
}
// 应当这样做!
const foo: string | null = 'foo';
if (foo) {
// ...
}
while (foo) {
// ...
}
在代码中使用显式和隐式的比较。
// 显式地和 0 进行比较,没问题!
if (arr.length > 0) {
// ...
}
// 依赖隐式类型转换,也没问题!
if (arr.length) {
// ...
}
3.7. 变量
必须必须使用 const
或 let
声明变量。尽可能地使用 const
,除非这个变量需要被重新赋值。
禁止使用 var
。
与大多数其它编程语言类似,使用 const
和 let
声明的变量都具有块级作用域。与之相反的是,使用 var
声明的变量在 JavaScript
中具有函数作用域,这会造成许多难以理解的
bug,因此禁止在 TypeScript
中使用 var
。
变量必须在使用前进行声明。
3.8. 异常
必须在实例化异常对象时,必须使用 new Error()
语法而非调用 Error()
函数。虽然这两种方法都能够创建一个异常实例,但是使用 new
能够与代码中其它的对象实例化在形式上保持更好的一致性。
// 应当这样做!
throw new Error('Foo is not a valid bar.');
// 不要这样做!
throw Error('Foo is not a valid bar.');
3.9. 对象迭代
禁止对对象使用 for (... in ...)
语法进行迭代很容易出错,因为它同时包括了对象从原型链中继承得来的属性。因此,禁止使用裸的 for (... in ...)
语句。
// 不要这样做!
for (const x in someObj) {
// x 可能包括 someObj 从原型中继承得到的属性。
}
在对对象进行迭代时,必须使用 if
语句对对象的属性进行过滤,或者使用 for (... of Object.keys(...))
。
// 应当这样做!
for (const x in someObj) {
if (!Object.prototype.hasOwnProperty.call(someObj, x)) continue;
// 此时 x 必然是定义在 someObj 上的属性。
}
// 应当这样做!
for (const x of Object.keys(someObj)) { // 注意:这里使用的是 for _of_ 语法!
// 此时 x 必然是定义在 someObj 上的属性。
}
// 应当这样做!
for (const [key, value] of Object.entries(someObj)) { // 注意:这里使用的是 for _of_ 语法!
// 此时 key 必然是定义在 someObj 上的属性。
}
推荐使用 lodash
的 forOwn
方法来进行对象迭代。
function Foo() {
this.a = 1;
this.b = 2;
}
Foo.prototype.c = 3;
_.forOwn(new Foo, function (value, key) {
console.log(key);
});
// => 输出 'a' 然后 'b' (无法保证遍历的顺序)
3.10. 容器迭代
禁止不要在数组上使用 for (... in ...)
进行迭代。因为它是对数组的下标而非元素进行迭代(还会将其强制转换为 string
类型)!
// 不要这样做!
for (const x in someArray) {
// 这里的 x 是数组的下标!(还是 string 类型的!)
}
如果要在数组上进行迭代,应当使用 for (... of someArr)
语句或者传统的 for
循环语句。
// 应当这样做!
for (const x of someArr) {
// 这里的x 是数组的元素。
}
// 应当这样做!
for (let i = 0; i < someArr.length; i++) {
// 如果需要使用下标,就对下标进行迭代,否则就使用 for/of 循环。
const x = someArr[i];
// ...
}
// 应当这样做!
for (const [i, x] of someArr.entries()) {
// 上面例子的另一种形式。
}
可使用 Array.prototype.forEach
、 Set.prototype.forEach
和 Map.prototype.forEach
进行数组遍历,但是某些情况下可能存在闭包问题。
3.11. 展开运算符
在复制数组或对象时,展开运算符 [...foo]
、{...bar}
是一个非常方便的语法。使用展开运算符时,对于同一个键,后出现的值会取代先出现的值
。
在使用展开运算符时,被展开的值必须与被创建的值相匹配。也就是说,在创建对象时只能展开对象,在创建数组时只能展开可迭代类型。
禁止禁止展开原始类型,包括 null
和 undefined
。
// 不要这样做!
const foo = {num: 7};
const bar = {num: 5, ...(shouldUseFoo && foo)}; // 展开运算符有可能作用于 undefined。
// 不要这样做!这会创建出一个没有 length 属性的对象 {0: 'a', 1: 'b', 2: 'c'}。
const fooStrings = ['a', 'b', 'c'];
const ids = {...fooStrings};
// 应当这样做!在创建对象时展开对象。
const foo = shouldUseFoo ? {num: 7} : {};
const bar = {num: 5, ...foo};
// 应当这样做!在创建数组时展开数组。
const fooStrings = ['a', 'b', 'c'];
const ids = [...fooStrings, 'd', 'e'];
3.12. 控制流语句 / 语句块
必须多行控制流语句必须使用大括号。
// 应当这样做!
for (let i = 0; i < x; i++) {
doSomethingWith(i);
andSomeMore();
}
if (x) {
doSomethingWithALongMethodName(x);
}
// 不要这样做!
if (x)
x.doFoo();
for (let i = 0; i < x; i++)
doSomethingWithALongMethodName(i);
这条规则的例外时,能够写在同一行的 if
语句可以省略大括号。
// 可以这样做!
if (x) x.doFoo();
3.13. switch
语句
必须所有的 switch 语句都必须包含一个 default
分支,即使这个分支里没有任何代码。
// 应当这样做!
switch (x) {
case Y:
doSomethingElse();
break;
default:
// 什么也不做。
}
非空语句组( case ... )
不允许越过分支向下执行(编译器会进行检查):
// 不能这样做!
switch (x) {
case X:
doSomething();
// 不允许向下执行!
case Y:
// ...
}
空语句组可以这样做:
// 可以这样做!
switch (x) {
case X:
case Y:
doSomething();
break;
default: // 什么也不做。
}
3.14. 相等性判断
必须必须使用三等号( ===
)和对应的不等号( !==
)。两等号(==
)会在比较的过程中进行类型转换,这非常容易导致难以理解的错误。并且在 JavaScript
虚拟机上,==
的运行速度比 ===
慢。
对于数据类型的判断尽量使用 lodash
中提供的方法。
- isNil(value):判断
value
是否为null
或undefined
。 - isNumber(value):判断
value
是否为数字。 - isNaN(value):判断
value
是否为NaN
。 - isEmpty(value):判断
value
是否为空(null
、undefined
、空字符串、空数组、空对象)。 - isUndefined(value):判断
value
是否为undefined
。 - isString(value):判断
value
是否为字符串。 - isBoolean(value):判断
value
是否为布尔值。 - isArray(value):判断
value
是否为数组。 - isObject(value):判断
value
是否为对象。 - isFunction(value):判断
value
是否为函数。 - isRegExp(value):判断
value
是否为正则表达式。 - isDate(value):判断
value
是否为日期。 - isError(value):判断
value
是否为错误。 - isSymbol(value):判断
value
是否为符号。 - isMap(value):判断
value
是否为 Map 类型。 - isSet(value):判断
value
是否为 Set 类型。 - isLength(value):判断
value
是否为有效的类数组长度。
3.15. 函数声明
使用 function foo() { ... }
的形式声明具名函数,包括嵌套在其它作用域中,例如其它函数内部的函数。
不要使用将函数表达式赋值给局部变量的写法(例如 const x = function() {...};
)。TypeScript
本身已不允许重新绑定函数,所以在函数声明中使用 const
来阻止重写函数是没有必要的。
顶层箭头函数可以用于显式地声明这一函数实现了一个接口。
interface SearchFunction {
(source: string, subString: string): boolean;
}
const fooSearch: SearchFunction = (source, subString) => {
};
3.16. 函数表达式
3.16.1. 在表达式中使用箭头函数
不应不要使用 ES6 之前使用 function
关键字定义函数表达式的版本。应当使用箭头函数
。
// 应当这样做!
bar(() => {
this.doSomething();
})
// 不要这样做!
bar(function () {
})
只有当函数需要动态地重新绑定 this
时,才能使用 function
关键字声明函数表达式,但是通常情况下代码中不应当重新绑定 this
。常规函数(相对于箭头函数和方法而言)不应当访问 this
。
3.16.2. 表达式函数体 和 代码块函数体
应当使用箭头函数时,应当根据具体情况选择表达式或者代码块作为函数体。
// 使用函数声明的顶层函数。
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));
function payMoney(amount: number) {
// 函数声明也没问题,但是不要在函数中访问 this。
}
}
只有在确实需要用到函数返回值的情况下才能使用表达式函数体。
// 不要这样做!如果不需要函数返回值的话,应当使用代码块函数体({ ... })。
myPromise.then(v => console.log(v));
// 应当这样做!使用代码块函数体。
myPromise.then(v => {
console.log(v);
});
3.16.3. 重新绑定 this
不应不要在函数表达式中使用 this
,除非它们明确地被用于重新绑定 this
指针。大多数情况下,使用箭头函数或者显式指定函数参数都能够避免重新绑定 this
的需求。
// 不要这样做!
function clickHandler() {
// 这里的 this 到底指向什么?
this.textContent = 'Hello';
}
// 不要这样做!this 指针被隐式地设为 document.body。
document.body.onclick = clickHandler;
// 应当这样做!在箭头函数中显式地对对象进行引用。
document.body.onclick = () => {
document.body.textContent = 'hello';
};
// 可以这样做!函数显式地接收一个参数。
const setTextFn = (e: HTMLElement) => {
e.textContent = 'hello';
};
document.body.onclick = setTextFn.bind(null, document.body);
3.17. 自动分号插入
不要依赖自动分号插入(ASI),必须显式地使用分号结束每一个语句。这能够避免由于不正确的分号插入所导致的 Bug,也能够更好地兼容对 ASI 支持有限的工具(例如 clang-format
)。
在 git
提交代码前,请检查分号。
3.18. @ts-ignore
不应尽量避免使用 @ts-ignore
。表面上看,这是一个“解决”编译错误的简单方法,但实际上,编译错误往往是由其它更大的问题导致的,因此正确的做法是直接解决这些问题本身。
举例来说,如果使用 @ts-ignore
关闭了一个类型错误,那么便很难推断其它相关代码最终会接收到何种类型。对于许多与类型相关的错误, any
类型 一节有一些关于如何正确使用 any
的有用的建议。
3.19. 类型断言、非空断言和 const 断言
不应类型断言( x as SomeType
)和非空断言( y!
)是不安全的。这两种语法只能够绕过编译器,而并不添加任何运行时断言检查,因此有可能导致程序在运行时崩溃。
因此,除非有明显或确切的理由
,否则不应使用类型断言和非空断言。
// 尽量避免这样做!
(x as Foo).foo();
y!.bar();
有时根据代码中的上下文可以确定某个断言必然是安全的。在这种情况下, 应当添加注释详细地解释 为什么这一不安全的行为可以被接受:
// 可以这样做!
// x 是一个 Foo 类型的示例,因为……
(x as Foo).foo();
// y 不可能是 null,因为……
y!.bar();
如果使用断言的理由很明显,注释就不是必需的。例如,生成的协议代码总是可空的,但有时根据上下文可以确认其中某些特定的由后端提供的字段必然不为空。在这些情况下应当根据具体场景加以判断和变通。
由于项目上类型定义和 C7N 类型定义不完善,这条规则可适当放宽。
3.19.1. 类型断言语法
必须类型断言必须使用 as
语法,不要使用尖括号(<>
)语法,这样能强制保证在断言外必须使用括号。
// 不要这样做!
const x = (<Foo>z).length;
const y = <Foo>z.length;
// 应当这样做!
const x = (z as Foo).length;
3.19.2. 类型断言和对象字面量
必须使用类型标记( : Foo
)而非类型断言( as Foo
)标明对象字面量的类型。在日后对接口的字段类型进行修改时,前者能够帮助程序员发现 Bug。
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;
}
3.19.3. const 断言
const
断言主要做以下推断:
- 表达式的任何类型不会被拓宽(例如 let a = "hhh", 不会被拓宽成 string 类型)
- 对象字面量只会获得只读属性
- 数组字面量变成只读的元组属性
const list = ['a', 'b', 'c'] as const; // 只读的元组属性 readonly ['a', 'b', 'c']
3.19.4. 非空断言不与等于检查相邻
禁止禁止非空断言(!
)与等于检查( =
或 ==
或 ===
)相邻,因为这样会造成歧义。它看起来类似于不等于检查( !=
!==
)。
interface Foo {
bar?: string;
num?: number;
}
const foo: Foo = getFoo();
// 以下写法会造成歧义å
const isEqualsBar = foo.bar! == 'hello';
const isEqualsNum = 1 + foo.num! == 2;
// 必须使用下面这种写法
const isEqualsBar = foo.bar == 'hello';
const isEqualsNum = (1 + foo.num!) == 2;
3.20. 成员属性声明
必须接口和类的声明必须使用 ;
分隔每个成员声明。
// 应当这样做!
interface Foo {
memberA: string;
memberB: number;
}
为了与类的写法保持一致,不要在接口中使用 ,
分隔字段。
// 不要这样做!
interface Foo {
memberA: string,
memberB: number,
}
内联对象类型声明必须使用 ,
作为分隔符。
// 应当这样做!
type SomeTypeAlias = {
memberA: string,
memberB: number,
};
let someProperty: { memberC: string, memberD: number };
3.21. 枚举
应当对于枚举类型,必须使用 enum
关键字,但不要使用 const enum
。TypeScript
的枚举类型本身就是不可变的, const enum
的写法是另一种独立的语言特性,其目的是让枚举对 JavaScript
程序员透明。
3.22. debugger 语句
禁止不允许在生产环境代码中添加 debugger
语句。
// 不要这样做!
function debugMe() {
debugger;
}
在 git
提交代码前,请检查代码中是否包含 debugger
语句。