3. 代码管理¶
3.1. 模块¶
3.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';
3.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');
小技巧
TypeScript 的命名空间早期也被称为内部模块并使用 module
关键字,形如 module Foo { ... }
。不要使用这种用法。任何时候都应当使用 ES6 的导入语法。
3.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 中的内容
}
3.2.1. 导出可见性¶
TypeScript 不支持限制导出符号的可见性。因此,不要导出不用于模块以外的符号。一般来说,应当尽量减小模块的外部 API 的规模。
3.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();
3.2.3. 容器类¶
不要为了实现命名空间创建含有静态方法或属性的容器类。
// 不要这样做!
export class Container {
static FOO = 1;
static bar() { return 1; }
}
应当将这些方法和属性设为单独导出的常数和函数。
// 应当这样做!
export const FOO = 1;
export function bar() { return 1; }
3.3. 导入¶
在 ES6 和 TypeScript 中,导入语句共有四种变体:
导入类型 |
示例 |
用途 |
---|---|---|
模块 |
|
TypeScript 导入方式 |
解构 |
|
TypeScript 导入方式 |
默认 |
|
只用于外部代码的特殊需求 |
副作用 |
|
只用于加载某些库的副作用(例如自定义元素) |
// 应当这样做!从这两种变体中选择较合适的一种(见下文)。
import * as ng from '@angular/core';
import {Foo} from './foo';
// 只在有需要时使用默认导入。
import Button from 'Button';
// 有时导入某些库是为了其代码执行时的副作用。
import 'jasmine';
import '@polymer/paper-button';
3.3.1. 选择模块导入还是解构导入?¶
根据使用场景的不同,模块导入和解构导入分别有其各自的优势。
虽然模块导入语句中出现了通配符 *
,但模块导入并不能因此被视为其它语言中的通配符导入。相反地,模块导入语句为整个模块提供了一个名称,模块中的所有符号都通过这个名称进行访问,这为代码提供了更好的可读性,同时令模块中的所有符号可以进行自动补全。模块导入减少了导入语句的数量(模块中的所有符号都可以使用),降低了命名冲突的出现几率,同时还允许为被导入的模块提供一个简洁的名称。在从一个大型 API 中导入多个不同的符号时,模块导入语句尤其有用。
解构导入语句则为每一个被导入的符号提供一个局部的名称,这样在使用被导入的符号时,代码可以更简洁。对那些十分常用的符号,例如 Jasmine 的 describe
和 it
来说,这一点尤其有用。
// 不要这样做!无意义地使用命名空间中的名称使得导入语句过于冗长。
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(...);
});
});
...
3.3.2. 重命名导入¶
在代码中,应当通过使用模块导入或重命名导出解决命名冲突。此外,在需要时,也可以使用重命名导入(例如 import {SomeThing as SomeOtherThing}
)。
在以下几种情况下,重命名导入可能较为有用:
避免与其它导入的符号产生命名冲突。
被导入符号的名称是自动生成的。
被导入符号的名称不能清晰地描述其自身,需要通过重命名提高代码的可读性,如将 RxJS 的
from
函数重命名为observableFrom
。
3.3.3. import type
和 export type
¶
不要使用 import type ... from
或者 export type ... from
。
小技巧
这一规则不适用于导出类型定义,如 export type Foo = ...;
。
// 不要这样做!
import type {Foo} from './foo';
export type {Bar} from './bar';
应当使用常规的导入语句。
// 应当这样做!
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
,这样不容易造成错误,同时能更好地表达设计思路。
3.4. 根据特征组织代码¶
应当根据特征而非类型组织代码。例如,一个在线商城的代码应当按照 products
, checkout
, backend
等分类,而不是 views
, models
, controllers
。