前端工程化的前世今生:从刀耕火种到智能构建
如果你在2012年刚入行做前端,大概率会经历这样的场景:你兴冲冲写完了一个登录页面的JS逻辑,兴高采烈地往HTML里加`<script>`标签,结果一打开页面就报错「$ is not defined」,翻来覆去找了半小时,才发现自己把jQuery的引用放在了自定义JS的后面;好不容易把顺序搞对了,上线前要手动把几十个JS文件合并、压缩,改一行代码就要重新来一遍,每次上线都要加班到半夜。这就是前端开发的「刀耕火种」时代——没有工程化工具,全靠开发者手动管理一切。
那时候的前端开发,本质上就是写一堆静态的HTML/CSS/JS文件,只要页面能跑通就行,根本谈不上「工程化(Front-end Engineering)」。但随着互联网的发展,前端要做的事情越来越多:从简单的静态页面,到复杂的富交互Web应用,再到现在的跨端开发、微前端架构,项目复杂度指数级上升,手动管理的方式很快就跟不上节奏了。于是,模块化(Modularization)、构建工具(Build Tools)这些概念应运而生,前端工程化的故事也就此展开。
---
1.1 模块化的救赎:从混乱到有序
我们先来说说前端工程化最核心的基础:模块化。想象一下,你要做一个电商网站,需要写商品列表、购物车、用户中心三个功能,如果没有模块化,你可能会把所有代码都写在一个`app.js`里,几千行甚至几万行的代码堆在一起,改个bug要找半天,而且全局变量满天飞:你写了个`getUserInfo`函数,同事也写了个同名的,一运行就互相覆盖,俗称「全局变量污染」。更麻烦的是复用:比如你写了一个格式化日期的函数,商品列表要用,购物车也要用,你只能复制两遍代码,要是哪天要改格式化的规则,两个文件都要改,漏了一个就是bug。
1.1.1 CommonJS:服务端的模块化方案
2009年,Node.js诞生,为了解决服务端JS的模块复用问题,CommonJS规范[^2]被提出。CommonJS的核心思想是「一个文件就是一个模块」,每个模块内部有独立的作用域,外部无法访问
2009年,Node.js诞生,为了解决服务端JS的模块复用问题,CommonJS规范[^2]被提出。CommonJS的核心思想是「一个文件就是一个模块」,每个模块内部有独立的作用域,外部无法访问模块内部的变量,只有通过模块导出的接口才能拿到内部内容。这种设计完美解决了全局变量污染和代码复用的问题,我们来看一个最简单的例子: ```javascript // 工具模块 utils.js,CommonJS规范 const formatDate = (date) => { const d = new Date(date) return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}` }
const add = (a, b) => a + b
// 导出模块 module.exports = { formatDate, add } ``` 在其他文件中,我们可以通过`require`方法引入这个模块,拿到导出的内容: ```javascript // 业务模块 app.js const { formatDate, add } = require('./utils.js')
console.log(formatDate(new Date())) // 输出类似 2024-05-20 console.log(add(1, 2)) // 输出 3 ``` 这种写法是不是比之前的全局变量写法清晰多了?你可以在任何需要的文件里引入`utils.js`,不需要重复复制代码,修格式化规则的时候只需要改一个地方就行。CommonJS很快在Node.js生态里普及开来,npm上的所有包几乎都是基于CommonJS规范的。
但CommonJS有个致命的缺陷:它是同步加载模块的。`require`方法会阻塞代码执行,直到模块加载完成才能继续往下走。这个设计在服务端完全没问题,因为服务端的代码是本地运行的,读取文件的速度极快,用户感知不到阻塞。但放在浏览器里就不一样了:浏览器需要从服务器下载模块文件,网络速度是不确定的,如果同步加载几十上百个模块,页面会一直白屏,用户体验极差。而且CommonJS的模块是运行时加载的,也就是说只有代码执行到`require`的时候才知道要加载哪个模块,没办法在打包阶段做静态优化,比如Tree Shaking(摇树优化,去掉没用的代码)。
> 💡 小贴士:为什么CommonJS不能在浏览器里直接用? > 同步加载会阻塞页面渲染,运行时的加载方式也做不到静态优化,所以CommonJS只能用在服务端环境,浏览器端需要异步加载的模块化方案。
1.1.2 AMD/CMD:浏览器端的模块化探索
既然CommonJS不适合浏览器,社区就开始了浏览器端模块化规范的探索,其中最知名的就是AMD(Asynchronous Module Definition,异步模块定义)和CMD(Common Module Definition,通用模块定义)。
AMD:RequireJS带来的异步加载方案
AMD是2009年由James Burke发起的规范,最著名的实现就是RequireJS。AMD的核心特点是依赖前置:在定义模块的时候,就把所有依赖的模块列出来,加载器会先加载所有依赖,再执行模块的回调函数。我们来看个例子: ```javascript // 用RequireJS定义一个依赖jQuery的用户模块 define(['jquery'], function($) { const getUserInfo = (id) => { return $.get(`/api/user/${id}`) }
// 导出模块接口 return { getUserInfo } }) ``` 在入口文件中,我们只需要列出所有顶层依赖,RequireJS会自动按顺序加载: ```javascript // 入口文件 main.js require(['jquery', './user.js'], function($, user) { // 所有依赖加载完成后执行 user.getUserInfo(1).then(data => { console.log('用户信息:', data) }) }) ``` AMD的异步加载特性完美解决了浏览器端的阻塞问题,而且支持动态加载模块,适合复杂的单页应用(SPA)。但它的缺点也很明显:配置复杂,依赖前置的写法不够灵活。比如你某个模块里只有某个条件分支才会用到某个依赖,你也得在`define`里提前列出来,加载器还是会提前加载这个依赖,造成不必要的资源浪费。
CMD:SeaJS带来的就近依赖方案
CMD是2011年由阿里巴巴的前端工程师玉伯发起的规范,实现是SeaJS。CMD的核心特点是依赖就近:什么时候需要依赖,什么时候再`require`,不需要提前声明,写法更贴近CommonJS的习惯,也更灵活。我们来看同一个用户模块的CMD写法: ```javascript // 用SeaJS定义用户模块 define(function(require, exports, module) { // 用到jQuery的时候再引入,不需要提前声明 const $ = require('jquery') // 用到工具函数的时候再引入 const { formatDate } = require('./utils.js')
const getUserInfo = (id) => { return $.get(`/api/user/${id}`).then(data => { // 用工具函数处理返回数据 data.createTime = formatDate(data.createTime) return data }) }
// 导出模块 module.exports = { getUserInfo } }) ``` 你看,CMD的写法和CommonJS几乎一模一样,只是外面套了个`define`函数,对习惯了服务端开发的开发者非常友好。而且依赖就近的写法避免了不必要的提前加载,性能比AMD更好。
当时AMD和CMD的争论非常激烈,就像现在Vue和React的阵营之争一样:AMD党认为依赖前置更清晰,方便静态分析;CMD党认为依赖就近更灵活,更符合开发习惯。但最终两者都没成为浏览器端的统一标准,因为2015年ECMAScript 2015(ES6)正式发布了原生的模块化方案——ES Module,从标准层面统一了前后端的模块化写法。
> 💡 小贴士:AMD和CMD的异同 > 相同点:都是异步加载,适合浏览器端;不同点:AMD依赖前置,CMD依赖就近,CMD写法更贴近CommonJS。
1.1.3 ES Module:原生模块化的终局
ES Module(简称ESM)是ECMAScript标准内置的模块化方案,2015年随ES6发布,2020年所有主流浏览器都原生支持了ES Module,Node.js也从v12开始支持ES Module,现在已经是前后端统一的模块化标准。
ES Module的语法非常简洁,用`export`导出内容,用`import`引入内容,我们来看例子: ```javascript // utils.js,ES Module规范 export const formatDate = (date) => { const d = new Date(date) return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}` }
export const add = (a, b) => a + b
// 默认导出 export default function multiply(a, b) { return a * b } ``` 引入的时候可以按需引入,也可以引入默认导出: ```javascript // app.js,ES Module规范 // 按需引入 import { formatDate, add } from './utils.js' // 引入默认导出 import multiply from './utils.js'
console.log(formatDate(new Date())) console.log(add(1, 2)) console.log(multiply(2, 3)) ``` ES Module相比CommonJS、AMD/CMD有三大核心优势: 1. 静态编译:ES Module的导入导出是静态的,可以在代码执行前就分析出所有的依赖关系,这意味着我们可以做Tree Shaking:打包的时候自动去掉没有被引用的代码,大幅减小包体积。比如上面的例子中,如果你只引入了`formatDate`,`add`和`multiply`的代码就会被完全从打包结果里去掉。 2. 异步加载:ES Module天生是异步加载的,不会阻塞页面渲染,浏览器会自动按需加载依赖的模块,非常适合大型单页应用。 3. 统一标准:前后端都可以用ES Module的语法,不需要写两套代码,Node.js和浏览器都原生支持,不需要额外的加载器。
当然ES Module也有兼容性问题,比如老版本的浏览器(比如IE11)不支持,所以生产环境还是需要构建工具把ES Module转成兼容性更好的代码。但不管怎样,ES Module已经是模块化的终极标准了,CommonJS、AMD/CMD这些社区规范都慢慢退出了历史舞台。
我们可以用一个表格来对比四种模块化规范的核心差异: | 规范 | 加载方式 | 适用环境 | 语法特点 | 支持静态分析/Tree Shaking | |------|----------|----------|----------|---------------------------| | CommonJS | 同步 | 服务端(Node.js) | `require`/`module.exports` | 不支持 | | AMD | 异步 | 浏览器端 | `define`/`require`,依赖前置 | 不支持 | | CMD | 异步 | 浏览器端 | `define`/`require`,依赖就近 | 不支持 | | ES Module | 异步 | 浏览器/服务端 | `import`/`export` | 支持 |
到这里,模块化的问题基本解决了,但新的问题又来了:我们写代码的时候会把项目拆成几十上百个小模块,每个模块是一个单独的文件,浏览器如果要加载这些文件,就需要发起几十上百个HTTP请求,网络开销非常大;而且我们写的JSX、TypeScript、Less、Sass这些语法,浏览器根本不认识,直接运行会报错;还有代码压缩、代码混淆、去掉console.log这些需求,靠人工手动处理完全不现实。于是,构建工具(Build Tools)就登场了。
---
1.2 构建工具的演进:从自动化到智能化
构建工具的核心作用,就是把开发者写的源代码,转换成浏览器能识别的、优化过的生产环境代码,同时提升开发效率。构建工具的发展也经历了几个阶段:从Task Runner(任务运行器)到Bundler(打包工具),再到现在的智能构建工具。
1.2.1 早期构建工具:Grunt与Gulp的Task Runner时代
2012年前后,前端项目开始变得复杂,开发者需要手动做很多重复的工作:把多个JS文件合并成一个、压缩CSS、压缩JS、监听文件变化自动刷新页面、把Less编译成CSS等等。这些工作都是机械的重复劳动,于是Task Runner类的构建工具就诞生了,最知名的就是Grunt和Gulp。
Grunt:基于配置的任务运行器
Grunt是2012年发布的,核心思路是基于配置文件来定义任务,每个任务由对应的插件实现。比如我们要实现「监听Less文件变化,自动编译成CSS,然后压缩」这个需求,只需要写一个`Gruntfile.js`配置文件: ```javascript // Gruntfile.js module.exports = function(grunt) { // 加载需要的插件 grunt.loadNpmTasks('grunt-contrib-less') grunt.loadNpmTasks('grunt-contrib-cssmin') grunt.loadNpmTasks('grunt-contrib-watch')
// 配置任务 grunt.initConfig({ // Less编译任务 less: { development: { files: { 'dist/style.css': 'src/style.less' } } }, // CSS压缩任务 cssmin: { target: { files: [{ expand: true, cwd: 'dist/', src: ['*.css'], dest: 'dist/', ext: '.min.css' }] } }, // 监听文件变化任务 watch: { styles: { files: 'src/**/*.less', tasks: ['less', 'cssmin'] } } })
// 注册默认任务,执行grunt命令就会按顺序执行less、cssmin、watch grunt.registerTask('default', ['less', 'cssmin', 'watch']) } ``` Grunt的优点是插件生态丰富,几乎所有重复的机械任务都能找到对应的插件,缺点也很明显:基于配置的写法非常繁琐,每个任务都要写一大段配置,而且任务之间是基于临时文件的,比如Less编译完生成CSS文件,CSS压缩再读取这个CSS文件,IO开销很大,速度慢。
Gulp:基于流的任务运行器
2014年,Gulp发布,解决了Grunt的很多痛点。Gulp的核心思路是「基于流(Stream)」,任务之间通过内存中的流传递数据,不需要生成临时文件,速度比Grunt快很多,而且配置写法更简洁,更贴近代码逻辑。同样的需求,Gulp的配置是这样的: ```javascript // gulpfile.js const gulp = require('gulp') const less = require('gulp-less') const cssmin = require('gulp-cssmin') const watch = require('gulp-watch')
// 编译Less并压缩的任务 function buildStyles() { return gulp.src('src/**/*.less') .pipe(less()) // 编译Less,在内存中传递CSS内容 .pipe(cssmin()) // 压缩CSS,还是在内存中处理 .pipe(gulp.dest('dist/')) // 最后才写入文件 }
// 监听文件变化 function watchFiles() { watch('src/**/*.less', buildStyles) }
// 导出默认任务 exports.default = gulp.series(buildStyles, watchFiles) ``` Gulp的写法更直观,流式处理也更快,很快取代了Grunt成为