手工时代:为什么我们需要工程化?
2008年的某个下午,你正在为一个网站添加一个新功能。你的项目文件夹里散落着十几个 `.js` 文件,`index.html` 页面顶部排着一长串 `<script>` 标签,像等待检阅的士兵。你小心翼翼地把 `utils.js` 从末尾挪到最前面,因为 `main.js` 里的函数依赖它;然后发现 `component-a.js` 又依赖 `utils.js` 里刚修改的一个函数,于是你又得调整顺序。改完后,你深吸一口气,在浏览器里刷新——页面空白,控制台报错:“`$` is not defined”。你叹了口气,知道肯定是 `jquery.js` 的位置放错了。你重新排列 `<script>` 标签,再次刷新,这次页面出来了,但样式乱成一团。你想起好像 `reset.css` 应该放在 `style.css` 前面……这就是我们即将回顾的“史前时代”,一个没有构建工具、没有模块系统、依赖关系靠手动维护的前端开发“手工时代”。它并不遥远,甚至许多今天入门的开发者,在初学 HTML 时也经历过类似的“原始”阶段。但正是这段混乱而充满回忆的岁月,催生了前端工程化的必然需求,并最终引发了从 Webpack 到 Vite 的这场深刻演进。
---
厨房里的“原始社会”:没有工具的烹饪困境
让我们用一个更贴近生活的比喻来理解那个时代:想象你是一位厨师,但你的厨房里没有料理机、没有定时器、没有标签收纳系统,甚至没有流水的水龙头。你要做一顿包含前菜、主菜、甜点的三人份晚餐。
你的食材散放在地上:土豆和胡萝卜混在一起,面粉和糖没有分装,香料瓶上没有任何标签。你要做一道“土豆炖牛肉”,得先从杂乱的蔬菜堆里手动挑出土豆,用一把钝刀费力地削皮,然后切成大小不一的块——因为没有料理机,切菜全凭刀工。接着,你发现牛肉被冻在了一起,得花时间解冻。与此同时,你要准备沙拉,但生菜和番茄也混在角落,你得翻找半天。
这还不是最糟的。这道菜需要一种特制酱汁,而酱汁的配方写在另一本 cookbook 的某一页上。你得到处翻找那本书,找到后,发现酱汁里需要一种叫“ ingredient-X ”的香料,但你的香料架上根本没有它——原来它被用完了,你得临时去市场买。买回来后,你才发现这个香料还有一个依赖:它需要先用另一种香料“ ingredient-Y ”腌制过才能用。于是你又回头处理“ ingredient-Y ”……在这个过程中,你的灶台上已经堆满了用过的碗碟、切剩的食材残渣。你同时要照看炖锅、烤箱和沙拉 bowl,时间紧迫,手忙脚乱,最终端上桌的菜肴可能咸淡不均,甚至某个环节出错导致整道菜失败。
这个厨房,就是早期前端开发的完美隐喻:
| 厨房要素 | 前端开发对应 | | :--- | :--- | | 散乱未分装的食材 | 散落的 `.js`, `.css`, 图片等静态资源文件 | | 没有标签的香料架 | 没有清晰模块划分的代码,所有逻辑混在一起 | | 需要跨书查找的配方 | 依赖的第三方库(如 jQuery),需要手动下载、引入 | | 香料之间的腌制依赖 | 文件间的加载顺序依赖(如 A 依赖 B,B 必须先在 HTML 中引入) | | 同时看管多个灶台 | 手动管理多个文件的修改,并频繁刷新浏览器测试 | | 没有流水的水槽 | 没有自动化流程,重复性劳动(如复制粘贴代码)全靠人工 |
在这个“原始厨房”里,厨师(开发者)的绝大部分精力没有用在“创造”(写业务逻辑),而是耗在了“管理”和“救火”上:寻找文件、调整顺序、解决因顺序错误导致的冲突、反复刷新验证、手动合并代码冲突。这直接导致了效率低下、错误频发和协作噩梦。
---
第一张“食谱”:`<script>` 标签的线性世界
在 Web 发展初期,浏览器本身非常“单纯”。它按顺序解析 HTML,遇到 `<script>` 标签(没有 `async` 或 `defer` 属性)就会暂停 HTML 解析,立即下载并执行该脚本。执行完后,再继续解析后面的 HTML。这个机制决定了早期前端代码的组织方式:线性、顺序、扁平。
一个典型的 2010 年左右的页面结构可能长这样:
```html <!DOCTYPE html> <html> <head> <meta charset="UTF-8"> <title>My Legacy App</title> <!-- 1. 必须先引入所有依赖库 --> <script src="libs/jquery-1.11.3.min.js"></script> <script src="libs/underscore-min.js"></script> <script src="libs/backbone-min.js"></script> <!-- 2. 然后是工具函数,它们可能被后面的业务代码依赖 --> <script src="js/utils/string-util.js"></script> <script src="js/utils/date-util.js"></script> <script src="js/utils/ajax-util.js"></script> <!-- 3. 接着是基础组件 --> <script src="js/components/header.js"></script> <script src="js/components/footer.js"></script> <script src="js/components/sidebar.js"></script> <!-- 4. 然后是页面特定的业务逻辑 --> <script src="js/pages/home-page.js"></script> <script src="js/pages/about-page.js"></script> <!-- 5. 最后是主入口,初始化整个应用 --> <script src="js/main.js"></script> <link rel="stylesheet" href="css/reset.css"> <link rel="stylesheet" href="css/layout.css"> <link rel="stylesheet" href="css/theme.css"> <link rel="stylesheet" href="css/pages/home.css"> </head> <body> <div id="app"></div> </body> </html> ```
这个结构揭示了手工时代的第一个核心痛点:脆弱的依赖顺序。
- **显式顺序依赖**:`main.js` 可能调用了 `utils/date-util.js` 里的 `formatDate` 函数,因此 `date-util.js` **必须**在 `main.js` 之前被引入。如果某天一个新同学在 `utils` 目录下加了 `number-util.js`,并在 `main.js` 里用了它,但他忘记在 HTML 里添加对应的 `<script>` 标签,或者加在了错误的位置,页面就会报错。**依赖关系完全靠开发者的记忆和手动维护,没有任何机器可验证的约束**。
- **全局命名空间污染**:所有通过 `<script>` 标签引入的脚本,都在同一个全局作用域(通常是 `window` 对象)中执行。`string-util.js` 里定义的函数 `trim()` 可能会被 `date-util.js` 里同名的函数覆盖,或者与页面中其他库(如 jQuery)的 `$` 符号冲突。为了避免冲突,开发者被迫使用各种命名约定,如 `myApp_utils_string_trim`,导致代码冗长且难以阅读。
- **文件爆炸与加载性能**:随着项目增长,文件数量急剧增加。每个 `<script>` 标签都对应一次独立的 HTTP 请求(在 HTTP/1.1 时代,浏览器对同一域名的并发请求数有限制,通常为 6-8 个)。几十个文件意味着几十次请求,网络延迟累积,页面白屏时间变长。虽然可以通过合并文件来缓解,但这又引入了新的手工步骤:**每次发布前,都需要手动或写一个简单的脚本将几十个文件拼接(concatenate)成少数几个大文件**。这个过程容易出错,且破坏了源代码与调试文件的结构对应关系。
- **代码复用与维护的噩梦**:假设 `utils/ajax-util.js` 里有一个通用的 `ajaxRequest` 函数,被 `home-page.js` 和 `about-page.js` 共同使用。现在你需要修改这个函数。你修改了 `ajax-util.js`,但如何确保所有使用到它的页面都得到了更新?在手工时代,你只能依靠**人工检索**:在项目里全局搜索 `ajaxRequest` 的出现位置,然后祈祷没有遗漏。如果一个函数被十个页面使用,而你在修改时只测试了其中五个,另外五个页面很可能在深夜出现诡异的 Bug。
“拼接(Concatenation)”就是当时应对复杂性的主要“解决方案”。它的逻辑很简单:既然文件太多、请求太慢、顺序太难管理,那就把所有代码按顺序合并成少数几个(甚至一个)大文件。但这就像为了省去去不同菜市场买菜的麻烦,一次性买回所有食材堆在厨房地上,反而让找东西变得更困难。它没有解决根本的模块划分和依赖问题,只是把问题隐藏了起来。合并后的文件依然是一个巨大的、全局的、顺序敏感的“泥球”,调试时堆栈信息混乱, source map 几乎不存在。当团队规模扩大,不同开发者修改不同模块,合并冲突成了每日的“惊喜”。
---
萌芽的智慧:模块化思想的曙光
在混乱中,总有人试图建立秩序。前端开发者从后端编程语言(如 Java、Python)的模块化系统中汲取灵感,开始思考:能否在浏览器端,也实现一种“导入(import)”和“导出(export)”的机制,让每个文件只关心自己的职责,并声明它需要什么,而不是靠外部手动安排顺序?
这就是模块化(Modularization) 思想的萌芽。其核心目标是:
1. 封装(Encapsulation):每个模块拥有自己的私有作用域,内部变量和函数不会污染全局。 2. 依赖声明(Explicit Dependency):模块在代码中明确声明它依赖哪些其他模块。 3. 依赖管理(Dependency Management):有一个机制(运行时或编译时)负责解析这些声明,确保依赖的模块先于当前模块被加载和执行。
在 native ECMAScript 模块(ES Modules)成为标准并被所有现代浏览器广泛支持之前,社区涌现了多种模块化规范和模式,它们都是在“手工时代”向“工程化时代”过渡的宝贵尝试。
1. Immediately-Invoked Function Expression (IIFE) 与全局对象模式
这是最早、最朴素的模块化模拟。利用 JavaScript 函数作用域的特性,将模块代码包裹在一个立即执行的匿名函数中,从而创建一个私有作用域。通过将需要导出的成员挂载到一个全局对象(如 `window.myApp`)上,实现有限的“公开”。
```javascript // utils/math.js (IIFE 模式) (function(global) { // 私有变量,外部无法访问 const secret = 42;
// 公开的 API,通过参数 global 挂载到全局对象 global.myApp = global.myApp || {}; global.myApp.math = { add: function(a, b) { return a + b; }, multiply: function(a, b) { return a * b; } }; })(window);
// main.js // 假设 myApp 全局对象已存在 console.log(myApp.math.add(2, 3)); // 5 // console.log(secret); // ReferenceError: secret is not defined ```
优点:简单,无需任何工具,在旧浏览器中也能工作,实现了基本的封装。 致命缺点:
- **依赖顺序依然存在**:`main.js` 必须在 `math.js` 之后加载,否则 `myApp.math` 是 `undefined`。依赖关系是**隐式**的(通过全局对象访问),没有在代码层面声明。
- **全局对象污染**:仍然需要创建一个全局对象 `myApp`,如果多个库都这么做,冲突风险仍在。
- **无法静态分析**:工具无法知道 `main.js` 依赖 `math.js`,因此无法进行优化(如 tree shaking)。
这就像在厨房里,每个厨师(模块)把自己的成品菜(导出的函数)放在一个公共的、标记模糊的共享餐桌(全局对象)上,其他厨师来取用。但餐桌空间有限,菜可能会被后来者覆盖,而且你必须知道“A 厨师先把红烧肉放上去,B 厨师才能去取”,这个顺序只能靠厨师们口头约定。
2. CommonJS (CJS) 与 Node.js 的“服务器端启示”
CommonJS 规范最初为服务器端 JavaScript(Node.js)设计。它的语法非常直观:
```javascript // math.js (CommonJS) const secret = 42; function add(a, b) { return a + b; } function multiply(a, b) { return a * b; }
// 导出:通过 module.exports 对象 module.exports = { add, multiply };
// main.js (CommonJS) // 导入:通过 require 函数 const math = require('./math'); console.log(math.add(2, 3)); ```
革命性的优点:
- **同步加载,简单直接**:`require` 是同步函数,运行时按需加载模块。
- **依赖显式声明**:`require('./math')` 清晰地写在代码里,人眼和工具都能看懂。
- **模块私有作用域**:每个文件是一个独立的模块,顶层变量不会污染全局。
- **模块缓存**:同一个模块被多次 `require` 时,只会执行一次,后续返回缓存的结果,避免重复计算。
然而,CommonJS 是为服务器设计的。它的同步加载模型在浏览器中会导致性能问题。想象一下,浏览器解析到 `main.js`,执行到 `require('./math')`,必须暂停当前脚本,去网络下载 `math.js`,下载并执行完后,才能继续。如果 `math.js` 又 `require` 了 `string-util.js`,那就得再暂停一次……这会造成“同步阻塞式网络请求”,页面渲染被严重拖慢,用户体验极差。
这就像在厨房里,你做菜做到一半,发现缺一种香料,必须立刻停下手头所有活,亲自跑一趟市场买回来,才能继续。市场可能很远(网络延迟),你站在店门口等老板配货(下载),期间锅里的菜可能已经糊了。在服务器端,这没问题,因为文件在本地磁盘,读取极快。但在浏览器,这是不可接受的。
3. Asynchronous Module Definition (AMD) 与 RequireJS
为了解决 CommonJS 在浏览器中的同步加载问题,AMD 规范诞生了。它采用异步加载模式,核心 API 是 `define` 和 `require`。
```javascript // math.js (AMD) define([], function() { const secret = 42; function add(a, b) { return a + b; } return { add, multiply }; });
// main.js (AMD) require(['./math', './string-util'], function(math, stringUtil) { console.log(math.add(2, 3)); console.log(stringUtil.capitalize('hello')); }); ```
关键特性:
- **异步加载**:`require` 不会阻塞浏览器,它会并行下载所有依赖的模块,等全部就绪后,再执行回调函数。
- **依赖作为数组声明**:`require(['./math', ...], callback)` 的数组形式,让依赖关系在**加载阶段**就明确列出,有利于工具分析。
- **模块即函数**:`define` 的第一个参数是依赖数组,第二个参数是工厂函数,依赖模块作为参数传入工厂函数。
AMD 很好地解决了浏览器环境下的异步加载问题,RequireJS 也成为当时最流行的模块加载器。但它也有缺点:
- **语法略显冗长**:所有模块都必须用 `define` 包裹,依赖列表是字符串数组,容易拼写错误且缺乏 IDE 智能提示(字符串无法静态分析)。
- **“过度”异步**:虽然解决了加载阻塞,但模块间的执行顺序仍需开发者通过依赖数组仔细编排,复杂度并未完全消除。
- **与 CommonJS 生态割裂**:Node.js 生态大量使用 CommonJS 模块,两者语法不兼容,导致前后端无法共享模块代码。
这就像厨房里引入了一个“采购员”(RequireJS)。你(主厨)告诉采购员:“我需要 A、B、C 三种食材,都买来了再叫我”。采购员跑去市场(网络)分别购买,买齐后一起回来。你不再需要自己跑腿(同步阻塞),但你需要提前、明确地告诉采购员你要什么,而且采购员可能买错(字符串拼写错误),或者市场没货(404),你依然要处理这些异常。
4. Universal Module Definition (UMD) 与“兼容并蓄”的挣扎
随着模块化规范林立,一个现实问题浮现:一个库如何同时兼容 AMD、CommonJS 和传统的全局变量模式,以便在任何环境下都能工作?UMD 应运而生。它是一个