前言
最近有测试和 local 投诉,我们后台管理系统的登录页面经常要加载很久,常常会出现页面已经显示出来了,但是点击登录毫无反应,直到全部加载后才能登录。于是,他们提出让我们去优化。
这是一个挺好的问题,登录页虽然不是移动端那种首页,但也是最先呈现给内部用户的。
定位耗时
遇到这种问题,首先需要找出耗时都花在了哪里,然后再去想具体办法去解决。首先,打开登录页面控制面板,Disable Cache 之后查看一下每个资源的耗时。

从图上可以明显看出来,有一个 2.2m 的文件足足耗时 5s 之久,刷新了很多次后耗时都是在 4s - 5s 之间,而文件的耗时主要在下载上面,看来主要的性能瓶颈就在这里了。 由于 JS 文件在腾讯云 CDN 上面配置了协商缓存(etag),所以在第二次加载的时候速度提升非常大,基本上不到 1s 就可以加载出来了。

那么这个大文件是什么文件呢?我去 Jenkins 上看一下构建记录,在 build 的时候看到这个文件就是基于第三方包打出来的 vendors 文件。

webpack4 splitChunks
既然知道这个是 vendors 文件了,那就来分析一下 webpack 构建。
在 webpack4 里面出现了 splitChunk 来拆分 chunk 文件,webpack4 会有一个默认的 vendors chunk,它会把 node_modules 都给打成一个包,类似于:
optimization: {
splitChunks: {
chunks: 'initial',
cacheGroups: {
vendors: {
test: /[\\/]node_modules[\\/]/,
priority: -10
}
只不过,Nuxt 在这个基础上又拆分出了一个 commons ,配置规则如下:
optimization.splitChunks.cacheGroups.commons = {
test: /node_modules[\\/](vue|vue-loader|vue-router|vuex|vue-meta|core-js|@babel\/runtime|axios|webpack|setimmediate|timers-browserify|process|regenerator-runtime|cookie|js-cookie|is-buffer|dotprop|nuxt\.js)[\\/]/,
chunks: 'all',
priority: 10,
name: true
}
priority 代表优先级,如果两个 cacheGroups 里面都引用了同一个库,那么就根据优先级来判断优先把这个库打进哪个 chunk 里面。
很明显 commons 的优先级要高于 vendors,所以会把 test 规则匹配到的第三方包优先拆分出来,这几个主要是 Nuxt 中依赖的一些库。
本地执行了一次 analyze 后,得到的构建图是这样的,可以看出来 vendors 明显远比其他的包都要大,尤其是 xlsx、iview、moment、lodash 这几个库,几乎占了一大半体积。

优化
生成多 HTML
既然知道 vendors 包里面都是一些第三方库了,那么是否可以只打出登录页依赖的第三方库,然后只去加载这个 chunk 文件呢?
我看了一下登录页逻辑很简单,不需要 lodash、moment,甚至连 iview 都不需要,完全可以自己去实现样式,这样就不必去加载体积这么大的 vendors chunk 了。
真是个好主意,可是问题来了,怎么才能不去加载 vendors 呢?如果是在 webpack 里面,这个很容易,我们可以通过
html-webpack-plugin
来加载多个 HTML 文件,针对登录页生成一个 HTML 文件,让它只去加载自身依赖的 chunk 文件。
于是我去看了一下 Nuxt 源码,发现这里还是暴露了配置给我们去定义一个新的 HTML 模板的。当然,到最后我也没去尝试这种方法,只是觉得应该可以实现。

从 HTML 模板中删除
Nuxt 会暴露给我们一个
app.html
模板文件,它会在服务端渲染出来数据,最后替换到这个文件里面。
<!DOCTYPE html>
<html {{ HTML_ATTRS }}>
<head {{ HEAD_ATTRS }}>
{{ HEAD }}
</head>
<body {{ BODY_ATTRS }}>
{{ APP }}
</body>
</html>
那么我们有没有可能在 Nuxt 替换这些占位符之前先去除掉不需要加载的 chunk 文件呢?其实也是可以的,只是需要修改到 Nuxt 的源码。
修改了源码之后,还需要用
patch-package
去打一个补丁,这样就可以做到修改
node_modules
里面的代码。
打开项目的 node_modules 文件夹,找到
@nuxt/vue-renderer/dist/vue-renderer.js
,在
SSRRenderer
这个类里面的
render
方法中,我们可以看到如下代码:

m.script.text({ body: true })
这句代码拿到的就是最后页面上渲染出来的
script
标签,如果在这里匹配到 vendors 包,把它给排除掉,之后在页面上就不会加载这个 JS 文件了。
我这里的方案是这样的,首先把登录页不需要且体积很大的几个包(iview、moment、lodash)给单独打了一个
my-vendors
的包,在 Nuxt 源码中用正则表达式去匹配这个文件名,然后手动
replace
掉(记得要把 link 标签里面预加载的也一起替换掉)
// nuxt.config.js
config.optimization.splitChunks.cacheGroups.myVendors = {
test: /node_modules[\\/](view-design|moment|moment-timezone|dayjs|crypto-js|simple-uploader\.js|vue2-google-maps|vuex-class|axios)[\\/]/,
// cacheGroupKey here is `commons` as the key of the cacheGroup
automaticNamePrefix: 'my-vendors', // 文件名以 my-vendors 为前缀
name: true,
chunks: 'all',
priority: 10
reuseExistingChunk: true
// vue-renderer.js
const scripts = APP.match(/(\<script[\s\S]*?>[\s\S]*?\<\/script\>)/g) || []
const script = scripts.find(s => s.indexOf('my-vendors') > -1);
APP = APP.replace(script, '')
const links = HEAD.match(/(\<link rel="preload" [\s\S]*?>)/g) || []
const link = links.find(s => s.indexOf('my-vendors') > -1);
HEAD = HEAD.replace(link || "", '')
最终的效果的确是不会加载这个文件了,但是点击事件失效了,对比前后两次加载的文件,差别只有这个 my-vendors.js,不清楚为什么点击事件失效,所以最终为了赶时间也就没使用这个方法。
服务端直出
除了上面两种方式之外,还有一种比较简单的方式。由于 Nuxt 本身就会启动一个服务,官方也支持我们使用
express\koa
等等来实现服务端的路由,所以我们可以把登录页面直接用纯服务端渲染,去掉所有不必要的第三方库和文件。
涉及到图片之类的,我事先把他们上传到了 CDN 上面,然后根据环境变量去加载不同的 CDN 地址。
// login/template.ts
export default (config) => {
return `
<title data-n-head="true">AirPay Admin</title>
<meta data-n-head="true" charset="utf-8">
<meta data-n-head="true" name="viewport" content="width=device-width, initial-scale=1">
<meta data-n-head="true" data-hid="description" name="description" content="Admin">
<link data-n-head="true" rel="icon" type="images/x-icon" href="/favicon.ico">
<link data-n-head="true" rel="preconnect" href="${
config.cdnServer.staticUrl
}" crossorigin="true">
<link data-n-head="true" rel="preconnect" href="${
config.apiServer.baseUrl
}" crossorigin="true">
<style>
article, aside, blockquote, body, button, dd, details, div, dl, dt, fieldset, figcaption, figure, footer, form, h1, h2, h3, h4, h5, h6, header, hgroup, hr, input, legend, li, menu, nav, ol, p, section, td, textarea, th, ul {
margin: 0;
padding: 0;
img {
border-style: none;
</style>
<script src="https://cdn.jsdelivr.net/npm/particles.js@2.0.0/particles.min.js"></script>
</head>
<div id="particles" class="particles"></div>
<div onclick="login()" class="login">
<div class="login-content">
<div style="width:350px" class="login-wrapper">
<div class="login-title">
slot="title"
style="text-transform: capitalize; color: #595d65; font-size: 16px; display: flex; height: 25px;"
<img src="${
config.cdnServer.staticUrl
}/static/admin-website/logo.png" alt="logo" class="login-logo" />
<div class="login-body">
<div class="login-button" onclick="login()">
<div class="login-icon"></div>
<span class="login-text">Sign in with Google</span>
<script>
particlesJS('particles', {
"particles": {},
"interactivity": {}
}, function() {
console.log('callback - particles.js config loaded');
</script>
<script>
function login() {}
</script>
</body>
</html>
}
然后,在
/login
路由下面引入这个模块,传入必要的配置后直接输出,记得设置
Content-Type
为
text/html
。
// login/index.ts
module.exports = function(
fastify: Fastify.FastifyInstance,
opts: Fastify.RouteShorthandOptions,
next: Function
fastify.get('/login', async (request, reply) => {
reply
.code(200)
.header('Content-Type', 'text/html; charset=utf-8')
.send(login(Config))