import 在解析期就定型: 静态结构换来实时绑定与 Tree-Shaking, 动态需求交给 import() 分包
CJS 的 require 是"跑到这行才去加载", 一切都在运行时发生; ESM 的 import 是"解析阶段就把整张依赖图画好" — 所以打包器能提前知道谁用谁、谁没用(tree-shaking), 还能让导出变成实时绑定: 源模块里的值变了, 所有引用方读到新值。
代价是灵活性: 路径不能拼变量、不能写在 if 里; 真需要动态时, 用 import() 返回一个 Promise, 顺手把那段代码拆成独立 chunk 按需加载。循环依赖在 ESM 下是"允许但危险"的: 图是静态的, 加载顺序固定, 只要你不在顶层就用对方的值, 函数体内再调用就安全。
// 错: if (dev) import './mock'; // 语法错误 // 对: const m = await import(dev ? './mock' : './real');
// main imports a, b → 执行序: a → b → main console.log('main');
// config.js: export const cfg = { env: 'prod' } // 任何地方 import, 拿到同一个 cfg 对象
export default { a: 1 }; // 每次模块求值一个对象 export const a = 1; // 命名导出利于摇树
export { x } from './m' 只转发不引入局部; barrel 文件常因此放大加载面。 // index.js export { deepUtil } from './deep.js'; // 关键: 只要有人 import index, deep.js 就会被加载求值
btn.onclick = async () => { const { renderChart } = await import('./heavy-chart.js'); renderChart(); // 首屏不背这个包的体积 };
url 拿自身地址; 打包器注入 env 等自定义字段。 console.log(import.meta.url); // file:///... 或 https://... // ESM 没有 __dirname: 用 new URL('.', import.meta.url)
// db.js (ESM) export const conn = await connect(); // 关键: import db 的模块自动等连接就绪
// package.json { "sideEffects": ["*.css", "./polyfills.js"] } // 关键: css/polyfill 这类"加载即生效"的要排除在外
{ "exports": {
".": { "import": "./esm/index.js", "require": "./cjs/index.js" },
"./package.json": "./package.json" } }
// 关键: 没列的子路径外部 import 直接报错import legacy from 'cjs-pkg'; // = module.exports const { fn } = legacy; // 稳妥: 先解构再转交 // 错: import { fn } from 'cjs-pkg' 动态导出可能 undefined
// a↔b 循环: bFn 在函数体里调用 aFn → 安全 // b 顶层写 const x = aFn() → ReferenceError
单页应用 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。
工具库 1.2MB, 用户只 import 一个函数却全量进包。三步收敛:
// 1. package.json 声明无副作用(有副作用的文件列出) { "sideEffects": false } // 2. 源码: 用命名导出, 别 export default 大对象 export function deepClone(o) { ... } // 3. 审计产物: 构建后确认产物只含被引用函数 // rollup.config: treeshake: { moduleSideEffects: false }
用户侧体积 1.2MB → 18KB; 附带产物: 库自身的冷启动也变快。
库要同时服务 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
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'); }
老模式 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 的模块根本不会执行
前端代码里残留 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
用户偷偷 import 内部文件, 内部重构全线爆炸。exports 白名单挡住深导入:
{ "exports": {
".": "./index.js",
"./utils/*": null } } // 显式禁止 utils 深导入
// 或仅开放 ./components/*:
// "./components/*": "./src/components/*.js"
// 未列路径: import 'lib/internal/x.js' → ERR_PACKAGE_PATH_NOT_EXPORTED
登录页懒加载 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 */ ...) 由构建器生成
三年老仓库 40% 依赖已无人引用。用静态分析工具清库:
npx knip # 未用导出/依赖/文件 + 循环依赖报告 npx depcheck # 未用依赖专项 npx madge --circular src # 循环依赖专项 # 治理顺序: 先删未用依赖 → 断循环(下沉共享) → 收敛未用导出
node_modules 体积 -38%, 循环依赖 12 条 → 0。
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
// 错 (b.js 顶层): const x = aFn(); // a 还没跑完 // 对: function bFn() { return aFn(); } // 运行时才调用
// 错: export default { clone, merge, deep, ... }; // 对: export function clone(){} export function merge(){}
// 错: window.__patched = true; // 模块顶层 // 对: export function setup() { ... } 由入口显式调用
// 错: import { one } from '@lib'; // 全家桶被求值 // 对: import { one } from '@lib/one';
// 错: await import(basePath); // 全部忽略/失败 // 对: await import(`./charts/${name}.js`); // 前缀静态可分析
// ERR_REQUIRE_ESM / Unknown file extension 高发根因 // 对: 明确 type + 后缀表达意图
// 现象: 同一库两个 instanceof 不相等, 计数器翻倍 // 对: 状态放 globalThis.__mylib_state ??= {}
// 源码 count 活绑定 → 编译产物是 getter 才保真 // 错: 在库里依赖 live binding 又只发 cjs
import.meta.url + fileURLToPath。 import { fileURLToPath } from 'node:url'; const __dirname = fileURLToPath(new URL('.', import.meta.url));
// "types" 必须在 import/require 之前 // "./": 错写 "./" 会吞掉所有子路径
showSkeleton(); const m = await import('./editor.js'); hideSkeleton(); m.mount(el);
// main→a→b→a: b 里 a 是半初始化 // 换成 test→b 直接跑: 顺序全变 → 偶发 undefined
// 新: import data from './d.json' with { type: 'json' };
// 错: x = 1; // ESM 下 ReferenceError // 对: let x = 1;
// 错: 库声明 sideEffects:false 但含 polyfill.js // 对: "sideEffects": ["./polyfills.js"]
jest.resetModules(); // 关键: 每用例重新实例化模块 const { store } = require('../store');
// vite/esbuild target 设 es2017 会报错 top-level await // 对: target es2022+, 或改 async init()
// 现象: react "Invalid hook call" 的常见根因 // 对: 包管理器 dedupe + peerDeps 声明
manualChunks: { common: ['./shared.js'] } // 断开 chunk 环// 对: tsc --declaration --emitDeclarationOnly // CI: 消费侧 import 编译通过作为发布门槛