Skip to content
概述
ECMAScript 模块(import / export)和 CommonJS(require / module.exports)是两套差异巨大的模块系统。前者的一个关键价值在于将依赖关系提前暴露给构建器和运行时,使打包工具能够以静态方式绘制模块依赖图,并在此基础上安全地移除未被使用的导出——即 tree shaking(一种通过静态分析剔除未引用代码的优化技术)。require 更接近普通的函数调用,其加载时机、目标模块乃至是否执行都可能在运行期才确定,这让静态分析和安全裁剪的难度显著上升。
基本概念
静态结构与动态调用
- 静态
import:import声明必须出现在模块顶层,导入路径为字符串字面量,导入名在解析阶段即可确定。构建工具在代码未执行时就能推导出完整的模块依赖图。 - 动态
require:require()是一个函数,可以在条件语句、循环、函数体内部调用,也可以传入变量作为路径。依赖关系依赖执行流,构建器只能在较保守的假设下推断模块间的引用。
导出绑定方式
- Live binding(实时绑定):ESM 的导入值是只读的“活绑定”,会反映导出模块中原始变量的变化。这种记录级别的导出/导入映射有利于构建器标记引用。
- 值拷贝 / 对象引用:CommonJS 将
module.exports对象的引用赋给导入方。解构如const { foo } = require('./lib')只是从已执行完成的模块对象上取值,打包器难以仅凭这一点追溯出单个导出是否被真实使用。
副作用
模块顶层执行的代码(如 console.log()、向全局对象挂载属性、注册插件等)被认为带副作用。即便某个导入未被消费,打包器也可能因无法判断这些顶层语句是否必须被执行而不敢整模块删除。
工作原理对比
以下从依赖图的建立、标记、裁剪几个阶段,说明 import 与 require 对 tree shaking 的不同影响。
| 维度 | import | require |
|---|---|---|
| 解析时机 | 编译期 / 模块实例化前 | 运行期 |
| 依赖关系 | 静态可分析 | 动态,依赖执行路径 |
| 提升行为 | 会被提升,可提前建立模块图 | 按代码执行位置加载 |
| 条件加载 | 不能在 if / 循环等控制流中使用 | 可放在任意控制流中 |
| 导出裁剪粒度 | 可按导出成员级别裁减 | 通常只能整模块保留 |
| tree shaking 友好度 | 友好 | 多数场景下明显受限 |
ESM 的 tree shaking 流程
给定以下源码:
js
// math.js
export function add(a, b) { return a + b; }
export function mul(a, b) { return a * b; }js
// app.js
import { add } from './math';
console.log(add(1, 2));构建工具的处理流程大致如下:
- 解析
import语句,得到确定性的依赖边app.js → math.js。 - 扫描
app.js对add的引用,在math.js中将add标记为“已消费”。 - 回溯
math.js的导出列表,发现mul未被消费,且该导出未产生必然的副作用,将mul相关代码从最终产物中删除(或标记为可删除)。
由于所有依赖和导出关系在模块实例化之前就已固定,构建器可以放心地把这些操作提前到打包阶段完成。Rollup 等项目正是依靠这一语言层面的静态约束,做到了较彻底的 tree shaking。
require 的局限
js
// main.js
const utils = require('./utils'); // 整个模块被加载
if (process.env.NODE_ENV !== 'production') {
require('./debug'); // 条件加载、带有副作用的模块
}对于打包器而言,这条 require 链存在几个难以跨越的障碍:
require可以在任意位置调用,依赖图不是“一步到位”生成,而是随执行逐步展开。- 很难判断一个条件分支内的
require是否会被触发,以及其顶层副作用是否为必要逻辑。冒然删除可能造成运行时错误。 - 拿到的是完整的
module.exports对象,即便只使用其中某个方法,构建器仍难以将未使用的成员安全剥离,因为执行模块时所有的顶层语句都已经运行。
因此,对 CommonJS 模块的“tree shaking”往往退化为较粗粒度的整模块废弃或基于启发式规则的保守裁剪,与 ESM 下的精确成员级删除有本质区别。
动态 import() 并未退化为 require
js
const page = await import('./pages/dashboard.js');动态 import() 虽然也在运行时触发,但它仍然属于 ESM 体系:依赖路径仍为静态字符串字面量,构建器可以将其识别为代码分割边界(code splitting),生成独立的异步 chunk。它对 tree shaking 的影响在于将一个模块推迟到异步点加载,而不会丧失静态依赖图的建立能力。这与 require() 退回函数调用模型不同。
副作用对 tree shaking 的制约
即使使用了 import,tree shaking 的结果也不一定理想。当被引入的模块存在顶层副作用时:
js
// lib.js
console.log('initialization');
window.__runtimeConfig = { debug: true };
export function init() { /* … */ }即使业务代码中未消费任何导出,构建器也可能因为不敢丢弃这些可能被依赖的副作用而保留整个模块。此时需要借助 package.json 中的 sideEffects 字段,显式告知构建器该模块或文件是否含副作用:
json
{
"name": "my-lib",
"sideEffects": false
}如果确实有个别文件带有必需的副作用(如全局 CSS),可以逐个声明:
json
{
"sideEffects": ["*.css", "src/init.js"]
}这是构建器进行安全删除的信任依据:声明为无副作用的模块,其未使用的导出和模块本身可被整段移除。声明不当(将真实有副作用的文件标记为无副作用)则可能导致初始化逻辑被错误剔除。
基本用法
基于 ESM 的 tree shaking 友好结构
js
// shape.js
export function circleArea(r) { return Math.PI * r * r; }
export function squareArea(s) { return s * s; }js
// app.js
import { circleArea } from './shape.js';
console.log(circleArea(5));打包器仅需保留 circleArea 及其依赖链,squareArea 在最终产物中被移除。
CommonJS 模式中裁剪困难
js
// shape.js
module.exports.circleArea = function(r) { return Math.PI * r * r; };
module.exports.squareArea = function(s) { return s * s; };js
// app.js
const { circleArea } = require('./shape');
console.log(circleArea(5));多数打包工具仍会将整个 shape 模块及其引用的全部依赖打入产物,因为它无法在静态阶段判断是否只有 circleArea 被用到。
条件 require 与动态路径
js
let mod;
if (featureFlag) {
mod = require('./featureA');
} else {
mod = require('./featureB');
}以及:
js
const mod = require('/path/to/' + moduleName);这两种模式使得构建器几乎无法安全裁剪,只能保守地将所有可能路径上的模块一并保留。
命令与配置示例
安装并使用打包工具
以 webpack 为例,安装依赖后运行构建:
bash
npm install --save-dev webpack webpack-cli
npx webpack --mode production默认的 production 模式会启用 usedExports 优化(标记使用到的导出)以及基于 sideEffects 的模块删除。
Rollup 亦可直接通过命令行打包 ESM 模块:
bash
npm install --save-dev rollup
npx rollup src/app.js --file dist/bundle.js --format es保留 ESM 结构,避免 Babel 提前转译
使用 @babel/preset-env 时,若将模块处理交给 webpack 或 Rollup,应避免 Babel 将 import 转换为 require:
json
{
"presets": [
["@babel/preset-env", {
"modules": false
}]
]
}这能保证打包工具拿到原生的 ESM 结构进行 tree shaking,而不是面对已降级为 CommonJS 的代码。
配置 package.json 的 sideEffects
json
{
"name": "my-utils",
"sideEffects": false
}打包器在 production 模式下遇到该包时,若未发现任何使用的导出,会移除整个模块。对于包含全局样式或 polyfill 的文件,则应在数组中显式列出:
json
{
"sideEffects": ["*.css"]
}webpack 配置中显式启用优化
webpack 的 optimization 配置允许显式控制:
js
// webpack.config.js
module.exports = {
mode: 'production',
optimization: {
usedExports: true, // 依赖图中标记使用到的导出
sideEffects: true // 识别并移除无副作用的未使用模块
}
};注意点
require解构不会触发细粒度裁剪:const { foo } = require('./utils')在语义上只是解构赋值,打包器通常仍需执行整个 CommonJS 模块,并从module.exports对象取出foo。这与 ESM 的导出绑定级别的可追踪性不同。import的 live binding 不阻塞 tree shaking:实时绑定是对运行时行为的约束,静态依赖图已在编译期固定。未引用的绑定仍可被安全移除(在确保不触发副作用的前提下)。- 动态
import()影响分割,不取消 tree shaking:异步导入的内容依然可被 tree shake,因为 bundler 在编译阶段能看到导入路径。 - 谨慎处理顶层副作用:即使一个模块完全使用
export function,只要其顶层存在console.log()、document.addEventListener()等语句,就可能被判定为带副作用,使得 tree shaking 彻底失效,除非通过sideEffects明确声明。 - 库作者应当提供 ESM 产物:如果发布的 npm 包只含有 CommonJS 构建,使用者在打包时将无法在该包上实施有效的 tree shaking。现代库通常会同时提供 ESM(如
module字段指向 es 模块文件)和 CommonJS 版本。
限制
- tree shaking 无法突破动态依赖:当模块路径由运行时变量决定,构建器无法分析依赖,也就无法安全移除。
- 部分工具对 CommonJS 的 tree shaking 存在启发式优化(如 webpack 在部分场景下尝试标记未使用的 CommonJS 导出),但无法做到与 ESM 同等的确定性。
sideEffects的声明依赖开发者准确判断,一旦标记错误可能造成运行时缺失功能。
应用
- 模块设计:为工具库提供扁平、多命名的导出,而非将所有功能挂载在一个默认导出对象上,这能帮助打包器实现成员级裁剪。
- 项目构建链:从源码到最后产物,应尽量保持 ESM 到打包阶段,避免通过 tsconfig、Babel 等先期将模块语法降级为 CommonJS。
- 迁移旧项目:将 CommonJS 代码转换至 ESM 时,最大的障碍通常不是
require→import的语法替换,而是运行时的条件加载、动态require以及散布在模块顶层的副作用逻辑。处理好这些问题才能让 tree shaking 发挥作用。 - 产物体积异常时排查:如果发现打包结果明显偏大,可先检查是否有环节提前将 ESM 转成了 CommonJS,或是否缺失
sideEffects声明导致大量模块被保守保留。
