4. 代码管理

4.1. 模块

4.1.1. 导入路径

TypeScript 代码必须使用路径进行导入。这里的路径既可以是相对路径,以 ... 开头,也可以是从项目根目录开始的绝对路径,如 root/path/to/file

在引用逻辑上属于同一项目的文件时,应使用相对路径 ./foo ,不要使用绝对路径 path/to/foo

应尽可能地限制父层级的数量(避免出现诸如 ../../../ 的路径),过多的层级会导致模块和路径结构难以理解。

import {Symbol1} from 'google3/path/from/root';
import {Symbol2} from '../parent/file';
import {Symbol3} from './sibling';

4.1.2. 用 命名空间 还是 模块?

在 TypeScript 有两种组织代码的方式:命名空间(namespace)和模块(module)。

不允许使用命名空间,在 TypeScript 中必须使用模块(即 ES6 模块 )。也就是说,在引用其它文件中的代码时必须以 import {foo} from 'bar' 的形式进行导入和导出。

不允许使用 namespace Foo { ... } 的形式组织代码。命名空间只能在所用的外部第三方库有要求时才能使用。如果需要在语义上对代码划分命名空间,应当通过分成不同文件的方式实现。

不允许在导入时使用 require 关键字(形如 import x = require('...'); )。应当使用 ES6 的模块语法。

// 不要这样做!不要使用命名空间!
namespace Rocket {
    function launch() { ... }
}

// 不要这样做!不要使用 <reference> !
/// <reference path="..."/>

// 不要这样做!不要使用 require() !
import x = require('mydep');

Tip

TypeScript 的命名空间早期也被称为内部模块并使用 module 关键字,形如 module Foo { ... } 。不要使用这种用法。任何时候都应当使用 ES6 的导入语法。

4.2. 导出

代码中必须使用具名的导出声明。

// Use named exports:
export class Foo { ... }

不要使用默认导出,这样能保证所有的导入语句都遵循统一的范式:

// 不要这样做!不要使用默认导出!
export default class Foo { ... }

为什么?因为默认导出并不为被导出的符号提供一个标准的名称,这增加了维护的难度和降低可读性的风险,同时并未带来明显的益处。如下面的例子所示:

// 默认导出会造成如下的弊端
import Foo from './bar';  // 这个语句是合法的。
import Bar from './bar';  // 这个语句也是合法的。

具名导出的一个优势是,当代码中试图导入一个并未被导出的符号时,这段代码会报错。例如,假设在 foo.ts 中有如下的导出声明:

// 不要这样做!
const foo = 'blah';
export default foo;

如果在 bar.ts 中有如下的导入语句:

// 编译错误!
import {fizz} from './foo';

会导致编译错误: error TS2614: Module '"./foo"' has no exported member 'fizz' 。反之,如果在 bar.ts 中的导入语句为:

// 不要这样做!这定义了一个多余的变量 fizz!
import fizz from './foo';

结果是 fizz === foo ,这往往不符合预期,且难以调试。

此外,默认导出会鼓励程序员将所有内容全部置于一个巨大的对象当中,这个对象实际上充当了命名空间的角色:

// 不要这样做!
export default class Foo {
    static SOME_CONSTANT = ...
    static someHelpfulFunction() { ... }
    ...
}

显然,这个文件中具有文件作用域,它可以被用做命名空间。但是,这里创建了第二个作用域——类 Foo ,这个类在其它文件中具有歧义:它既可以被视为类型,又可以被视为值。

因此,应当使用文件作用域作为实质上的命名空间,同时使用具名的导出声明:

// 应当这样做!
export const SOME_CONSTANT = ...
export function someHelpfulFunction()
export class Foo {
    // 只有类 Foo 中的内容
}

4.2.1. 导出可见性

TypeScript 不支持限制导出符号的可见性。因此,不要导出不用于模块以外的符号。一般来说,应当尽量减小模块的外部 API 的规模。

4.2.2. 可变导出

虽然技术上可以实现,但是可变导出会造成难以理解和调试的代码,尤其是对于在多个模块中经过了多次重新导出的符号。这条规则的一个例子是,不允许使用 export let

// 不要这样做!
export let foo = 3;
// 在纯 ES6 环境中,变量 foo 是一个可变值,导入了 foo 的代码会观察到它的值在一秒钟之后发生了改变。
// 在 TypeScript 中,如果 foo 被另一个文件重新导出了,导入该文件的代码则不会观察到变化。
window.setTimeout(() => {
    foo = 4;
}, 1000 /* ms */);

如果确实需要允许外部代码对可变值进行访问,应当提供一个显式的取值器。

// 应当这样做!
let foo = 3;
window.setTimeout(() => {
    foo = 4;
}, 1000 /* ms */);
// 使用显式的取值器对可变导出进行访问。
export function getFoo() { return foo; };

有一种常见的编程情景是,要根据某种特定的条件从两个值中选取其中一个进行导出:先检查条件,然后导出。这种情况下,应当保证模块中的代码执行完毕后,导出的结果就是确定的。

function pickApi() {
    if (useOtherApi()) return OtherApi;
    return RegularApi;
}
export const SomeApi = pickApi();

4.2.3. 容器类

不要为了实现命名空间创建含有静态方法或属性的容器类。

// 不要这样做!
export class Container {
    static FOO = 1;
    static bar() { return 1; }
}

应当将这些方法和属性设为单独导出的常数和函数。

// 应当这样做!
export const FOO = 1;
export function bar() { return 1; }

4.3. 导入

在 ES6 和 TypeScript 中,导入语句共有四种变体:

导入类型 示例 用途
模块 import * as foo from '...'; TypeScript 导入方式
解构 import {SomeThing} from '...'; TypeScript 导入方式
默认 import SomeThing from '...'; 只用于外部代码的特殊需求
副作用 import '...'; 只用于加载某些库的副作用(例如自定义元素)
// 应当这样做!从这两种变体中选择较合适的一种(见下文)。
import * as ng from '@angular/core';
import {Foo} from './foo';

// 只在有需要时使用默认导入。
import Button from 'Button';

// 有时导入某些库是为了其代码执行时的副作用。
import 'jasmine';
import '@polymer/paper-button';

4.3.1. 选择模块导入还是解构导入?

根据使用场景的不同,模块导入和解构导入分别有其各自的优势。

虽然模块导入语句中出现了通配符 * ,但模块导入并不能因此被视为其它语言中的通配符导入。相反地,模块导入语句为整个模块提供了一个名称,模块中的所有符号都通过这个名称进行访问,这为代码提供了更好的可读性,同时令模块中的所有符号可以进行自动补全。模块导入减少了导入语句的数量(模块中的所有符号都可以使用),降低了命名冲突的出现几率,同时还允许为被导入的模块提供一个简洁的名称。在从一个大型 API 中导入多个不同的符号时,模块导入语句尤其有用。

解构导入语句则为每一个被导入的符号提供一个局部的名称,这样在使用被导入的符号时,代码可以更简洁。对那些十分常用的符号,例如 Jasmine 的 describeit 来说,这一点尤其有用。

// 不要这样做!无意义地使用命名空间中的名称使得导入语句过于冗长。
import {TableViewItem, TableViewHeader, TableViewRow, TableViewModel,
TableViewRenderer} from './tableview';
let item: TableViewItem = ...;
// 应当这样做!使用模块作为命名空间。
import * as tableview from './tableview';
let item: tableview.Item = ...;
import * as testing from './testing';

// 所有的测试都只会重复地使用相同的三个函数。
// 如果只需要导入少数几个符号,而这些符号的使用频率又非常高的话,
// 也可以考虑使用解构导入语句直接导入这几个符号(见下文)。
testing.describe('foo', () => {
testing.it('bar', () => {
    testing.expect(...);
    testing.expect(...);
});
});
// 这样做更好!为这几个常用的函数提供局部变量名。
import {describe, it, expect} from './testing';

describe('foo', () => {
it('bar', () => {
    expect(...);
    expect(...);
});
});
...

4.3.2. 重命名导入

在代码中,应当通过使用模块导入或重命名导出解决命名冲突。此外,在需要时,也可以使用重命名导入(例如 import {SomeThing as SomeOtherThing} )。

在以下几种情况下,重命名导入可能较为有用:

  1. 避免与其它导入的符号产生命名冲突。
  2. 被导入符号的名称是自动生成的。
  3. 被导入符号的名称不能清晰地描述其自身,需要通过重命名提高代码的可读性,如将 RxJS 的 from 函数重命名为 observableFrom

4.3.3. import typeexport type

不要使用 import type ... from 或者 export type ... from

Tip

这一规则不适用于导出类型定义,如 export type Foo = ...;

// 不要这样做!
import type {Foo} from './foo';
export type {Bar} from './bar';

应当使用常规的导入语句。

// 应当这样做!
import {Foo} from './foo';
export {Bar} from './bar';

TypeScript 的工具链会自动区分用作类型的符号和用作值的符号。对于类型引用,工具链不会生成运行时加载的代码。这样做的原因是为了提供更好的开发体验,否则在 import typeimport 之间反复切换会非常繁琐。同时, import type 并不提供任何保证,因为代码仍然可以通过其它的途径导入同一个依赖。

如果需要在运行时加载代码以执行其副作用,应使用 import '...' ,参见 导入 一节。

使用 export type 似乎可以避免将某个用作值的符号导出为 API。然而,和 import type 类似, export type 也不提供任何保证,因为外部代码仍然可以通过其它途径导入。如果需要拆分对 API 作为值的使用和作为类型的使用,并保证二者不被混用的话,应当显式地将其拆分成不同的符号,例如 UserServiceAjaxUserService ,这样不容易造成错误,同时能更好地表达设计思路。

4.4. 根据特征组织代码

应当根据特征而非类型组织代码。例如,一个在线商城的代码应当按照 productscheckoutbackend 等分类,而不是 viewsmodelscontrollers