Appearance
import / require 不是语法糖差异,直接影响模块图和 tree shaking
import 的价值不在于“写法更现代”,而在于它把依赖关系提前暴露给构建器和运行时。require 更像一次普通函数调用,什么时候调用、传什么参数、会不会走分支,理论上都要等执行期才知道。
tree shaking 能成立,前提就是模块依赖图和导出绑定尽量静态。
| 维度 | import | require |
|---|---|---|
| 解析时机 | 编译期 / 模块实例化前 | 运行期 |
| 依赖关系 | 静态可分析 | 动态,可能依赖执行路径 |
| 绑定方式 | live binding | 导出对象的值拷贝/引用结果 |
| 提升行为 | 会被 hoist,先建模块依赖图 | 按代码执行到的位置加载 |
| 条件加载 | 不能直接写条件 import | 可以放在 if、函数里 |
| tree shaking | 友好 | 很差,很多场景直接失效 |
判断标准
只要构建工具不能确定:
- 这个模块一定会被加载
- 这个模块里到底用了哪个导出
- 某段代码是否有副作用
它就不敢删。
所以 tree shaking 本质不是“删除没用代码”,而是“在足够确定的静态信息下安全删除”。
import 为什么天然适合 tree shaking
ESM 的几个约束很关键:
js
import { foo } from './utils.js'
import bar from './bar.js'这里依赖边在解析源码时就确定了:
- 依赖源是字符串字面量
- 导入名是明确的
- 不能把
import放进任意控制流 - 导入绑定是只读、live 的
构建器拿到源码后,可以很早构建 module graph。之后再做两件事:
- 标记哪些 export 被真实消费
- 沿引用链回溯,把未使用分支裁掉
Rollup 这类工具对 ESM tree shaking 效果最好,核心原因就是它不是在猜,而是在利用语言层面的静态约束。
require 卡在哪
js
const mod = require(path)
const util = require('./util')
if (flag) {
require('./debug')
}对 bundler 来说,这里面有几个问题:
require(path)的目标不确定require可以出现在任何位置,依赖图不稳定- 拿到的是整个
module.exports对象,常见用法会让成员级裁剪变困难 - 条件分支里的
require可能带副作用,不能轻易删
CommonJS 不是完全不能优化,但优化空间明显更窄。很多时候只能做到:
- 整模块保留
- 粗粒度 dead code elimination
- 借助额外约定做保守裁剪
这和真正的 ESM tree shaking 不是一回事。
import() 和 require() 不是一类东西
js
const page = await import('./page.js')动态 import() 虽然也是运行时触发,但它仍属于 ESM 体系:
- 依赖边通常仍可识别
- bundler 会把它当成 code splitting 边界
- 可以生成异步 chunk
所以动态 import() 影响的是“是否同步静态引入”,不等于退化成 CommonJS。require() 更多是把模块加载退回到执行期函数调用模型。
真正影响 tree shaking 的不只是语法,还有副作用
即使用了 import,也不代表一定 shake 得干净。
js
import { something } from './lib'如果 ./lib 顶层做了这些事:
js
console.log('init')
window.xxx = 1
registerGlobalPlugin()那这个模块就带副作用。即使 something 没被用到,bundler 也可能不敢整段删。
所以 tree shaking 依赖三层条件:
- ESM 静态结构
- 细粒度 export
- 模块副作用可判定或显式声明
package.json 里的 sideEffects: false 就是在帮 bundler 缩小保守范围,但这个声明写错会把真实初始化逻辑删掉,风险不小。
几个常见误区
Babel 把 import 转成 require 之后,tree shaking 还会一样好
不一样。
如果构建链过早把 ESM 降级成 CommonJS,后面的 bundler 会失去静态模块结构,tree shaking 能力通常显著下降。
这也是为什么现代前端构建链会尽量保留 ESM 到 bundling 阶段,而不是先全量转成 CJS。
require 也能按需引用属性,所以也能细粒度裁剪
js
const { foo } = require('./utils')语义上像解构,但 bundler 往往还是先把整个 CommonJS 模块执行出来,再从导出对象里取属性。
这里缺少 ESM 那种“导出绑定级别”的静态可追踪性。
tree shaking = 压缩
不是。
压缩是改短变量名、折叠常量、删空白。
tree shaking 是删不可达或未使用导出。两者阶段不同,依赖的信息也不同。
从运行时看,两套模块系统关注点不同
ESM
浏览器 / Node 在模块实例化阶段就会:
- 解析 import/export
- 建依赖图
- 处理循环依赖绑定
- 创建 live binding
执行前,图已经基本稳定。
这很适合 bundler 在“执行前”做静态裁剪。
CommonJS
Node 执行到:
js
const x = require('./x')才会同步加载、执行模块、缓存结果。
这套模型更偏命令式:
- 执行顺序重要
- 条件分支可能改变依赖形态
- 导出值常是运行后才形成的对象
这使优化更依赖保守假设。
一个实际判断
下面这种库结构,tree shaking 通常比较好:
js
export function a() {}
export function b() {}
export function c() {}使用:
js
import { a } from './lib'构建器有机会只保留 a 的相关代码。
但如果库写成:
js
module.exports = {
a: require('./a'),
b: require('./b'),
c: require('./c')
}即使只拿 a,很多 bundler 也会先把整块入口和相关子模块保住,尤其当这些 require 链上还有副作用时。
对工程的直接影响
- 写业务代码时,
import/export不只是风格统一,它决定打包器能不能把依赖图看清楚。 - 写工具库时,扁平、命名导出、低副作用,比“全塞进一个默认导出对象”更利于裁剪。
- 迁移老项目时,最大阻碍通常不是语法替换,而是 CommonJS 代码里混杂的动态加载和顶层副作用。
- 如果产物体积异常大,先查是不是某个转译步骤把 ESM 提前编译成了 CommonJS。
最后记一个结论
import 和 require 的差异,本质是:
import让模块系统偏“声明式”,依赖图可提前建立require让模块系统偏“命令式”,依赖图要跟着执行流走
tree shaking 恰好最依赖前者。
所以讨论 import 和 require 哪个更好时,真正该看的不是语法喜好,而是这条链路:
模块声明能力 -> bundler 静态分析能力 -> 无用代码删除边界 -> 最终 bundle 体积和可维护性
