模块这块我踩过循环依赖:拿到的是半成品,报错位置还很迷惑。后来才补上条件导出和 import.meta 的用途。
Q1: 模块化的历史?为什么 JavaScript 需要模块化?
1
2005 年: 脚本地狱(Script Hell)
早期的 JavaScript 没有模块系统。所有代码都通过
<script> 标签加载,全局变量互相覆盖,加载顺序必须手动管理。一个中等规模的项目可能有 50 多个 <script> 标签,顺序一改就报错。2
2009 年: CommonJS 与 AMD 诞生
Ryan Dahl 创建 Node.js,带来了 CommonJS 规范——用
require() 导入、module.exports 导出。同一时期,浏览器端诞生了 AMD(Asynchronous Module Definition)规范,RequireJS 是它的实现,用 define() 定义模块。两个规范分别统治了服务端和浏览器端。3
2015 年: ES Modules 成为语言标准
ES6 正式引入了
import / export 语法,JavaScript 终于有了语言层面的模块标准。ES Modules 支持静态分析,让 Tree Shaking 成为可能——这是构建工具性能优化的基础。模块化让 JavaScript 从”脚本语言”进化为”工程语言”——每个文件是一个独立的模块,有自己的作用域和明确的依赖声明。
没有模块化
一家公司没有部门划分,所有员工都在一个大开间里。张三的文件和李四的文件混在一起,谁都能改别人的文件。新来的员工要花两周搞清楚”这些文件的先后顺序是什么”。一旦有人改了共享文件,全公司的工作都可能受影响。
引入模块化
有了部门(模块)之后,每个部门有自己的办公室(作用域),通过正式的申请流程(import/export)与其他部门交互。新员工入职时只需要了解自己部门的接口,不需要知道其他部门的内部实现。
Q2: CommonJS —— Node.js 的模块系统
CommonJS 是 Node.js 默认的模块系统。它的核心特点是:同步加载、运行时解析、值的拷贝。CommonJS 的加载机制
CommonJS 的值拷贝特性
CommonJS 的循环依赖处理
Q3: ES Modules —— JavaScript 的语言标准
ES Modules(ESM)是 ES6 引入的官方模块系统。它的核心特点是:静态分析、异步加载、值的引用(live bindings)。ES Modules 的静态分析能力
ES Modules 的import / export 必须在模块顶层,不能在条件语句或函数内使用。这不是限制,而是为了让打包工具在编译时就能分析出模块的依赖关系:
ES Modules 的 Live Bindings(活绑定)
CommonJS vs ES Modules 对比
Q4: 循环依赖 —— 模块化的经典陷阱
循环依赖是模块化中最容易出 bug 的场景:模块 A 依赖模块 B,模块 B 又依赖模块 A。CommonJS 如何处理循环依赖
ES Modules 如何处理循环依赖
如何避免循环依赖
Q5: Tree Shaking —— ES Modules 的杀手级特性
Tree Shaking 是 ES Modules 带来的最重要的构建优化:在打包时删除没有被使用的导出代码。 它的名字来源于”摇树”——摇动树干,枯叶自然掉落。Tree Shaking 的工作原理
什么情况下 Tree Shaking 会失效
Webpack 的 Tree Shaking 配置
Q6: 动态导入(Dynamic Import)—— 按需加载
动态导入用import() 函数实现运行时的模块加载,返回一个 Promise。它是代码分割和懒加载的基础。
React 中的懒加载
Webpack 的代码分割
动态导入 vs 静态导入的选择
Q7: 模块化在构建工具中的实现
Webpack 的模块化实现
Webpack 把所有模块(包括 CommonJS 和 ES Modules)统一转换为自己的运行时格式:Vite 的模块化实现
Vite 和 Webpack 的核心区别:Webpack 先打包再启动开发服务器,Vite 利用浏览器原生 ESM 直接启动。Webpack vs Vite 对比
Q8: 模块化的最佳实践
文件组织结构
Barrel Export 模式
模块边界(Module Boundary)
底层源码:Node.js 和浏览器如何加载模块?
Node.js 的 require() 是怎么实现的?
Node.js 的模块加载器在 lib/internal/modules/cjs/loader.js 中实现:
- 模块缓存:
Module._cache用文件路径作为 key,确保每个文件只执行一次 - 包装函数:每个模块代码被包装在一个函数中,
exports、require、module作为参数传入 - 路径解析:
Module._resolveFilename负责解析require()的参数为绝对路径
浏览器原生 ESM 是怎么加载的?
浏览器的 ESM 加载分三个阶段:
十、及格回答 vs 满分回答
问题:“CommonJS 和 ES Modules 有什么区别?”
及格回答(60-70分):“CommonJS 用 require 导入,ES Modules 用 import 导入。CommonJS 是同步的,ES Modules 是异步的。CommonJS 在 Node.js 用,ES Modules 在浏览器用。”问题出在:知道表面区别,不理解底层机制。 满分回答(90-100分):
CommonJS 和 ES Modules 最本质的区别是静态分析能力。ES Modules 的import/export必须在模块顶层,不能在条件语句中使用,这让打包工具在编译时就能构建完整的依赖图——这是 Tree Shaking 的基础。CommonJS 的require()是一个普通函数调用,可以在任何地方执行,编译时无法确定依赖关系。[1]
第二个关键区别是导出值的行为。CommonJS 的require()返回的是module.exports对象本身,但导出那一刻基本类型的值已经作为属性存进对象,模块内部后续对变量的重新赋值不会回写这个属性,所以观察起来像”值的拷贝”。ES Modules 导出的是 live binding——导入方拿到的是对导出变量的引用,模块内部修改变量后,导入方能看到最新值。这意味着 CommonJS 的require()在执行时就已经确定了值,而 ES Modules 的值在求值阶段才确定。[2]
第三个区别是循环依赖的处理。CommonJS 遇到循环依赖时,返回的是”当前已执行部分的 exports”——一个不完整的对象。ES Modules 通过 live binding 机制,即使变量还没被赋值,绑定关系已经建立,等赋值完成后导入方能拿到最新值。但两种方式在实践中都是”设计问题的信号”,应该通过提取公共模块或依赖注入来消除循环依赖。[3]
在实际项目中,Node.js 从 12.17 / 13.2 起正式稳定支持 ESM(.mjs扩展名或package.json的"type": "module"),现代前端项目统一使用 ESM。开发时用 Vite 享受原生 ESM 的快速启动,生产构建用 Webpack/Rollup 的 Tree Shaking 优化体积。
常见追问
require 和 import 的执行时机有什么区别?
require 和 import 的执行时机有什么区别?
答题要点:
require 是运行时同步执行,import 是编译时静态解析。高分回答:
“
require() 是一个普通的 JavaScript 函数调用,在运行时同步执行。当代码执行到 const a = require('./a') 时,Node.js 会暂停当前模块的执行,去加载并执行模块 a,拿到返回值后继续执行。import 语句在编译阶段就被解析了。引擎在执行任何代码之前,就已经知道了所有的依赖关系。import 语句会被提升到模块顶部(类似变量提升),实际的值绑定在求值阶段完成。什么是 Tree Shaking?它的前提条件是什么?
什么是 Tree Shaking?它的前提条件是什么?
答题要点:
Tree Shaking 依赖 ES Modules 的静态分析能力。高分回答:
“Tree Shaking 是在打包时删除未使用代码的优化技术。它的工作原理是:
- 构建模块依赖图(利用 ESM 的静态 import/export)
- 标记所有被引用的导出
- 删除未被标记的导出
- 使用 ES Modules 语法:CommonJS 的
require()是动态的,无法静态分析 - 声明 sideEffects:在
package.json中设置"sideEffects": false,告诉打包工具这些模块没有副作用,可以安全删除 - 使用 const 导出:
export const比export let更容易被 Tree Shaking(不会被重新赋值) - 避免 Barrel Export 的陷阱:如果 barrel 文件中有副作用代码,可能影响 Tree Shaking”
Node.js 中怎么使用 ES Modules?
Node.js 中怎么使用 ES Modules?
答题要点:
两种方式:.mjs 扩展名或 package.json 的 type: “module”。高分回答:
“Node.js 从 12.17 / 13.2 起正式稳定支持 ESM(12.0 到 12.16 需要
--experimental-modules),有两种启用方式:- 文件扩展名用
.mjs - 在
package.json中设置\"type\": \"module\"
- ESM 中不能使用
require()、module、exports、__dirname、__filename - 需要替代方案:
import.meta.url是当前模块的文件 URL,可替代__filename;path.dirname(fileURLToPath(import.meta.url))等价于__dirname(Node 20.11+ 也可直接用import.meta.dirname) - CommonJS 和 ESM 可以互相导入,但有兼容性限制
循环依赖在 CommonJS 和 ESM 中分别会怎样?
循环依赖在 CommonJS 和 ESM 中分别会怎样?
答题要点:
CommonJS 返回不完整的 exports,ESM 通过 live binding 获取最新值。高分回答:
“CommonJS:遇到循环依赖时,返回的是”当前已执行部分的
module.exports”。如果模块 A 执行到一半就去 require 模块 B,而 B 又 require A,B 拿到的是 A 的”半成品”。ES Modules:通过 live binding 机制处理。在实例化阶段就建立了 import 和 export 的绑定关系,即使变量还没被赋值。求值阶段按依赖顺序执行,赋值完成后导入方通过 live binding 能拿到最新值。但两种方式在实际项目中都不可靠。最好的做法是消除循环依赖:提取公共模块、使用依赖注入、或者重新设计模块职责。”Vite 为什么比 Webpack 快?
Vite 为什么比 Webpack 快?
答题要点:
Vite 利用浏览器原生 ESM,不需要在开发时打包。高分回答:
“Vite 在开发模式下比 Webpack 快的根本原因是不需要打包。Webpack 的开发模式流程:分析依赖 → 打包所有模块到一个 Bundle → 启动开发服务器 → 文件修改 → 重新打包 → 刷新。项目越大,启动越慢。Vite 的开发模式流程:启动开发服务器(几乎瞬间) → 浏览器请求哪个模块,实时转换哪个模块返回。利用浏览器原生 ESM 的
import 语句,每个 import 都是一个 HTTP 请求,Vite 拦截这些请求,用 esbuild 实时转换。对于 node_modules 中的 CommonJS 模块,Vite 用 esbuild 做预构建(Pre-bundling),一次性转为 ESM 并缓存。生产构建 Vite 用 Rollup,同样支持 Tree Shaking 和代码分割。”什么是 Module Federation?它解决了什么问题?
什么是 Module Federation?它解决了什么问题?
答题要点:
Module Federation 让多个独立构建的应用在运行时共享模块。高分回答:
“Module Federation 是 Webpack 5 引入的特性,它允许多个独立构建的应用在运行时共享模块。它解决的核心问题是:微前端场景下,多个子应用如何共享依赖(如 React、lodash),避免重复加载。关键特性:
- 运行时加载:不需要提前打包,运行时动态加载远程模块
- 依赖共享:shared 配置确保 React 等公共依赖只加载一次
- 版本协商:如果两个应用用的 React 版本不同,Webpack 会加载两个版本”
如何设计一个支持 Tree Shaking 的工具库?
如何设计一个支持 Tree Shaking 的工具库?
答题要点:
从源码到构建到 package.json 都需要配合。高分回答:
“设计一个支持 Tree Shaking 的工具库,需要从三个层面配合:1. 源码层面:2. 构建层面:
- 每个函数单独导出,不要把所有函数放在一个大对象里
- 使用
export const而不是export let - 避免在模块顶层执行副作用代码
- 输出 ESM 格式(
"module": "dist/index.esm.js") - 用 Rollup 构建(Tree Shaking 能力最强)
- 不要压缩和混淆开发版本(方便调试和 Tree Shaking)
exports 字段让打包工具可以直接导入子路径(import debounce from 'my-lib/debounce'),进一步提升 Tree Shaking 效果。条件按书写顺序匹配,先命中 import 或 require,最后用 "default" 兜底;"types" 条件供 TypeScript 解析类型。”ESM 和 CommonJS 在 Node.js 中混用有什么坑?
ESM 和 CommonJS 在 Node.js 中混用有什么坑?
答题要点:
双包问题(Dual Package Hazard)和导入方式的兼容性限制。高分回答:
“ESM 和 CommonJS 在 Node.js 中混用最大的坑是双包问题(Dual Package Hazard):如果一个库同时提供了 CJS 和 ESM 两个版本,当主应用用 ESM 导入,而某个依赖用 CJS 导入同一个库时,Node.js 会加载两个实例——它们各自有不同的内部状态,导致 解决方案:
instanceof 检查失败、单例模式失效。- 只发布 ESM 版本(需 Node 12.17+)
- 用 Conditional Exports 但确保内部状态用全局单例
- 在
package.json中用"type": "module"统一为 ESM
require(),如果需要动态导入,必须用 await import()。CJS 中导入 ESM 必须用 await import()(需要在 async 上下文中)。“Q9: ESModule 的工作原理是什么?
ES Modules 不仅仅是”用import/export 替代 require/module.exports”。它的本质是一套在编译时静态分析依赖、在求值阶段通过 live binding 建立引用关系的模块系统。理解 ESM 的工作原理,是理解 Tree Shaking、代码分割、Vite 快速启动的前提。
阶段 1:构建(Construction)
引擎从入口文件开始,递归解析所有import 语句,构建完整的模块依赖图(Module Graph)。这个阶段只做静态分析,不执行任何代码。
阶段 2:实例化(Instantiation)
引擎为每个模块创建一个 Module Record(模块记录),为所有export 变量分配内存空间(此时变量未赋值,值为 undefined),然后将 import 和 export 的变量”链接”起来——这就是 live binding。
阶段 3:求值(Evaluation)
按照依赖顺序(拓扑排序)执行模块代码,变量在此时获得实际值。由于 live binding 已经建立,模块间可以实时看到彼此的最新值。Live Binding vs 值拷贝
这是 ESM 与 CommonJS 最本质的区别:ESM 与 Tree Shaking 的关系
ESM 的静态分析能力是 Tree Shaking 的基础。因为import/export 必须在模块顶层(不能在条件语句或函数内),打包工具可以在编译时精确知道哪些导出被使用了:
require() 是普通函数调用,可以在任何地方执行,编译时无法确定依赖关系,所以无法 Tree Shaking。
延伸阅读与引用参考
- Node.js 官方文档: Modules:Node.js 模块系统的权威文档,包含 CommonJS 的加载机制和缓存策略。[1]
- MDN: JavaScript Modules:ES Modules 的官方教程,包含浏览器端和 Node.js 端的使用方式。[2]
- Vite 官方文档: Why Vite:Vite 作者尤雨溪对”为什么需要新一代构建工具”的深度解释,理解原生 ESM 的价值。[3]
