文章

ES Module规范与特性深度解析

ES Module规范与特性深度解析

一句话概括

ES Module (ESM) 是 JavaScript 官方模块化标准,通过 import / export 语法实现静态化模块化,支持编译时优化(如 Tree Shaking)。

背景

在 ES Module 出现之前,JavaScript 社区主要有两种模块化方案:

  • CommonJS:Node.js 采用,同步加载,适合服务端
  • AMD/CMD:浏览器端异步加载方案

ES Module 在 ES6 (ES2015) 中正式标准化,成为 JavaScript 官方模块系统,兼顾浏览器和服务端。

ES Module 的优势:

  • 静态分析(编译时确定依赖关系)
  • 支持 Tree Shaking(删除未使用代码)
  • 异步加载(适合浏览器)
  • 循环依赖处理更好
  • 未来标准(浏览器原生支持)

概念与定义

核心概念

  • 模块(Module):一个独立的 JavaScript 文件,默认启用严格模式
  • export:导出模块接口(命名导出 / 默认导出)
  • import:导入其他模块的导出
  • 静态分析:编译时确定模块依赖关系(而非运行时)

模块特性

  1. 自动严格模式:模块默认 "use strict",无需手动声明
  2. 模块作用域:顶层变量不会污染全局
  3. 静态化import / export 必须位于顶层,不能在条件语句中
  4. 实时绑定(Live Binding):导出的是值的引用,而非拷贝

最小示例

导出模块(math.js):

1
2
3
4
5
6
7
8
// 命名导出
export const add = (a, b) => a + b;
export const subtract = (a, b) => a - b;

// 默认导出
export default {
  multiply: (a, b) => a * b
};

导入模块(main.js):

1
2
3
4
5
6
7
8
9
// 导入命名导出
import { add, subtract } from './math.js';

// 导入默认导出
import multiply from './math.js';

console.log(add(5, 3));      // 8
console.log(subtract(5, 3)); // 2
console.log(multiply.multiply(5, 3)); // 15

核心知识点拆解

1. 导出方式详解

(1)命名导出(Named Export)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 方式 1:声明时导出
export const PI = 3.14159;
export function circleArea(r) {
  return PI * r * r;
}

// 方式 2:统一导出
const add = (a, b) => a + b;
const subtract = (a, b) => a - b;

export { add, subtract };

// 方式 3:重命名导出
export { add as plus, subtract as minus };

特点:

  • 可以导出多个值
  • 导入时必须使用相同的名称(或可重命名)
  • 支持导出时重命名

(2)默认导出(Default Export)

1
2
3
4
5
6
7
8
9
10
11
12
// 方式 1:导出表达式
export default function(a, b) {
  return a + b;
}

// 方式 2:导出值
export default 42;

// 方式 3:导出对象
export default {
  add: (a, b) => a + b
};

特点:

  • 每个模块只能有一个默认导出
  • 导入时可以任意命名
  • 适合导出模块主功能

(3)混合导出

1
2
3
// math.js
export const PI = 3.14159;  // 命名导出
export default (a, b) => a + b;  // 默认导出
1
2
3
// main.js
import add, { PI } from './math.js';
console.log(add(PI, PI)); // 6.28318

2. 导入方式详解

(1)导入命名导出

1
2
3
4
5
6
7
8
9
// 导入指定导出
import { add, subtract } from './math.js';

// 重命名导入
import { add as plus } from './math.js';

// 导入所有命名导出(作为对象)
import * as math from './math.js';
console.log(math.add(5, 3));

(2)导入默认导出

1
2
// 默认导出可以任意命名
import myAdd from './math.js';

(3)混合导入

1
import defaultExport, { namedExport1, namedExport2 } from './module.js';

(4)仅执行模块(不导入)

1
import './polyfill.js';  // 执行副作用代码

3. 静态分析 vs 动态加载

静态导入(Static Import)

1
2
3
4
5
6
7
// ✅ 正确:顶层导入
import { add } from './math.js';

// ❌ 错误:不能在条件语句中
if (condition) {
  import { add } from './math.js';  // SyntaxError
}

优势:

  • 编译时确定依赖关系
  • 支持 Tree Shaking
  • 静态分析工具优化

动态导入(Dynamic Import)

1
2
3
4
5
6
7
8
9
10
11
12
// ✅ 正确:动态导入返回 Promise
if (condition) {
  import('./math.js').then(module => {
    console.log(module.add(5, 3));
  });
}

// 使用 async/await
async function loadModule() {
  const math = await import('./math.js');
  console.log(math.add(5, 3));
}

使用场景:

  • 条件加载
  • 按需加载(路由懒加载)
  • 运行时确定模块路径

4. 实时绑定(Live Binding)

ES Module 导出的是值的引用(实时绑定):

1
2
3
4
5
6
// counter.js
export let count = 0;

export function increment() {
  count++;
}
1
2
3
4
5
6
// main.js
import { count, increment } from './counter.js';

console.log(count); // 0
increment();
console.log(count); // 1(值已更新!)

对比 CommonJS(值拷贝):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// CommonJS
// counter.js
let count = 0;
module.exports = { count, increment };

function increment() {
  count++;
}

// main.js
const { count, increment } = require('./counter.js');
console.log(count); // 0
increment();
console.log(count); // 0(还是 0!值拷贝)

5. Tree Shaking 原理

Tree Shaking 是删除未使用代码的优化技术,依赖 ES Module 的静态分析。

示例:

1
2
3
4
// math.js
export const add = (a, b) => a + b;
export const subtract = (a, b) => a - b;
export const multiply = (a, b) => a * b;
1
2
3
4
// main.js
import { add } from './math.js';  // 只导入 add

console.log(add(5, 3));

打包结果(经过 Tree Shaking):

1
2
3
// multiply 和 subtract 被删除(未使用)
const add = (a, b) => a + b;
console.log(add(5, 3));

CommonJS 无法 Tree Shaking(运行时加载):

1
2
// CommonJS 无法通过静态分析确定哪些导出被使用
const { add } = require('./math.js');  // 运行时才知道导入什么

实战案例

案例 1:创建一个工具库(ES Module 版本)

项目结构:

1
2
3
4
5
6
7
my-utils/
├── package.json
├── src/
│   ├── string.js
│   ├── array.js
│   └── object.js
└── index.js

package.json:

1
2
3
4
5
6
7
8
{
  "name": "my-utils",
  "type": "module",
  "main": "./index.js",
  "exports": {
    ".": "./index.js"
  }
}

src/string.js:

1
2
3
4
5
export const capitalize = (str) =>
  str.charAt(0).toUpperCase() + str.slice(1);

export const camelCase = (str) =>
  str.replace(/-(\w)/g, (_, c) => c.toUpperCase());

src/array.js:

1
2
3
4
5
6
export const unique = (arr) => [...new Set(arr)];

export const flatten = (arr) =>
  arr.reduce((flat, item) =>
    flat.concat(Array.isArray(item) ? flatten(item) : item), []
  );

index.js(统一导出):

1
2
3
export * from './src/string.js';
export * from './src/array.js';
export * from './src/object.js';

使用:

1
2
3
4
import { capitalize, unique } from 'my-utils';

console.log(capitalize('hello')); // Hello
console.log(unique([1, 2, 2, 3])); // [1, 2, 3]

案例 2:动态导入实现路由懒加载

React 路由懒加载(使用 React.lazy):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import { lazy, Suspense } from 'react';

// 动态导入组件
const Home = lazy(() => import('./pages/Home.js'));
const About = lazy(() => import('./pages/About.js'));

function App() {
  return (
    <Suspense fallback={<div>Loading...</div>}>
      <Routes>
        <Route path="/" element={<Home />} />
        <Route path="/about" element={<About />} />
      </Routes>
    </Suspense>
  );
}

Vue 路由懒加载:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import { createRouter } from 'vue-router';

const routes = [
  {
    path: '/',
    component: () => import('./pages/Home.vue')  // 动态导入
  },
  {
    path: '/about',
    component: () => import('./pages/About.vue')
  }
];

const router = createRouter({
  routes
});

案例 3:循环依赖的处理

ES Module 能更好地处理循环依赖:

1
2
3
4
5
6
7
8
9
10
11
12
13
// a.js
import { b } from './b.js';
export const a = 'a';
console.log('在 a 中,b =', b);  // 'b'(已完成执行)

// b.js
import { a } from './a.js';
export const b = 'b';
console.log('在 b 中,a =', a);  // 'a'(实时绑定,即使 a 还未执行完)

// main.js
import { a, b } from './a.js';
console.log(a, b);  // 'a', 'b'

执行结果:

1
2
3
在 b 中,a = a(实时绑定,能访问)
在 a 中,b = b
a b

原因:

  • ES Module 使用实时绑定(Live Binding)
  • 即使模块未执行完,也能访问导出的绑定

底层原理

模块加载流程(浏览器)

  1. 解析(Parsing):解析 import / export 语句,构建模块依赖图
  2. 加载(Loading):递归加载所有依赖模块
  3. 连接(Linking):建立模块间的引用关系(实时绑定)
  4. 执行(Evaluation):执行模块代码

模块记录(Module Record)

浏览器内部用 模块记录 表示模块:

1
2
3
4
5
6
ModuleRecord {
  status: 'unlinked' | 'linking' | 'linked' | 'evaluating' | 'evaluated',
  dependencies: [...],  // 依赖的模块
  exportNames: [...],   // 导出的名称
  evalutationCode: ...   // 模块代码
}

实时绑定的实现

1
2
3
4
5
6
7
8
9
// 伪代码
Module {
  exports: {
    count: <绑定到原始变量的引用>
  }
}

// 当访问 module.exports.count 时
// 实际上访问的是原始变量(实时更新)

高频面试题解析

Q1: ES Module 和 CommonJS 的核心区别?

答:

特性CommonJSES Module
加载方式同步加载(运行时)异步加载(编译时静态分析)
导出方式module.exports / exportsexport / export default
导入方式require()import / import()
值传递值拷贝(缓存)实时绑定(Live Binding)
静态分析不支持(运行时加载)支持(编译时解析)
Tree Shaking不支持支持
循环依赖可能返回不完整模块更好的支持(实时绑定)
使用场景Node.js 服务端浏览器 + 服务端(通用)

示例:值拷贝 vs 实时绑定

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// CommonJS(值拷贝)
// counter.js
let count = 0;
module.exports = { count, increment };

function increment() { count++; }

// main.js
const { count, increment } = require('./counter');
console.log(count); // 0
increment();
console.log(count); // 0(值拷贝,不会更新)

// ============================================

// ES Module(实时绑定)
// counter.js
export let count = 0;
export function increment() { count++; }

// main.js
import { count, increment } from './counter.js';
console.log(count); // 0
increment();
console.log(count); // 1(实时绑定,已更新)

Q2: 为什么 ES Module 支持 Tree Shaking,而 CommonJS 不支持?

答:

  • ES Module 是静态的import / export 必须在顶层,编译时就能确定依赖关系
  • CommonJS 是动态的require() 可以写在条件语句、函数内,运行时才知道导入什么

示例:

1
2
3
4
5
6
// ES Module(静态,可 Tree Shaking)
import { add } from './math.js';  // 编译时确定只导入 add

// CommonJS(动态,无法 Tree Shaking)
const moduleName = condition ? './math.js' : './other.js';
const { add } = require(moduleName);  // 运行时才知道导入什么

Q3: 动态导入(import())和静态导入(import)的区别?

答:

特性静态导入(import)动态导入(import())
位置必须顶层可以在任何地方
加载时机编译时运行时
返回值无(直接导入)Promise
是否异步
使用场景常规导入按需加载、条件加载

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 静态导入(编译时确定)
import { add } from './math.js';

// 动态导入(运行时加载)
if (needMath) {
  import('./math.js').then(module => {
    console.log(module.add(5, 3));
  });
}

// 使用 async/await
async function loadMath() {
  const math = await import('./math.js');
  console.log(math.add(5, 3));
}

Q4: 如何在 Node.js 中使用 ES Module?

答:

方式 1:package.json 中声明 "type": "module"

1
2
3
4
5
6
// package.json
{
  "name": "my-app",
  "type": "module",
  "main": "index.js"
}
1
2
3
// index.js(可以使用 import/export)
import fs from 'fs';
export const hello = 'world';

方式 2:文件后缀使用 .mjs

1
2
// module.mjs
export const foo = 'bar';
1
2
3
// main.mjs
import { foo } from './module.mjs';
console.log(foo);

方式 3:在 CommonJS 中动态导入 ES Module

1
2
3
4
5
// CommonJS 模块
(async () => {
  const { foo } = await import('./module.mjs');
  console.log(foo);
})();

Q5: export * 和 export default 的区别?

答:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 命名导出(export)
export const a = 1;
export const b = 2;

// 导入时必须使用相同名称(可重命名)
import { a, b } from './module.js';
import { a as A } from './module.js';

// ============================================

// 默认导出(export default)
export default 42;

// 导入时可以任意命名
import myNumber from './module.js';

混合使用:

1
2
3
4
5
6
7
// module.js
export const PI = 3.14;
export default (a, b) => a + b;

// main.js
import add, { PI } from './module.js';
console.log(add(PI, PI));

总结与扩展

核心要点

  1. ES Module 是 JavaScript 官方标准
    • 使用 import / export 语法
    • 静态化设计,编译时确定依赖
  2. 两种导出方式
    • 命名导出:export const foo = ...
    • 默认导出:export default ...
  3. 实时绑定(Live Binding)
    • 导出的是值的引用(而非拷贝)
    • 循环依赖处理更好
  4. 支持 Tree Shaking
    • 静态分析未使用代码
    • 删除死代码,减小打包体积
  5. 动态导入(import())
    • 返回 Promise
    • 支持按需加载、条件加载

扩展学习

  • Module Record 规范:ECMA-262 模块记录定义
  • 打包工具原理:Webpack / Rollup 如何处理 ES Module
  • 浏览器原生 ESM<script type="module"> 的使用
  • Node.js 双模块系统:CommonJS 与 ES Module 互操作

实战建议

  1. 优先使用 ES Module
    • 新项目统一使用 ESM
    • 老项目逐步迁移
  2. 利用 Tree Shaking 优化打包体积
    • 使用命名导出(便于静态分析)
    • 避免默认导出的整个对象
  3. 合理使用动态导入
    • 路由懒加载
    • 大型库按需加载
  4. 注意浏览器兼容性
    • 旧浏览器使用打包工具(Babel / Webpack)
    • 现代浏览器可原生支持 ESM

参考资料:

  • MDN - ES Module
  • ECMA-262 规范 - Module Record
  • Node.js 官方文档 - ES Module
  • 《深入浅出 ES Module》 - David Flanagan
本文由作者按照 CC BY 4.0 进行授权