概览
前端工程化是指将前端开发中的设计、开发、测试和部署等环节进行标准化和自动化,以提高开发效率和代码质量,并降低维护成本。
模块化:使用模块化思想可以将复杂的代码拆分成小的可重用的模块,并且使得不同模块之间的依赖关系更加清晰。
自动化构建:通过使用构建工具(如 Gulp、Webpack、Rollup 等),可以自动化地完成代码编译、压缩、打包、转换、优化等任务,从而提高开发效率。
自动化测试:通过使用自动化测试框架和工具(如 Jest、Mocha、Chai、Selenium 等),可以自动化地完成单元测试、集成测试、UI 测试等任务,从而提高代码质量并减少故障。
自动化部署:通过使用自动化部署工具(如 Jenkins、Travis CI、GitLab CI/CD 等),可以自动化地完成代码上传、服务器部署、数据库更新等任务,从而减少手动操作产生的错误和漏洞。
规范化管理:通过使用代码规范(如 ESLint、Stylelint、Prettier 等)和版本控制系统(如 Git),可以规范开发流程和代码风格,提高代码可读性和可维护性。
模块化规范
模块
将一个复杂的程序依据一定的规则(规范)封装成几个块(文件), 并进行组合在一起 块的内部数据与实现是私有的, 只是向外部暴露一些接口(方法)与外部其它模块通信
历史进程
全局 function 模式 => namespace 模式 => IIFE 自执行 => 模块化规范 全局变量、命名冲突 => 数据污染 => 多个 script 多请求、依赖顺序
模块化的好处
- 避免命名冲突(减少命名空间污染)
- 更好的分离, 按需加载
- 更高复用性
- 高可维护性
模块化规范
1. CommonJS 规范
一个文件为一个模块,通过 require 方法来同步加载依赖的模块,exports / module.exports 导出暴露的接口。
优点:服务器端模块重用,NPM 中模块包多,有将近 20 万个。
缺点:加载模块是同步的,只有加载完成后才能执行后面的操作,也就是当要用到该模块了,现加载现用,不仅加载速度慢,而且还会导致性能、可用性、调试和跨域访问等问题。Node.js 主要用于服务器编程,加载的模块文件一般都存在本地硬盘,加载起来比较快,不用考虑异步加载的方式,因此,CommonJS 规范比较适用。然而,这并不适合在浏览器环境,同步意味着阻塞加载,浏览器资源是异步加载的,因此有了 AMD CMD 解决方案。
实现:服务器端的 Node.js;Browserify;
2. AMD 规范
适配浏览器,可以实现异步加载依赖模块,并且会提前执行。
优点:在浏览器环境中异步加载模块;并行加载多个模块;
缺点:开发成本高,代码的阅读和书写比较困难,模块定义方式的语义不顺畅;不符合通用的模块化思维方式,是一种妥协的实现;
实现:RequireJS; curl;
3. CMD
Common Module Definition 规范和 AMD 很相似,尽量保持简单,并与 CommonJS 和 Node.js 的 Modules 规范保持了很大的兼容性。
优点:依赖就近,延迟执行 可以很容易在 Node.js 中运行;
缺点:依赖 SPM 打包,模块的加载逻辑偏重;
实现:Sea.js ;coolie
AMD 和 CMD 区别:
- 对于依赖的模块,AMD 是提前执行,CMD 是延迟执行。不过 RequireJS 从 2.0 开始,也改成可以延迟执行(根据写法不同,处理方式不同)。CMD 推崇 as lazy as possible.
- AMD 推崇依赖前置,CMD 推崇依赖就近。
ES6 Module 和 CommonJS 模块的区别:
- CommonJS 是对模块的浅拷⻉,ES6 Module 是对模块的引⽤,即 ES6 Module 只存只读,不能改变其值,也就是指针指向不能变,类似 const;
- import 的接⼝是 read-only(只读状态),不能修改其变量值。 即不能修改其变量的指针指向,但可以改变变量内部指针指向,可以对 commonJS 对重新赋值(改变指针指向),但是对 ES6 Module 赋值会编译报错。
- CommonJS 是执行阶段进行模块解析,ES6 Module 是编译阶段解析。
- this 的指向不同,CommonJS this 指向当前 module 的默认 exports,ES6 Module 执行 undefined。
相同点: 都有缓存:都会缓存模块,模块加载一次后会缓存起来,后续再次加载会用缓存里的模块。
构建工具
1. webpack
基本知识点 1 - module、chunk 和 bundle
module: 只要是文件,都是一个 module
chunk: 代码块,是 webpack 根据功能拆分出来的(chunk 是无法在打包结果中看到的,打包结果中看到的是 bundle),包含三种情况:
- 你的项目入口(entry)
- 通过 import()动态引入的代码
- 通过 splitChunks 拆分出来的代码
bundle: bundle 是 webpack 打包之后的各个文件,一般就是和 chunk 是一对一的关系,bundle 就是对 chunk 进行编译压缩打包等处理之后的产出。
我们直接写出来的是 module,webpack 处理时是 chunk,最后生成浏览器可以直接运行的 bundle。 参考
基本知识点 2 - hash、chunkhash、contenthash
hash 计算与整个项目的构建相关;
chunkhash 计算与同一 chunk 内容相关;
contenthash 计算与文件内容本身相关。参考
基本知识点 3 - devtool
inline: Source Map 内容通过 base64 放在 js 文件中引入。
hidden: 代码中没有 sourceMappingURL,浏览器不自动引入 Source Map。
eval: 生成代码和 Source Map 内容混淆在一起,通过 eval 输出。
nosources: 使用这个关键字的 Source Map 不包含 sourcesContent,调试时只能看到文件信息和行信息,无法看到源码。
cheap: 不包含列信息,并且源码是进过 loader 处理过的
cheap-module: 不包含列信息,源码是开发时的代码
参考
基本知识点 4 - webpack 中,filename 和 chunkFilename 的区别是什么?
filename 指列在 entry 中,打包后输出的文件的名称。
chunkFilename 指未列在 entry 中,却又需要被打包出来的文件的名称。
参考
基本知识点 5 - SplitChunks
chunks 选项,决定要提取那些模块。
默认是 async:只提取异步加载的模块出来打包到一个文件中。
异步加载的模块:通过 import(‘xxx’)或 require([‘xxx’],() =>{})加载的模块。
initial:提取同步加载和异步加载模块,如果 xxx 在项目中异步加载了,也同步加载了,那么 xxx 这个模块会被提取两次,分别打包到不同的文件中。
同步加载的模块:通过 import xxx 或 require(‘xxx’)加载的模块。
all:不管异步加载还是同步加载的模块都提取出来,打包到一个文件中。
参考
基本知识点 6 - webpackChunkName、webpackPrefetch、webpackPreload
webpackChunkName 是为预加载的文件取别名
webpackPrefetch 会在浏览器闲置下载文件
webpackPreload 会在父 chunk 加载时并行下载文件。
模块联邦
假设
// app1/webpack.config.js
module.exports = {
...
plugins: [
new ModuleFederationPlugin({
name: "app1",
library: { type: "var", name: "app1" },
filename: "remoteEntry.js",
exposes: {
'./Button': './src/Button',
'./say': path.join(__dirname, './say.js')
},
shared: { react: { singleton: true }, 'react-dom': { singleton: true } },
})
]
};
// app2/webpack.config.js
module.exports = {
...
plugins: [
new ModuleFederationPlugin({
name: "app2",
library: { type: "var", name: "app2" },
remotes: {
// 或者直接导入 app1@http://127.0.0.1:3001/remoteEntry.js
app1: "app1",
}
})
]
};
// or
// <!-- app2/index.html -->
<script src="http://127.0.0.1:3001/remoteEntry.js"></script>
// app2/index.js
const remoteSay = import('app1/say');
remoteSay.then(({ say }) => {
say('app2');
});
app2 远程模块的加载步骤:
- 下载并执行 remoteEntry.js(直接定义的方式:通过 JSONP 的形式去加载远程应用,拿到远程应用的 remoteEntry.js 文件后再去执行),挂载入口点对象到 window.app1,他有两个函数属性,init 和 get。init 方法用于初始化作用域对象 initScope,get 方法用于下载 moduleMap 中导出的远程模块。
- 加载 app1 到本地模块
- 创建 app1.init 的执行环境,收集依赖到共享作用域对象 shareScope
- 执行 app1.init,初始化 initScope
- 用户 import 远程模块时调用 app1.get(moduleName) 通过 Jsonp 懒加载远程模块,然后缓存在全局对象 window[‘webpackChunk’ + appName]
- 通过 webpack_require 读取缓存中的模块,执行用户回调
共享依赖:
当应用配置了 shared 后,那么依赖了这些共享依赖的模块在加载前都会先调用 webpack_require.I 去初始化共享依赖,使用 webpack_require.S 对象来保存着每个应用的共享依赖版本信息,每个应用引用共享依赖时,会根据不同的自己配制的规则从webpack_require.S 获取到适合的依赖版本,webpack_require.S 是应用间共享依赖的桥梁。
2. vite
现在常用的构建工具如 webpack,主要是通过抓取 - 编译 - 构建整个应用的代码生成一份编译、优化后能良好兼容各个浏览器的生产环境代码。在开发环境流程也基本相同,需要先将整个应用构建打包后,再把打包后的代码交给 dev server。
webpack 等构建工具的诞生给前端开发带来了极大的便利,但随着前端业务的复杂化,js 代码量呈指数增长,打包构建时间越来越久,dev server 性能遇到瓶颈:
- 缓慢的服务启动:大型项目中 dev server 启动时间达到几十秒甚至几分钟
- 缓慢的 HMR 热更新: 即使采用了 HMR 模式,其热更新速度也会随着应用规模的增长而显著下降,
缓慢的开发环境,大大降低了开发者的幸福感,在以上背景下 vite 应运而生。
什么是 vite ?
基于 esbuild 与 rollup 依赖浏览器自身 ESM 编译功能,实现极致开发体验的新一代构建工具。
开发环境
- 利用浏览器原生的 ES Module 编译能力,省略费时的编译环节,直给浏览器开发环境源码,dev server 只提供轻量服务。
- 浏览器执行 ESM 的 import 时,会向 dev server 发起该模块的 ajax 请求,服务器对源码做简单处理后返回给浏览器。
- Vite 中 HMR 是在原生 ESM 上执行的。当编辑一个文件时,Vite 只需要精确地使已编辑的模块失活,使得无论应用大小如何,HMR 始终能保持快速更新。 使用 esbuild 处理项目依赖,esbuild 使用 go 编写,比一般 node.js 编写的编译器快几个数量级。
生产环境
集成 Rollup 打包生产环境代码,依赖其成熟稳定的生态与更简洁的插件机制。
处理流程对比
Webpack 通过先将整个应用打包,再将打包后代码提供给 dev server,开发者才能开始开发。
Vite 直接将源码交给浏览器,实现 dev server 秒开,浏览器显示页面需要相关模块时,再向 dev server 发起请求,服务器简单处理后,将该模块返回给浏览器,实现真正意义的按需加载。
实现原理
ESbuild 编译
依赖预构建
- 模块化兼容:Vite 在预构建阶段将依赖中各种其他模块化规范(CommonJS、UMD)转换成 ESM,以提供给浏览器。
- 性能优化:npm 包中大量的 ESM 代码,大量的 import 请求,会造成网络拥塞。Vite 使用 esbuild,将有大量内部模块的 ESM 关系转换成单个模块,以减少 import 模块请求次数。
按需加载
- 服务器只在接受到 import 请求的时候,才会编译对应的文件,将 ESM 源码返回给浏览器,实现真正的按需加载。
缓存
- HTTP 缓存: 充分利用 http 缓存做优化,依赖(不会变动的代码)部分用 max-age,immutable 强缓存,源码部分用 304 协商缓存,提升页面打开速度。
- 文件系统缓存: Vite 在预构建阶段,将构建后的依赖缓存到 node_modules/.vite,相关配置更改时,或手动控制时才会重新构建,以提升预构建速度。
重写模块路径
浏览器 import 只能引入相对/绝对路径,而开发代码经常使用 npm 包名直接引入 node_module 中的模块,需要做路径转换后交给浏览器。
- es-module-lexer 扫描 import 语法
- magic-string 重写模块的引入路径
// 开发代码
import { createApp } from 'vue'
// 转换后
import { createApp } from '/node_modules/vue/dist/vue.js'
优势
- 快!快!非常快!!
- 高度集成,开箱即用。
- 基于 ESM 急速热更新,无需打包编译。
- 基于 esbuild 的依赖预处理,比 Webpack 等 node 编写的编译器快几个数量级。
- 兼容 Rollup 庞大的插件机制,插件开发更简洁。
- 不与 Vue 绑定,支持 React 等其他框架,独立的构建工具。
- 内置 SSR 支持。
- 天然支持 TS。
Tree Shaking
Tree-Shaking 是一种基于 ES Module 规范的 Dead Code Elimination 技术,它会在运行过程中静态分析模块之间的导入导出,确定 ESM 模块中哪些导出值未曾其它模块使用,并将其删除,以此实现打包产物的优化。
实现原理
Webpack 中,Tree-shaking 的实现一是先标记出模块导出值中哪些没有被用过,二是使用 Terser 删掉这些没被用到的导出语句。标记过程大致可划分为三个步骤:
- Make 阶段,收集模块导出变量并记录到模块依赖关系图 ModuleGraph 变量中
- Seal 阶段,遍历 ModuleGraph 标记模块导出变量有没有被使用
- 生成产物时,若变量没有被其它模块使用则删除对应的导出语句
1. 收集模块导出
- 将模块的所有 ESM 导出语句转换为 Dependency 对象,并记录到 module 对象的 dependencies 集合
- 所有模块都编译完毕后,触发 compilation.hooks.finishModules 钩子,开始执行 FlagDependencyExportsPlugin 插件回调
- FlagDependencyExportsPlugin 插件从 entry 开始读取 ModuleGraph 中存储的模块信息,遍历所有 module 对象
- 遍历 module 对象的 dependencies 数组,找到所有 HarmonyExportXXXDependency 类型的依赖对象,将其转换为 ExportInfo 对象并记录到 ModuleGraph 体系中
2. 标记模块导出
遍历 module 对象对应的 exportInfo 数组,确定其对应的 Dependency 对象是否被其他模块使用
3. 生成代码
- import 被标记为 /_ harmony import _/
- 被使用过的 export 标记为 /_ harmony export([type]) _/,其中[type]和 webpack 内部有关,可能是 binding,immutable 等。
- 没有被使用的 export 标记为/_ unused harmony export [FuncName] _/,其中[FuncName]为 export 的方法名。
4. 删除 Dead Code
Terser、UglifyJS 等 DCE 工具“摇”掉标记中无效的代码。
总结
- 根据模块的 dependencies 列表收集模块导出值,并记录到 ModuleGraph 体系的 exportsInfo 中。
- 收集模块的导出值的使用情况,并记录到 exportInfo._usedInRuntime 集合中。
- 根据导出值的使用情况生成不同的导出语句。
- 使用 DCE 工具删除 Dead Code,实现完整的树摇效果。
Webpack 热更新(HMR)的实现原理
- 第一步,webpack watch 模式下,文件系统中某一个文件发生修改,webpack 监听到文件变化,根据配置文件对模块重新编译打包,并将打包后的代码通过 JS 对象保存在内存中。
- 第二步,webpack-dev-server 的中间件 webpack-dev-middleware 调用 webpack 暴露的 API 对代码变化进行监听,并且告诉 webpack 将代码打包到内存中。
- 第三步,webpack-dev-server 对文件变化的监控。当配置文件中设置 devServer.watchContentBase 为 true,Server 会监听配置文件夹中静态文件的变化,变化后通知浏览器进行 live reload(浏览器刷新)。
- 第四部,webpack-dev-server 通过 sockjs 再浏览器端和服务器端建立 websocket 长连接,将 webpack 编译打包的各个阶段的状态信息告诉浏览器端,同时包括第三步的 Server 监听静态文件变化的信息。浏览器端根据 socket 消息进行不同操作,最主要的消息是新模板的 hash 值,后续根据该值进行 HMR。
- webpack-dev-server/client 端并不能请求更新的代码,也不会执行热更新模块操作,而是把这些工作交给 webpack。webpack/hot/dev-server 的工作是根据 webpack-dev-server/client 传递给它的信息以及 dev-server 的配置决定是刷新浏览器还是进行热更新。
- HotModuleReplacement.runtime 是客户端 HMR 的中枢,它接收上一步传递给他的新模块 hash 值,通过 JsonpMainTemplate.runtime 向 server 端发送 Ajax 请求,服务端返回 json,该 json 包含了所有要更新模块的 hash 值。获取更新列表后,该模块再次发起 jsonp 请求,获取最新的模块代码。
- HotModulePlugin 将会对新旧模块进行对比,决定是否更新模块,再决定更新模块后,检查模块之间的依赖关系,更新模块的同时更新模块间的依赖引用。
- 当 HMR 失败后,回到 live reload 操作。通过浏览器刷新获取最新打包代码。
Bun 是什么?
Bun 不仅是一个专注性能与开发者体验的全新 JavaScript 运行时,还是一个快速的、全能的工具包,可用于运行、构建、测试和调试JavaScript和TypeScript代码,无论是单个文件还是完整的全栈应用。