模块化开发

张玥 2026年9月20日 阅读时间 30分钟
模块化 ES Modules CommonJS 打包工具 工程化 架构设计
前端开发技巧之模块化开发

如果说 HTML 语义化决定了“页面结构是否说得清”,那么模块化决定了“代码结构是否撑得住”。一个几千行的项目,用全局变量也能跑起来;但到了几万行、十几个人协作、需要按需加载和长期维护的时候,模块化就成了分水岭。模块化不是一个语法特性,而是一整套关于“边界”的工程约定——它规定了什么该被暴露、什么该被隐藏、谁可以依赖谁、依赖以何种方式被解析和加载。本篇 30 分钟长文,从模块化要解决的原始问题讲起,沿着 CommonJS、AMD、UMD、ES Modules 的演进脉络,深入到模块解析算法、打包器原理、tree shaking、代码分割、依赖设计原则、包管理与 Monorepo,最后落到样式模块化与一份可执行的检查清单。读完之后,你应该能对任何一个前端项目的模块结构做出判断,并知道该怎么改。

一、模块化到底解决什么问题

在讨论任何语法之前,先回到最初。2005 年前后的前端页面,脚本是这样组织的:

<!-- index.html -->
<script src="utils.js"></script>
<script src="ajax.js"></script>
<script src="slider.js"></script>
<script src="main.js"></script>

所有脚本共享同一个全局作用域。于是下面这类问题会不可避免地出现:

// utils.js
var count = 0;
function format() { return count.toString(); }

// slider.js(另一位同事写的)
var count = 10; // 静默覆盖了 utils 的 count

// main.js
format(); // 结果是 "10",而不是 "0"

没有报错,没有警告,只是结果悄悄变了。这类问题在多人协作的项目里非常难排查,因为出错的地方和产生错误的地方相隔很远。

模块化要解决的五件事

封装 内部实现不污染外部,外部也不能随意改动内部状态
依赖声明 模块显式声明自己需要什么,而不是靠“谁先加载谁后加载”
命名空间隔离 不同模块里可以安全地使用同名变量与函数
可复用与可分发 一个模块可以被复制、发布、被其他项目安装使用
可静态分析 工具能读懂依赖关系,从而做 tree shaking、类型检查、按需加载

最后一条最容易被忽略,但它恰恰是现代前端工程化的根基。只有当依赖关系是静态可分析的,打包器才能知道哪些代码没用上、哪些可以拆包、哪些必须提前加载。这是 ES Modules 相对 CommonJS 最本质的优势,后面会详细展开。

模块化的本质是“边界管理”

一个模块向外暴露的接口,叫做它的公共 API;模块内部不被外部访问的部分,叫做实现细节。模块化的全部工作,就是划清这条线:

边界模糊
模块导出一个巨大的对象,内部状态、工具函数、常量全部挂在上面。调用方可以随意修改内部状态,导致行为不可预测。
边界清晰
只导出必要的能力,内部状态用闭包或模块作用域保护起来。调用方只能通过公开接口改变状态。

这条线划得好不好,直接决定了一个项目在半年后是“改一行就够”还是“改一处崩三处”。

二、模块化演进史:从全局污染到语言标准

JavaScript 在诞生之初并没有模块系统。这门语言被设计用来做表单校验,谁也没想到它会长成今天的样子。模块化的历史,本质上是一段“补课史”。点击下面的每个阶段,看看它解决了什么、又留下了什么问题。

1
全局脚本
1995 – 2005
所有代码挂在 window 上,靠 <script> 顺序保证依赖。命名冲突、加载顺序脆弱、无法复用,是这一时期的三大顽疾。
2
命名空间 + IIFE
2005 – 2010
用立即执行函数创造私有作用域,只把需要暴露的部分挂到一个全局命名空间上。解决了污染问题,但依赖关系依然是隐式的。
3
CommonJS
2009
Node.js 带来的服务端模块规范:require 同步加载、module.exports 导出。首次让依赖变成显式声明,但同步加载不适合浏览器。
4
AMD / RequireJS
2010
为浏览器设计的异步模块定义:define([deps], factory)。支持并行加载与依赖前置,代价是回调嵌套、书写啰嗦。
5
UMD
2011
一段“兼容一切”的包装代码:检测环境,分别适配 CommonJS、AMD 和全局变量。库作者的最爱,但代码可读性差,也无法被静态分析。
6
ES Modules
2015
语言层面的官方模块系统:import / export,静态结构、实时绑定、自动严格模式。终于让工具能够读懂依赖图。
7
打包器时代
2017 – 2020
Webpack、Rollup、Parcel 把各种模块格式统一编译、打包、压缩、拆分。模块化从“语法问题”升级为“构建问题”。
8
原生 ESM / Vite
2020 至今
浏览器全面支持 <script type="module">,开发期不再需要打包。Vite 用原生 ESM 做 dev server,用 Rollup 做生产构建,启动速度提升一个数量级。

IIFE 模式:模块化的第一次尝试

在 CommonJS 出现之前,最流行的模块写法是立即执行函数:

// 一个 IIFE 模块
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 推动,核心思想是依赖前置、异步加载:

// AMD:依赖数组 + 工厂函数
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 采用的模块规范,也是理解模块化绕不开的一站。它的写法极其简单:

// math.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 最经典的面试题,也是真实项目里最容易写错的地方:

// Node 内部大致是这样做的
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,只有第一次会真正执行,之后都直接返回缓存的导出对象。

// counter.js
console.log('模块被加载了');
module.exports = { value: 0 };

// a.js
require('./counter'); // 打印"模块被加载了"

// b.js
require('./counter'); // 什么都不打印,用缓存

缓存的 key 是模块文件的绝对路径。这意味着:同一个文件通过不同路径引用(例如 ./utils 和 ../src/utils),只要解析后是同一个绝对路径,就共享缓存;但如果因为符号链接、大小写差异导致路径不同,就会重复加载。

值拷贝:CommonJS 的一个关键特性

这是 CommonJS 与 ES Modules 最重要的区别之一。CommonJS 导出的是值的快照:

// counter.js
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 会返回不完整的导出对象:

// a.js
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 是编译期确定。

// 1. 命名导出(可以多个)
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,结果完全不同:

// counter.js
export let count = 0;
export function increment() { count++; }

// main.js
import { count, increment } from './counter.js';
increment();
console.log(count); // 1 —— 实时绑定生效了

ES Modules 的导入是指向原模块变量的只读引用。原变量变了,导入方看到的值也跟着变。但导入方不能直接给它赋值:

import { count } from './counter.js';
count = 10; // ❌ TypeError: Assignment to constant variable

这种设计既保证了模块内部状态不被外部随意篡改,又让“状态共享”变得自然。它是 ES Modules 比 CommonJS 更适合做状态管理的基础。

ES Modules 的循环依赖

ES Modules 通过提升(hoisting)处理循环依赖:所有 import 语句会在模块代码执行前先完成“连接”,导出绑定在模块开始执行前就已经建立。但因为实时绑定的存在,如果你在依赖模块执行完之前就访问它,会触发暂时性死区(TDZ):

// a.js
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 注入的环境变量):

console.log(import.meta.url);
// "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。于是打包器需要知道“这个模块有没有副作用”。

// utils.js —— 纯函数,无副作用,可以被安全 tree shake
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 name = getModuleName();
const mod = require(name); // 打包器:我哪知道 name 是什么

module.exports[dynamicKey] = value; // 导出内容运行时才知道

打包器无法在编译期确定依赖图,只能把整个模块都保留下来。这就是为什么库作者要同时提供 ESM 和 CJS 两份产物,并在 module 字段里指向 ESM 版本。

打包器在做什么

把上面这些串起来,一个打包器的完整流程大致是:

① 从入口开始解析
↓
② 解析模块标识符 → 定位真实文件
↓
③ 用 loader 把各种格式转成 JS 模块
↓
④ 构建模块依赖图(Module Graph)
↓
⑤ 标记无用导出,tree shaking
↓
⑥ 代码分割,生成多个 chunk
↓
⑦ 生成运行时代码 + 压缩输出

理解这个流程的价值在于:当构建产物出问题时,你知道该去哪个环节找原因。比如“某个模块没被 tree shake 掉”,问题可能出在第 ③ 步(loader 转换后引入了副作用)或第 ⑤ 步(sideEffects 配置不对)。

六、代码分割与按需加载

把所有代码打成一个 bundle.js,是最简单也最糟糕的做法。用户打开首页,却下载了整个后台管理系统的代码——首屏时间被无谓地拉长。代码分割要解决的就是这件事。

三种分割方式

入口分割 多个 entry 各自产出独立 chunk,适合多页应用
动态导入 import() 返回 Promise,打包器自动为该模块生成独立 chunk
公共提取 splitChunks 把多个入口共用的依赖提到独立 chunk,避免重复下载

路由级懒加载:收益最大的一刀

单页应用最有效的优化,通常是按路由拆分:

// Vue Router
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
路由分割后 · Dashboard420 KB
路由分割后 · Settings150 KB

首屏体积从 1.2 MB 降到 180 KB,用户不必为暂时用不到的功能付出下载代价。这就是代码分割最直观的收益。

prefetch 与 preload:让加载“提前发生”

分割之后的新问题是:用户点击“设置”时,需要等待 chunk 下载,中间出现空白。解决办法是预测用户行为,提前把可能用到的 chunk 拉下来。

// Webpack 魔法注释
import(
  /* webpackPrefetch: true */ // 空闲时下载,优先级低
  './Settings.vue'
);

import(
  /* webpackPreload: true */ // 与父 chunk 并行下载,优先级高
  './CriticalComponent.vue'
);

两者的区别很关键:

  • prefetch:告诉浏览器“这个资源之后可能会用到”,在空闲时以低优先级下载。用错了会浪费带宽,因为它可能下载用户永远不会访问的页面。
  • preload:告诉浏览器“这个资源当前页面马上就要用”,与主资源并行下载。用错了会抢占关键资源带宽,反而拖慢首屏。

经验法则:首屏必需但发现较晚的资源用 preload,下一个可能到达的路由用 prefetch,其余一律不加。

分割的粒度:不是越细越好

一个常见的误区是把每个组件都拆成独立 chunk。结果是一个页面要发几十个请求,HTTP 开销和调度成本反而拖慢了加载。

过度分割
每个组件一个 chunk,首屏并发 40 个请求。HTTP/2 也会因为头部压缩、优先级调度而变慢,且不利于缓存复用。
合理粒度
按路由或功能模块分割,单 chunk 控制在 100–300 KB。把多个入口共用的依赖提取为 vendor chunk,充分利用长缓存。

分包策略与缓存命中

好的分包策略不只是“切小”,还要让缓存最大化。核心思路是把变化频率不同的代码分开:

// webpack.config.js
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,设计越好。

原则三:依赖必须显式

模块要用到什么,就应该在顶部明确写出来。依赖全局变量、依赖加载顺序、依赖“某个地方已经初始化过了”,都是隐性依赖,它们让代码变得不可预测。

// ❌ 隐性依赖:依赖全局的 window.apiClient
export function getUser(id) {
  return window.apiClient.get(`/users/${id}`);
}

// ✅ 显式依赖:调用方必须传入
export function createUserService(client) {
  return {
    getUser: (id) => client.get(`/users/${id}`)
  };
}

显式依赖让模块可测试——你可以在测试里传入一个假的 client,而不必去 mock 全局对象。

原则四:依赖方向必须单向

模块之间的依赖应该形成一个有向无环图(DAG)。一旦出现环,就意味着两个模块互相纠缠,无法独立理解、独立测试、独立替换。

一个健康的前端项目依赖分层大致如下:

应用层 · pages / routes
↓ 只能向下依赖
业务组件
业务逻辑 hooks
状态管理
↓
领域模型
API 服务层
通用组件库
↓
工具函数
常量定义
类型声明

规则很简单:上层可以依赖下层,下层绝对不能依赖上层。工具函数不应该知道任何业务概念;API 服务层不应该引用任何 UI 组件。

原则五:循环依赖必须消除

循环依赖往往不是因为“设计需要”,而是因为“东西放错了地方”。常见的三种解法:

  • 提取公共部分:A 和 B 互相依赖,往往是因为它们都需要 C。把 C 抽成独立模块,环就断了。
  • 依赖注入:把 B 需要的 A 的能力,通过参数传进去,而不是让 B 直接 import A。
  • 引入事件机制:A 触发事件,B 监听事件,双方都不需要知道对方的存在。
// ❌ 循环:a.js 引 b.js,b.js 又引 a.js
// 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、最好测试、最好复用。副作用应该被集中管理,而不是散落在各个模块的顶层。

纯函数 输入相同则输出相同,不修改外部状态 —— 最容易复用
受控副作用 副作用发生在函数调用时,而非模块加载时 —— 可控
顶层副作用 模块一被 import 就执行 —— 阻碍 tree shaking,难以测试
顶层副作用
// analytics.js
window.dataLayer = window.dataLayer || [];
window.dataLayer.push({ event: 'pageview' });
只要有人 import 它,就会上报一次,完全不受控。
显式调用
// analytics.js
export function track(event) { ... }
// main.js
import { 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),把内部实现细节挡在外面:

// src/components/index.js —— 统一出口
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 的差异

npm Node 自带;v3 之后采用扁平化 node_modules,安装快但存在幽灵依赖问题
yarn 早期以速度和 lockfile 稳定性取胜;Berry 版本引入 PnP,去掉了 node_modules
pnpm 用全局内容寻址存储 + 硬链接,磁盘占用最小,且严格隔离依赖,杜绝幽灵依赖

幽灵依赖:一个隐蔽的坑

幽灵依赖(Phantom Dependency)指的是:你的代码 import 了一个没有写在 package.json 里的包,只是因为它是某个依赖的依赖,被扁平化安装到了顶层 node_modules。

// package.json 里只声明了 A
{ "dependencies": { "A": "^1.0.0" } }

// 但 A 依赖了 lodash,于是 lodash 被装到了顶层
// 你的代码可以直接这样写,而且能跑
import _ from 'lodash'; // ⚠️ 幽灵依赖

// 直到某天 A 升级,不再依赖 lodash —— 构建突然失败

pnpm 通过严格的符号链接结构从根源上避免了这个问题:node_modules 下只有你显式声明的包,未声明的包根本无法被解析。这既是优点(更安全),也是迁移成本(老项目可能暴露出大量隐藏的幽灵依赖)。

Monorepo:一个仓库,多个包

当项目拆成多个包(组件库、工具库、主应用、文档站),有两种组织方式:

  • Multi-repo:每个包一个仓库。独立发布、独立权限,但跨包修改需要发版、升版本、装依赖,迭代成本高。
  • Monorepo:所有包放一个仓库。跨包修改一次提交完成,依赖关系显式可见,但需要工具支撑。

现代 Monorepo 的典型结构:

my-project/
├── 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)通过命名约定来模拟命名空间:

/* Block */
.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 在构建期把类名重写成唯一的哈希值,从根本上杜绝冲突:

/* Button.module.css */
.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,让样式真正成为组件的一部分:

import styled from 'styled-components';

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 class="px-4 py-2 rounded-md bg-blue-600 text-white hover:bg-blue-700 transition">
  提交
</button>

它彻底消除了“命名”这件事,也就不存在命名冲突;产物只包含实际用到的类,体积可控。缺点是 HTML 会变得很长,且需要团队接受一种新的心智模型。

设计令牌:跨方案的公共基础

无论选哪种样式方案,有一件事都应该做:把颜色、间距、圆角、字号等设计决策抽成变量,让它们成为唯一的真实来源。

/* tokens.css */
: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 冗长,学习曲线

没有绝对最优的方案。真正重要的是:团队内部保持统一,并且把设计决策收敛到令牌层。

十、十个最常见的模块化误区

误区 1 把“按文件类型分目录”当成模块化:把所有 .js 放一起、所有 .css 放一起
误区 2 一个 utils.js 塞进几百个毫无关联的函数,成为新的全局垃圾桶
误区 3 为了“看起来模块化”而给每个函数单独建文件,导致依赖图极其碎片化
误区 4 在模块顶层执行副作用(发请求、改全局、注册事件),阻碍 tree shaking
误区 5 用 export default 导出所有东西,导致导入方可以随意改名,重构困难
误区 6 深层导入别人的包(lib/src/internal/x),依赖了未承诺的私有路径
误区 7 无视幽灵依赖,package.json 里没声明却在代码里用
误区 8 lockfile 不提交版本库,或者频繁手动删除重装
误区 9 把所有代码打成一个 bundle,只做压缩不做分割
误区 10 认为“用了打包工具就等于模块化”,忽略了模块边界的设计

关于 export default 的争议

export default 用起来很方便,但它在工程上有三个明显缺点:

  • 导入方可随意命名:import A from './x' 和 import B from './x' 都合法,全项目搜索不到统一的名字,重构和排查都变难。
  • 不利于 tree shaking:默认导出的对象往往是一个整体,打包器很难只保留其中一部分。
  • 不利于 IDE 自动导入:编辑器很难为默认导出推断出合理的导入名,补全体验差。

一个被广泛采纳的折中方案是:对外只提供命名导出,只在“一个模块确实只导出一个东西”时使用 default,并且导出后立刻具名化。

// ❌ 到处都是 default
export default { add, sub, mul };

// ✅ 命名导出,名字固定,工具友好
export { add, sub, mul };

// ✅ 单一职责模块可以 default,但导入时保持同名
// Button.jsx
export default function Button() { /* ... */ }

关于“工具函数”垃圾桶

几乎每个项目都有一个 utils.js,半年后它会有 800 行,包含日期格式化、字符串处理、DOM 操作、请求封装、数组去重……任何人想加东西都往里塞,因为“反正它在 utils 里”。

解法不是禁止 utils,而是给工具函数分类并设定边界:

// ❌ utils.js —— 什么都往里放
// ✅ 按领域拆分
src/utils/
├── date.js // 日期相关
├── string.js // 字符串相关
├── array.js // 数组相关
├── dom.js // DOM 相关
└── index.js // 统一出口

更严格的团队还会加一条规则:业务逻辑不允许放进 utils。utils 只放与业务无关的纯函数,一旦某个函数开始出现业务概念,就说明它应该被移到对应的业务模块里。

十一、重构实战:把一个失控的模块拆开

下面是一个典型的“什么都做”的模块。它负责请求数据、处理数据、更新 DOM、绑定事件、管理状态。点击按钮对比重构前后的结构。

// user-panel.js —— 一个模块做了五件事
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(),实现按需加载。

重构的渐进路径

现实中很少有人能停下来重构一个月。更可行的做法是小步快跑:

  1. 先加测试:在重构前,用测试把现有行为固定下来。没有测试的重构,本质上是在赌。
  2. 先抽纯函数:把 toUserViewModel 这类无副作用的逻辑先抽出去,风险最低、收益立竿见影。
  3. 再抽 IO 边界:把请求、DOM 操作、埋点这些副作用隔离到独立模块。
  4. 最后调整编排层:让主模块变成纯粹的“装配与调度”,依赖通过参数注入。
  5. 保留原有导出:对外入口暂时不变,避免一次性影响所有调用方,等内部稳定后再逐步迁移。

这套路径的核心思想是:每一步都保持系统可运行,每一小步都能独立回滚。

十二、模块化检查清单与代码基线

把全文结论浓缩成一份可以贴在工位上的清单。每次新建模块或提交代码前扫一眼,能挡掉绝大多数结构性问题。

  • 每个模块只做一件事,能用一个准确的动词短语描述它的职责。
  • 依赖必须显式:模块需要什么,就在顶部 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 };
}

模块化最容易被误解的地方在于:它看起来像是在讨论语法,实际上讨论的是边界与契约。语法决定了模块怎么写,而边界决定了项目能走多远。

一个模块化做得好的项目,新人接手时不需要读完所有代码,只要看清依赖图和各模块的出口,就能理解整个系统的骨架;一个模块化做得差的项目,代码总量可能更少,但没人敢动任何一处。

所以,下一次你准备新建一个文件的时候,不妨先问自己三个问题:它负责什么?它需要谁?谁需要它?——把这三个问题回答清楚,模块的边界自然就出来了。