模块化开发:从混乱到秩序的进化之路

说实话,我刚入行那会儿,前端项目还处在「刀耕火种」的年代。一个页面引十几个 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

这里有个坑,我刚开始也踩过:exportsmodule.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.jsonexports 字段区分

一句话总结:CommonJS 是过去,ES Module 是现在和未来。理解两者的区别,能让你在混用场景下少踩很多坑。

好了,模块化这块就聊到这儿。下一章我们聊聊打包工具——Webpack、Vite 这些家伙是怎么把模块「捏」到一起的。