ES 模块入门
本教程共 80 篇 · 第 54 篇 · 更新于 2026-08-10 · 约 10 分钟阅读
本节目标:掌握 TypeScript 中的 ES 模块写法——命名导出、默认导出、重导出、动态 import(),以及 TypeScript 判断文件是否为模块的规则。
在 JavaScript 的演进历史中,模块化方案经历过多次迭代。从早期的 IIFE 到 CommonJS,再到 AMD——最终,社区和 TC39 委员会在 ES2015 里统一了标准:ES Module。TypeScript 从 1.5 开始就支持这套语法,到了 7.0,ES Module 更是成了唯一的推荐方案。
一个文件是不是模块?
先看一个经常让新手困惑的问题:export {} 到底是干嘛的?
TypeScript 按照和 JavaScript 相同的规则判断文件身份:如果文件里包含顶层 import 或 export 语句,它就是一个模块(module);否则,它是一个脚本(script)。
模块和脚本的区别很大——模块有自己的作用域,里面的变量不会跑到全局。脚本没有这个隔离,所有顶层声明都暴露到全局空间。
// math.ts——这是一个模块,因为有 export
export const PI = 3.14159;
// utils.ts——这不是模块,它是一个脚本
const VERSION = "1.0.0";
如果某个文件没有任何 import 或 export,但你又希望它是个模块,加一行:
export {};
这行代码本身不导出任何东西,但它让文件变成了模块。TS 7.0 中 types 的默认值改为 [] 后,这个技巧用得更频繁——因为全局类型不再自动加载了。
命名导出(Named Export)
最常见的导出方式,一个文件可以导出任意多个命名成员:
// math.ts
export const PI = 3.14159;
export const E = 2.71828;
export function add(a: number, b: number): number {
return a + b;
}
export class Calculator {
multiply(a: number, b: number): number {
return a * b;
}
}
导入时使用解构语法,名字必须一致:
// app.ts
import { PI, add, Calculator } from "./math.js";
console.log(PI); // 3.14159
console.log(add(1, 2)); // 3
const calc = new Calculator();
console.log(calc.multiply(2, 3)); // 6
注意导入路径末尾的 .js——TypeScript 的 ES 模块导入习惯带上完整扩展名。这是遵循 Node.js 和浏览器原生 ESM 规范的写法,TypeScript 编译器会自动将 .js 对应到 .ts 源文件。
如果想给导入的成员换个名字,用 as:
import { PI as π, add as sum } from "./math.js";
console.log(π); // 3.14159
console.log(sum(5, 3)); // 8
默认导出(Default Export)
一个模块只能有一个默认导出:
// greeter.ts
export default function greet(name: string): string {
return `Hello, ${name}!`;
}
导入时不用解构语法,直接取一个名字:
import greet from "./greeter.js";
console.log(greet("TypeScript")); // Hello, TypeScript!
默认导出的东西可以是任何值:函数、类、对象、甚至是原始值。
Note默认导出和命名导出可以共存。但实际项目里混用会让引用方困惑——是直接用默认名还是解构名?团队内部最好统一风格。
// shapes.ts
export default class Circle {
constructor(public radius: number) {}
}
export function squareArea(side: number): number {
return side * side;
}
// app.ts
import Circle, { squareArea } from "./shapes.js";
const c = new Circle(5);
console.log(squareArea(4)); // 16
命名空间导入
把整个模块的所有导出打包成一个对象,用 * as:
import * as MathLib from "./math.js";
console.log(MathLib.PI);
console.log(MathLib.add(3, 4));
const c = new MathLib.Calculator();
这种写法适合工具函数库——所有导出一目了然,而且不会和当前作用域的变量名冲突。
副作用导入
有时你只想执行一个模块的代码,不需要引用它的导出:
import "./setup.js";
这行代码会让 setup.js 里的所有顶层代码执行一遍。常见用途是注册全局 polyfill、运行初始化脚本。TypeScript 不会移除这类导入——即使你没有使用任何导出,它依然保留在编译输出中。
重导出(Re-export)
重导出让你把一个模块的导出”转发”到另一个模块,这在构建库的 index.ts 入口文件时特别有用:
// utils/index.ts
export { sleep, debounce } from "./async.js";
export { formatDate, parseDate } from "./date.js";
上面的代码等价于从 ./async.js 导入再导出,但只写了一行。还可以在重导出时改名:
export { sleep as wait } from "./async.js";
全部重导出:
export * from "./async.js";
这一行会把 ./async.js 里所有命名导出都重新暴露出去。但默认导出不会包含在内——export * 只转发命名导出。
如果想转发一个模块的默认导出,需要写:
export { default } from "./core.js";
// 或者改名后再导出:
export { default as CoreModule } from "./core.js";
动态 import()
前面讲的都是静态导入——模块路径在编译时就确定了。动态 import() 让模块加载发生在运行时:
async function loadChart() {
const { renderChart } = await import("./chart.js");
renderChart(data);
}
import() 返回一个 Promise,解析后拿到模块的命名空间对象。这意味着你可以按需加载——只在用户触发某个操作时才引入对应的模块。
动态导入最大的好处是代码分割(code splitting)。打包工具(如 esbuild、Rollup)看到 import() 会自动把这个模块拆成一个独立的 chunk,浏览器按需下载。
TipTypeScript 7.0 本身不处理代码分割——那是打包工具的事。但 TypeScript 的类型系统完全理解动态导入,
import()的返回类型会被正确推断为模块类型。
TypeScript 特有的模块语法
还有两种 ES 模块的 TypeScript 扩展值得一提:
import type 专门用来导入类型,编译后不产生任何 JavaScript 代码。下一章会详细讲。
import x = require("...") 是兼容 CommonJS 的语法:
import fs = require("fs");
const content = fs.readFileSync("config.json", "utf8");
这种写法在 TS 7.0 仍然可用,但官方建议新代码使用 ES Module 语法配合 module: "nodenext" 或 "node16"。如果你在维护用 CommonJS 的老项目,这个语法比 const fs = require("fs") 好的地方是类型推断更准确。
小结
ES 模块是 TypeScript 组织代码的基石。记住这几条核心规则:顶层 import/export 决定文件是不是模块;命名导出可以多个、默认导出只能一个;重导出是库入口文件的常用技巧;动态 import() 实现按需加载。这些规则在 TS 7.0 和未来版本中都是稳定不变的。