Skip to Content
TypeScript源文件结构

源文件结构

文件由以下部分按顺序组成:

  1. 版权信息(如有)
  2. 带有 @fileoverview 的 JSDoc(如有)
  3. 导入语句(如有)
  4. 文件的具体实现

存在的每个部分之间用恰好一个空行分隔。

版权信息

如果文件中需要包含许可证或版权信息,请在文件顶部以 JSDoc 形式添加。

@fileoverview JSDoc

文件可以有一个顶层的 @fileoverview JSDoc。如果存在,它可以提供文件内容的描述、用途或依赖项信息。换行时不进行缩进。

示例:

/** * @fileoverview Description of file. Lorem ipsum dolor sit amet, consectetur * adipiscing elit, sed do eiusmod tempor incididunt. */

导入

ES6 和 TypeScript 中有四种导入语句(Import Statement)的变体:

导入类型示例用途
模块导入(module)1import * as foo from '...';TypeScript 导入
命名导入(named)2import {SomeThing} from '...';TypeScript 导入
默认导入(default)import SomeThing from '...';仅在其他外部代码需要时使用
副作用导入(side-effect)import '...';仅用于导入那些在加载时产生副作用的库(例如自定义元素)
// 好的写法:根据情况在两种方式中选择合适的(见下文)。 import * as ng from '@angular/core'; import {Foo} from './foo'; // 仅在需要时使用:默认导入。 import Button from 'Button'; // 有时需要导入库以获取其副作用: import 'jasmine'; import '@polymer/paper-button';

导入路径

TypeScript 代码必须使用路径来导入其他 TypeScript 代码。路径可以是相对路径(即以 ... 开头),或者以基础目录为根的路径,例如 root/path/to/file

在引用同一(逻辑)项目中的文件时,代码应该使用相对导入(./foo)而非绝对导入 path/to/foo,这样可以在移动项目时无需修改这些导入。

考虑限制父级路径的层数(../../../),因为这会使模块和路径结构难以理解。

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

命名空间导入与命名导入

命名空间导入(Namespace Import)和命名导入(Named Import)都可以使用。

对于在文件中频繁使用的符号或名称明确的符号,推荐使用命名导入,例如 Jasmine 的 describeit。命名导入可以根据需要使用 as 重命名为更清晰的名称。

当从大型 API 中使用许多不同符号时,推荐使用命名空间导入。命名空间导入虽然使用了 * 字符,但不等同于其他语言中的“通配符”导入。相反,命名空间导入为模块的所有导出提供了一个名称,每个导出的符号都成为该模块名称的属性。命名空间导入有助于提高常见名称(如 ModelController)的可读性,而无需声明别名。

// 不好:过长的导入语句,不必要地使用了命名空间化的名称。 import {Item as TableviewItem, Header as TableviewHeader, Row as TableviewRow, Model as TableviewModel, Renderer as TableviewRenderer} from './tableview'; let item: TableviewItem|undefined;
// 更好:使用模块进行命名空间化。 import * as tableview from './tableview'; let item: tableview.Item|undefined;
import * as testing from './testing'; // 不好:模块名称没有提高可读性。 testing.describe('foo', () => { testing.it('bar', () => { testing.expect(null).toBeNull(); testing.expect(undefined).toBeUndefined(); }); });
// 更好:为这些常用函数提供本地名称。 import {describe, it, expect} from './testing'; describe('foo', () => { it('bar', () => { expect(null).toBeNull(); expect(undefined).toBeUndefined(); }); });
特殊情况:Apps JSPB protos

Apps JSPB protos 必须使用命名导入,即使这会导致很长的导入行。

此规则的目的是帮助构建性能和死代码消除(Dead Code Elimination),因为 .proto 文件通常包含许多不需要同时使用的 message。通过利用解构导入,构建系统可以对 Apps JSPB 消息创建更细粒度的依赖关系,同时保持基于路径导入的便利性。

// 好的写法:从 proto 文件中导入你需要的确切符号集。 import {Foo, Bar} from './foo.proto'; function copyFooBar(foo: Foo, bar: Bar) {...}

重命名导入

代码应该通过使用命名空间导入或重命名导出本身来解决名称冲突。代码可以在需要时重命名导入(import {SomeThing as SomeOtherThing})。

以下三种场景中重命名会很有帮助:

  1. 需要避免与其他导入符号的冲突时。
  2. 导入的符号名称是自动生成的时。
  3. 导入符号的名称本身不够清晰时,重命名可以提高代码清晰度。例如,使用 RxJS 时,将 from 函数重命名为 observableFrom 可能更具可读性。

导出

在所有代码中使用命名导出(Named Export):

// 使用命名导出: export class Foo { ... }

不要使用默认导出(Default Export)。这确保所有导入遵循统一的模式。

// 不要使用默认导出: export default class Foo { ... } // 不好!

为什么?

默认导出不提供规范名称(Canonical Name),这使得集中维护变得困难,同时对代码所有者的好处相对较小,包括可能降低可读性:

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

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 { // 这里只放类相关的内容 }

导出可见性

TypeScript 不支持限制导出符号的可见性。只导出模块外部使用的符号。通常应尽量减少模块的导出 API 表面。

可变导出

无论技术上是否支持,可变导出(Mutable Export)会创建难以理解和调试的代码,特别是在跨多个模块进行重新导出时。换句话说,不允许使用 export let

export let foo = 3; // 在纯 ES6 中,foo 是可变的,导入者会在一秒后观察到值的变化。 // 在 TS 中,如果 foo 被第二个文件重新导出,导入者将不会看到值的变化。 window.setTimeout(() => { foo = 4; }, 1000 /* ms */);

如果需要支持外部可访问且可变的绑定,应该改用显式的 getter 函数。

let foo = 3; window.setTimeout(() => { foo = 4; }, 1000 /* ms */); // 使用显式 getter 来访问可变导出。 export function getFoo() { return foo; };

对于根据条件导出两个值之一的常见模式,先进行条件检查,然后再导出。确保在模块主体执行完毕后所有导出都是最终的。

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

容器类

不要为了命名空间化而创建包含静态方法或属性的容器类(Container Class)。

export class Container { static FOO = 1; static bar() { return 1; } }

相反,导出单独的常量和函数:

export const FOO = 1; export function bar() { return 1; }

导入和导出类型

导入类型

当你仅将导入的符号用作类型时,可以使用 import type {...}。对于值使用常规导入:

import type {Foo} from './foo'; import {Bar} from './foo'; import {type Foo, Bar} from './foo';

为什么?

TypeScript 编译器会自动处理这种区别,不会为类型引用插入运行时加载。那为什么还要标注类型导入呢?

TypeScript 编译器可以在两种模式下运行:

  • 在开发模式下,我们通常需要快速的迭代循环。编译器在没有完整类型信息的情况下转译为 JavaScript。这要快得多,但在某些情况下需要 import type
  • 在生产模式下,我们需要正确性。编译器会对所有内容进行类型检查,并确保 import type 的使用是正确的。

注意:如果你需要强制运行时加载以获取副作用,请使用 import '...';

导出类型

在重新导出类型时使用 export type,例如:

export type {AnInterface} from './foo';

为什么?

export type 可用于在逐文件转译中允许类型重新导出。参见 isolatedModules 文档

export type 可能看起来也有助于避免为某个 API 导出值符号。然而,它并不能提供保证:下游代码仍然可能通过不同的路径导入该 API。更好的拆分和保证 API 的类型与值用途的方式是实际拆分这些符号,例如拆分为 UserServiceAjaxUserService。这种方式不容易出错,也能更好地传达意图。

使用模块而非命名空间

TypeScript 支持两种组织代码的方式:命名空间(Namespace)模块(Module),但命名空间是不允许的。也就是说,你的代码必须使用 import {foo} from 'bar'; 形式的导入和导出来引用其他文件中的代码。

你的代码禁止使用 namespace Foo { ... } 结构。namespace 只能在需要与外部第三方代码交互时使用。要在语义上对代码进行命名空间化,请使用单独的文件。

代码禁止使用 require(如 import x = require('...');)进行导入。使用 ES6 模块语法。

// 不好:不要使用命名空间: namespace Rocket { function launch() { ... } } // 不好:不要使用 <reference> /// <reference path="..."/> // 不好:不要使用 require() import x = require('mydep');

注意:TypeScript 的 namespace 以前被称为内部模块(Internal Module),并且曾经使用 module 关键字,形式为 module Foo { ... }。也不要使用这种形式。始终使用 ES6 导入。

Last updated on