背景与问题分析
在维护公司的PC端hybrid应用时,其中一个版本上线了许多新功能,这些新功能大多数是通过新增弹窗实现的扩展,所以弹窗的打开速度对于提升用户体验来说非常关键。以设置弹窗为例,在Intel i5-10500 8GB内存,windows 10操作系统环境下,设置页面的首次内容渲染时间(FCP)约为261.7ms,能看到明显的白屏。
针对这一问题,我们对弹窗打开过程中的每一个部分进行了分析,弹窗打开流程可以划分为用户点击弹窗入口、客户端创建弹窗、在新窗口中加载html、加载css和js以及渲染等步骤,其中前端可优化部分为后三者。
- 加载html,加载时间基本取决于html的大小,而html文件基本在10kB以内,没有太多优化空间
- 加载css和js,所有弹窗的页面在同一个工程里,且采用的是单页面应用的开发方式,即使弹窗内容很少,也需要将大部分页面的js、css文件都加载进来,冗余内容很多,一些公共css文件体积很大,也可能存在冗余
- 渲染,必须要等待js加载完成并执行,页面才能呈现内容,且前端框架采用的是vue2,渲染速度落后于vue3
优化方案
针对以上问题,我们讨论得出了以下几点解决方案:
- 按多页面应用的方式重构弹窗代码,使每个弹窗只需要加载自己用到的资源,缩小js/css资源体积
- 在多页面的基础上,将弹窗逐个升级为vue3框架,提高渲染效率
- 用css变量代替sass的@import和@extend实现动态应用主题颜色,缩小公共css资源体积
- 用@prerenderer/webpack-plugin实现页面打包时预渲染,在js加载完成前就呈现页面,js加载完成后激活交互
多页面打包
在webpack5中配置多页面应用打包,只需要为每个页面提供一个入口,并添加一个pages配置即可:
值得关注的是,通过htmlWebpackPlugin.options.[props]不仅可以读取title,还可以读取其它属性,如我们可以配置:
css
pages: {
index: {
// ...
filename: 'index.html',
ydk: './lib/ydk.js'
}
}
并在index.html中通过以下方式读取ydk属性:
xml
<script type="text/javascript" src="<%=htmlWebpackPlugin.options.ydk %>"></script>
在桌面端工程中,这个特性帮助我们实现了sdk的两端兼容以及一些js库的按需加载。
完成多页面配置后,工程的部分目录结构及打包结果如下:
以设置页为例,在优化前,打开设置页需要请求3.2MB的资源,而采用多页面应用模式后,需要加载的资源减少至776KB
css资源体积优化
在实现多页面打包后,设置页776KB的资源中,包含了一个256KB的css文件,远远大于其它资源文件。经检查,它是一个包含主题色板的公共css,但即使是压缩前的主题色板,也只有20KB不到,为什么打包压缩后却大了几十倍呢?直接打开这个256KB的文件查看后,我们发现踩了一个坑。
在开发过程中,为了实现类似于"常态下展示颜色1,hover后展示颜色2"的需求,我们经常使用了sass的@import和@extend语法,在普通的style标签内,这样并不会出现任何问题,但如果位于vue的标签内,通过@import引入的css文件,会被vue转换为带有属性选择器的样式,从而产生一组与引入的css不一致的样式表。因此,对于一个公共css,每通过@import在中引入一次,就会在最终打包结果中出现一个被转换后的副本。这就是20KB不到的公共css打包后膨胀到256KB的原因。
为了解决这个问题,我们使用了css变量代替@import和@extend,只需要全局定义一次,即可直接使用
css
@import "@/assets/css/deskUiLibrary.scss";
p {
@extend .color_text_1;
}
/* 转变为 */
p {
color: var(--color_text_1);
}
优化后,公共css的大小缩小至27.9KB,打开设置页需要加载的资源缩小至554KB
预渲染
预渲染是一种在用户请求前完成渲染的渲染方式,通过启动无头浏览器,读取网页路由并将网页内容渲染到静态html上,在用户请求时直接返回渲染好的静态页面。在hybrid应用中,客户端读取本地前端资源的过程可以看成是向服务器请求页面,如果能在打包时完成预渲染,加载完html和css即可呈现页面,能进一步缩短白屏时间。
配置
@prerenderer/webpack-plugin的配置:
javascript
// ...
new PrerenderPlugin({
indexPath: "index.html", // html入口
routes: ['/'], // 要预渲染的路由
renderer: new Prerenderer({ // 渲染器(一般选择puppeteer,js-dom无法适配很多BOM api)
maxConcurrentRoutes: 4, // 最大同时渲染数
// 三种触发方式
renderAfterDocumentEvent: 'prerendered', // 在document触发指定事件后截取渲染好的html
// renderAfterTime: 1000, // 在指定时间后截取渲染好的html
// renderAfterElementExists: '#app', // 在指定元素出现后截取渲染好的html
// 指定inject和injectProperty后,可以在预渲染时通过window.__PRERENDER_INJECTED获取到 {isPrerender: true}
injectProperty: '__PRERENDER_INJECTED',
inject: {
isPrerender: true,
},
// 是否使用无头浏览器,设置为false会在预渲染时弹出一个浏览器窗口
headless: true,
}),
postProcess(context) { // 编辑输出的内容
console.log('context',context);
context.outputPath = num + 'prerender.html';
num++;
},
})
// ...
多页面下,需为每一个页面配置不同的indexPath和routes:
javascript
// 预渲染打包
...Object.keys(pages).filter((key) => pages[key].prerender).map((key) => {
const page = pages[key];
return new PrerenderPlugin({
indexPath: page.filename,
routes: page.prerenderRoutes || ['/'],
renderer: new Prerenderer({
maxConcurrentRoutes: 4,
renderAfterDocumentEvent: 'prerendered',
injectProperty: '__PRERENDER_INJECTED',
inject: {
isPrerender: true,
},
}),
postProcess(context) {
console.log('context', context); // 包含originalRoute, route, outputPath和html四个字段,可以对其进行修改
// 修改outputPath可以改变输出文件的文件名和路径
context.outputPath = page.filename.replace(DEFAULT_FILENAME_PREFIX, context.originalRoute.replace(/[#/]+/g, '_').replace(/_$/, ''));
// 修改html可以改变输出内容
// context.html = context.html.replace('app','testApp');
},
})
}),
vue激活
使用预渲染后,html文件里包含了已经渲染好的DOM,但vue挂载时,默认仍然会重新渲染一份DOM,然后挂载在指定元素下,这个过程可能导致页面闪动,无法达到利用预渲染缩短白屏时间的效果。因此还需要使用vue的服务端渲染api:createSSRApp。使用creatSSRApp代替creatApp时,vue会进入激活模式,创建应用实例后,不会挂载新的DOM节点,而是将每个组件与它应该控制的 DOM 节点相匹配,并添加 DOM 事件监听器,实现客户端激活。激活后,页面才具有交互功能。
但桌面端本地资源与网络资源的区别在于没有服务端的参与,在预渲染时和客户端打开弹窗时执行的是同一套代码,所以我们必须在运行时区分当前是在预渲染还是在端内运行。这时@prerenderer/webpack-plugin的inject功能就派上用场了,通过配置inject和injectProperty参数,可以利用window.[injectProperty]区分当前是否是在预渲染,并执行相应的逻辑。
需要注意的是,vue期望匹配的html是由renderToString api渲染得到的,这在vue的文档中有体现。因此在预渲染时需要使用renderToString生成html而不是mount。
javascript
function render(app, prerender, onBeforePrerender, el) {
// 利用inject功能判断是否是在预渲染
if (prerender && window.__PRERENDER_INJECTED && window.__PRERENDER_INJECTED.isPrerender) {
// 执行一些预渲染前的操作
if (onBeforePrerender) {
app = onBeforePrerender(app);
}
// 调用renderToString渲染为html字符串后手动插入#app中
renderToString(app).then((html) => {
document.getElementById('app').innerHTML = html;
// 触发事件让预渲染插件截取渲染结果
setTimeout(() => {
document.dispatchEvent(new Event("prerendered"));
})
})
} else {
app.mount(el, !!prerender);
}
}
激活不匹配问题
如果预渲染的HTML的DOM结构不符合页面应用实例的期望,就会出现激活不匹配。这时,vue会尝试自动恢复并调整预渲染的 DOM 以匹配应用实例的状态,但这个过程依然可能导致页面闪动,这是我们不希望看到的。所以我们需要尽可能避免激活不匹配,以下是几种容易导致激活不匹配的情况:
- 组件模板中存在不符合规范的 HTML 结构,如<div>不能放在<p>中,浏览器会将形如<p><div></div><p>的结构纠正为<p></p><div></div><p></p>,从而导致激活不匹配。
- 渲染所用的数据中包含随机生成的值。在有道词典桌面端中,存在一些页面需要根据当前平台(windows/mac)渲染不同的内容,由于预渲染发生在打包时,打得的包是根据打包机器的平台来渲染的,所以也会出现激活不匹配的情况,需要特殊处理。
- 如果渲染的应用包含 Teleport,那么其传送的内容将不会包含在主应用渲染出的字符串中。在大多数情况下,更推荐的方案是在客户端挂载时条件式地渲染 Teleport。
预渲染的局限性
预渲染方式能有效缩短弹窗的白屏时间,但仍然存在一些局限性,并不能运用到所有弹窗:
- 每个路由都会被预渲染成一个html,对于路由很多的页面,会产生大量html文件,不利于维护,也容易导致整体包体积增大
- 在有动态内容的页面不适合使用,如依据url参数的不同渲染不同的内容等,动态内容意味着激活不匹配,容易发生页面闪动,无法起到提高用户体验的作用
优化效果
我们在完成多页面打包优化以及完成预渲染优化两个关键节点,对设置弹窗的优化效果进行了测试
- 测试环境:Intel i5-10500, 8GB 内存, windows 10操作系统
- 测试指标:首次内容绘制(FCP),用户感知的窗口打开时间(等于客户端创建窗口时间+FCP,约等于用户点击打开弹窗到看见页面内容的时间)
测试结果如下:
在完成上述优化后,设置页FCP较优化前缩短了53.5%,用户感知的窗口打开时间较优化前缩短了43%