如果说 HTML 语义化决定了“页面结构是否说得清”,那么模块化决定了“代码结构是否撑得住”。一个几千行的项目,用全局变量也能跑起来;但到了几万行、十几个人协作、需要按需加载和长期维护的时候,模块化就成了分水岭。模块化不是一个语法特性,而是一整套关于“边界”的工程约定——它规定了什么该被暴露、什么该被隐藏、谁可以依赖谁、依赖以何种方式被解析和加载。本篇 30 分钟长文,从模块化要解决的原始问题讲起,沿着 CommonJS、AMD、UMD、ES Modules 的演进脉络,深入到模块解析算法、打包器原理、tree shaking、代码分割、依赖设计原则、包管理与 Monorepo,最后落到样式模块化与一份可执行的检查清单。读完之后,你应该能对任何一个前端项目的模块结构做出判断,并知道该怎么改。
一、模块化到底解决什么问题
在讨论任何语法之前,先回到最初。2005 年前后的前端页面,脚本是这样组织的:
<script src="utils.js"></script>
<script src="ajax.js"></script>
<script src="slider.js"></script>
<script src="main.js"></script>
所有脚本共享同一个全局作用域。于是下面这类问题会不可避免地出现:
var count = 0;
function format() { return count.toString(); }
// slider.js(另一位同事写的)
var count = 10; // 静默覆盖了 utils 的 count
// main.js
format(); // 结果是 "10",而不是 "0"
没有报错,没有警告,只是结果悄悄变了。这类问题在多人协作的项目里非常难排查,因为出错的地方和产生错误的地方相隔很远。
模块化要解决的五件事
最后一条最容易被忽略,但它恰恰是现代前端工程化的根基。只有当依赖关系是静态可分析的,打包器才能知道哪些代码没用上、哪些可以拆包、哪些必须提前加载。这是 ES Modules 相对 CommonJS 最本质的优势,后面会详细展开。
模块化的本质是“边界管理”
一个模块向外暴露的接口,叫做它的公共 API;模块内部不被外部访问的部分,叫做实现细节。模块化的全部工作,就是划清这条线:
这条线划得好不好,直接决定了一个项目在半年后是“改一行就够”还是“改一处崩三处”。
二、模块化演进史:从全局污染到语言标准
JavaScript 在诞生之初并没有模块系统。这门语言被设计用来做表单校验,谁也没想到它会长成今天的样子。模块化的历史,本质上是一段“补课史”。点击下面的每个阶段,看看它解决了什么、又留下了什么问题。
window 上,靠 <script> 顺序保证依赖。命名冲突、加载顺序脆弱、无法复用,是这一时期的三大顽疾。require 同步加载、module.exports 导出。首次让依赖变成显式声明,但同步加载不适合浏览器。define([deps], factory)。支持并行加载与依赖前置,代价是回调嵌套、书写啰嗦。import / export,静态结构、实时绑定、自动严格模式。终于让工具能够读懂依赖图。<script type="module">,开发期不再需要打包。Vite 用原生 ESM 做 dev server,用 Rollup 做生产构建,启动速度提升一个数量级。IIFE 模式:模块化的第一次尝试
在 CommonJS 出现之前,最流行的模块写法是立即执行函数:
var Counter = (function () {
// 私有变量,外部完全访问不到
var _count = 0;
function increment() {
_count += 1;
return _count;
}
// 只暴露公共 API
return {
increment: increment,
getCount: function () { return _count; }
};
})();
Counter.increment(); // 1
Counter.increment(); // 2
console.log(Counter._count); // undefined,拿不到
IIFE 已经具备了模块的两个核心特征:私有作用域和显式导出。但它有两个致命缺陷:
- 依赖靠全局变量传递:
(function ($) { ... })(jQuery)这种写法,本质上还是依赖 jQuery 已经挂在 window 上,加载顺序依然脆弱。 - 无法被工具分析:所有依赖都是运行时通过参数注入的,静态分析工具完全看不出模块之间的真实关系。
AMD 与 CMD:浏览器端的探索
2010 年前后,随着单页应用兴起,前端开始需要真正的模块加载器。AMD(Asynchronous Module Definition)由 RequireJS 推动,核心思想是依赖前置、异步加载:
define(['jquery', './utils'], function ($, utils) {
return {
render: function (data) {
$('#app').html(utils.template(data));
}
};
});
// 使用
require(['./renderer'], function (renderer) {
renderer.render(data);
});
同期国内还流行过 CMD(SeaJS),主张依赖就近、延迟执行,写法更接近 CommonJS。AMD 与 CMD 之争持续了数年,最终随着 ES Modules 的普及而共同退出历史舞台。
它们留下的最大遗产是:“依赖必须显式声明”这个观念,从此成为前端共识。
三、CommonJS:Node.js 的模块系统
CommonJS 是 Node.js 采用的模块规范,也是理解模块化绕不开的一站。它的写法极其简单:
function add(a, b) { return a + b; }
function sub(a, b) { return a - b; }
module.exports = {
add: add,
sub: sub
};
// main.js —— 引入
const math = require('./math');
console.log(math.add(1, 2)); // 3
// 或者只取需要的部分
const { add } = require('./math');
module.exports 与 exports 的区别
这是 CommonJS 最经典的面试题,也是真实项目里最容易写错的地方:
var module = { exports: {} };
var exports = module.exports; // 指向同一个对象
// ✅ 可行:往同一个对象上加属性
exports.foo = 1;
// ❌ 无效:exports 被重新赋值,指向了新对象
exports = { foo: 1 }; // module.exports 没变
// ✅ 可行:直接替换 module.exports
module.exports = { foo: 1 };
一句话记住:exports 只是 module.exports 的初始引用,最终被 require 返回的永远是 module.exports。
运行时加载与缓存机制
CommonJS 的 require 是同步执行的:它会立即执行目标模块的代码,拿到 module.exports 后返回。同一个模块被多次 require,只有第一次会真正执行,之后都直接返回缓存的导出对象。
console.log('模块被加载了');
module.exports = { value: 0 };
// a.js
require('./counter'); // 打印"模块被加载了"
// b.js
require('./counter'); // 什么都不打印,用缓存
缓存的 key 是模块文件的绝对路径。这意味着:同一个文件通过不同路径引用(例如 ./utils 和 ../src/utils),只要解析后是同一个绝对路径,就共享缓存;但如果因为符号链接、大小写差异导致路径不同,就会重复加载。
值拷贝:CommonJS 的一个关键特性
这是 CommonJS 与 ES Modules 最重要的区别之一。CommonJS 导出的是值的快照:
let count = 0;
function increment() { count++; }
module.exports = { count: count, increment: increment };
// main.js
const counter = require('./counter');
counter.increment();
console.log(counter.count); // 0!不是 1
因为导出时 count 的值被复制到了 exports 对象上,之后模块内部再修改 count,也不会影响已经复制出去的那份。但注意:如果导出的是对象,那么导出的就是对象的引用,内部修改对象属性是能被外部看到的。
循环依赖:CommonJS 的表现
当 A 依赖 B、B 又依赖 A 时,CommonJS 会返回不完整的导出对象:
exports.done = false;
const b = require('./b');
exports.done = true;
// b.js
const a = require('./a');
console.log(a.done); // false —— a 还没执行完
// main.js
require('./a');
Node 遇到 require('./b') 时,发现 b 正在被加载(缓存里存的是 a 的未完成 exports),于是直接把这份半成品返回给 b。等 b 执行完,a 才继续往下走。
循环依赖本身不是错误,但它会让代码的正确性依赖于执行顺序,非常脆弱。正确的做法是消除循环,而不是调试它。
四、ES Modules:语言层面的模块化
2015 年,ES6 终于把模块系统写进了语言标准。ES Modules 与 CommonJS 不是“写法不同”,而是设计哲学根本不同:CommonJS 是运行时加载,ES Modules 是编译期确定。
export const PI = 3.14159;
export function add(a, b) { return a + b; }
export class Vector { /* ... */ }
// 2. 统一导出
const name = '张玥';
const age = 28;
export { name, age };
// 3. 重命名导出
export { name as authorName };
// 4. 默认导出(一个模块只能有一个)
export default function init() { /* ... */ }
// 5. 再导出:统一出口的常用技巧
export { add } from './math.js';
export * from './utils.js';
export * as ns from './helpers.js';
静态结构:ES Modules 最核心的设计
ES Modules 的 import 和 export 语句必须出现在模块顶层,不能写在 if 里,也不能拼接字符串。这个限制看起来是负担,实则是它一切优势的来源:
- 编译期就能构建依赖图:打包器不需要执行代码,就能扫描出模块之间的引用关系。
- 支持 tree shaking:知道哪些导出被用到、哪些没有,才能安全地删除无用代码。
- 支持循环依赖的正确处理:通过实时绑定而非值拷贝,避免 CommonJS 那种“拿到半成品”的问题。
- 支持提前加载:浏览器可以在解析阶段就并行请求依赖模块,而不必等执行到
require那一行。
一句话总结:CommonJS 的依赖关系是“运行时才知道”,ES Modules 的依赖关系是“读代码就知道”。这个差别,决定了现代前端构建工具能做到什么程度。
实时绑定:导出的是“引用”,不是“值”
还记得前面 CommonJS 那个 count 的例子吗?换成 ES Modules,结果完全不同:
export let count = 0;
export function increment() { count++; }
// main.js
import { count, increment } from './counter.js';
increment();
console.log(count); // 1 —— 实时绑定生效了
ES Modules 的导入是指向原模块变量的只读引用。原变量变了,导入方看到的值也跟着变。但导入方不能直接给它赋值:
count = 10; // ❌ TypeError: Assignment to constant variable
这种设计既保证了模块内部状态不被外部随意篡改,又让“状态共享”变得自然。它是 ES Modules 比 CommonJS 更适合做状态管理的基础。
ES Modules 的循环依赖
ES Modules 通过提升(hoisting)处理循环依赖:所有 import 语句会在模块代码执行前先完成“连接”,导出绑定在模块开始执行前就已经建立。但因为实时绑定的存在,如果你在依赖模块执行完之前就访问它,会触发暂时性死区(TDZ):
import { b } from './b.js';
export const a = 'A';
console.log(b);
// b.js
import { a } from './a.js';
export const b = 'B';
console.log(a); // ❌ ReferenceError: Cannot access 'a' before initialization
结论依然不变:循环依赖是一盏红灯,看到它就应该去重构,而不是去研究怎么让它“能跑”。
顶层 await 与 import.meta
ES2022 为模块带来了两个实用特性。顶层 await 允许在模块顶层直接等待异步操作,不再需要包一层 async 函数:
const config = await fetch('/api/config').then(r => r.json());
export { config };
// 注意:任何 import 这个模块的模块,都会等待它完成
// 所以顶层 await 应该谨慎使用,避免拖慢整个应用启动
import.meta 则提供了模块自身的元信息,最常用的是 import.meta.url(当前模块的 URL)和 import.meta.env(Vite 注入的环境变量):
// "https://example.com/src/utils.js"
// 在 CommonJS 里对应 __filename 和 __dirname
// 在 ESM 中需要这样推导:
import { fileURLToPath } from 'node:url';
import { dirname } from 'node:path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
五、模块解析算法与打包器原理
写下一行 import x from 'foo',工具到底去哪里找这个 foo?这背后是一套明确但容易被忽略的规则。
三类模块标识符
./utils、../lib/api —— 相对于当前文件解析
/src/main.js、https://cdn.com/a.js —— 直接定位
react、lodash/debounce —— 到 node_modules 里找
裸模块的解析规则由 Node.js 定义,浏览器端的打包器基本沿用了它:从当前目录开始,逐级向上查找 node_modules,找到包目录后再读取 package.json 决定入口文件。
package.json 里的入口字段
| 字段 | 用途 | 使用场景 |
|---|---|---|
main |
CommonJS 入口 | Node.js 与老打包器的默认入口 |
module |
ESM 入口 | Rollup / Webpack 优先使用,便于 tree shaking |
browser |
浏览器端替换 | 替换 Node 专有模块,如 fs → 空实现 |
exports |
现代条件导出 | 精确控制子路径导出,替代 main / module |
types |
类型声明入口 | TypeScript 查找 .d.ts 文件 |
sideEffects |
副作用标记 | 告诉打包器哪些文件不能安全删除 |
现代库通常这样写 exports:
"name": "my-lib",
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.mjs",
"require": "./dist/index.cjs"
},
"./utils": "./dist/utils.mjs"
},
"sideEffects": false
}
注意 exports 字段的封闭性:一旦声明了 exports,包内未被列出的路径就无法被外部导入。这是一把双刃剑——它保护了内部结构,但也可能破坏使用者的深层导入。
tree shaking 到底是怎么做的
tree shaking 这个词来自 Rollup,核心思想是:如果某个导出从未被任何模块导入,就可以安全地删掉它。但“安全”二字有前提。
第一步是静态分析:打包器从入口出发,沿着 import 语句递归扫描,构建出完整的模块依赖图,并记录每个模块的哪些导出被引用了。
第二步是副作用判断:删掉一个模块容易,但如果这个模块在被导入时有副作用(例如注册全局事件、修改原型、注入样式),删掉就会出 bug。于是打包器需要知道“这个模块有没有副作用”。
export function add(a, b) { return a + b; }
export function sub(a, b) { return a - b; }
// main.js —— 只用了 add,sub 会被删掉
import { add } from './utils.js';
console.log(add(1, 2));
// side-effect.js —— 有副作用,删掉会出问题
window.__APP_VERSION__ = '1.0.0';
document.body.classList.add('ready');
export function noop() {}
在 package.json 里写 "sideEffects": false,就是告诉打包器“这个包里的所有文件都没有副作用,可以放心删”。如果只有部分文件有副作用,可以写成数组:
"sideEffects": [
"./src/polyfill.js",
"./src/styles/**/*.css"
]
}
最后一步是压缩器的二次清理:Terser / esbuild 会在打包后进一步删除未被引用的变量、函数和分支。两次处理叠加,才能真正把无用代码清干净。
为什么 CommonJS 很难 tree shake
因为 require 可以出现在任何位置,参数可以是变量,导出对象可以在运行时被动态修改:
const mod = require(name); // 打包器:我哪知道 name 是什么
module.exports[dynamicKey] = value; // 导出内容运行时才知道
打包器无法在编译期确定依赖图,只能把整个模块都保留下来。这就是为什么库作者要同时提供 ESM 和 CJS 两份产物,并在 module 字段里指向 ESM 版本。
打包器在做什么
把上面这些串起来,一个打包器的完整流程大致是:
理解这个流程的价值在于:当构建产物出问题时,你知道该去哪个环节找原因。比如“某个模块没被 tree shake 掉”,问题可能出在第 ③ 步(loader 转换后引入了副作用)或第 ⑤ 步(sideEffects 配置不对)。
六、代码分割与按需加载
把所有代码打成一个 bundle.js,是最简单也最糟糕的做法。用户打开首页,却下载了整个后台管理系统的代码——首屏时间被无谓地拉长。代码分割要解决的就是这件事。
三种分割方式
import() 返回 Promise,打包器自动为该模块生成独立 chunk
splitChunks 把多个入口共用的依赖提到独立 chunk,避免重复下载
路由级懒加载:收益最大的一刀
单页应用最有效的优化,通常是按路由拆分:
const routes = [
{
path: '/',
component: () => import('./views/Home.vue')
},
{
path: '/dashboard',
component: () => import('./views/Dashboard.vue')
},
{
path: '/settings',
component: () => import('./views/Settings.vue')
}
];
这样,首页只需要下载首页的代码。用户点击“设置”时,才去请求 Settings 那个 chunk。
体积对比:一个真实的例子
首屏体积从 1.2 MB 降到 180 KB,用户不必为暂时用不到的功能付出下载代价。这就是代码分割最直观的收益。
prefetch 与 preload:让加载“提前发生”
分割之后的新问题是:用户点击“设置”时,需要等待 chunk 下载,中间出现空白。解决办法是预测用户行为,提前把可能用到的 chunk 拉下来。
import(
/* webpackPrefetch: true */ // 空闲时下载,优先级低
'./Settings.vue'
);
import(
/* webpackPreload: true */ // 与父 chunk 并行下载,优先级高
'./CriticalComponent.vue'
);
两者的区别很关键:
prefetch:告诉浏览器“这个资源之后可能会用到”,在空闲时以低优先级下载。用错了会浪费带宽,因为它可能下载用户永远不会访问的页面。preload:告诉浏览器“这个资源当前页面马上就要用”,与主资源并行下载。用错了会抢占关键资源带宽,反而拖慢首屏。
经验法则:首屏必需但发现较晚的资源用 preload,下一个可能到达的路由用 prefetch,其余一律不加。
分割的粒度:不是越细越好
一个常见的误区是把每个组件都拆成独立 chunk。结果是一个页面要发几十个请求,HTTP 开销和调度成本反而拖慢了加载。
分包策略与缓存命中
好的分包策略不只是“切小”,还要让缓存最大化。核心思路是把变化频率不同的代码分开:
optimization: {
splitChunks: {
chunks: 'all',
cacheGroups: {
// 第三方库:很少变,单独打包,长期缓存
vendor: {
test: /[\\/]node_modules[\\/]/,
name: 'vendors',
priority: 10
},
// 业务公共模块:偶尔变
common: {
minChunks: 2,
name: 'common',
priority: 5
}
}
}
}
配合文件名里的 [contenthash],内容不变则文件名不变,浏览器缓存就能持续命中。这样发布新版本时,用户只需要重新下载变动的那一小部分。
七、模块设计原则与依赖管理
语法和工具只是手段,真正决定项目长期健康度的,是模块如何被划分、依赖如何流动。
原则一:单一职责
一个模块应该只有一个被修改的理由。如果一个模块因为“接口变了”要改,又因为“样式调整”要改,还因为“数据库字段变了”要改,那它承担了太多职责。
user.js 里同时包含:请求用户接口、格式化用户数据、渲染用户卡片、处理表单校验。
api/user.js(请求)、models/user.js(数据)、components/UserCard.js(渲染)、validators/user.js(校验)。
原则二:高内聚、低耦合
高内聚指模块内部的元素彼此紧密相关,放在一起才有意义;低耦合指模块之间尽可能少地互相了解。
衡量耦合度的一个实用指标是:如果我修改了模块 A 的内部实现,需要同步修改多少个其他模块?答案越接近 0,设计越好。
原则三:依赖必须显式
模块要用到什么,就应该在顶部明确写出来。依赖全局变量、依赖加载顺序、依赖“某个地方已经初始化过了”,都是隐性依赖,它们让代码变得不可预测。
export function getUser(id) {
return window.apiClient.get(`/users/${id}`);
}
// ✅ 显式依赖:调用方必须传入
export function createUserService(client) {
return {
getUser: (id) => client.get(`/users/${id}`)
};
}
显式依赖让模块可测试——你可以在测试里传入一个假的 client,而不必去 mock 全局对象。
原则四:依赖方向必须单向
模块之间的依赖应该形成一个有向无环图(DAG)。一旦出现环,就意味着两个模块互相纠缠,无法独立理解、独立测试、独立替换。
一个健康的前端项目依赖分层大致如下:
规则很简单:上层可以依赖下层,下层绝对不能依赖上层。工具函数不应该知道任何业务概念;API 服务层不应该引用任何 UI 组件。
原则五:循环依赖必须消除
循环依赖往往不是因为“设计需要”,而是因为“东西放错了地方”。常见的三种解法:
- 提取公共部分:A 和 B 互相依赖,往往是因为它们都需要 C。把 C 抽成独立模块,环就断了。
- 依赖注入:把 B 需要的 A 的能力,通过参数传进去,而不是让 B 直接 import A。
- 引入事件机制:A 触发事件,B 监听事件,双方都不需要知道对方的存在。
// a.js
import { b } from './b';
export const a = () => b();
// ✅ 解法:引入事件总线,切断直接依赖
// bus.js
export const bus = new EventTarget();
// a.js
import { bus } from './bus';
export function a() {
bus.dispatchEvent(new Event('need-b'));
}
// b.js
import { bus } from './bus';
bus.addEventListener('need-b', () => { /* ... */ });
原则六:管理副作用
没有副作用的模块最好 tree shake、最好测试、最好复用。副作用应该被集中管理,而不是散落在各个模块的顶层。
// analytics.jswindow.dataLayer = window.dataLayer || [];window.dataLayer.push({ event: 'pageview' });只要有人 import 它,就会上报一次,完全不受控。
// analytics.jsexport function track(event) { ... }// main.jsimport { track } from './analytics';track('pageview');
公共 API 的设计
一个模块对外暴露什么,决定了别人怎么用它。好的公共 API 有几个共同点:
- 少而精:暴露的接口越少,未来能改动内部实现的空间越大。每多一个导出,就多一份兼容负担。
- 语义清晰:
createUser()比handleData()好,isValidEmail()比check()好。 - 参数对象化:参数超过三个时,改用对象。这样未来加参数不会破坏调用方。
- 稳定的返回结构:返回对象比返回数组更易扩展;返回 Promise 比回调更易组合。
- 错误可预期:抛出什么错误、返回什么错误码,都要写清楚并有文档。
function createUser(name, age, email, role, active, avatar) { /* ... */ }
// ✅ 对象参数,未来加字段不影响调用方
function createUser({ name, age, email, role = 'user', active = true, avatar }) {
/* ... */
}
用统一出口管理公共 API
大型项目常用一个 index.js 作为模块的公共出口(barrel file),把内部实现细节挡在外面:
export { Button } from './Button';
export { Input } from './Input';
export { Modal } from './Modal';
// 外部只认这个出口
import { Button, Input } from '@/components';
// 内部文件怎么重组、改名、拆分,外部都不用改
但要注意一个陷阱:统一出口会把所有子模块都拉进依赖图。如果 barrel 文件导出了 50 个组件,而你只用了一个,打包器仍然需要解析全部 50 个文件(虽然最终可能 tree shake 掉大部分)。在大型项目中,这会显著拖慢构建速度。折中方案是:对外提供 barrel 出口,但对内部高频引用使用直接路径。
八、包管理与 Monorepo
当项目从“一个仓库一个包”变成“一个仓库多个包”,包管理就从“装依赖”升级为“治理依赖”。
语义化版本:^ 与 ~ 的区别
| 写法 | 允许的范围 | 说明 |
|---|---|---|
1.2.3 |
精确匹配 | 锁定版本,最保守 |
~1.2.3 |
1.2.x | 允许补丁更新,不允许次版本更新 |
^1.2.3 |
1.x.x | 允许次版本与补丁更新,不允许主版本更新 |
* / latest |
任意版本 | 极度危险,几乎不应出现在生产项目中 |
语义化版本的约定是:主版本号变更表示有破坏性改动。但现实中,这条约定被违反的频率相当高。所以真正可靠的保障不是版本号,而是 lockfile。
lockfile:可复现构建的基石
package-lock.json(npm)、yarn.lock、pnpm-lock.yaml 记录了整棵依赖树的精确版本与完整性哈希。它保证:
- 今天在你机器上装出来的依赖,和三个月后在 CI 上装出来的完全一致。
- 即使某个间接依赖发布了新版本,也不会被自动升级进来。
- 依赖包内容被篡改时,哈希校验会失败。
lockfile 必须提交到版本库。把它加进 .gitignore 是一个常见但严重的错误。
npm、yarn、pnpm 的差异
幽灵依赖:一个隐蔽的坑
幽灵依赖(Phantom Dependency)指的是:你的代码 import 了一个没有写在 package.json 里的包,只是因为它是某个依赖的依赖,被扁平化安装到了顶层 node_modules。
{ "dependencies": { "A": "^1.0.0" } }
// 但 A 依赖了 lodash,于是 lodash 被装到了顶层
// 你的代码可以直接这样写,而且能跑
import _ from 'lodash'; // ⚠️ 幽灵依赖
// 直到某天 A 升级,不再依赖 lodash —— 构建突然失败
pnpm 通过严格的符号链接结构从根源上避免了这个问题:node_modules 下只有你显式声明的包,未声明的包根本无法被解析。这既是优点(更安全),也是迁移成本(老项目可能暴露出大量隐藏的幽灵依赖)。
Monorepo:一个仓库,多个包
当项目拆成多个包(组件库、工具库、主应用、文档站),有两种组织方式:
- Multi-repo:每个包一个仓库。独立发布、独立权限,但跨包修改需要发版、升版本、装依赖,迭代成本高。
- Monorepo:所有包放一个仓库。跨包修改一次提交完成,依赖关系显式可见,但需要工具支撑。
现代 Monorepo 的典型结构:
├── package.json # 根:声明 workspaces
├── pnpm-workspace.yaml
├── packages/
│ ├── ui/ # 组件库
│ │ └── package.json
│ ├── utils/ # 工具库
│ │ └── package.json
│ └── config/ # 共享配置
└── apps/
├── web/ # 主应用
└── docs/ # 文档站
根 package.json 里声明工作区:
"name": "my-project",
"private": true,
"workspaces": ["packages/*", "apps/*"]
}
这样 apps/web 就可以直接依赖 packages/ui,本地修改即时生效,不需要发版。跨包引用通常写成 "@my/ui": "workspace:*",由包管理器在本地建立链接。
Monorepo 的代价
Monorepo 不是银弹,它引入了新的复杂度:
- 构建编排:改了
utils,哪些包需要重新构建?需要工具(Turborepo、Nx)计算依赖图并做增量构建。 - 版本与发布:多个包如何协同发版?需要 Changesets 这类工具统一管理。
- CI 成本:仓库变大后,全量 CI 会变得很慢,必须做“只构建受影响的包”。
- 权限控制:所有代码在一个仓库,细粒度权限更难做。
一个务实的判断标准:如果多个包之间存在频繁的跨包修改,Monorepo 收益明显;如果各包基本独立演进,Multi-repo 更简单。不要因为“大厂都在用”就盲目上 Monorepo。
九、样式与资源的模块化
JavaScript 有了模块系统,CSS 却一直活在全局作用域里。一个 .title 类名,可能在整个项目的几十个地方被定义和覆盖,最终演变成没人敢删的“祖传样式”。
全局 CSS 的三个老问题
- 命名冲突:两个组件都定义了
.card,后加载的覆盖先加载的。 - 无法确定影响范围:删掉一段 CSS,你无法确定它会不会影响别的页面。
- 无法表达依赖:组件用了哪些样式、样式依赖哪些变量,全靠人工维护。
方案一:BEM 命名约定
BEM(Block Element Modifier)通过命名约定来模拟命名空间:
.card { }
/* Element:双下划线 */
.card__title { }
.card__body { }
/* Modifier:双连字符 */
.card--featured { }
.card__title--large { }
/* HTML */
<div class="card card--featured">
<h3 class="card__title">标题</h3>
</div>
BEM 的优点是零工具依赖、可读性强。缺点是类名冗长,且完全依赖开发者自觉——没有工具强制你遵守。
方案二:CSS Modules
CSS Modules 在构建期把类名重写成唯一的哈希值,从根本上杜绝冲突:
.root {
padding: 8px 16px;
border-radius: 6px;
}
.primary {
background: #2563eb;
color: white;
}
// Button.jsx
import styles from './Button.module.css';
export function Button({ primary, children }) {
return (
<button className={`${styles.root} ${primary ? styles.primary : ''}`}>
{children}
</button>
);
}
/* 编译后:.Button_root__a3f2d { ... } */
CSS Modules 保留了“写 CSS”的手感,同时获得了作用域隔离。配合 composes 还能实现样式复用。
方案三:CSS-in-JS
把样式写进 JavaScript,让样式真正成为组件的一部分:
const Button = styled.button`
padding: 8px 16px;
border-radius: 6px;
background: ${props => props.$primary ? '#2563eb' : '#e2e8f0'};
color: ${props => props.$primary ? 'white' : '#0f172a'};
`;
// 使用时样式随组件走,天然隔离
<Button $primary>提交</Button>
CSS-in-JS 的优势是动态样式能力极强、样式与组件强绑定。代价是运行时开销(虽然现在已经很小)、构建配置更复杂、以及团队需要适应新的写法。
方案四:原子化 CSS
Tailwind 这类原子化方案走的是另一条路:不写自定义类名,而是用大量细粒度的工具类拼装样式。
提交
</button>
它彻底消除了“命名”这件事,也就不存在命名冲突;产物只包含实际用到的类,体积可控。缺点是 HTML 会变得很长,且需要团队接受一种新的心智模型。
设计令牌:跨方案的公共基础
无论选哪种样式方案,有一件事都应该做:把颜色、间距、圆角、字号等设计决策抽成变量,让它们成为唯一的真实来源。
:root {
/* 颜色 */
--color-primary: #2563eb;
--color-success: #10b981;
--color-danger: #ef4444;
/* 间距 */
--space-xs: 4px;
--space-sm: 8px;
--space-md: 16px;
--space-lg: 24px;
/* 圆角与阴影 */
--radius-sm: 4px;
--radius-md: 8px;
--shadow-sm: 0 1px 3px rgba(0,0,0,0.06);
}
有了令牌,换主题就只需要覆盖变量,而不必去改每一个组件。这也是设计系统(Design System)能落地的前提。
四种方案的取舍
| 方案 | 隔离方式 | 适合场景 | 主要代价 |
|---|---|---|---|
| BEM | 命名约定 | 中小项目、服务端渲染、老项目改造 | 类名冗长,靠自觉 |
| CSS Modules | 构建期哈希 | 组件化项目、需要保留 CSS 写法 | 动态样式能力弱 |
| CSS-in-JS | 运行时/编译期 | 强动态样式、组件库、主题系统 | 心智负担与构建复杂度 |
| 原子化 CSS | 无类名 | 快速迭代、设计系统统一 | HTML 冗长,学习曲线 |
没有绝对最优的方案。真正重要的是:团队内部保持统一,并且把设计决策收敛到令牌层。
十、十个最常见的模块化误区
.js 放一起、所有 .css 放一起
utils.js 塞进几百个毫无关联的函数,成为新的全局垃圾桶
export default 导出所有东西,导致导入方可以随意改名,重构困难
lib/src/internal/x),依赖了未承诺的私有路径
关于 export default 的争议
export default 用起来很方便,但它在工程上有三个明显缺点:
- 导入方可随意命名:
import A from './x'和import B from './x'都合法,全项目搜索不到统一的名字,重构和排查都变难。 - 不利于 tree shaking:默认导出的对象往往是一个整体,打包器很难只保留其中一部分。
- 不利于 IDE 自动导入:编辑器很难为默认导出推断出合理的导入名,补全体验差。
一个被广泛采纳的折中方案是:对外只提供命名导出,只在“一个模块确实只导出一个东西”时使用 default,并且导出后立刻具名化。
export default { add, sub, mul };
// ✅ 命名导出,名字固定,工具友好
export { add, sub, mul };
// ✅ 单一职责模块可以 default,但导入时保持同名
// Button.jsx
export default function Button() { /* ... */ }
关于“工具函数”垃圾桶
几乎每个项目都有一个 utils.js,半年后它会有 800 行,包含日期格式化、字符串处理、DOM 操作、请求封装、数组去重……任何人想加东西都往里塞,因为“反正它在 utils 里”。
解法不是禁止 utils,而是给工具函数分类并设定边界:
// ✅ 按领域拆分
src/utils/
├── date.js // 日期相关
├── string.js // 字符串相关
├── array.js // 数组相关
├── dom.js // DOM 相关
└── index.js // 统一出口
更严格的团队还会加一条规则:业务逻辑不允许放进 utils。utils 只放与业务无关的纯函数,一旦某个函数开始出现业务概念,就说明它应该被移到对应的业务模块里。
十一、重构实战:把一个失控的模块拆开
下面是一个典型的“什么都做”的模块。它负责请求数据、处理数据、更新 DOM、绑定事件、管理状态。点击按钮对比重构前后的结构。
let users = [];
let loading = false;
let error = null;
export async function init() {
// 1. 请求数据
loading = true;
try {
const res = await fetch('/api/users');
users = await res.json();
} catch (e) {
error = e.message;
} finally {
loading = false;
}
// 2. 处理数据(格式化日期、拼接名字)
const list = users.map(u => ({
...u,
joined: new Date(u.createdAt).toLocaleDateString(),
fullName: `${u.firstName} ${u.lastName}`
}));
// 3. 渲染 DOM
const el = document.querySelector('#user-list');
el.innerHTML = list.map(u =>
`<li data-id="${u.id}">${u.fullName}</li>`
).join('');
// 4. 绑定事件
el.addEventListener('click', e => {
const id = e.target.dataset.id;
if (id) selectUser(id);
});
// 5. 管理状态与副作用
document.title = `用户列表(${users.length})`;
track('user_list_loaded');
}
function selectUser(id) { /* 又去改 users、又去改 DOM */ }
重构带来的六个具体收益
- 可测试:
toUserViewModel是纯函数,可以直接单元测试;fetchUsers注入 client,可以 mock。 - 可复用:
renderUserList可以在用户列表页、搜索结果页、管理后台复用。 - 依赖显式:
createUserPanel需要什么,参数里写得清清楚楚,不再依赖全局 fetch。 - 职责单一:接口变了改
api,字段变了改models,设计变了改components。 - 副作用可控:埋点通过参数传入,测试时可以传空函数,不再污染测试环境。
- 可分割:
createUserPanel可以被动态import(),实现按需加载。
重构的渐进路径
现实中很少有人能停下来重构一个月。更可行的做法是小步快跑:
- 先加测试:在重构前,用测试把现有行为固定下来。没有测试的重构,本质上是在赌。
- 先抽纯函数:把
toUserViewModel这类无副作用的逻辑先抽出去,风险最低、收益立竿见影。 - 再抽 IO 边界:把请求、DOM 操作、埋点这些副作用隔离到独立模块。
- 最后调整编排层:让主模块变成纯粹的“装配与调度”,依赖通过参数注入。
- 保留原有导出:对外入口暂时不变,避免一次性影响所有调用方,等内部稳定后再逐步迁移。
这套路径的核心思想是:每一步都保持系统可运行,每一小步都能独立回滚。
十二、模块化检查清单与代码基线
把全文结论浓缩成一份可以贴在工位上的清单。每次新建模块或提交代码前扫一眼,能挡掉绝大多数结构性问题。
- 每个模块只做一件事,能用一个准确的动词短语描述它的职责。
- 依赖必须显式:模块需要什么,就在顶部 import 什么,不依赖全局变量。
- 依赖方向单向:工具层不依赖业务层,业务层不依赖页面层。
- 没有循环依赖,构建工具报出的循环警告必须处理,不能忽略。
- 公共 API 尽量小:只导出必要的能力,内部实现细节不外泄。
- 优先命名导出,
export default只用于“模块确实只导出一个东西”的场景。 - 模块顶层无副作用:不发请求、不改全局、不注册事件。
- 副作用通过参数注入,便于测试与替换。
- 纯函数优先:能用纯函数表达的,就不要写成有状态的类。
- 参数超过三个改用对象,避免顺序敏感与扩展困难。
- 不深层导入第三方包的内部路径,只使用它公开的入口。
- package.json 里声明的依赖,与代码里实际 import 的一致,杜绝幽灵依赖。
- lockfile 提交到版本库,并保证 CI 使用
npm ci这类严格安装命令。 - 路由级代码分割:首屏只加载首屏需要的代码。
- 合理配置 splitChunks,把变化频率不同的代码分到不同 chunk。
- 库项目配置 sideEffects,让使用方能够正确 tree shake。
- 样式有明确的作用域方案(BEM / CSS Modules / CSS-in-JS / 原子化),并团队统一。
- 设计令牌集中管理,颜色、间距、圆角不在组件里硬编码。
- 重构前先补测试,重构中保持每一步可运行、可回滚。
- 定期用工具可视化依赖图,发现意外依赖与层级违规。
src/
├── api/ // 网络请求:只负责 IO,不含业务
│ ├── client.js // axios/fetch 实例与拦截器
│ └── user.js
├── models/ // 数据形状:纯函数,负责转换与校验
│ └── user.js
├── components/ // 通用组件:不含业务逻辑
│ ├── Button/
│ │ ├── Button.jsx
│ │ ├── Button.module.css
│ │ └── index.js
│ └── index.js // 统一出口
├── features/ // 业务功能:编排 api + models + components
│ └── userPanel.js
├── stores/ // 全局状态
├── utils/ // 与业务无关的纯工具
│ ├── date.js
│ ├── string.js
│ └── index.js
├── styles/ // 全局样式与设计令牌
│ ├── tokens.css
│ └── reset.css
└── pages/ // 页面:只做路由级编排
└── UserList.jsx
// features/userPanel.js
import { fetchUsers } from '../api/user';
import { toUserViewModel } from '../models/user';
import { renderUserList } from '../components';
/**
* 创建用户面板控制器
* @param {Object} options
* @param {Object} options.client - HTTP 客户端
* @param {HTMLElement} options.el - 挂载节点
* @param {Function} options.track - 埋点函数
* @returns {{ load: Function, destroy: Function }}
*/
export function createUserPanel({ client, el, track }) {
let users = [];
let destroyed = false;
async function load() {
if (destroyed) return;
try {
const raw = await fetchUsers(client);
users = raw.map(toUserViewModel);
renderUserList(el, users);
track('user_list_loaded', { count: users.length });
} catch (err) {
track('user_list_failed', { message: err.message });
throw err;
}
}
function destroy() {
destroyed = true;
el.innerHTML = '';
}
return { load, destroy };
}
模块化最容易被误解的地方在于:它看起来像是在讨论语法,实际上讨论的是边界与契约。语法决定了模块怎么写,而边界决定了项目能走多远。
一个模块化做得好的项目,新人接手时不需要读完所有代码,只要看清依赖图和各模块的出口,就能理解整个系统的骨架;一个模块化做得差的项目,代码总量可能更少,但没人敢动任何一处。
所以,下一次你准备新建一个文件的时候,不妨先问自己三个问题:它负责什么?它需要谁?谁需要它?——把这三个问题回答清楚,模块的边界自然就出来了。