Skip to content

TypeScript 模块和命名空间

TypeScript 模块

任何包含 import 或 export 语句的文件,就是一个模块(module)。相应地,如果文件不包含 export 语句,就是一个全局的脚本文件。

模块本身就是一个作用域,不属于全局作用域。模块内部的变量、函数、类只在内部可见,对于模块外部是不可见的。

TypeScript 模块除了支持所有 ES 模块的语法,特别之处在于允许导出和引入类型。

ts
// a.ts
export type Bool = true | false;

// 或
type Bool = true | false;

export { Bool };

另一个模块引用

ts
// b.ts
import { Bool } from "./a";

let foo: Bool = true;

编译时,可以两个脚本同时编译。也可以只编译b.ts,因为它是入口脚本,tsc 会自动编译它依赖的所有脚本。

$ tsc b.ts

import type 语句

import 在一条语句中,可以同时引入 ts 类型和正常 js 模块。

ts
// a.ts
export interface A {
  foo: string;
}

export let a = 123;

// b.ts
import { A, a } from "./a";

这样很不利于区分类型和正常 js 模块,容易造成混淆。为了解决这个问题,TypeScript 引入了两个解决方法。

第一个方法是在 import 语句引入的类型前面加上type关键字。

ts
import { type A, a } from "./a";

第二个方法是使用 import type 语句,这个语句只能引入类型。

ts
// 正确
import type { A } from "./a";

// 报错
import type { a } from "./a";

import type 在一个名称空间下,引入所有类型的写法如下。

import type * as TypeNS from "moduleA";

同样的,export 语句也有两种方法,表示导出的是类型。

ts
type A = "a";
type B = "b";

// 方法一
export { type A, type B };

// 方法二
export type { A, B };

下面是 export type 将一个类作为类型导出的例子。

ts
class Point {
  x: number;
  y: number;
}

export type { Point };

上面示例中,Point只能作为类型引入,不能当作正常 js 模块使用。

import type 编译设置

对于引入类型(type)的 import 语句,编译成 JavaScript 时怎么处理呢?

TypeScript 提供了importsNotUsedAsValues编译设置项,有三个可能的值。

(1)remove:这是默认值,自动删除引入类型的 import 语句。

(2)preserve:保留引入类型的 import 语句。

(3)error:保留引入类型的 import 语句(与preserve相同),但是必须写成import type的形式,否则报错。

下面是一个引入类型的 import 语句。

import { TypeA } from "./a";

上面示例中,TypeA是一个类型。

remove的编译结果会将该语句删掉。

preserve的编译结果会保留该语句,但会删掉其中涉及类型的部分。

import "./a";

上面就是preserve的编译结果,可以看到编译后的import语句不从a.js引入任何东西,但是会引发a.js的执行,因此会保留a.js里面的副作用

error的编译结果与preserve相同,但在编译过程中会报错,因为它要求引入类型的import语句必须写成import type 的形式。原始语句改成下面的形式,就不会报错。

import type { TypeA } from "./a";

模块定位

模块定位(module resolution)指的是确定 import 语句和 export 语句里面的模块文件位置。

import { TypeA } from "./a";

上面示例中,TypeScript 怎么确定./a到底是指哪一个模块,这就叫做“模块定位”。

模块定位有两种方法,一种称为 Classic 方法,另一种称为 Node 方法。可以使用编译参数moduleResolution,指定使用哪一种方法。

没有指定定位方法时,就看原始脚本采用什么模块格式。如果模块格式是 CommonJS(即编译时指定--module commonjs),那么模块定位采用 Node 方法,否则采用 Classic 方法(模块格式为 es2015、 esnext、amd, system, umd 等等)。

相对模块,非相对模块

  • 相对模块指的是路径以/./../开头的模块。
  • 非相对模块指的是不带有路径信息的模块。

Classic 方法

  • Classic 方法以当前脚本的路径作为“基准路径”,计算相对模块的位置。
  • 至于非相对模块,也是以当前脚本的路径作为起点,一层层查找上级目录

Node 方法

Node 方法就是模拟 Node.js 的模块加载方法。

相对模块依然是以当前脚本的路径作为“基准路径”。比如,脚本文件a.ts里面有一行代码let x = require("./b");,TypeScript 按照以下顺序查找。

  1. 当前目录是否包含b.tsb.tsxb.d.ts
  2. 当前目录是否有子目录b,该子目录是否存在文件package.json,该文件的types字段是否指定了入口文件,如果是的就加载该文件。
  3. 当前目录的子目录b是否包含index.tsindex.tsxindex.d.ts

非相对模块则是以当前脚本的路径作为起点,逐级向上层目录查找是否存在子目录node_modules。比如,脚本文件a.js有一行let x = require("b");,TypeScript 按照以下顺序进行查找。

  1. 当前目录的子目录node_modules是否包含b.tsb.tsxb.d.ts
  2. 当前目录的子目录node_modules,是否存在文件package.json,该文件的types字段是否指定了入口文件,如果是的就加载该文件。
  3. 当前目录的子目录node_modules里面,是否包含子目录@types,在该目录中查找文件b.d.ts
  4. 当前目录的子目录node_modules里面,是否包含子目录b,在该目录中查找index.tsindex.tsxindex.d.ts
  5. 进入上一层目录,重复上面 4 步,直到找到为止。

路径映射

tsconfig.json文件里面,可以手动指定脚本模块的路径。

(1)baseUrl

baseUrl字段可以手动指定脚本模块的基准目录。

{
  "compilerOptions": {
    "baseUrl": "."
  }
}

上面示例中,baseUrl是一个点,表示基准目录就是tsconfig.json所在的目录。

(2)paths

paths字段指定非相对路径的模块与实际脚本的映射

{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "jquery": ["node_modules/jquery/dist/jquery"]
    }
  }
}

上面示例中,加载模块jquery时,实际加载的脚本是node_modules/jquery/dist/jquery,它的位置要根据baseUrl字段计算得到。

注意,上例的jquery属性的值是一个数组,可以指定多个路径。如果第一个脚本路径不存在,那么就加载第二个路径,以此类推。

(3)rootDirs

rootDirs字段指定模块定位时必须查找的其他目录。

{
  "compilerOptions": {
    "rootDirs": ["src/zh", "src/de", "src/#{locale}"]
  }
}

TypeScript 命名空间

namespace 的作用

  • 用于将相关代码组织在一起,形成一个逻辑容器。
  • 出现在 ES 模块(ESM)之前,是 TypeScript 早期的模块化方案。
  • 官方现已不推荐使用,建议优先使用 ES 模块(import/export)。

基本用法

  • namespace 内部的成员默认对外不可见
  • 要在外部使用,必须用 export 显式导出:
    ts
    namespace Utils {
      export function log(msg: string) { ... }
    }
      
    Utils.log("hello");

编译结果

  • namespace 会被编译为一个全局对象export 的成员成为其属性。
  • 因此,namespace 会生成运行时代码(不是纯类型),可能污染全局作用域。

import 别名功能

  • namespace 内或外,可用 import 为长路径起别名:
    ts
    import polygons = Shapes.Polygons;
    let sq = new polygons.Square();

嵌套与类型支持

  • 支持嵌套命名空间,但内层也需 export 才能被外部访问。
  • 可包含类型定义(如 interfaceclass),这些也可被导出用作类型。

与模块的对比

  • 模块(ESM)是标准、推荐方式:每个文件一个模块,天然作用域隔离,无需编译转换。
  • namespace 的主要劣势:非标准、需编译、可能全局污染。
  • 若坚持用 namespace 跨文件,需用三斜杠指令引入:
    ts
    /// <reference path="utils.ts" />

最佳实践

  • 优先使用 ES 模块export/import)替代 namespace
  • 示例重构:
    ts
    // shapes.ts
    export class Triangle {}
    export class Square {}
    
    // 使用
    import * as shapes from "./shapes";
    let t = new shapes.Triangle();