JavaScript · ES Modules 与工程化

import 在解析期就定型: 静态结构换来实时绑定与 Tree-Shaking, 动态需求交给 import() 分包

ESM: import 写在顶层 → 解析期就固定整张依赖图 main.js import A from './a' import { b } './b' 构建期即可分析 a.js export util() b.js export const b = 1 依赖图静态 → 打包器能整图分析: 做分包、摇树、预加载 Tree-Shaking: 沿图标记死代码 imported ✓ unused ✗ 被摇掉 used ✓ pure ✓ 前提: 导出可静态枚举 类方法/原型补丁摇不动 副作用模块要声明 sideEffects 一个 import 模块带副作用 → 整个模块保留 实时绑定 vs 值拷贝 ESM: live binding b.js 里 count 改了 main 里读到的就是新值(只读视图) 绑定的是"变量"不是"值" CJS: 值拷贝快照 require 时把 exports 拷一份 之后源模块再改, 你看不到 例外: 对象是引用, 内部改动能看到 // live binding 演示 // counter.js export let count = 0; export function inc() { count++ } // main.js import { count, inc } from './counter.js' inc(); console.log(count); // → 1 读的是活变量, 不是拷贝 // CJS 同场景: const { count } = require(...) 拿到 0 的快照 // 想要动态值, CJS 只能导出 getter 或整个对象 // 循环依赖: a ↔ b 互相 import, 谁先初始化? // a.js import { bFn } from './b.js' export function aFn() { bFn(); } // b.js (入口是 a: b 在 a 半初始化时被加载) import { aFn } from './a.js' bFn(); // 关键: 函数体运行时才调用 aFn → 安全 // 危险: 顶层立即用 import 的值 → 拿到未初始化(TDZ) // const x = aFn() 在 b 顶层 → ReferenceError // 循环里只准"延迟使用"(函数体内), 不准顶层即用

静态是超级能力

  • • import 必须顶层、路径必须是字面量
  • • 构建期得到完整依赖图
  • • 摇树、分包、预加载全靠它
  • • 动态需求显式走 import()

实时绑定 ≠ 值拷贝

  • • ESM 导出的是"变量的只读视图"
  • • CJS 导出的是快照(对象内部除外)
  • • 循环依赖里"延迟使用"是安全的
  • • 顶层即用会踩 TDZ

摇树的三个开关

  • • 命名导出优于 default 大对象
  • • package.json 声明 sideEffects: false
  • • 别在模块顶层做副作用(补丁/注册)
  • • 类与原型方法摇不动

💡 一句话理解

CJS 的 require 是"跑到这行才去加载", 一切都在运行时发生; ESM 的 import 是"解析阶段就把整张依赖图画好" — 所以打包器能提前知道谁用谁、谁没用(tree-shaking), 还能让导出变成实时绑定: 源模块里的值变了, 所有引用方读到新值。

代价是灵活性: 路径不能拼变量、不能写在 if 里; 真需要动态时, 用 import() 返回一个 Promise, 顺手把那段代码拆成独立 chunk 按需加载。循环依赖在 ESM 下是"允许但危险"的: 图是静态的, 加载顺序固定, 只要你不在顶层就用对方的值, 函数体内再调用就安全。

🧠 必知必会 必考 & 必会

静态结构
import/export 必须出现在顶层, 模块说明符必须是字符串字面量 — 这是"整图可分析"的前提。
// 错: if (dev) import './mock';    // 语法错误
// 对: const m = await import(dev ? './mock' : './real');
执行顺序
ESM 按依赖图深度优先、先子后父求值: 先把所有 import 的模块跑完, 再执行本模块。
// main imports a, b → 执行序: a → b → main
console.log('main');
单例模块
同一模块只会被实例化执行一次, 之后全是共享同一个实例 — 模块天然是单例。
// config.js: export const cfg = { env: 'prod' }
// 任何地方 import, 拿到同一个 cfg 对象
export default 语义
default 是"匿名导出槽"; 它是表达式求值结果, 函数声明除外; 一个模块只能一个。
export default { a: 1 };   // 每次模块求值一个对象
export const a = 1;         // 命名导出利于摇树
re-export
export { x } from './m' 只转发不引入局部; barrel 文件常因此放大加载面。
// index.js
export { deepUtil } from './deep.js';
// 关键: 只要有人 import index, deep.js 就会被加载求值
import() 动态加载
运行时按需加载, 返回 Promise; 打包器把目标拆成独立 chunk。
btn.onclick = async () => {
  const { renderChart } = await import('./heavy-chart.js');
  renderChart();   // 首屏不背这个包的体积
};
import.meta
模块元数据: url 拿自身地址; 打包器注入 env 等自定义字段。
console.log(import.meta.url);   // file:///... 或 https://...
// ESM 没有 __dirname: 用 new URL('.', import.meta.url)
顶层 await
ESM 支持模块顶层 await(ES2022), 后续依赖方会等它 — 适合初始化型模块。
// db.js (ESM)
export const conn = await connect();
// 关键: import db 的模块自动等连接就绪
sideEffects 字段
package.json 声明"我的模块无副作用", 打包器才敢放心摇树。
// package.json
{ "sideEffects": ["*.css", "./polyfills.js"] }
// 关键: css/polyfill 这类"加载即生效"的要排除在外
exports 字段
现代包入口的唯一权威: 限定子路径、选择 esm/cjs 条件, 挡住未公开的深导入。
{ "exports": {
    ".": { "import": "./esm/index.js", "require": "./cjs/index.js" },
    "./package.json": "./package.json" } }
// 关键: 没列的子路径外部 import 直接报错
CJS 互操作
ESM 里引 CJS: default 是 module.exports; 命名导入靠静态分析猜, 解构函数/动态赋值常翻车。
import legacy from 'cjs-pkg';     // = module.exports
const { fn } = legacy;             // 稳妥: 先解构再转交
// 错: import { fn } from 'cjs-pkg'  动态导出可能 undefined
循环依赖
ESM 处理循环靠"先声明绑定后求值": 函数体内延迟使用安全; 顶层即用触发 TDZ。
// a↔b 循环: bFn 在函数体里调用 aFn → 安全
// b 顶层写 const x = aFn() → ReferenceError

🏭 生产实战 real world

场景 1 · 首屏瘦身: 路由级 dynamic import 分包

单页应用 2.8MB 首包, 图表/编辑器/大表格全在里面。按路由拆 chunk, 首屏只下真正用到的:

// 路由表: 组件改懒加载
const routes = {
  '/dashboard': () => import('./pages/dashboard.js'),
  '/editor':    () => import('./pages/editor.js'),    // monaco 只在这进包
};
async function navigate(path) {
  loading.start();
  const { render } = await routes[path]();   // 按需下载+执行
  render(container);
  loading.end();
}

首包 2.8MB → 760KB, FCP 从 4.1s 降到 1.6s。

场景 2 · 库的摇树落地: sideEffects 与发布审计

工具库 1.2MB, 用户只 import 一个函数却全量进包。三步收敛:

// 1. package.json 声明无副作用(有副作用的文件列出)
{ "sideEffects": false }
// 2. 源码: 用命名导出, 别 export default 大对象
export function deepClone(o) { ... }
// 3. 审计产物: 构建后确认产物只含被引用函数
// rollup.config: treeshake: { moduleSideEffects: false }

用户侧体积 1.2MB → 18KB; 附带产物: 库自身的冷启动也变快。

场景 3 · 双格式发布: ESM + CJS 双轨不翻车

库要同时服务 bundler(ESM)与老 Node(CJS), 关键是 exports 条件映射 + 防双实例:

{ "name": "mylib",
  "type": "module",
  "exports": {
    ".": {
      "types": "./types/index.d.ts",
      "import": "./esm/index.js",     // bundler/现代 node
      "require": "./cjs/index.cjs"     // 老 node/测试框架
    } } }
// 关键: 状态(单例)库要防"esm 与 cjs 各一份实例"
// → 全局唯一状态走 globalThis 注册或仅提供无状态 API

场景 4 · 循环依赖重构: 把共享下沉

order 与 invoice 互相引用, 测试偶发 undefined is not a function。下沉共享类型与工具, 断开环:

// 前: order.js ↔ invoice.js 互相 import
// 后:
// shared/types.js   ← 两个方向都只依赖它
// order.js → shared/types.js ← invoice.js
// 无法完全断开时: 延迟到函数体内 import() 或参数注入
invoiceFor(order) { const { build } = await import('./invoice.js'); }

场景 5 · 懒初始化单例: 顶层 await 替代 init() 仪式

老模式 await init(); const db = getDb(); 每个入口都要写。改成初始化即模块:

// db.js (ESM)
export const pool = await createPool();    // 顶层 await
export async function query(sql, p) { return pool.query(sql, p); }
// 业务模块: import { query } from './db.js' 直接用
// 关键: 连接就绪前, 依赖 db 的模块根本不会执行

场景 6 · 环境注入: import.meta.env 而非 process.env

前端代码里残留 process.env 在浏览器直接崩。统一走打包器注入的 import.meta.env:

// vite/现代 bundler
const api = import.meta.env.VITE_API_BASE;
// 类型安全: src/env.d.ts 里声明
// interface ImportMetaEnv { readonly VITE_API_BASE: string }
// CI 注入: VITE_API_BASE=https://api.prod build

场景 7 · 深导入治理: exports 锁住包的公共面

用户偷偷 import 内部文件, 内部重构全线爆炸。exports 白名单挡住深导入:

{ "exports": {
    ".": "./index.js",
    "./utils/*": null } }        // 显式禁止 utils 深导入
// 或仅开放 ./components/*:
// "./components/*": "./src/components/*.js"
// 未列路径: import 'lib/internal/x.js' → ERR_PACKAGE_PATH_NOT_EXPORTED

场景 8 · 关键路由预加载: modulepreload

登录页懒加载 dashboard, 跳转时白屏 800ms。空闲时预取目标 chunk:

// 登录成功后, 空闲预取下一跳
requestIdleCallback(() => {
  const l = document.createElement('link');
  l.rel = 'modulepreload';
  l.href = '/assets/dashboard-abc123.js';   // 构建产物真实文件名
  document.head.append(l);
});
// 或 import(/* webpackPrefetch: true */ ...) 由构建器生成

场景 9 · 依赖审计: 找出没人用的导出与依赖

三年老仓库 40% 依赖已无人引用。用静态分析工具清库:

npx knip                # 未用导出/依赖/文件 + 循环依赖报告
npx depcheck            # 未用依赖专项
npx madge --circular src   # 循环依赖专项
# 治理顺序: 先删未用依赖 → 断循环(下沉共享) → 收敛未用导出

node_modules 体积 -38%, 循环依赖 12 条 → 0。

场景 10 · Worker 里的 ESM: type: 'module'

Worker 里想复用业务模块, 老式经典 worker 只能 importScripts 拼。现代写法直接支持 ESM:

const w = new Worker('/workers/render.js', { type: 'module' });
// render.js 内部可以正常 import 共享模块:
// import { geometry } from '../shared/geometry.js';
// Node 侧: new Worker('./render.js') 直接支持 ESM

⚠️ 编码注意与常见坑 pitfalls

坑 1 · 循环依赖顶层即用 — 半初始化模块上读值, TDZ ReferenceError. 正解: 函数体内延迟使用。
// 错 (b.js 顶层): const x = aFn();   // a 还没跑完
// 对: function bFn() { return aFn(); }  // 运行时才调用
坑 2 · default 大对象导出 — 整对象导出让摇树失效, 用户被迫全量加载. 正解: 命名导出。
// 错: export default { clone, merge, deep, ... };
// 对: export function clone(){} export function merge(){}
坑 3 · 模块顶层做副作用 — import 即注册/打补丁, 摇树不敢摇、顺序成谜. 正解: 副作用封装成显式函数。
// 错: window.__patched = true;  // 模块顶层
// 对: export function setup() { ... } 由入口显式调用
坑 4 · barrel 放大加载面 — index.js 转发 100 个模块, import 一个全加载. 正解: 直接从具体文件 import, 或 per-file index。
// 错: import { one } from '@lib';    // 全家桶被求值
// 对: import { one } from '@lib/one';
坑 5 · 动态拼 import 路径 — 变量路径无法静态分析, 打包器拆不出 chunk. 正解: 至少写出前缀供分析。
// 错: await import(basePath);      // 全部忽略/失败
// 对: await import(`./charts/${name}.js`); // 前缀静态可分析
坑 6 · type 字段混乱 — .js 到底按 CJS 还是 ESM 解析取决于最近 package.json, 混着用就炸. 正解: ESM 包写 type:module, CJS 文件用 .cjs 后缀。
// ERR_REQUIRE_ESM / Unknown file extension 高发根因
// 对: 明确 type + 后缀表达意图
坑 7 · dual package 双实例 — esm/cjs 两种入口各执行一次, 模块级单例出现两份状态. 正解: 无状态 API 或 globalThis 唯一注册。
// 现象: 同一库两个 instanceof 不相等, 计数器翻倍
// 对: 状态放 globalThis.__mylib_state ??= {}
坑 8 · 转译器降级破坏 live binding — babel/tsc 把 ESM 转 CJS 后, 实时绑定变快照. 正解: 依赖 live binding 的逻辑留意目标格式。
// 源码 count 活绑定 → 编译产物是 getter 才保真
// 错: 在库里依赖 live binding 又只发 cjs
坑 9 · __dirname 缺失 — ESM 里没有 __dirname/__filename. 正解: import.meta.url + fileURLToPath。
import { fileURLToPath } from 'node:url';
const __dirname = fileURLToPath(new URL('.', import.meta.url));
坑 10 · exports 路径写错 — 条件顺序/types 拼错, 发布后用户解析失败. 正解: 发布前用发布预演(如 verdaccio/npm pack + 安装验证)。
// "types" 必须在 import/require 之前
// "./": 错写 "./" 会吞掉所有子路径
坑 11 · import() 没 loading 态 — chunk 下载 2s, 用户以为白屏死点. 正解: import 前先渲染骨架屏。
showSkeleton();
const m = await import('./editor.js');
hideSkeleton(); m.mount(el);
坑 12 · 循环里靠顺序侥幸 — 循环依赖的初始化顺序取决于"谁是入口", 换入口就翻车. 正解: 当作 bug 重构, 不当特性用。
// main→a→b→a: b 里 a 是半初始化
// 换成 test→b 直接跑: 顺序全变 → 偶发 undefined
坑 13 · import 断言语法漂移 — import ... assert 的 JSON 导入正迁移到 with, 跨版本注意. 正解: 锁工具链版本, 或运行时 fetch+json()。
// 新: import data from './d.json' with { type: 'json' };
坑 14 · 严格模式暗坑 — ESM 隐含严格模式, 未声明赋值/八进制/with 直接抛错. 正解: 按严格模式审查老代码迁移。
// 错: x = 1;      // ESM 下 ReferenceError
// 对: let x = 1;
坑 15 · polyfill 模块被摇掉 — 只 import 不用的"副作用模块"在 sideEffects:false 下被删除. 正解: 声明进 sideEffects 白名单。
// 错: 库声明 sideEffects:false 但含 polyfill.js
// 对: "sideEffects": ["./polyfills.js"]
坑 16 · require 缓存误解 — CJS require 有缓存, 测试间模块状态互串. 正解: 测试里 resetModules 或状态外置注入。
jest.resetModules();   // 关键: 每用例重新实例化模块
const { store } = require('../store');
坑 17 · 顶层 await 用错目标 — 编译到不支持的目标/格式直接构建失败. 正解: 确认输出格式为 ESM 且目标支持, 否则降级为显式 init()。
// vite/esbuild target 设 es2017 会报错 top-level await
// 对: target es2022+, 或改 async init()
坑 18 · node_modules 双版本 — 依赖树里同名库两份, instanceof/单例全乱. 正解: 锁版本/提升, 库侧写 peerDependencies。
// 现象: react "Invalid hook call" 的常见根因
// 对: 包管理器 dedupe + peerDeps 声明
坑 19 · 循环 chunk 依赖 — 分包后 A chunk 静态引 B, B 又引 A, 加载序死锁. 正解: 共享代码抽 common chunk, 打包器 manualChunks 归位。
manualChunks: { common: ['./shared.js'] }  // 断开 chunk 环
坑 20 · types 与实现脱节 — 手写 d.ts 和实际导出对不上, 用户类型报错但运行正常. 正解: 从源码生成声明(tsc -d), 不手写。
// 对: tsc --declaration --emitDeclarationOnly
// CI: 消费侧 import 编译通过作为发布门槛