桌面端webview弹窗首屏速度优化

背景与问题分析

在维护公司的PC端hybrid应用时,其中一个版本上线了许多新功能,这些新功能大多数是通过新增弹窗实现的扩展,所以弹窗的打开速度对于提升用户体验来说非常关键。以设置弹窗为例,在Intel i5-10500 8GB内存,windows 10操作系统环境下,设置页面的首次内容渲染时间(FCP)约为261.7ms,能看到明显的白屏。

针对这一问题,我们对弹窗打开过程中的每一个部分进行了分析,弹窗打开流程可以划分为用户点击弹窗入口、客户端创建弹窗、在新窗口中加载html、加载css和js以及渲染等步骤,其中前端可优化部分为后三者。

  1. 加载html,加载时间基本取决于html的大小,而html文件基本在10kB以内,没有太多优化空间
  2. 加载css和js,所有弹窗的页面在同一个工程里,且采用的是单页面应用的开发方式,即使弹窗内容很少,也需要将大部分页面的js、css文件都加载进来,冗余内容很多,一些公共css文件体积很大,也可能存在冗余
  3. 渲染,必须要等待js加载完成并执行,页面才能呈现内容,且前端框架采用的是vue2,渲染速度落后于vue3

优化方案

针对以上问题,我们讨论得出了以下几点解决方案:

  1. 按多页面应用的方式重构弹窗代码,使每个弹窗只需要加载自己用到的资源,缩小js/css资源体积
  2. 在多页面的基础上,将弹窗逐个升级为vue3框架,提高渲染效率
  3. 用css变量代替sass的@import和@extend实现动态应用主题颜色,缩小公共css资源体积
  4. 用@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 以匹配应用实例的状态,但这个过程依然可能导致页面闪动,这是我们不希望看到的。所以我们需要尽可能避免激活不匹配,以下是几种容易导致激活不匹配的情况:

  1. 组件模板中存在不符合规范的 HTML 结构,如<div>不能放在<p>中,浏览器会将形如<p><div></div><p>的结构纠正为<p></p><div></div><p></p>,从而导致激活不匹配。
  2. 渲染所用的数据中包含随机生成的值。在有道词典桌面端中,存在一些页面需要根据当前平台(windows/mac)渲染不同的内容,由于预渲染发生在打包时,打得的包是根据打包机器的平台来渲染的,所以也会出现激活不匹配的情况,需要特殊处理。
  3. 如果渲染的应用包含 Teleport,那么其传送的内容将不会包含在主应用渲染出的字符串中。在大多数情况下,更推荐的方案是在客户端挂载时条件式地渲染 Teleport。

预渲染的局限性

预渲染方式能有效缩短弹窗的白屏时间,但仍然存在一些局限性,并不能运用到所有弹窗:

  1. 每个路由都会被预渲染成一个html,对于路由很多的页面,会产生大量html文件,不利于维护,也容易导致整体包体积增大
  2. 在有动态内容的页面不适合使用,如依据url参数的不同渲染不同的内容等,动态内容意味着激活不匹配,容易发生页面闪动,无法起到提高用户体验的作用

优化效果

我们在完成多页面打包优化以及完成预渲染优化两个关键节点,对设置弹窗的优化效果进行了测试

  • 测试环境:Intel i5-10500, 8GB 内存, windows 10操作系统
  • 测试指标:首次内容绘制(FCP),用户感知的窗口打开时间(等于客户端创建窗口时间+FCP,约等于用户点击打开弹窗到看见页面内容的时间)

测试结果如下:
在完成上述优化后,设置页FCP较优化前缩短了53.5%,用户感知的窗口打开时间较优化前缩短了43%

相关推荐
WebInfra10 分钟前
Rspack 1.3 发布:内存大幅优化,生态加速发展
前端·javascript·github
zoahxmy092925 分钟前
Canvas 实现单指拖动、双指拖动和双指缩放
前端·javascript
花花鱼25 分钟前
vue3 动态组件 实例的说明,及相关的代码的优化
前端·javascript·vue.js
Riesenzahn27 分钟前
CSS的伪类和伪对象有什么不同?
前端·javascript
Riesenzahn27 分钟前
请描述下null和undefined的区别是什么?这两者分别运用在什么场景?
前端·javascript
__不想说话__28 分钟前
前端视角下的AI应用:技术融合与工程实践指南
前端·javascript·aigc
niusir28 分钟前
使用 useCallback 和 useMemo 进行 React 性能优化
前端·javascript·react.js
六月的可乐43 分钟前
【干货】前端实现文件保存总结
前端·javascript·面试
mCell1 小时前
每秒打印一个数字:从简单到晦涩的多种实现
前端·javascript·面试
Carlos_sam1 小时前
OpenLayers:如何使用渐变色
前端·javascript