本文按照前端打包构建工具发布的顺序,根据原理分析各个打包构建工具的特点,涉及前端打包构建工具以及计算机网络常见面试题。
为什么要前端构建工具?
模块化开发、兼容性、压缩减小代码体积、分片......
前端构建工具发展
-
2009年,CommonJS,浏览器以外的 JS API 规范发布。同年,NodeJS 发布,并将 CommonJS 作为其模块化规范。
-
2011年,RequireJS 发布,也就是以后的 AMD 规范。
-
2013年,Grunt、Gulp发布。
-
2014年,兼容浏览器端和服务器端的模块化规范 UMD 发布。
-
2014年,babel 发布。同年,webpack 发布。
-
2015年,ES6 规范发布,JS 正式有了官方的模块化规范。
-
2015年,基于 ES6 的 rollup 发布,实现了 Tree Shaking 的能力。
-
2017年,Parcel 发布。
-
2019年,snowpack发布,能将 node_modules 转为 ESM。
-
2020年,go 语言开发的 esbuild 发布。
-
2021年,Vite 发布。
-
2021年,rust 语言开发的 SWC 发布。
初代构建工具
初代构建工具包括 Grunt 和 Gulp,现在已经很少使用了,这两种构建工具具有一个相同的特点:都是基于任务的构建工具。
Grunt
Grunt 的配置文件Gruntfile.js
示例如下:
module.exports = function(grunt) {
grunt.initConfig({
pkg: grunt.file.readJSON('package.json'),
uglify: {
options: {
banner: '/*! <%= pkg.name %> <%= grunt.template.today("yyyy-mm-dd") %> */\n'
},
build: {
src: 'src/<%= pkg.name %>.js',
dest: 'build/<%= pkg.name %>.min.js'
}
}
});
grunt.loadNpmTasks('grunt-contrib-uglify');
grunt.registerTask('default', ['uglify']);
};
可以看到 grunt 的配置被放在对象中,通过grunt.initConfig
方法传递,也就是说它是配置驱动的构建工具。
他的缺点在于,任务数量很多时,配置将会相当复杂。并且,它的任务包括读取磁盘内容,处理文件,写入磁盘这三个流程,如果要对多个文件进行多个任务,那么会有多次磁盘读写的操作(I/O 不友好)。
Gulp
Gulp 也是基于任务驱动的构建工具,目前阿里的 ahooks 使用了 gulp 来打包 cjs。
Gulp 最大的特点在于它是编程式的构建工具。
Gulp 的配置文件示例如下:
const { src, dest, task } = require('gulp');
const babel = require('gulp-babel');
gtask('js', function() {
return src('src/*.js')
.pipe(babel())
.pipe(dest('output/'));
})
其中,src
和dist
方法用于处理文件,可以看到 gulp 通过pipe
管道,或者说是一种流水线的方式来处理文件,并且它也是基于任务的。
相比Grunt,gulp 通过管道机制,解决了反复读写的问题。
webpack
webpack是现代前端构建工具的基石,它有以下几个核心概念:
-
Entry
-
Output
-
Module
-
Resolve
-
Chunk
-
Loader
-
Plugin
webpack以串行的方式运行,它的本质就是要构建如官网中的依赖图,流程如下:
-
初始化:合并 shell 语句和配置文件中的参数。
-
开始编译:根据参数初始化 Compiler 对象,加载对应的插件,执行 run 方法。
-
找到入口:根据配置找到 entry 入口文件。
-
开始编译:从入口文件出发,调用模块对应的 Loader 翻译成AST,当遇到导入语句时,就要将它加入到依赖的模块列表,并且进行递归,直到弄清所有模块的依赖关系。
-
输出资源:将转换后的模块组装成chunk,并且转换成独立的文件加入到输出列表。
-
写入文件:根据 output 把文件写入到文件系统。
webpack的问题是:
-
在ESM以前,CommonJS中只能通过script标签引入,是没有作用域这一概念的,所以webpack打包后的产物都是以IIFE(立即执行函数)的方式来实现作用域。
浏览器为什么不支持CommonJS?
因为 CommonJS 是同步的,如果有异步操作就需要去等待,这样会阻塞我们的运行过程,影响用户的体验,而node中是服务器端,可以用同步的规范。
基于webpack改进的构建工具
Rollup
rollup是基于webpack改进的构建工具,相比webpack,rollup速度更快,打包后代码体积更小,因为它使用 ES6 模块化规范,能够支持 Tree-shaking,所以更受一些第三方类库的青睐,我们的 Vue 和 React 框架就使用了 rollup 进行构建。
以Vue为例,他通过genConfig
函数生成了 rollup 可以识别的配置:
function genConfig (name) {
const opts = builds[name] // 根据传入的name获取builds中对应的配置对象
const config = {
input: opts.entry, // 入口
external: opts.external, // 需要排除的外部依赖
plugins: [
flow(), // 用flow做类型保护
alias(Object.assign({}, aliases, opts.alias)) // 收集所有的参数
].concat(opts.plugins || []),
// 出口
output: {
file: opts.dest, // 目标文件
format: opts.format, // 格式化类型
banner: opts.banner, // 文案
name: opts.moduleName || 'Vue'
},
onwarn: (msg, warn) => {
if (!/Circular/.test(msg)) {
warn(msg)
}
}
}
Object.defineProperty(config, '_name', {
enumerable: false,
value: name
})
return config // 这样就生成了方便rollup去解析的config对象
}
可以看出rollup与webpack类似,都是配置为核心的构建工具,但是他的配置相对简单,没有 loader 去对文件进行处理,并且没有 devServer 和 HMR,所以 rollup 一般不用于业务开发。
Parcel
要说配置简单,就不得不说Parcel,因为它的配置完全是内置的,也就是黑盒的。
他提供了基本的能力,包括code splitting、HMR、sourcemap、publicPath、tree shaking、scope hoist、share module、UMD等,对于没有定制需要的项目可以使用。
突破JS语言特性的构建工具
不得不说前端越来越卷了,为了卷死同行,构建工具开始使用其他语言的特性来提升性能。
SWC
SWC 是基于 rust 语言开发的,用来对标 babel 的编译器(比 babel 快20倍),因为 JS 构建工具的性能瓶颈在于 JS 语言本身的特性(单线程)。它包含了 Compiler 和 Bundler 的能力,只不过目前将它用作 Compiler。
SWC的用法和 babel 基本一致,webpack当中也有对应的swc-loader
,我们通过创建.swcrc
来进行配置。
{
"$schema": "https://json.schemastore.org/swcrc",
"jsc": {
"parser": {
"syntax": "ecmascript", // 还支持TS
"jsx": false,
"dynamicImport": false,
"privateMethod": false,
"functionBind": false,
"exportDefaultFrom": false,
"exportNamespaceFrom": false,
"decorators": false,
"decoratorsBeforeExport": false,
"topLevelAwait": false,
"importMeta": false
},
"transform": null,
"target": "es5",
"loose": false,
"externalHelpers": false,
"keepClassNames": false
},
"minify": false
}
缺点是 TS 支持做的还不够好,目前使用上还存在一些问题。
esbuild
用实力说话:
特性:
-
速度快,无需缓存
-
支持ES6和CommonJS模块
-
支持Tree-Shaking
为什么快?
Go语言
JS 是一门解释型语言,即每次执行需要将源码编译成机器码,然后执行;Go 语言是一门编译型语言,在编译阶段已经将源码转为机器码,后续只要执行机器码即可。
多线程
Go 语言具有多线程的能力,并且多个线程共享内存空间,虽然 JS 中可以引入 WebWorker,但是每个线程都有自己独立的内存堆,需要通过 postMessage 来共享线程间的数据。
全量定制
esbuild 完全重写了整个编译流程,开发成本较高,但收益巨大。
-
重写了 ts 转译工具,并且只关注于代码转换,放弃类型检查
-
混合多个编译过程(词法分析、语法分析、代码转换、代码生成)
统一的数据结构
在编译时共用相似的 AST 结构,提升内存使用效率。
-
esbuild 目前对于前端框架的支持较弱,并且没有像 ES5 降级、HMR等能力,所以目前普遍使用基于 esbuild 的下一代前端构建工具。
基于esbuild的bundleless工具
bundleless原理
HTTP2.0
HTTP2.0相比1.0有哪些区别?
HTTP2.0和HTTP1.x的区别在于:
-
二进制分帧
-
多路复用
-
头部压缩
-
服务器推送
因为HTTP2的特性,我们就不再需要将文件进行合并,从而减少请求次数了。
ESModule
浏览器可以直接使用<script type="module">
来使用 ESM。
Bundleless 具有以下优势:
-
冷启动时间大大缩短:因为只需要关注当前请求需要的模块,而不是整个 bundle。
-
HMR 速度不受项目整体体积影响:启动两个server,一个负责运行项目,一个负责HMR,两个server进行 WebSocket 连接,当浏览器发起 ESM 请求,只需要将当前文件进行编译,返回给浏览器。
Snowpack
Snowpack 实现了免打包和快速 HMR,优化了打包时的性能。
Vite
Vite是最新一代前端构建工具,旨在解决冷启动慢、HMR延迟等问题。
开发环境
开发环境无需打包,使用 esbuild 对 node_modules 进行与构建,将结果存到node_modules/.vite
,如果 package.json, lockfile, vite.config.js中的字段发生变化,就会重新触发预构建。
依赖浏览器的缓存技术,例如设置max-age
对模块进行缓存。
生产环境
生产环境中即使有 HTTP2 的支持,但为了生产环境中的性能需要,还是需要进行 Tree-shaking、懒加载以及分 chunk。所以 vite 目前选择了 rollup 来进行打包。
请求拦截
因为除了原生 JS 资源外,还可能有 jsx、tsx、vue 等多种文件,所以针对不同资源的请求要进行拦截,Vite 的做法是启动 Koa 服务器,将对应的资源转成 JS 模块来进行加载。
-
在请求中,对于
node_modules
模块的请求的路径会被替换成/@modules/
,当浏览器收到后,会对/@modules/
再次发起请求,并且再次被 Vite 拦截,由 Vite 访问真正的模块后返回给浏览器。 -
对于.vue文件,Vite会分为三个请求(template, script, style),浏览器会先收到包含 script 逻辑的 App.vue 的响应,然后解析到 template 和 style 的路径后,会再次发起 HTTP 请求来请求对应的资源,此时 Vite 对其拦截并再次处理后返回相应的内容。
热更新
Vite 的热更新就是在客户端与服务端建立了一个 websocket 连接,当代码被修改时,服务端发送消息通知客户端去请求修改模块的代码,完成热更新。
-
服务端:服务端做的就是监听代码文件的改变,在合适的时机向客户端发送 websocket 信息通知客户端去请求新的模块代码。
-
客户端:Vite 中客户端的 websocket 相关代码在处理 html 中时被写入代码中。可以看到在处理 html 时,vite/client 的相关代码已经被插入。
Turbopack
Turbopack 还没有正式发布,它是基于 Rust 语言编写的构建工具,创建 Turbopack 就是为了提高 Next.js 的速度,希望它能够取代 Webpack,成为下一代 Web 打包工具。https://turbo.build/pack