es6模块是编译时静态加载,支持tree shaking;commonjs是运行时动态加载,无法静态分析依赖。前者import/export必须在顶层且路径静态,后者require可动态执行、路径可变量拼接。

因为 ES6 的 import/export 是静态语法,构建工具在不运行代码的前提下就能 100% 确定谁引用了谁、哪些导出没被使用;而 CommonJS 的 require/module.exports 是动态的,依赖运行时行为,无法提前判断。
ES6 模块:编译期就“看得清”
ES6 模块的导入导出必须写在顶层作用域,路径和名称都得是静态字符串,不能出现在条件语句、函数里,也不能拼接变量。这种限制让 Webpack、Rollup 这类工具能直接扫描源码,画出一张准确的“引用关系图”。比如:
-
import { throttle } from './utils.js'→ 工具立刻知道只用了throttle,debounce如果没被其他地方引用,就可以安全删除 -
export function init() {}和export const config = {}→ 每个导出项独立可追踪,删一个不影响另一个
CommonJS 模块:运行前“猜不准”
CommonJS 允许把 require 写在任意位置,路径可以是变量、拼接字符串,甚至根据环境判断加载哪个文件:
-
const mod = require(process.env.NODE_ENV === 'dev' ? './dev.js' : './prod.js')→ 构建时不知道最终加载哪个文件 -
module.exports = {}; module.exports[a + b] = fn;→ 导出了什么,只有执行后才知道 -
if (Math.random() > 0.5) { require('./feature') }→ 是否引入都不确定,更别说分析内部导出
这些特性对灵活性友好,但彻底破坏了静态分析的基础——工具不敢随便删代码,怕删掉运行时才用到的部分。
Babel 转译是个常见陷阱
即使你写了 import,如果 Babel 配置了 modules: "commonjs",它会把所有 ES6 模块语法转成 require 形式。结果就是:代码看起来是 ESM,实际产物是 CommonJS,Tree Shaking 自然失效。
- 正确做法:Babel 中设
modules: false,让 import/export 原样保留,交给打包工具处理 - 验证方式:检查最终打包前的中间代码(如 Webpack 的
stats或dist目录下的未压缩文件),确认 import/export 没被转掉
副作用会让“摇树”变谨慎
即使语法合规,如果模块执行时有副作用(比如往全局挂变量、监听事件、修改原型),工具也不敢轻易删掉它——哪怕没被显式 import。这时需要通过 package.json 中的 "sideEffects": false 显式声明“本包所有文件都纯净”,或用数组列出真正有副作用的文件(如 ["*.css", "index.js"])。
否则,像 import './polyfill.js' 这种只执行不导出的写法,可能被误删,导致功能异常。











