Skip to content

import / require 不是语法糖差异,直接影响模块图和 tree shaking

import 的价值不在于“写法更现代”,而在于它把依赖关系提前暴露给构建器和运行时。require 更像一次普通函数调用,什么时候调用、传什么参数、会不会走分支,理论上都要等执行期才知道。
tree shaking 能成立,前提就是模块依赖图和导出绑定尽量静态。

维度importrequire
解析时机编译期 / 模块实例化前运行期
依赖关系静态可分析动态,可能依赖执行路径
绑定方式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。之后再做两件事:

  1. 标记哪些 export 被真实消费
  2. 沿引用链回溯,把未使用分支裁掉

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。

最后记一个结论

importrequire 的差异,本质是:

  • import 让模块系统偏“声明式”,依赖图可提前建立
  • require 让模块系统偏“命令式”,依赖图要跟着执行流走

tree shaking 恰好最依赖前者。

所以讨论 importrequire 哪个更好时,真正该看的不是语法喜好,而是这条链路:

模块声明能力 -> bundler 静态分析能力 -> 无用代码删除边界 -> 最终 bundle 体积和可维护性