前端模块化:CommonJS与ES6 Module的底层逻辑与实战对比

发布时间:2026/9/15 1:32:32
前端模块化:CommonJS与ES6 Module的底层逻辑与实战对比 很多人刚接触前端模块化的时候第一反应往往是被一长串概念糊脸CommonJS、AMD、CMD、UMD、ES6 Module、Webpack、Vite……面试题里最常被问的就是“CommonJS和ES6模块到底有什么区别”而实际开发的时候又会发现Node.js里用的是require前端代码里写的是import两个东西还经常混着出现。这题我已经给不少新人讲过今天直接用一篇实战指南把这条线捋明白保证你看完能真正理解“模块化”这三个字背后的底层逻辑而不是死记面试答案。这篇文章会从最原始的“为什么需要模块化”讲起再逐一拆解CommonJS的require/module.exports、ES6模块的import/export对比它们在设计上的本质差异最后手写一个迷你模块加载器来验证理解。适合刚入门前端、对模块化概念一知半解、或者准备前端面试的朋友也适合后端转前端、想在团队里把代码组织得更规范的同学参考。1. 模块化到底解决的是什么问题先把痛点看透1.1 没有模块化的前端世界有多混乱先别急着背语法我们回到十几年前的前端页面。那时候写JavaScript基本靠script标签一个一个往HTML里塞代码之间没有任何隔离手段。你在一号脚本里声明了一个var name 张三二号脚本里可能也有一个var name后加载的脚本会毫不犹豫地覆盖前面的值。更要命的是如果两个库都用window.utils这种全局对象稍不留神就互相踩踏报错的时候你根本不知道是哪段代码干的。多说一句依赖顺序的问题。以前引入第三方库必须严格按顺序来比如先引jQuery再引插件一旦顺序错了插件初始化的时候找不到$直接白屏。这种“靠人为约定维持秩序”的方式在项目只有几个文件的时候还算能忍一旦文件数量上到几十个、上百个就变成了一场灾难。你只能靠CtrlF搜索变量名去猜是谁污染了全局排查成本高得离谱。1.2 模块化设计的基本目标模块化不是某个人拍脑袋想出来的而是工程化的必然产物。它要解决的痛点归纳起来其实只有三点。第一是封装。每个模块有自己独立的作用域内部变量默认对外不可见想暴露给外部使用的内容必须显式导出。这就像抽屉一样每个模块是一个带锁的抽屉别人拿不到你的私人物品。第二是复用。某个功能写好了可以在不同项目中直接搬过去用不需要复制粘贴再改名。良好的模块是“一次编写、处处引用”的改一处逻辑所有引用它的地方同步生效。第三是依赖管理。模块与模块之间的引用关系是显式声明的比如A模块依赖B模块那A的代码里就得明确写require(B)或者import ... from B构建工具和运行时都能根据这个关系图把代码正确加载出来不再依赖手工调整script标签顺序。1.3 为什么前端会出现这么多模块规范看历史的时候你会发现模块规范之多本质上是“不同运行环境、不同阶段”的解决方案各领风骚。最早的CommonJS是为了让JavaScript能在服务器端也就是Node.js承担模块化开发而设计的它的核心是require和module.exports同步加载、语法简单在Node里跑得很舒服。但浏览器环境不一样因为模块文件是通过网络加载的如果在页面初始化时同步加载所有模块请求一个卡一个性能完全没法看。于是社区搞出了AMD代表作RequireJS和CMD代表作Sea.js用异步加载的方式解决浏览器端的问题。后来又有人做了UMD试图一套代码同时兼容CommonJS和AMD以及浏览器全局变量。到了ES6JavaScript语言本身终于正式定义了模块系统也就是我们现在天天写的import和export。它不属于任何运行时而是语言层面的标准既能在浏览器里跑配合script typemodule也能在Node.js里跑配合.mjs或type: module。理解了这个演进路径你再看市面上各种“XX模块规范”心态就会稳很多它们都是特定时期的答案而ES6模块是官方给出的最终方案。2. CommonJS背后的设计逻辑与核心语法2.1 出身决定了它的基因同步加载与运行时确定CommonJS最初就是为Node.js设计的而Node.js跑在服务器上模块文件都存在本地磁盘读文件是毫秒级的事情所以它采用同步加载的策略完全合理。require方法执行的时候一去加载、二去执行、三去返回导出的内容整个过程是阻塞的但服务器场景不在乎这点性能损耗。第二个跟出身有关的基因是运行时确定依赖关系。CommonJS的require可以写在条件语句里、可以写在函数体里、甚至可以用变量拼接路径来动态指定加载哪个模块。比如if (config.env production) { const api require(./config.prod.js); } else { const api require(./config.dev.js); }这种写法在CommonJS里完全合法。加载时机是在代码执行到这一行才发生的所以叫“运行时加载”。这一点看起来很灵活但它也意味着构建工具拿到一段CommonJS代码时没法静态分析出项目里到底引了哪些模块——因为得真正运行代码才知道require(./config. name)指向哪个文件。2.2 module.exports与exports的区分新人必踩的坑CommonJS每个文件都是一个模块模块内部默认有一个module对象这个对象上挂着一个exports属性初始值是一个空对象{}。大家常写的exports.foo 123本质上是往这个空对象上挂属性。请看下面的对比// 方式一直接给exports挂属性可以 exports.name 张三; exports.say function () { console.log(hello); }; // 方式二直接给module.exports赋值可以 module.exports { name: 张三, say() { console.log(hello); } }; // 方式三直接给exports重新赋值不行 exports { name: 李四 };这里面的核心原因在于require返回的是module.exports所指向的那个对象而不是exports。exports只是module.exports的一个引用别名。你往exports.name上挂属性等于往module.exports指向的对象上挂属性没问题但你把exports整体重新赋值成另一个对象这个新对象和module.exports就断了关系require回来的仍然是原来的空对象。我见过很多新人在模块底部急着补一句exports xxx结果怎么require都是空对象排查了半天才发现是这么回事。这里提供一个记忆口诀永远不要在模块里给exports整体重新赋值要导出整体就用module.exports要导出多个属性就用exports.xxx。如果你写的是TypeScript也可以直接用export default或export const编译到CommonJS时工具链会自动处理这个映射。2.3 require的加载机制与缓存require还有一个容易被忽略的特性模块会缓存。第一次require(./foo)的时候Node会加载并执行foo模块然后把它的exports对象缓存起来后续再require(./foo)直接返回缓存里的结果不会重新执行模块代码。这个机制有两个实际影响新手一定要知道。第一个影响是“模块顶层代码只会执行一次”。如果你在模块顶层打印日志、连接数据库、初始化变量重复require不会重复执行这可以避免一些重复初始化的开销。第二个影响是“导出的对象是共享引用”。不同模块require同一个模块得到的是同一个对象所以你如果在一个地方修改了这个对象的某个属性其他地方读到的也是修改后的值这在某些场景下可以用作简单的全局状态共享。不过缓存也带来一个经典面试坑如果全局缓存被改了但子模块里的引用没变就可能出现数据不同步的错觉。更常见的情况是你改了某个模块的代码但进程没重启require到的还是旧缓存。Node开发中常见的“改了代码没生效必须重启服务”就是这么来的因为模块缓存没有自动失效机制。2.4 循环依赖Node是怎么处理的循环依赖指的是A模块require了B模块而B模块又require了A模块。在大型项目里这种引用关系很容易被无意中引入。CommonJS对循环依赖的处理方式非常“朴实”加载到一半发现对方还没执行完就先返回对方当前已导出的内容其余的等执行完后补上。举个例子A模块先执行它require了BB开始执行B又require了A但这时A还没执行完module.exports上可能什么都没有所以B拿到的就是A的“半成品”。如果B在拿到半成品后立刻就去用A导出的某个变量极大概率拿到的是undefined从而报错。解决方案通常是把对对方的访问放在函数内部、延迟到运行时再读取而不是在模块顶层直接解构使用。3. ES6模块浏览器和未来的正确打开方式3.1 import与export的基础语法全家桶ES6模块用import和export作为关键字语法相对CommonJS更丰富。先看导出部分的几种写法// 命名导出可以导出多个 export const name 张三; export function say() { console.log(hello); } // 默认导出每个模块只能有一个 export default function main() { console.log(this is main); } // 混合导出命名 默认 export const version 1.0.0; export default class App {}再看导入部分// 导入命名导出的内容 import { name, say } from ./module.js; // 用as重命名导入 import { name as userName } from ./module.js; // 导入默认导出 import main from ./module.js; // 同时导入默认和命名 import main, { name } from ./module.js; // 整体导入命名空间 import * as utils from ./module.js;这里有个很多人写混的地方import * as utils拿到的是一个模块命名空间对象它包含这个模块的所有导出内容但是没有办法触发tree shaking的精准摇树因为打包器没法静态判断你到底用了哪些属性。实际开发中如果需要压缩体积建议尽量用具名导入import { a, b }让打包器能知道哪些导出被真正用到了。3.2 静态分析与tree shaking的底层原因ES6模块有一个非常关键的语法约束import和export语句只能出现在模块顶层不能嵌套在条件语句、函数或者其他块级作用域里。同时也不能用变量拼接路径来动态导入导入路径必须是静态字符串。这些限制表面上看起来不灵活却换来了一个巨大的工程红利——静态分析。因为语法上是静态的打包工具Webpack、Rollup、Vite等拿到一份代码就能构建出完整的依赖关系图知道哪些导出被引用、哪些没有从而在打包阶段把未被引用的代码删掉这就是tree shaking的核心原理。你可以把tree shaking理解成“摇苹果树”整棵树的苹果都能看见你只摘需要的几个剩下的留在树上打包体积自然就小了。CommonJS做不到这一点所以很多老项目里就算用了require引入了库打包器也只能把整个库都塞进去很难按需裁剪。这也是为什么现在新库、新框架的源码都在往ESM格式迁移底层原因就在这。3.3 严格模式、实时绑定与this指向ES6模块还有几个“默认行为”要和CommonJS区分开。第一个是ES6模块默认启用严格模式不需要在文件顶部写use strict。这意味着给未声明变量赋值会直接报错this在普通函数里指向undefined而不是全局对象这些细节在模块内写代码时要注意。第二个是“实时绑定”与“值拷贝”的区别。CommonJS的require相当于把module.exports的对象引用了过来对于基础类型的值在模块内部改变不会影响外部因为本质上是同一份引用的不同属性读取但对于对象类型外部拿到的和内部是同一个对象。而ES6模块导入的是只读的实时绑定翻译成人话就是你可以随时读到模块内变量的最新值但是不能对导入的变量重新赋值。// counter.mjs export let count 0; export function increment() { count; } // main.mjs import { count, increment } from ./counter.mjs; console.log(count); // 0 increment(); console.log(count); // 1这里能读到最新值如果尝试对导入的count赋值比如count 10会直接抛错。这个“只读实时绑定”的特性在循环依赖场景下反而比CommonJS更宽容一些因为依赖的是模块环境记录的绑定关系只要在真正读取的时候模块已经初始化完成就能拿到正确值。3.4 ES6模块的循环依赖表现前面说到CommonJS处理循环依赖时B拿到的可能是A的半成品。ES6模块在语法层面支持实时绑定之后情况稍微好一点导入方拿到的不是值拷贝而是对模块内部绑定的引用所以只要真正使用的时候对方模块已经把值填充完毕就能读到正确值。但注意“稍微好一点”不代表可以乱写。如果A和B互相依赖且双方都在模块顶层立即使用了对方的导出内容依然可能出现初始化顺序导致的undefined。所以无论用哪种模块规范工程上的最佳实践都是尽可能避免循环依赖通过抽出公共模块、调整依赖方向、或者把顶层立即执行逻辑改成延迟调用等方式来解耦。4. CommonJS和ES6模块的全维度PK4.1 一张表看懂核心差异这里直接上一张对比表面试前把这张表吃透基本就能应付大多数考察了对比维度CommonJSES6 Module适用环境Node.js原生支持浏览器与Node.js需配置或.mjs加载时机运行时加载代码执行到才加载编译时静态分析依赖关系可静态推导加载方式同步加载浏览器下异步加载Node下视文件类型导出方式module.exports、exports.xxxexport、export default导入方式require()可动态拼接路径import ... from必须静态字符串顶层this指向当前模块的exports指向undefined严格模式默认不启用默认启用值传递基础类型是值拷贝对象是引用实时绑定只读导入tree shaking无法静态分析难以实现天然支持循环依赖可能拿到半成品需延迟访问实时绑定但顶层立即使用仍有风险动态导入require天然支持可用import()返回Promise这张表要记的不是“谁好谁坏”而是“谁在什么场景下更合适”。4.2 工程中该怎么选一个务实的决策思路很多新人会纠结那我到底该用require还是import我的答案分三层。第一层纯前端项目、用现代构建工具的项目统一用ES模块。Vite、Webpack对ESM支持都非常成熟源码写import/export既能享受tree shaking也是社区主流方向。第二层写Node.js服务端如果不是新项目大概率还是CommonJS。因为大量npm老包、Node官方文档、历史代码都是用require写的。当然现在Node已经支持ESM了你可以结合package.json的type: module来切换但也要注意生态兼容性有些CJS包在ESM项目里导入时会有默认导出与命名导出的坑。第三层现实中两者经常混用。比如Vite项目里可能因为某个工具库只提供CJS格式而require它或者你在Node的ESM文件里通过createRequire创建require来加载CJS模块。混用本身不是问题问题是要搞清楚“谁来转换、何时转换”构建工具和Node运行时会帮你处理大部分情况但你要知道边界在哪。4.3 Node.js里的特殊情况package.json的type字段与文件后缀在Node.js里使用ESM有几个规则值得记牢。如果你把package.json里加上type: module那么该包下所有.js文件都会被当作ESM处理如果把type设为commonjs或者不写.js文件默认是CommonJS。当然你也可以直接改用扩展名区分.mjs永远按ESM解析.cjs永远按CommonJS解析。我之前在一个老项目里踩过一个坑项目里没有type: module但我新建了一个.mjs文件里面用import结果运行时发现它引用的某个依赖是CJS格式的直接在Node的ESM里import一个CJS模块时Node会做CJS与ESM的互操作默认导出值可能会包在default属性里。这时候需要小心处理// 从CJS模块导入默认导出 import pkg from cjs-module; // 某些情况下pkg并不是你预期的对象而是{ default: {...} }这种边界问题很容易让人抓狂建议新人先统一格式等熟悉了再尝试混用。5. 实战手写一个迷你的CommonJS模块加载器5.1 核心思想模块代码只是一次函数调用理论知识讲再多都不如亲手实现一遍来得扎实。这里我用一个极简的示例来模拟Node里CommonJS的require机制代码量非常小但把精髓都涵盖了。本质上CommonJS的模块化就是把每个文件代码包裹在一个函数里这个函数的参数是module、exports、require等对象。当require被调用时加载器读取文件内容包一层函数传入对应的参数然后调用它。我们手动模拟时不需要读写文件直接用new Function构造函数体再加一个模块注册表就行。5.2 实现代码// mini-cjs-loader.js const path require(path); const fs require(fs); const vm require(vm); // 模块注册表用于缓存已经加载过的模块 const cache {}; function customRequire(modulePath, fromFile) { // 转换成绝对路径作为模块的唯一标识 const absPath path.resolve(path.dirname(fromFile), modulePath); if (cache[absPath]) { return cache[absPath].exports; } // 读取模块源码 const code fs.readFileSync(absPath, utf-8); const module { exports: {} }; cache[absPath] module; // 这里模拟的是Node最朴素的模块包裹逻辑 const wrapper [ (function (exports, require, module, __filename, __dirname) {, code, }) ]; const wrappedCode wrapper.join(\n); const compiledWrapper vm.runInThisContext(wrappedCode, { filename: absPath }); // 传入自定义的require使得模块内require也会走同一套逻辑 const localRequire (p) customRequire(p, absPath); compiledWrapper.call(module.exports, module.exports, localRequire, module, absPath, path.dirname(absPath)); return module.exports; } module.exports customRequire;上面的代码有几个细节值得注意。第一cache[absPath] module发生在执行模块代码之前这一步很关键就是为了处理循环依赖时能返回“半成品”。第二执行包裹函数时this指向module.exports所以模块顶层写this.xxx 1时挂到的是exports上这和真实Node的行为一致。第三vm.runInThisContext只是用来在当前全局上下文里执行代码实际Node内部还有更完整的模块解析算法但核心思想就是这样。5.3 用同一套心智模型去理解ESM打包器理解了CommonJS加载器之后你会发现ESM打包器比如Rollup、Vite做的事情也很类似只不过阶段更靠前。它们在构建时把所有模块读进来分析import/export的关系生成一张依赖图然后把所有模块打包成一个或多个文件模块之间的引用通过统一的运行时函数来连通。与CommonJS直接“运行时读文件执行”不同ESM打包器通常把模块代码放进一个对象映射里运行时通过模块ID找到对应模块再通过__esModule等标记来兼容不同模块格式。你只要记住一个最朴素的心智模型模块化最终都要落到“模块注册表”和“模块加载函数”上无论是Node自带还是Webpack自研的运行时万变不离其宗。6. 面试高频题与避坑清单6.1 面试官最爱的三个问题下钻第一问“require和import的区别是什么”这是送分题但很多人答得太浅。除了回答加载时机运行时 vs 静态、语法形式同步 vs 支持异步、以及是否可做tree shaking最好能补充一句“CommonJS的require可以动态拼路径ESM的import不行但可以通过import()动态导入”。这样既展示了你对机制的理解又能引向进阶话题。第二问“为什么ES模块能实现tree shakingCommonJS不能”答到“ES模块的导入导出是顶层静态语法可以编译时分析依赖关系”就合格如果能进一步说“CommonJS的require支持表达式拼路径分析器无法静态推导出所有依赖”就更完善。这里建议再用一个例子补充CJS模块经常整体导出对象打包器根本不知道你用了哪些属性最多只能做保守的标记清除效果远不如原生ESM的精准裁剪。第三问“循环依赖时两者表现有什么不同”先说CommonJS返回半成品再说ESM实时绑定可能拿到最新值但都要强调工程上尽量避免。加分项是把第一节的module.exports赋值时机和“导出的是引用还是值”一并讲清楚面试官一听就知道你是真懂。6.2 开发中常见的坑与解决办法第一个坑是exports整体覆盖。刚才已经详细讲过这里再补一句如果用了Babel、TypeScript编译很多工具会自动兼容但在纯Node环境里踩一次就能记一辈子。第二个坑是导入路径不加后缀。Node ESM环境下import ./foo默认不会自动补.js后缀必须写成import ./foo.js这和CommonJS的require(./foo)自动解析扩展名不一样。老项目切ESM时最容易遇到这类报错。第三个坑是默认导出和命名导出混用导致的结构不一致。比如某个CJS包在Node里module.exports { a, b }你在ESM里import { a } from pkg没问题但你想import pkg from pkg拿到的可能是{ default: { a, b } }这种嵌套结构一不注意就pkg.a是undefined。这时候可以借助Node的处理规则或者用createRequire来按CJS方式加载。第四个坑是循环依赖的隐性风险。项目重构时把A模块的一个工具函数挪到B模块结果B又依赖了A这种间接循环很难一眼看出来。我一般建议在团队里用madge这类工具定期检查依赖关系图把循环依赖作为代码评审的减分项。别等到线上出了诡异bug再去查模块加载顺序那滋味真的不好受。第五个坑是“以为import了就可以随便改”。ES模块导入的变量是只读的这对很多习惯写Java、C的人是一个思维转变。不能对导入的内容赋值要在源模块里通过导出函数来修改状态这反而能帮助你写出更清晰的数据流。6.3 学习路径建议如果你刚学到这里接下来的练习我建议分成三步走。第一步自己写几个小文件分别用CommonJS和ESM实现同样的工具函数跑通Node环境观察两者的语法差异。第二步用Vite或Webpack搭一个简单项目写几个import示例跑一遍构建看看dist文件夹下打包结果再对比一下引入一个未被使用的导出函数前后打包体积的变化对tree shaking形成直观感受。第三步去看你常用的UI组件库或工具库的package.json看看它们的main字段通常是CJS入口和module字段通常是ESM入口以及exports字段的导出映射理解“双格式发布”是怎么一回事。我个人在实际操作中体会到模块化真正的难点不在语法而在于建立“依赖图”的思维方式。你能不能在动手之前就画出这个项目里谁依赖谁决定了你能不能优雅地设计模块边界。这个能力不是靠背题能练出来的得靠写代码和读别人的源码慢慢磨。最后再分享一个实用小技巧排查模块加载问题时可以在入口文件打印require.cacheNode的CommonJS或者依赖关系图工具ESM项目先看清加载顺序和缓存状态大多数诡异问题都能在依赖图里找到答案。模块化这条路没人能跳过踩过的坑越多你的代码边界感就会越清晰。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询