// 模板代码示意,router.config.template.ejs
import { RouteConfig } from 'vue-router';
<% chooseModules.forEach(function(item){%>
import <%=item %> from '<%=deelRouteName(item) %>';
<% }) %>
let routesConfig: Array<RouteConfig> = [];
/* eslint-disable */
routesConfig = [
<% chooseModules.forEach(function(item){%>
<%=item %>,
<% }) %>
export default routesConfig;
dev-server.js
的核心在于启动一个 inquirer 交互命令行服务,让用户选择需要构建的模块,类似于这样:
模板代码示意 router.config.template.ejs
是 EJS 模板文件,chooseModules
是我们在终端输入时,获取到的用户选择的模块集合数组,根据这个列表,我们去生成新的 routesConfig
文件。
这样,我们就实现了分模块构建,按需进行依赖收集。以我们的项目为例,我们的整个项目大概有 20 个不同的模块,几十万行代码:
构建模块数
实际效果大致如下,无需启动所有模块,只启动我们选中的模块进行对应的开发即可:
这样,如果单次开发只涉及固定的模块,单次项目冷启动的时间,可以从原本的 4min+ 下降到 18s 左右,而有缓存状态下二次构建 1 个模块,仅仅需要 4.5s,属于一个比较大的提升。
受限于 Webpack 所使用的语言的性能瓶颈,要追求更快的构建性能,我们不可避免的需要把目光放在其他构建工具上。这里,我们的目光聚焦在了 Vite 与 esbuild 上。
使用 Vite 优化开发时构建
Vite,一个基于浏览器原生 ES 模块的开发服务器。利用浏览器去解析 imports,在服务器端按需编译返回,完全跳过了打包这个概念,服务器随起随用。同时不仅有 Vue 文件支持,还搞定了热更新,而且热更新的速度不会随着模块增多而变慢。
当然,由于 Vite 本身特性的限制,目前只适用于在开发阶段替代 Webpack。
我们都知道 Vite 非常快,它主要快在什么地方?
项目冷启动更快
热更新更快
那么是什么让它这么快?
Webpack 与 Vite 冷启动的区别
我们先来看看 Webpack 与 Vite 的在构建上的区别。下图是 Webpack 的遍历递归收集依赖的过程:
上文我们也讲了,Webpack 启动时,从入口文件出发,调用所有配置的 Loader 对模块进行编译,再找出该模块依赖的模块,再递归本步骤直到所有入口依赖的文件都经过了本步骤的处理。
这一过程是非常非常耗时的,再看看 Vite:
Vite 通过在一开始将应用中的模块区分为 依赖 和 源码 两类,改进了开发服务器启动时间。它快的核心在于两点:
使用 Go 语言的依赖预构建:Vite 将会使用 esbuild 进行预构建依赖。esbuild 使用 Go 编写,并且比以 JavaScript 编写的打包器预构建依赖快 10-100 倍。依赖预构建主要做了什么呢?
开发阶段中,Vite 的开发服务器将所有代码视为原生 ES 模块。因此,Vite 必须先将作为 CommonJS 或 UMD 发布的依赖项转换为 ESM
Vite 将有许多内部模块的 ESM 依赖关系转换为单个模块,以提高后续页面加载性能。如果不编译,每个依赖包里面都可能含有多个其他的依赖,每个引入的依赖都会又一个请求,请求多了耗时就多
按需编译返回:Vite 以 原生 ESM 方式提供源码。这实际上是让浏览器接管了打包程序的部分工作:Vite 只需要在浏览器请求源码时进行转换并按需提供源码。根据情景动态导入代码,即只在当前屏幕上实际使用时才会被处理。
Webpack 与 Vite 热更新的区别
使用 Vite 的另外一个大的好处在于,它的热更新也是非常迅速的。
我们首先来看看 Webpack 的热更新机制:
一些名词解释:
Webpack-complier
:Webpack 的编译器,将 Javascript 编译成 bundle(就是最终的输出文件)
HMR Server
:将热更新的文件输出给 HMR Runtime
Bunble Server
:提供文件在浏览器的访问,也就是我们平时能够正常通过 localhost 访问我们本地网站的原因
HMR Runtime
:开启了热更新的话,在打包阶段会被注入到浏览器中的 bundle.js,这样 bundle.js 就可以跟服务器建立连接,通常是使用 Websocket ,当收到服务器的更新指令的时候,就去更新文件的变化
bundle.js
:构建输出的文件
Webpack 热更新的大致原理是,文件经过 Webpack-complier 编译好后传输给 HMR Server,HMR Server 知道哪个资源 (模块) 发生了改变,并通知 HMR Runtime 有哪些变化,HMR Runtime 就会更新我们的代码,这样浏览器就会更新并且不需要刷新。
而 Webpack 热更新机制主要耗时点在于,Webpack 的热更新会以当前修改的文件为入口重新 build 打包,所有涉及到的依赖也都会被重新加载一次。
而 Vite 号称 热更新的速度不会随着模块增多而变慢。它的主要优化点在哪呢?
Vite 实现热更新的方式与 Webpack 大同小异,也通过创建 WebSocket 建立浏览器与服务器建立通信,通过监听文件的改变向客户端发出消息,客户端对应不同的文件进行不同的操作的更新。
Vite 通过 chokidar
来监听文件系统的变更,只用对发生变更的模块重新加载,只需要精确的使相关模块与其临近的 HMR 边界连接失效即可,这样 HMR 更新速度就不会因为应用体积的增加而变慢而 Webpack 还要经历一次打包构建。所以 HMR 场景下,Vite 表现也要好于 Webpack。
通过不同的消息触发一些事件。做到浏览器端的即时热模块更换(热更新)。通过不同事件,触发更细粒度的更新(目前只有 Vue 和 JS,Vue 文件又包含了 template、script、style 的改动),做到只更新必须的文件,而不是全量进行更新。在些事件分别是:
connected: WebSocket 连接成功
vue-reload: Vue 组件重新加载(当修改了 script 里的内容时)
vue-rerender: Vue 组件重新渲染(当修改了 template 里的内容时)
style-update: 样式更新
style-remove: 样式移除
js-update: js 文件更新
full-reload: fallback 机制,网页重刷新
本文不会在 Vite 原理上做太多深入,感兴趣的可以通过官方文档了解更多 -- Vite 官方文档 -- 为什么选 Vite
基于 Vite 的改造,相当于在开发阶段替换掉 Webpack,下文主要讲讲我们在替换过程中遇到的一些问题。
基于 Vue-cli 4 的 Vue2 项目改造,大致只需要:
安装 Vite
配置 index.html(Vite 解析 <script type="module" src="...">
标签指向源码)
配置 vite.config.js
package.json 的 scripts
模块下增加启动命令 "vite": "vite"
当以命令行方式运行 npm run vite
时,Vite 会自动解析项目根目录下名为 vite.config.js
的文件,读取相应配置。而对于 vite.config.js
的配置,整体而言比较简单:
Vite 提供了对 .scss, .sass, .less, 和 .stylus 文件的内置支持
天然的对 TS 的支持,开箱即用
基于 Vue2 的项目支持,可能不同的项目会遇到不同的问题,根据报错逐步调试即可,譬如通过一些官方插件兼容 .tsx
、.jsx
当然,对于项目的源码,可能需要一定的改造,下面是我们遇到的一些小问题:
tsx 中使用装饰器导致的编译问题,我们通过魔改了 @vitejs/plugin-vue-jsx
,使其支持 Vue2 下的 jsx
由于 Vite 仅支持 ESM 语法,需要将代码中的模块引入方式由 require
改为 import
Sass 预处理器无法正确解析样式中的 /deep/
,可使用 ::v-deep
替换
其他一些小问题,譬如 Webpack 环境变量的兼容,SVG iCON 的兼容
对于需要修改到源码的地方,我们的做法是既保证能让 Vite 进行适配,同时让该改动不会影响到原本 Webpack 的构建,以便在关键时刻或者后续迭代能切回 Webpack
解决完上述的一些问题后,我们成功地将开发时基于 Webpack 的构建打包迁移到了 Vite,效果也非常惊人,全模块构建耗时只有 2.6s:
至此,开发阶段的构建耗时从原本的 4.5min 优化到了 2.6s:
构建模块数
好,上述我们基本已经完成了整个开发阶段的构建优化。下一步是优化生产构建。
我们的生产发布是基于 GitLab 及 Jenkins 的完整 CI/CD 流。
在优化之前,看看我们的整个项目线上发布的耗时:
可以看到,生产环境构建时间较长, build 平均耗时约 9 分钟,整体发布构建时长在 15 分钟左右,整体构建环节耗时过长, 效率低下,严重影响测试以及回滚 。
好,那我们看看,整个构建流程,都需要做什么事情:
其中, Build base 和 Build Region 阶段存在较大优化空间。
Build base 阶段的优化,涉及到环境准备,镜像拉取,依赖的安装。前端能发挥的空间不大,这一块主要和 SRE 团队沟通,共同进行优化,可以做的有增加缓存处理、外挂文件系统、将依赖写进容器等方式。
我们的优化,主要关注 Build Region 阶段,也就是核心关注如何减少 npm run build
的时间。
文章开头有贴过 npm run build
的耗时分析,简单再贴下:
一般而言, 代码编译时间和代码规模正相关。
根据以往优化经验,代码静态检查可能会占据比较多时间,目光锁定在 eslint-loader
上。
在生产构建阶段,eslint 提示信息价值不大,考虑在 build 阶段去除,步骤前置。
同时,我们了解到,可以通过 esbuild-loader 插件去替代非常耗时的 babel-loader、ts-loader 等 loader。
因此,我们的整体优化方向就是:
改写打包脚本,引入 esbuild 插件
优化构架逻辑,减少 build 阶段不必要的检查
优化前后流程对比:
优化构架逻辑,减少 build 阶段不必要的检查
这个上面说了,还是比较好理解的,在生产构建阶段,eslint 提示信息价值不大,考虑在 build 阶段去除,步骤前置。
比如在 git commit
的时候利用 lint-staged
及 git hook
做检查, 或者利用 CI 在 git merge
的时候加一条流水线任务,专门做静态检查。
我们两种方式都有做,简单给出接入 Gitlab CI 的代码:
// .gitlab-ci.yml
stages:
- eslint
eslint-job:
image: node:14.13.0
stage: eslint
script:
- npm run lint
- echo 'eslint success'
retry: 1
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event" && $CI_MERGE_REQUEST_TARGET_BRANCH_NAME == "test"'
通过 .gitlab-ci.yml
配置文件,指定固定的时机进行 lint 指令,前置步骤。
改写打包脚本,引入 esbuild 插件
这里,我们主要借助了 esbuild-loader。
上面其实我们也有提到 esbuild,Vite 使用 esbuild 进行预构建依赖。这里我们借助的是 esbuild-loader,它把 esbuild 的能力包装成 Webpack 的 loader 来实现 Javascript、TypeScript、CSS 等资源的编译。以及提供更快的资源压缩方案。
接入起来也非常简单。我们的项目是基于 Vue CLi 的,主要修改 vue.config.js
,改造如下:
// vue.config.js
const { ESBuildMinifyPlugin } = require('esbuild-loader');
module.exports = {
// ...
chainWebpack: (config) => {
// 使用 esbuild 编译 js 文件
const rule = config.module.rule('js');
// 清理自带的 babel-loader
rule.uses.clear();
// 添加 esbuild-loader
.use('esbuild-loader')
.loader('esbuild-loader')
.options({
loader: 'ts', // 如果使用了 ts, 或者 vue 的 class 装饰器,则需要加上这个 option 配置, 否则会报错: ERROR: Unexpected "@"
target: 'es2015',
tsconfigRaw: require('./tsconfig.json')
// 删除底层 terser, 换用 esbuild-minimize-plugin
config.optimization.minimizers.delete('terser');
// 使用 esbuild 优化 css 压缩
config.optimization
.minimizer('esbuild')
.use(ESBuildMinifyPlugin, [{ minify: true, css: true }]);
移除 ESLint,以及接入 esbuild-loader 这一番组合拳打完,本地单次构建可以优化到 90 秒。
在项目体量差不多的情况下,他们的生产构建耗时(npm run build
)在 2 分钟出头,细究其原因在于:
他们的项目是 React + TSX,我这次优化的项目是 Vue,在文件的处理上就需要多过一层 vue-loader
;
他们的项目采用了微前端,对项目对了拆分,主项目只需要加载基座相关的代码,子应用各自构建。需要构建的主应用代码量大大减少,这是主要原因;
是的,后续我们还有许多可以尝试的方向,譬如我们正在做的一些尝试有:
对项目进行微前端拆分,将相对独立的模块拆解出来,做到独立部署
基于 Jenkinks 构建时,在 Build Base 阶段优化的提升,譬如将构建流程前置,结合 CDN 做快速回滚,以及将依赖预置进 Docker 容器中,减少在容器中每次 npm install
时间的消耗等
同时,我们也必须看到,前端技术日新月异,各种构建工具目不暇给。前端从最早期的刀耕火种,到逐步向工程化迈进,到如今的泛前端工程化囊括的各式各样的标准、规范、各种提效的工具。构建效率优化可能会处于一种一直在路上的状态。当然,这里不一定有最佳实践,只有最适合我们项目的实践,需要我们不断地去摸索尝试。
本文到此结束,希望对你有帮助 😃
如果还有什么疑问或者建议,可以多多交流,原创文章,文笔有限,才疏学浅,文中若有不正之处,万望告知。