模块化开发:从混乱到秩序的进化之路
说实话,我刚入行那会儿,前端项目还处在「刀耕火种」的年代。一个页面引十几个 script 标签,全局变量满天飞。谁先加载、谁后加载,全靠程序员自觉。嗯,那滋味,谁经历过谁知道。
后来项目越来越大,这种「野路子」彻底撑不住了。模块化,就成了前端工程化的第一道坎。
模块化的历史:为什么我们需要它?
你想想看,没有模块化的时候,代码长什么样?
<!-- 老项目常见操作 -->
<script src="jquery.js"></script>
<script src="utils.js"></script>
<script src="page.js"></script>
<script src="app.js"></script>
问题很明显:
- 命名冲突:两个文件都定义了
init函数,后加载的会覆盖前面的 - 依赖关系不明确:
page.js依赖utils.js,但没人知道顺序对不对 - 难以维护:改一个全局变量,可能炸掉整个页面
我记得有个项目,上线前发现某个功能不工作了。查了半天,原来是新来的同事在 utils.js 里加了个 var count = 0,跟另一个模块的变量重名了。这种问题,靠人眼根本防不住。
所以社区开始想办法。从最初的 IIFE(立即执行函数表达式),到 AMD、CMD,再到 CommonJS 和 ES Module,每一步都是在解决「如何把代码关进盒子里」这个问题。
核心思想:每个文件就是一个模块,模块内部的作用域是独立的,只有通过导出才能暴露给外部使用。
CommonJS 规范:Node.js 的选择
CommonJS 是 Node.js 默认的模块系统。它的设计哲学很朴素:同步加载。因为服务端读取本地文件很快,不需要考虑异步。
基本语法
// math.js
const add = (a, b) => a + b
const subtract = (a, b) => a - b
// 导出方式一:exports
exports.add = add
exports.subtract = subtract
// 导出方式二:module.exports
module.exports = { add, subtract }
// main.js
const math = require('./math.js')
console.log(math.add(2, 3)) // 5
这里有个坑,我刚开始也踩过:exports 和 module.exports 到底啥区别?
说白了,exports 只是 module.exports 的一个引用。如果你直接给 exports 赋值,比如 exports = { add },那就切断了这个引用,导出会失效。
我曾经踩过的坑:在某个 Node 服务里,我写了 exports = { getData },结果其他模块怎么都拿不到这个函数。查了半天才发现,应该用 module.exports = { getData }。记住:exports 只能添加属性,不能重新赋值。
CommonJS 的特点
- 同步加载:适合服务端,不适合浏览器
- 值拷贝:导出的是值的拷贝,模块内部修改不影响外部
- 运行时加载:
require可以在代码任意位置调用 - 缓存机制:同一个模块只会执行一次,后续
require直接返回缓存
ES Module:浏览器的原生方案
ES Module(简称 ESM)是 ECMAScript 2015(ES6)引入的官方模块标准。它的设计目标是静态分析,让编译器在代码运行前就能确定模块的依赖关系。
基本语法
// math.js
export const add = (a, b) => a + b
export const subtract = (a, b) => a - b
// 或者统一导出
const add = (a, b) => a + b
const subtract = (a, b) => a - b
export { add, subtract }
// 默认导出
export default function multiply(a, b) {
return a * b
}
// main.js
import { add, subtract } from './math.js'
import multiply from './math.js'
console.log(add(2, 3)) // 5
console.log(multiply(2, 3)) // 6
ES Module 的特点
- 异步加载:天生支持浏览器环境
- 值引用:导出的是值的引用,模块内部修改会影响外部
- 静态加载:
import必须放在文件顶部,不能动态调用 - 严格模式:ES Module 默认启用严格模式
个人习惯:我写新项目时,一律用 ES Module。原因很简单——静态分析能带来更好的 tree-shaking(摇树优化),打包后的体积更小。而且 TypeScript 对 ESM 的支持也更完善。
两者的区别:一张表说清楚
| 对比维度 | CommonJS | ES Module |
|---|---|---|
| 加载方式 | 同步加载 | 异步加载 |
| 输出方式 | 值拷贝 | 值引用 |
| 加载时机 | 运行时加载 | 编译时加载(静态分析) |
| 语法 | require() / module.exports |
import / export |
| 适用环境 | Node.js | 浏览器 + Node.js(需配置) |
| 动态导入 | 支持(任意位置 require) | 支持(import() 函数) |
| Tree-shaking | 不支持 | 支持 |
这里有个关键点:值拷贝 vs 值引用。我举个例子你就明白了。
// CommonJS 值拷贝
// counter.js
let count = 0
module.exports = {
count,
increment() { count++ }
}
// main.js
const counter = require('./counter.js')
console.log(counter.count) // 0
counter.increment()
console.log(counter.count) // 还是 0!因为导出的是拷贝
// ES Module 值引用
// counter.js
export let count = 0
export function increment() { count++ }
// main.js
import { count, increment } from './counter.js'
console.log(count) // 0
increment()
console.log(count) // 1!因为导出的是引用
这个差异在实际项目中影响很大。如果你用 CommonJS 做状态管理,可能会遇到「改了但没生效」的诡异问题。
两者的互操作:混用的艺术
现实项目中,我们经常需要混用 CommonJS 和 ES Module。比如一个用 Webpack 打包的项目,依赖了某个只提供 CommonJS 的 npm 包。
互操作的基本原则:
- ES Module 可以导入 CommonJS:大多数打包工具都支持
- CommonJS 不能直接导入 ES Module:因为 ESM 是异步的,CJS 是同步的
在 ES Module 中导入 CommonJS
// 假设有个 CommonJS 模块
// lodash.cjs
module.exports = { shuffle: (arr) => arr.sort(() => Math.random() - 0.5) }
// 在 ESM 中导入
import lodash from './lodash.cjs'
// 或者
import { shuffle } from './lodash.cjs'
在 CommonJS 中导入 ES Module
这个比较麻烦。我建议用 import() 动态导入:
// 在 CommonJS 中动态导入 ESM
async function loadESM() {
const esModule = await import('./es-module.mjs')
return esModule.default
}
我曾经踩过的坑:在 Node.js 14 之前,混用 CJS 和 ESM 简直是噩梦。我记得有个项目,package.json 里没配 "type": "module",结果 .mjs 和 .cjs 文件互相引用,报了一堆错。后来统一改成 ESM,世界清净了。
实战建议:怎么选?
我个人建议:
- 新项目:直接用 ES Module。浏览器和 Node.js 都原生支持了,大势所趋
- 老项目迁移:逐步替换。先把
require改成import,注意检查循环依赖 - Node.js 服务端:如果项目不大,CommonJS 也够用。但新项目建议用 ESM
- npm 包发布:最好同时提供 CJS 和 ESM 两种格式,用
package.json的exports字段区分
一句话总结:CommonJS 是过去,ES Module 是现在和未来。理解两者的区别,能让你在混用场景下少踩很多坑。
好了,模块化这块就聊到这儿。下一章我们聊聊打包工具——Webpack、Vite 这些家伙是怎么把模块「捏」到一起的。