
1. 模块化到底解决了什么问题先聊点实际的。很多人学Modules模块的时候第一反应是“不就是把代码拆开放几个文件嘛”然后就开始看语法、记关键字、背导入导出的写法。但真到了项目一上手拆文件拆得乱七八糟依赖关系理不清改一个工具函数能牵连出十几个报错。这时候回头看才发现压根没搞懂模块化在解决什么问题。我自己的转折点是在一个不算特别大的项目里——大概两万多行代码前后端不分离整个逻辑层全堆在几个文件里。第一个文件三千多行将近一半是全局变量和挂载在不同作用域里的辅助函数。每次新增需求我都要靠编辑器的全局搜索功能一遍一遍确认“这个函数到底在哪里被用过”。后来实在受不了了才下定决心把所有代码按模块重写。那次重构给我最大的收获不是代码变好看了而是终于理解了一句话模块化的本质不是“拆文件”而是管理代码之间的依赖关系。从实际开发的视角看模块化解决的是三个极其具体的问题。第一个问题全局作用域污染。在没有模块系统的年代每个用script标签引入的JS文件顶层声明的变量和函数全都会挂到全局对象上。你写了个var utils {...}别人也写了个var utils {...}后加载的文件直接覆盖前面那个运行时才开始报错想排查得靠猜。这相当于好几个人同时往一个仓库里堆东西没人规定哪个货架是谁的最后谁也找不到自己的货。第二个问题依赖关系不透明。文件A用了文件B里的函数但你必须手动保证B先加载。靠什么保证靠注释、靠约定、靠经验。一旦某个公共文件被好几个地方同时引用顺序稍微一乱运行时就崩。模块系统把“我需要依赖谁”这层关系写进了代码本身加载器可以自动推导顺序不需要人肉维护脚本标签的顺序。第三个问题复用和隔离。你辛辛苦苦写了一个校验函数、一个请求封装、一个日期处理工具下次新项目想直接用要么复制粘贴要么把整个文件搬过去。复制粘贴的问题是修了这里的bug忘了修那边同样的一份搬整个文件的问题是一堆无关代码也跟着进来了。模块可以精确控制“对外暴露什么、对内保留什么”复用的时候只拿你需要的那部分隔离性也有了。用一个生活化的类比没有模块化的时候你的代码就是一个巨大的房间所有工具和材料都摊在同一个台面上找东西靠翻有了模块化就像给每种工具配了抽屉抽屉上贴了标签你只需要通过标签取用不需要知道抽屉内部是怎么整理的。这个类比贯穿整个模块化学习过程非常有用。所以学习模块化之前先建立这个观念模块化是一套代码组织与依赖管理的解决方案语法只是它的外在表现。带着这个视角看后面那些import、export、require、define思路会通顺很多。2. 从CommonJS到ES Modules演进逻辑比语法更重要很多人学模块系统的时候最大的困惑不是某个语法不会写而是“为什么有这么多种模块规范我到底该学哪个”。我的建议是别急着“选一个学”把演进脉络捋一遍很多疑问自然就解开了。2.1 早期尝试没有标准之前大家都在摸着石头过河在ES Modules出现之前JavaScript语言层面根本没有模块的概念。浏览器端大家靠的是全局变量加命名空间也就是上面说的那套原始方案。这个方案的问题太多了间隔性地折磨了开发者好多年。后来社区里陆续出现了一些模块加载方案比较有代表性的是AMDAsynchronous Module Definition和它的流行实现。AMD的设计思路是“异步加载”因为浏览器端加载资源天然是异步的所以用它写的模块定义本身就带依赖数组加载器负责按依赖关系去拉取对应的文件。CJSCommonJS是另一个方向的力作它的设计更加简单直接使用require同步加载模块服务端开发非常自然。因为Node.js早期就采用了CommonJS规范所以服务端JavaScript生态很快就建立起来了。这段历史里值得记住的关键点是AMD和CJS各自主导了浏览器和服务端两个场景但两套系统互相不兼容。你写了一个CJS模块浏览器直接跑不了写了一个AMD模块Node.js又加载不了。于是又有人搞了UMDUniversal Module Definition本质上是“一套代码同时兼容多个模块标准”的兼容层写法。UMD的代码往往看起来很绕因为它在开头用一组判断语句去检测当前环境支持哪种模块系统然后选择对应的导出方式。UMD今天还在一些浏览器端的全局脚本里能见到但它的出现本身就说明了一个问题社区分裂的状态是不可持续的语言层面需要一套统一的模块标准。2.2 CommonJS的工作机制理解它才能理解Node.js先看一段最常见的CommonJS代码// math.js const PI 3.14159; function circleArea(r) { return PI * r * r; } module.exports { circleArea, PI, };// app.js const math require(./math.js); console.log(math.circleArea(5)); // 78.53975CommonJS的核心机制有两个module.exports定义导出require导入。很多人会问“module这个变量是哪来的”这是Node.js在包装每个文件时传入的参数。实际上Node.js会把每个文件包裹在一个函数里(function(exports, require, module, __filename, __dirname) { // 你的代码在这里 });所以module、require这些看起来像全局变量的东西其实只是函数参数。这也就解释了为什么你在Node.js的模块文件里可以直接用它们而浏览器里没有。CommonJS另一个广为人知的特性是同步加载。require是同步执行的这意味着在服务器上没问题——文件都在本地磁盘读取再快不过。但如果在浏览器里用CommonJS你就得先把所有模块打包成一个文件或者做同步的XHR请求否则页面加载体验会很差。这就是为什么浏览器端必须发展出异步加载方案而不是直接照搬CommonJS。2.3 ES Modules语言层面的终局应答ES Modules简称ESM是ECMAScript标准的原生模块系统也就是现在我们在浏览器里直接用import、export写的那种。浏览器和Node.js从某个版本开始都原生支持ESM这个统一进程让“一套模块代码到处跑”真正变成了可能。ESM的语法非常简洁对绝大多数场景来说有两种写法就够了// utils.js export function formatDate(date) { return date.toISOString().split(T)[0]; } export const APP_NAME 学习笔记;// main.js import { formatDate, APP_NAME } from ./utils.js; console.log(formatDate(new Date())); console.log(APP_NAME);这里是ESM和CommonJS一个非常重要的语法差异ESM的import和export只能写在模块顶层不能写在条件语句或函数作用域里。初看会觉得很限制自由但这恰恰是ESM的关键设计——它必须能被静态分析。所谓静态分析就是解析器不用运行代码光看结构就能确定这个模块导入了什么、导出了什么。这个能力催生了三个非常有价值的特性确定性模块之间的依赖关系在加载前就完全确定不会出现运行时才知道缺了谁的尴尬。Tree Shaking打包器可以静态分析出哪些导出没被使用然后放心地把它们抹掉减小最终包体积。循环依赖处理因为是先建立“导入导出关系表”再执行代码所以即使两个模块互相引用也能先拿到对方的引用等到真正调用时再取值。用一个不算太严谨但很好理解的比喻CommonJS像是你到食堂打菜师傅现炒现卖你点一个菜师傅才开始做ESM像看菜单点菜菜品都是提前准备好的你下单时后厨已经把所有菜的配料备好了只是等你确认而已。2.4 横向对比表收藏这一张就够了把几种模块规范的核心维度放在一起看会非常清楚模块规范加载方式主要环境导出导入语法是否静态分析现状CommonJS同步Node.js早期module.exports/require否仍大量存在AMD异步浏览器早期define/require否基本被ESM替代UMD视环境切换跨环境兼容条件判断 两种导出否仅存量项目使用ES Modules同步/异步浏览器 Node.jsexport/import是当前标准主流这张表对我自己的学习帮助很大。每次听到一种“新规范”先放到这张表里定位一下属于哪个阶段、解决什么问题就能快速理解它存在的理由。3. ESM核心细节你必须掌握的export和import写法语法这东西看着简单实际写起来容易在细节上翻车。这一节把所有常用的导出导入写法整理一遍同时标出哪些是坑。3.1 export的各种形态第一种是命名导出最常用也是最推荐的方式// 方式一直接导出声明的变量/函数 export const MAX_RETRY 3; export function request(url) { // ... } // 方式二先声明再统一导出 function parseJSON(str) { return JSON.parse(str); } const STATUS_MAP { success: 200, error: 500, }; export { parseJSON, STATUS_MAP };第二种是默认导出每个模块只能有一个// logger.js export default function log(message) { console.log([LOG] ${message}); }默认导出的本质是什么它其实是把导出对象放在一个叫default的特殊键里。所以你在导入时给默认导出的内容起什么名字都行因为它只是键名default的局部别名。第三种是重命名导出适合需要给外部提供一个更清晰的公开API时使用// internal.js const internalName 重命名示例; export { internalName as publicName };我建议一个原则能用命名导出就用命名导出慎用默认导出。原因后面会单独讲这里先记住结论。3.2 import的各种形态导入形态跟导出是配套的。命名导出的导入用花括号默认导出的导入直接写变量名import { parseJSON, STATUS_MAP } from ./api/utils.js; import log from ./logger.js;也可以用as改个名字避免冲突import { parseJSON as parse } from ./api/utils.js; import { STATUS_MAP as AuthStatus } from ./auth/constants.js;还有一个便捷写法一次性导入所有命名导出import * as api from ./api/utils.js; api.parseJSON({a:1});注意这里的用法。import * as api导入的是一个模块命名空间对象里面包含这个模块的所有命名导出。如果你用这种方式导入了一个模块又想调用它的默认导出需要写api.default很容易踩坑。所以我的习惯是需要模块的多个API时才用这种写法否则尽量显式列出来。还有一种特殊写法叫副作用导入只执行模块代码不导入任何东西import ./polyfill.js; import ./styles/global.css;这种写法适合导入只需要执行一次副作用的模块比如补丁、初始化逻辑、样式文件等。3.3 动态import按需加载的正确姿势ESM的静态import永远在模块加载阶段执行不适合做“用户点击某个按钮后才加载某个大模块”这种场景。动态import()语法就是为此存在的// 按需加载一个图表库 async function loadChartModule() { const chartModule await import(./heavy/chart.js); chartModule.render(); } button.addEventListener(click, () { loadChartModule(); });动态import()返回一个Promise可以await。这个写法的好处不仅是“延迟加载”更重要的是配合打包器可以把动态导入的内容单独拆成一个chunk用户访问首屏时不需要下载它真正用到时才开始加载。这是性能优化的利器。有一点要注意动态import()不是ESM的替代品而是补充。静态导入用于声明模块的常规依赖动态导入用于代码分割和按需加载。能静态导入的时候尽量静态导入这样打包器能更好地做分析和优化。3.4 导出默认值到底坑在哪默认导出看起来简单实际使用中有几个问题很常见。第一个问题是命名不一致。团队里每个人导入默认导出时起名都可能不同同一个模块在不同文件里叫法五花八门。这本质上破坏了命名的统一性增加了检索成本。第二个问题是Tree Shaking困难。打包器对命名导出做静态分析很容易因为它知道哪个名字没用到。但默认导出是一个整体对象打包器很难判断你只用到了这个对象的百分之几。实际效果就是默认导出往往导致打包体积变大。第三个问题是重构困难。如果把默认导出改回命名导出所有引用它的地方都得改。如果是网上用别人库的项目这个改动成本会传导到所有使用者身上。所以我的习惯是一个模块可以有多个命名导出尽量不要有默认导出。如果必须用默认导出比如一个模块只有一个核心功能那就在文件里明确注释说明并保持所有导入处命名一致。4. 在Node.js里落地ESM双模块系统的共存之道很多人在Node.js里写ESM的时候会各种不顺畅。有时候require用不了有时候import报错还有时候用TypeScript还得额外配置。这一节把Node.js对ESM的支持机制讲清楚。4.1 package.json里的 type 字段在Node.js中一个文件到底按CommonJS解析还是按ESM解析由扩展名和最近的package.json共同决定。核心规则很简单.js文件默认是CommonJS除非最近的package.json里写了type: module。.mjs文件总是按ESM解析。.cjs文件总是按CommonJS解析。实际操作中项目怎么选我见过三种常见配置方式第一种纯ESM项目直接把package.json写成{ name: my-server, type: module }这样项目里所有.js文件默认都是ESMimport/export直接写非常清爽。第二种纯CommonJS项目保持默认什么都不写或者显式写type: commonjs。存量项目大多是这么干的。第三种混合项目。比如一个项目的大部分代码是ESM但有少数工具脚本依赖CJS生态这时候可以给这些工具脚本用.cjs扩展名或者放在一个内部package.json里声明type: commonjs的子目录中。反过来也一样。我自己踩过一个坑一个子目录里的脚本明明按require写的但项目根目录的type是module导致所有require直接报ReferenceError: require is not defined。排查了很久才意识到根目录的type字段会向下传递。解决办法就是在子目录里加一个package.json声明type: commonjs或者把文件改成.cjs。4.2 在ESM里使用CJS模块Node.js的ESM对CommonJS模块的兼容性做得还不错。最常用的方式是直接用import导入CommonJS模块。import path from node:path; import fs from node:fs; // 或者 import { join, dirname } from node:path;但有个非常容易踩的细节CommonJS的默认导入和命名导入的边界不一样。当你import path from node:path时Node.js会把CommonJS的module.exports整个对象作为默认导出而当你import { join } from node:path时Node.js通过静态分析CJS模块的导出属性也能把join识别出来。不过这个分析不是100%可靠某些复杂的CJS模块比如动态构造exports的库可能无法被正确分析出命名导出。需要兼容性保险的时候可以这样写import path from node:path; const { join } path;先拿到完整的默认导出对象再手动解构完全绕开静态分析的不确定性。4.3 在CommonJS里使用ESM模块反过来CommonJS里想要require一个ESM模块真正的答案是不要用require加载ESM要用动态import()。// 这是无法工作的 // const mod require(./esm-module.mjs); // 这是可行的 async function loadESM() { const mod await import(./esm-module.mjs); console.log(mod.someExport); }为什么require加载不了ESM核心原因是同步和异步的矛盾。require是同步的而ESM模块图Module Graph的解析是异步的。在Node.js中require无法等待一个异步过程返回所以加载ESM模块就会直接失败。Node.js官方也在推进require(esm)的支持但社区整体认为用动态import()是当前最稳妥的方式。4.4 Node.js环境中的路径解析细节还有一个经常被忽略但很实用的点ESM里的相对路径导入必须写完整文件名包括扩展名。CommonJS允许你require(./utils)Node.js会自动补上.js但ESM不允许这样必须写import { x } from ./utils.js。这是一个非常疼的兼容点尤其是从CommonJS项目迁到ESM项目的时候批量改代码改得人想吐。但现在主流编辑器我用的这个还好都有自动修复或者用codemod工具做批量替换。如果是从TypeScript切换到Node原生ESM还要注意不能省略.js扩展名即使源文件是.ts编译出来的运行时导入语句里也要带.js因为这个路径是给运行时用的不是给编译器用的。5. 浏览器端的ESM从typemodule到构建工具的变迁浏览器原生支持ESM是这个模块标准真正走向统一的关键。但原生ESM在浏览器里的表现和打包产品里不太一样。这一节说说浏览器原生ESM能做什么、不能做什么以及构建工具在其中扮演的角色。5.1 原生ESM的用法在浏览器里用ESM不需要任何打包器只要你写出模块文件然后用script typemodule引用即可!-- index.html -- script typemodule import { renderCard } from ./card.js; renderCard(document.getElementById(app)); /script// card.js export function renderCard(container) { container.innerHTML div classcard卡片内容/div; }浏览器原生ESM的特点在于模块文件按需加载import语句指向的相对路径会直接触发网络请求。模块默认是严格模式。模块是单例的同一个URL的模块只会执行一次。模块的作用域是独立的不自动挂到全局对象上。在实际项目中直接使用原生ESM的体验说实话小demo非常舒服因为不用配置任何构建工具浏览器直接跑。但一旦项目规模变大问题就来了每个模块文件都是一个独立的HTTP请求。如果你写了上百个模块用户首次加载就要发上百个请求这在移动端网络环境下的性能是灾难级别的。所以在生产环境里我们几乎不会把上千个原生ESM文件直接发布上线而是用构建工具把它们打包合并成少量chunk。5.2 构建工具为什么会存在构建工具不是“为了复杂而复杂”它是为了解决原生ESM在生产环境中的性能问题而诞生的。以现在主流的打包器为例它的核心工作有三件第一件依赖分析。从入口文件开始构建工具会读取每个import语句递归解析出整个模块依赖图明确每个模块之间的引用关系。第二件产物生成。把所有模块打包成更少的文件。它可以把多个模块合并进一个chunk也可以利用动态import()把某些部分拆成独立chunk但凡是拆开的它都会自动维护加载顺序。第三件代码转换。这里包含了兼容性转译、压缩、资源内联等。很多东西在这个阶段被处理了比如新版语法被转译成旧版语法CSS和图片资源被处理成可导入的模块等。理解了这个过程就会明白为什么有些项目本地开发用了Vite运行起来那么快是因为它在开发阶段没有打包而是直接用浏览器原生ESM跑源码只在构建上线时才打包压缩。这种“开发用原生ESM、发布用打包产物”的做法已经是现在前端工程化的主流模式。5.3 code splitting拆包的实际意义拆包code splitting带来的最直观收益就是把首屏需要的代码和后续才需要的代码分开让用户首次打开页面时能少下载几十甚至几百KB。生产和开发看代码时你可能经常看到像下面这样利用动态import()做按需加载的例子async function openEditor() { // 这个模块只在打开编辑器时加载 const { default: Editor } await import(./editor.js); editor new Editor(document.getElementById(editor-container)); }打包器看到一个动态import()时会把这个模块单独打成一个chunk。用户首次进入页面只加载主包和主包依赖的同步模块点击“打开编辑器”之后才去下载编辑器这个chunk。代码分割还能做得更细比如把第三方库和业务代码分开、按路由拆分页面组件等。这里不展开讲框架相关的路由懒加载只说一个原则凡是“特定操作才用到的代码”都应该拆出去这是降低首屏加载成本最直接的手段。6. 模块系统里的循环依赖原理、现象和解决套路循环依赖是模块开发中最让人头疼的问题之一。两个模块互相引用看似写得没问题但执行起来就是各种undefined。这一节说说原理再说套路。6.1 CommonJS下的循环依赖现象先看一个最经典的例子// a.js const b require(./b.js); function callB() { b.say(); } module.exports { callB }; // b.js const a require(./a.js); function say() { console.log(B: hello); } module.exports { say };// main.js const a require(./a.js); a.callB();这个例子在CommonJS下大概率能跑通因为b.js虽然用了const a require(./a.js)但真正调用a的是a.callB()它是在运行时才执行的那时a.js已经完成了导出。但如果循环依赖触及了“模块加载阶段就互相取用”的场景就很尴尬了// a.js const b require(./b.js); const name A; module.exports { name: 我是${name}我的伙伴是${b.partner} }; // b.js const a require(./a.js); const name B; module.exports { name: 我是${name}我的伙伴是${a.name} };加载a.js的时候它先require(./b.js)于是开始加载b.js而b.js里又require(./a.js)这时Node.js发现a.js已经在加载中了就直接返回它当前的module.exports——注意此时a.js的module.exports还没被赋值完只有默认的空对象{}。所以b.js拿到的a.name是undefined。CommonJS的哲学是模块加载是“执行到哪算哪”返回的就是那一刻的中间状态后续变更不会再更新已经返回的引用。6.2 ESM下为什么会“好一点”又“有条件”ESM的设计对循环依赖的容错性好一些因为模块图是先解析后执行的。加载模块时先把“导出名字”全部注册好再执行各模块的函数体。所以即使两个模块循环引用互相拿到的也是对方的导出绑定而不是一个空对象。比如上面那个a.name和b.partner的例子如果用ESM写// a.mjs import { partner } from ./b.mjs; export const name A; export const desc 我是${name}我的伙伴是${partner}; // b.mjs import { name } from ./a.mjs; const partner B; export { partner };这个例子里模块的执行顺序和依赖关系纠葛会非常关键。如果b.mjs先执行它import { name } from ./a.mjs时a.mjs还没有执行完那么name其实是undefined——虽然语法上不报错但值还是没就绪。所以ESM对循环依赖的处理本质上是“提供了一种约束下的可用性”只要你不在模块顶层立刻使用导入的值而是在函数体内使用循环依赖就完全没问题。ESM的导出是动态绑定的函数内取到的值是模块执行后的最终值。// a.mjs import { partner } from ./b.mjs; export const name A; export function getDesc() { return 我是${name}我的伙伴是${partner}; } // b.mjs import { name } from ./a.mjs; export const partner B;调用getDesc()时partner已经初始化完成了所以能正确输出。这是ESM处理循环依赖的关键前提导出绑定是活的但读取时机要在所有模块初始化完成之后。6.3 处理循环依赖的经验套路循环依赖本身是代码结构不良的信号。遇到它我的优先级是这样先从结构上消除。把公共依赖抽成第三模块。比如A依赖B、B依赖A通常意味着有个C同时被A和B需要把C抽出来循环就没了。这是最根本的解法。调整引用时机。如果只是某个函数的执行期才需要对方的某个导出就把import放在函数体内ESM的动态import()或者把顶层引用挪到函数内部再访问避免模块初始化阶段互相取值。用事件/回调解耦。如果两个模块需要互相通知状态引入一个事件总线或回调注册机制让它们通过中间层通信而不是直接引用对方。实在不行再动态导入。在ESM里用动态import()去按需获取对方模块虽然绕但能跑。我见过不少项目把循环依赖留到生产环境靠运气跑短期内不出问题但某次改动就可能引爆。这属于技术债建议发现后尽快处理掉。7. 模块化工程实践从目录到控制的全局设计学习模块化最终要落到真实项目里。这一节从目录设计、模块粒度、公共模块控制、以及构建配置验证几个角度聊一聊工程层面的落地心得。7.1 目录结构如何支持模块化模块化不只是文件层面的拆分目录结构的设计直接决定模块边界的清晰程度。一个比较经典的参考结构是src/ ├── api/ # 接口请求模块 │ ├── user.js │ └── product.js ├── components/ # 通用UI组件模块 ├── utils/ # 纯函数工具模块 ├── hooks/ # 逻辑复用模块可选 ├── pages/ # 页面级模块 │ ├── home/ │ └── detail/ └── index.js # 入口模块这套结构的核心逻辑是每一层有清晰的职责模块之间的依赖方向尽量单向。比如pages依赖components、utils、api但utils不应该反过来依赖pages。如果工具函数里冒出一个import from ../pages/home基本说明结构乱了该重构了。依赖方向的控制比依赖关系的数量更值得关注。模块化不是消灭依赖而是让依赖可控、可预测。7.2 模块的粒度多大才合适这是一个很实际的问题。模块拆得太大退化成“把大象放进冰箱”的伪模块化拆得太小每个文件只有几行反而让项目文件数量膨胀维护起来更累。我自己的判断标准很简单一个模块应该有一个明确的主题你能用一句话说清它是干什么的。如果一句话说不清就该拆。一个模块的核心函数/组件数量建议在5个以内超过这个数就要考虑是否拆分成多个。如果两个文件经常同时被修改可以考虑合并成一个模块。如果同一个文件里几块代码相互之间没有直接引用各自独立发展那就拆开。这里没有绝对标准但可以通过“改需求时改动的文件数量”来反推正常情况下一个需求应该只改少量模块的少量文件。如果你的每次改动都要跨十几个文件说明模块边界划错了要么拆得过碎要么功能职责有交叉。7.3 哪些模块应该被抽成公共模块项目里总有一部分代码是会被多处引用的比如请求封装、表单校验、时间格式化、主题配置等。把这些抽成公共模块带来两个直接好处修bug只需改一处所有引用地方同步生效。新老项目都能直接复用避免重复造轮子。但公共模块也有失控的风险。我见过一个项目里的utils.js最后膨胀到两千多行什么都有——日期、数组、对象、字符串、验证、正则……看起来“通用”实际上没人能说明白里面到底有哪些函数。时间长了别人不敢删也不敢改。公共模块的正确做法是按领域拆分日期处理一个文件字符串处理一个文件数组操作一个文件自定义hooks放另外的目录。每个文件保持独立可测试、可读。公共模块的API设计还要注意向后兼容因为被引用的地方多改动风险大最好在改之前先想清楚影响范围。7.4 用构建配置验证你的模块划分是否合理这是我最近实践的很有效的一个方法看打包产物来反推模块设计是否合理。用构建工具生成一份模块依赖报告比如webpack的stats.json、Vite的build --report或者用Rollup的分析插件。分析报告里能看出每个模块被谁引用、体积多大、是否被重复打包。下面这几种现象值得警惕一个入口文件直接依赖了一百多个模块说明边界没控制好应该按功能切片。某个公共模块出现在了很多chunks里如果体积很大考虑提取成独立chunk并做缓存优化。某个模块文件体积异常大比如一个“工具函数”文件打出来200KB可能里面混入了不该出现的大库代码。构建工具不只是打包它也是理解项目结构的一面镜子。建议每个月固定时间生成一份报告看看项目变化趋势比靠记忆维护边界靠谱得多。8. 常见问题与排查技巧实录这一节集中整理我实际开发里经常遇到、以及教别人时经常被问到的模块相关问题。每个问题都附排查思路。8.1 问题一require is not defined/module is not defined这是浏览器里直接跑CommonJS代码时报的经典错但更常见的是Node项目里type: module导致require失效。排查思路先看当前文件所在目录最近的package.json确认type字段是什么。如果type是module但你确实需要CJS写法把文件扩展名改成.cjs或者把文件移到声明type: commonjs的子目录里。如果不想改扩展名就用动态import()代替require。8.2 问题二ERR_MODULE_NOT_FOUND路径写对了还是找不到这个报错基本都出在同一原因ESM导入了不带扩展名的路径。比如// 这样写会报错 import { format } from ./utils; // 必须带上扩展名 import { format } from ./utils.js;在Node.js中ESM不会像CommonJS那样自动补扩展名。如果是TypeScript项目写了allowImportingTsExtensions或用了路径别名也要确认编译产物里路径是否还包含实际扩展名。还有一个隐藏点如果目录里的文件是index.js导入一个目录的方式无效必须写完整的文件路径。8.3 问题三Cannot use import statement outside a module浏览器页面这是浏览器里常见的ESM用法错误。通常原因是script标签没加typemodule。在非模块脚本里写了import语句。排查方式是检查HTML里的script标签确保是script typemodule。如果你是把ESM代码作为脚本直接引入也会报这个错因为普通脚本不是模块脚本不能使用模块语法。8.4 问题四循环依赖导致xxx is undefined但语法上没错前面详细讲过原理。排查思路是先找出互相引用的模块对然后检查是“模块顶层直接取用”还是“函数体内取用”。前者容易踩坑后者相对安全。如果发现顶层取用优先抽公共依赖或把取用逻辑挪进函数体。8.5 问题五打包后运行正常但本地开发某些功能报错这种问题最烦人。常见原因是本地开发和生产构建的模块解析方式不同比如开发时用原生ESM按文件路径加载构建后文件被合并/改名某些路径假设失效。某个模块只在构建时被转译开发时的原始代码没问题。排查优先级看报错栈指向哪个文件——本地报错多指源码文件构建报错指向产物文件。如果指向产物文件可以用source map反查源码位置。更常见的情况是本地直接跑了构建产物导致的报错那就要检查构建配置里的入口和代码分割是否正确。9. 扩展思考模块化思想的延伸模块化并不只属于JavaScript。几乎所有现代编程语言都有模块系统或等价机制比如Python的import、Java的包和import、Go的package、Rust的mod。虽然语法差异巨大但底层思想是一致的把代码按领域切分明确公开接口隐藏内部实现管理依赖关系。这种思想还可以继续往上抽象。在系统架构层面“微服务”本质上就是“进程级别的模块化”在代码架构层面设计模式里的“门面模式”“中介者模式”也在做类似的事隔离依赖、统一入口。理解了模块化的本质之后很多更高层的架构设计会变得好懂很多。举一个我自己上过头的例子以前写函数式工具库总想着一个文件里塞二十个函数因为自己觉得“都是工具函数放一起方便”。后来一位有经验的前辈看了一眼就说一个文件里功能太多等下别人都不知道你这个文件干什么的。后来拆完自己用起来都舒服很多。从那以后我写任何项目都会先停下来问一句这段代码的主题是什么它依赖什么对外提供什么这三个问题基本就是模块化设计的核心心法。还有一点模块化的好坏其实要看“可替换性”。如果一个模块可以被另一个同等接口的模块无缝替换那它的设计就是成功的。比如今天用A封装了请求明天换成B封装调用方代码不需要改动。能做到这一点说明接口设计合理、边界清晰。这也是模块化设计里我认为最值钱、也最容易被忽视的评价标准。10. 从学习到落地给你的模块化进阶清单最后这部分我不写总结了直接给一份可执行的“模块化进阶清单”。如果你正在学习模块化按这个顺序动手会踏实很多。10.1 学习优先级建议先把ESM语法吃透。export、import、默认导出、命名导出、重命名、命名空间导入、动态导入。每一个都写几行代码跑一遍。理解CommonJS的运行机制。至少要知道module.exports和require是Node.js模块包装函数的参数知道它会缓存模块、知道它的同步加载特性。跑通Node.js里的双模块系统。用一个小项目同时包含.mjs、.cjs、type: module等组合观察报错和结果。用浏览器原生ESM写一个无构建的小demo。比如一个简单的卡片列表页面用几个模块文件互相引用感受原生加载。然后用一个打包器构建它。对比原生加载和打包产物在请求数量、体积上的差异理解构建工具存在的意义。最后练习代码分割。把某个功能改成动态导入用构建报告对比前后体积变化。10.2 练手项目举例如果你需要一个练手载体不用去造复杂业务系统做一个小而完整的工具页面就够了一个待办事项应用。你可以把它拆成这些模块models/todo.js待办数据模型和数据操作方法。storage.js本地存储读写模块。render.js渲染列表模块。events.js用户事件绑定模块。index.js入口串联上面所有模块。这个项目规模不大但足够你练习模块划分、命名导出、动态导入、以及理解模块之间的依赖关系。10.3 两个每天都能用的小技巧最后分享两个让我受益最多的小技巧。第一个是**“模块开头先写注释”**。不是写那种满篇废话的注释而是写三行这个模块是干什么的、它依赖哪些模块、对外主要提供什么。遇到两三个月后再回来看代码的情况这三行能帮你省下起码半小时回忆时间。第二个是**“每个文件至少一个单元测试”**。测试不用多覆盖这个模块对外暴露的核心功能就行。模块的隔离性让测试变得非常容易反过来测试也强制你把模块做小做清晰。我自己的体会是一旦你开始写测试模块化会自然地向好的方向演化因为可测性本身就是对模块设计质量的一种评测。模块化这条路说难不难说简单也不简单。语法十几分钟能学会但把边界画好、把依赖理清是需要反复实践才能掌握的。希望这份笔记能帮你在学习路上少踩几个坑。