Skip to main content
模块这块我踩过循环依赖:拿到的是半成品,报错位置还很迷惑。后来才补上条件导出和 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)与其他部门交互。新员工入职时只需要了解自己部门的接口,不需要知道其他部门的内部实现。
面试时先讲”为什么需要模块化”(全局污染、依赖管理),再讲”怎么实现的”(CommonJS vs ES Modules),最后讲”Tree Shaking 和构建优化”。展示你从历史到原理到工程实践的完整理解。

Q2: CommonJS —— Node.js 的模块系统

CommonJS 是 Node.js 默认的模块系统。它的核心特点是:同步加载、运行时解析、值的拷贝。

CommonJS 的加载机制

CommonJS 的值拷贝特性

这是最容易出错的地方。CommonJS 在 require 时会执行模块代码,然后返回 module.exports 对象本身。但由于导出时 { count, increment } 里的 count 只是当时值的快照,后续模块内部的 count++ 不会回写到这个对象属性上。

CommonJS 的循环依赖处理

CommonJS 的 require() 就像打一通同步电话——你打电话给同事要一份文件(require),同事必须立刻找到文件发给你,你拿到文件后才能继续工作。如果同事也需要你的文件(循环依赖),你们就陷入了”你等我、我等你”的尴尬——最终先拿到的是对方”还没做完的半成品”。

Q3: ES Modules —— JavaScript 的语言标准

ES Modules(ESM)是 ES6 引入的官方模块系统。它的核心特点是:静态分析、异步加载、值的引用(live bindings)。

ES Modules 的静态分析能力

ES Modules 的 import / export 必须在模块顶层,不能在条件语句或函数内使用。这不是限制,而是为了让打包工具在编译时就能分析出模块的依赖关系:

ES Modules 的 Live Bindings(活绑定)

ES Modules 的导出是 live binding——导入方拿到的是对导出变量的引用,而不是值的拷贝。当模块内部修改了导出变量,导入方会看到最新值。

CommonJS vs ES Modules 对比


Q4: 循环依赖 —— 模块化的经典陷阱

循环依赖是模块化中最容易出 bug 的场景:模块 A 依赖模块 B,模块 B 又依赖模块 A。

CommonJS 如何处理循环依赖

ES Modules 如何处理循环依赖

如何避免循环依赖

大多数循环依赖说明模块的职责划分有问题。如果 A 和 B 互相依赖,通常意味着它们应该合并成一个模块,或者把共享的部分提取到第三个模块。不要试图”解决”循环依赖,而应该消除它。

Q5: Tree Shaking —— ES Modules 的杀手级特性

Tree Shaking 是 ES Modules 带来的最重要的构建优化:在打包时删除没有被使用的导出代码。 它的名字来源于”摇树”——摇动树干,枯叶自然掉落。

Tree Shaking 的工作原理

什么情况下 Tree Shaking 会失效

Webpack 的 Tree Shaking 配置

Tree Shaking 就像自助餐的按需取用。你走进餐厅(打包入口),只拿了你吃的菜(import 的模块)。厨房(打包工具)发现你没拿”红烧肉”(未使用的导出),就把红烧肉从菜单上划掉了(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)

  1. 依赖方向单一化:上层模块可以依赖下层模块,下层不能依赖上层
  2. 提取公共模块:如果 A 和 B 都需要的功能,提取到 C
  3. 依赖注入:通过参数传入依赖,而不是直接 import
  4. Lint 规则:用 eslint-plugin-import 的 no-cycle 规则自动检测循环依赖

底层源码:Node.js 和浏览器如何加载模块?

Node.js 的模块加载器在 lib/internal/modules/cjs/loader.js 中实现:
这段代码揭示了 Node.js 模块的几个关键机制:
  1. 模块缓存:Module._cache 用文件路径作为 key,确保每个文件只执行一次
  2. 包装函数:每个模块代码被包装在一个函数中,exports、require、module 作为参数传入
  3. 路径解析:Module._resolveFilename 负责解析 require() 的参数为绝对路径
浏览器的 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() 是一个普通的 JavaScript 函数调用,在运行时同步执行。当代码执行到 const a = require('./a') 时,Node.js 会暂停当前模块的执行,去加载并执行模块 a,拿到返回值后继续执行。import 语句在编译阶段就被解析了。引擎在执行任何代码之前,就已经知道了所有的依赖关系。import 语句会被提升到模块顶部(类似变量提升),实际的值绑定在求值阶段完成。
答题要点: Tree Shaking 依赖 ES Modules 的静态分析能力。高分回答: “Tree Shaking 是在打包时删除未使用代码的优化技术。它的工作原理是:
  1. 构建模块依赖图(利用 ESM 的静态 import/export)
  2. 标记所有被引用的导出
  3. 删除未被标记的导出
前提条件:
  1. 使用 ES Modules 语法:CommonJS 的 require() 是动态的,无法静态分析
  2. 声明 sideEffects:在 package.json 中设置 "sideEffects": false,告诉打包工具这些模块没有副作用,可以安全删除
  3. 使用 const 导出:export const 比 export let 更容易被 Tree Shaking(不会被重新赋值)
  4. 避免 Barrel Export 的陷阱:如果 barrel 文件中有副作用代码,可能影响 Tree Shaking”
答题要点: 两种方式:.mjs 扩展名或 package.json 的 type: “module”。高分回答: “Node.js 从 12.17 / 13.2 起正式稳定支持 ESM(12.0 到 12.16 需要 --experimental-modules),有两种启用方式:
  1. 文件扩展名用 .mjs
  2. 在 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 返回不完整的 exports,ESM 通过 live binding 获取最新值。高分回答: “CommonJS:遇到循环依赖时,返回的是”当前已执行部分的 module.exports”。如果模块 A 执行到一半就去 require 模块 B,而 B 又 require A,B 拿到的是 A 的”半成品”。ES Modules:通过 live binding 机制处理。在实例化阶段就建立了 import 和 export 的绑定关系,即使变量还没被赋值。求值阶段按依赖顺序执行,赋值完成后导入方通过 live binding 能拿到最新值。但两种方式在实际项目中都不可靠。最好的做法是消除循环依赖:提取公共模块、使用依赖注入、或者重新设计模块职责。”
答题要点: 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 是 Webpack 5 引入的特性,它允许多个独立构建的应用在运行时共享模块。它解决的核心问题是:微前端场景下,多个子应用如何共享依赖(如 React、lodash),避免重复加载。
关键特性:
  1. 运行时加载:不需要提前打包,运行时动态加载远程模块
  2. 依赖共享:shared 配置确保 React 等公共依赖只加载一次
  3. 版本协商:如果两个应用用的 React 版本不同,Webpack 会加载两个版本”
答题要点: 从源码到构建到 package.json 都需要配合。高分回答: “设计一个支持 Tree Shaking 的工具库,需要从三个层面配合:1. 源码层面:
  • 每个函数单独导出,不要把所有函数放在一个大对象里
  • 使用 export const 而不是 export let
  • 避免在模块顶层执行副作用代码
2. 构建层面:
  • 输出 ESM 格式("module": "dist/index.esm.js")
  • 用 Rollup 构建(Tree Shaking 能力最强)
  • 不要压缩和混淆开发版本(方便调试和 Tree Shaking)
3. package.json 层面:
exports 字段让打包工具可以直接导入子路径(import debounce from 'my-lib/debounce'),进一步提升 Tree Shaking 效果。条件按书写顺序匹配,先命中 import 或 require,最后用 "default" 兜底;"types" 条件供 TypeScript 解析类型。”
答题要点: 双包问题(Dual Package Hazard)和导入方式的兼容性限制。高分回答: “ESM 和 CommonJS 在 Node.js 中混用最大的坑是双包问题(Dual Package Hazard):如果一个库同时提供了 CJS 和 ESM 两个版本,当主应用用 ESM 导入,而某个依赖用 CJS 导入同一个库时,Node.js 会加载两个实例——它们各自有不同的内部状态,导致 instanceof 检查失败、单例模式失效。
解决方案:
  1. 只发布 ESM 版本(需 Node 12.17+)
  2. 用 Conditional Exports 但确保内部状态用全局单例
  3. 在 package.json 中用 "type": "module" 统一为 ESM
另一个常见坑: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 的导入是 live binding(引用),但导入方不能直接修改导入的变量。这是规范的硬性要求——import 绑定是只读的(read-only view)。要修改导出值,只能通过调用导出的函数。

ESM 与 Tree Shaking 的关系

ESM 的静态分析能力是 Tree Shaking 的基础。因为 import/export 必须在模块顶层(不能在条件语句或函数内),打包工具可以在编译时精确知道哪些导出被使用了:
CommonJS 的 require() 是普通函数调用,可以在任何地方执行,编译时无法确定依赖关系,所以无法 Tree Shaking。
CommonJS 就像现场施工——工人到了现场才知道要什么材料(运行时解析),缺什么临时去拿。ES Modules 就像先画好完整的设计图(编译时静态分析),所有的材料清单(依赖关系)在开工前就确定了。有了设计图,就能精确知道哪些材料会被用到(Tree Shaking),哪些是多余的可以直接划掉。

延伸阅读与引用参考