现在前端项目的开发过程离不开构建工具帮助,我们应该去了解一下构建工具发展历史、底层基本原理,处理一些问题的时候往往能起到事半功倍效果。伴随对以下问题的思考,开始构建之旅。
- 前端为什么需要构建工具?
- 什么原因导致构建工具演变?
- 我的项目更适合使用哪个构建工具?
什么是构建?
构建就是把我们在开发时编写的代码,转换成生产时部署的代码。
虽然市面上存在着五花八门的构建工具,但它们的最终目标都是一致的,那就是转换开发环境下的代码为生产环境中的代码。围绕着这个终极目标,不同的构建工具基于各种场景并侧重实现了对应的功能特性。(如文件打包、代码压缩、code splitting、tree shaking、hot module replacement等功能)
前端构建工具演变历程
从什么时候开始需要构建,到今天构建工具层出不穷各领风骚。这个过程我们到底经历了哪些故事?我们又可以依靠哪些工具来实现我们不同时期的目标?这一切离不开前端工程的模块化的演进史~
# 刀耕火种-无模块化时代
JS内联外联
前端代码是否必须通过构建才可以在浏览器中运行呢? 当然不是。同学是否记得刚开始学习前端时的情景,只需要按格式写几个HTML标签,之后再插入一段简单的JS代码,打开浏览器Hellp World就展示在页面上。
<html>
<head></head>
<body>
<div id="root"></div>
<script type="text/javascript">
document.getElementById('root').innerText = 'Hello World'
</script>
</body>
</html>
可以看到,这段内联JS代码并没有经过任何构建工具的处理就能够成功运行在浏览器之中。这样的代码在小型项目之中还算勉强可以维护,但当项目进入真正的实战开发,代码规模开始急速扩张后,大量逻辑混杂在一个文件之中就变得让人难以直视。早期的前端项目组织如下:
<html>
<head></head>
<body>
<div id="root"/>
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
<script type="text/javascript">
$(document).ready(function(){
$('#root')[0].innerText = 'Hello World'
})
</script>
</body>
</html>
通过JS的内联外联组织代码,将不同的代码放在不同的文件中, 但是这也仅仅解决了代码组织混乱的问题,还存在很多问题:
- 大量的全局变量,代码之间的依赖是不透明的,任何代码都可能改变了全局变量。
- 脚本的引入需要依赖特定的顺序。
后续出现过一些IIFE、命名空间等解决方案,但是从本质上都没有解决依赖全局变量通信的问题。在这样的背景下,JS模块化成为迫切需要的能力。然而,由于JS的先天不足,模块化并不存在于JS最初的设计之中,因此不同的模块化方式开始逐渐涌现。JS模块化也成为前端走上工程化道路的关键因素。
社区模块化时代
AMD/CMD - 异步模块加载
为了解决浏览器端JS模块化的问题, 出现了通过引入相关工具库的方式来解决这一问题。出现了两种应用比较广的规范及其相关库:AMD (RequireJs) 和 CMD(Sea.js) 。AMD推崇依赖前置、提前执行,CMD推崇依赖就近、延迟执行。相关写法
Require.js
define(["jquery"], function ($) {
$(document).ready(function(){
$('#root')[0].innerText = 'Hello World';
})
return $
})
Sea.js
define(function(require, exports, module) {
var $ = require('jquery');
$('#header').hide();
});
两种模块化规范实现的原理基本上是一致的,只不过各自坚持的理念不同。两者都是以异步的方式获取当前模块所需的模块,不同的地方在于AMD在获取到相关模块后立即执行,CMD则是在用到相关模块的位置再执行的。
AMD/CMD解决问题:
- 手动维护代码引用顺序。从此不再需要手动调整HTML文件中的脚本顺序,依赖数组会自动侦测模块间的依赖关系,并自动化的插入页面。
- 全局变量污染问题。将模块内容在函数内实现,利用闭包导出的变量通信, 不会存在全局变量污染的问题
从内外联JS到AMD/CMD模块化前端代码的出现,前端开始逐步走出了刀耕火种的时代。不过到目前为止,这些工程本质上都可以直接运行于浏览器之中,前端工程化还处于萌芽阶段,各式各样的前端自动化构建工具都还未出场。
NodeJS兴起
在Google Chrome推出V8引擎后,基于其高性能和平台独立的特性,Nodejs这个JS运行时也现世了。至此,JS打破了浏览器的限制,拥有了文件读写的能力。Nodejs 不仅在服务器领域占据一席之地,也将前端工程化带进了正轨。
在这个背景下,第一批基于Node.js的构建工具出现了。
Grunt
Grunt主要能够帮助我们自动化的处理一些反复重复的任务,例如压缩、编译、单元测试、linting等。
Grunt的I/O操作比较“呆板”,每个任务执行结束后都会将文件写入磁盘,下个任务执行时再将文件从磁盘中读出,这样的操作会产生一些问题:
- 运行速度较慢
- 硬件压力大
Gulp
Gulp最大特点是引入了流的概念,同时提供了一系列常用的插件去处理流,流可以在插件之间传递。同时Gulp设计简单,既可以单独使用,也可以结合别的工具一起使用。
const { src, dest } = require('gulp');
// gulp提供的一系列api
// src 读取文件
// dest 写入文件
const babel = require('gulp-babel');
exports.default = function() {
return src('src/*.js')
.pipe(babel())
.pipe(dest('output/'));
}
Browserify
随着Node.js的兴起,CommonJS模块化规范 成为了当时的主流规范。但是我们知道CommonJS所使用的require语法是同步的,当代码执行到require方法的时候,必须要等这个模块加载完后,才会执行后面的代码。这种方式在服务端是可行的, 这是因为服务器只需要从本地磁盘中读取文件,速度还是很快的,但是在浏览器端,我们通过网络请求获取文件,网络环境以及文件大小都可能使页面无响应。
browserify 致力于打包产出在浏览器端可以运行的CommonJS规范的JS代码。
var browserify = require('browserify')
var b = browserify()
var fs = require('fs')
// 添加入口文件
b.add('./src/browserifyIndex.js')
// 打包所有模块至一个文件之中并输出bundle
b.bundle().pipe(fs.createWriteStream('./output/bundle.js'))
browserify职责单一,只负责js模块合并打包,同时其代码风格与gulp契合度较高类,因此开发者常常将它们结合起来使用。Gulp+browserify的构建模式在一段时期内几乎是前端公认的工程化标配。
ESM-规范出现
在经历了AMD/CMD/CommonJS等模块化规范多年的割据混战之后,在2015年JavaScript官方的模块化标准ESM(ECMAScript module)终于姗姗来迟。与之前的规范不同的是ESM规范本身只阐述了应该如何将文件解析为模块记录,如何实例化和对该模块求值,但并没有对文件获取的方式做出要求,因此ESM同时支持同步和异步的模块加载方式。
Webpack
其实在ESM标准出现之前, webpack已经诞生了,只是没有火起来。webpack的理念更偏向于工程化,因为当时的前端开发并没有太复杂,有一些 mvc 框架但都是昙花一现,前端的技术栈在 requireJs/sea.js、grunt/gulp、browserify、webpack 这几个工具之间抉择。伴随着MVC框架以及ESM的出现与兴起,webpack2顺势发布,宣布支持AMD\CommonJS\ESM、css/less/sass/stylus、babel、TypeScript、JSX、Angular 2 组件和 vue 组件。从来没有一个如此大而全的工具支持如此多的功能,几乎能够解决目前所有构建相关的问题。SPA应用的复杂度使得webpack搭配React/Vue/Angular成为最佳选择,至此webpack真正成为了前端工程化的核心。
module.exports = {
entry: 'src/js/index.js', // SPA入口文件
output: {
filename: 'bundle.js'
}
module: { // 模块匹配和处理 大部分都是做编译处理
rules: [
{ test: /.js$/, use: 'babel-loader' }, // babel转换语法
]
},
plugins: [ // 插件
// 根据模版创建html文件
new HtmlWebpackPlugin({ template: './src/index.html' }),
],
}
webpack要兼顾各种方案的支持, 也暴露出其缺点:
- 配置往往非常繁琐,开发人员心智负担大。
- webpack为了支持cjs和esm,自己做了polyfill,导致产物代码很“丑”。
在webpack出现两年后, rollup诞生了~。
Rollup
rollup 是一款面向未来的构建工具,完全基于ESM模块规范进行打包,率先提出了Tree-Shaking的概念。并且配置简单,易于上手,成为了目前最流行的JS库打包工具。
import resolve from 'rollup-plugin-node-resolve';
import babel from 'rollup-plugin-babel';
export default {
input: 'src/main.js', // 入口文件
output: {
file: 'bundle.js',
format: 'esm' // 输出模块规范
},
plugins: [
resolve(), // 转换commonjs模块为ESM
babel({ // babel转换语法
exclude: 'node_modules/**'
})
]
}
rollup 基于esm, 实现了强大的 Tree-Shaking 功能, 使得构建产物足够的简洁、体积足够的小。但是要考虑浏览器的兼容性问题的话, 往往需要配合额外的polyfill库。
ESM-规范原生支持
Esbuild
在实际开发过程中,随着项目规模逐渐庞大,前端工程的启动和打包的时间也不断上升,一些工程动辄几分钟甚至十几分钟,漫长的等待,真的让人绝望。这使得打包工具的性能被越来越多的人关注。
esbuild是一个非常新的模块打包工具,它提供了类似webpack资源打包的能力,但是拥有着超高的性能。
esbuild支持ES6/CommonJS规范、Tree Shaking、TypeScript、JSX等功能特性。提供了JS API/Go API/CLI多种调用方式。
// JS API调用
require('esbuild').build({
entryPoints: ['app.jsx'],
bundle: true,
outfile: 'out.js',
}).catch(() => process.exit(1))
根据官方提供的性能对比, 我们可以看到性能足有百倍的提升,为什么会这么快~?
语言优势
- esBuild是选择 Go 语言编写的,而在esBuild之前,前端构建工具都是基于Node,使用JS进行编写。JavaScript是一门解释性脚本语言,即使V8引擎做了大量优化(JWT 即时编译),本质上还是无法打破性能的瓶颈。而 Go 是一种编译型语言,在编译阶段就已经将源码转译为机器码,启动时只需要直接执行这些机器码即可。
- Go 天生具有多线程运行能力,而 JavaScript 本质上是一门单线程语言。esBuild进过精心的设计,将代码parse、代码生成等过程实现完全并行处理。
性能至上原则
- esBuild 只提供现代Web应用最小的功能集合,所以其架构复杂度相对较小,更容易将性能做到极致
- 在webpack、rollup这类工具中, 我们习惯于使用多种第三方工作来增强工程能力。比如:babel、eslint、less等。在代码经过多个工具流转的过程中,存在着很多性能上的浪费, 比如:多次进行代码-> AST 、AST->代码的转换。esBuild 对此类工具完全进行了定制化重写,舍弃部分可维护性,追求极致的编译性能。
虽然esBuild性能非常高,但是其提供的功能很基础,不适合直接用到生产环境,更适合作为底层的模块构建工具,在它基础上进行二次封装。
Vite
vite 是下一代前端开发与构建工具,提供 noBundle 的开发服务,并内置丰富的功能, 无需复杂配置。
vite 在开发环境和生产环境分别做了不同的处理,在开发环境中底层基于 esBuild 进行提速, 在生产环境中使用rollup进行打包。
为什么vite开发服务这么快
传统bundle based服务
- 无论是webpack还是rollup提供给开发者使用的服务,都是基于构建结果的。
- 基于构建结果提供服务,意味着提供服务前一定要构建结束,随着项目膨胀,等待时间也会逐渐变长。
noBundle服务
- 对于vite、snowpack这类工具,提供的都是No Bundle服务,无需等待构建,直接提供服务。
- 对于项目中的第三方依赖, 仅在初次启动和依赖变化时重构建,会执行一个
依赖预构建的过程。由于是基于esBuild做的构建,所以非常快。 - 对于项目代码,则会依赖于浏览器的ESM的支持,直接按需访问,不必全量构建。
为什么在生产环境中构建使用rollup?
- 考虑到浏览器兼容性以及实际网络中使用ESM可能会造成RTT时间过长,所以仍然需要打包构建。
- 又因为Esbuild虽然快速,但其一方面还未到1.0的稳定版本,另一方面对代码分割和css处理等支持较弱,所以目前仍然使用Rollup进行生产环境的实际打包构建,不过未来很有可能改为esbuild。
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import {resolve} from 'path';
export default defineConfig({
resolve:{
alias:{
'@':resolve('src')
}
},
plugins: [vue()]
})
Snowpack
SnowPack是Pika团队开发的轻量且快速的前端构建工具。该团队旨在让Web应用提速90% 。SnowPack与Vite大部分功能和思想都非常相似(两者互相学习,如vite依赖预构建学习了snowpack,snowpack的HMR学习了vite)。不过,相比Vite的实用主义,Snowpack更理想化,技术至上一些。
两者的不同主要有以下两点:
- SnowPack支持Streaming Imports。
Streaming Imports原理非常简单,就是将本地npm包依赖转换为远程CDN中的npm包依赖。
// Original file import "react" // After compile (Streaming Imports) import "https://pkg.snowpack.dev/react" 复制代码这样做的好处有以下几点:
- 由于远程的npm包已经经过了打包编译,开发构建工具无需对依赖进行处理,节约了构建时间
- 开发者无需在本地下载安装依赖项项目就可以运行
- 远程npm包分布在CDN边缘节点中,用户页面在打开时会就近下载依赖,节约项目加载时间
之所以SnowPack能够想到并使用这种模式是因为Pika团队之前开发了一个叫做SkyPack的项目,该项目就是将npm包打包编译压缩后传至CDN中供开发者使用。
- SnowPack在生产模式中默认使用了Esbuild,不打包直接输出ESM,与开发模式行为保持一致。不过它支持用户选择其它打包构建工具如Rollup和Webpack。(注意:选择多不意味着一定就是好的,Snowpack选择多但每个模式都存在一些问题,而Vite与rollup深入结合带来的问题会相对少一些。)