首屏性能优化——写一个支持预渲染以及骨架屏的webpack插件

建议看此文时,先去看谷歌出的关于各种渲染方式区别的一篇博客文章Rendering on the Web。先了解清楚CSR,SSR,预渲染,同构的区别。

首屏渲染优化的那些事

最近产品觉得项目的首屏白屏时间太长了,严重影响到了用户的使用体验,于是我这边捣鼓了一下如何优化首屏的渲染时间。在不改变原有项目的结构上,最终决定了使用预渲染以及骨架屏的方式来进行首屏渲染的优化。

不过翻了一下github上并没有两者结合的比较好的开源库= =。针对性的库倒是有:

  1. 骨架屏—— 饿了么开源的page-skeleton-webpack-plugin,几百年没人维护了
  2. 骨架屏——考拉海购开源的awesome-skeleton
  3. 预渲染—— vue核心开发者开源的prerender-spa-plugin

最终决定在prerender-spa-plugin的基础上fork了一份,把awesome-skeleton的部分骨架屏实现进行了集合。搞了个能够自动化生成预渲染以及骨架屏的webpack插件-prerender-skeleton-plugin。项目内用了一下感觉还不错。

这边接下来主要是记录思考过程中的笔记。

为什么会有首屏白屏

浏览器渲染包含 HTML 解析、DOM 树构建、CSSOM 构建、JavaScript 解析、布局、绘制等等,大致如下图所示:

alt

要搞清楚为什么会有白屏,就需要利用这个理论基础来对实际项目进行具体分析。通过 DevTools 进行分析:

alt

  1. 等待 HTML 文档返回,此时处于白屏状态。
  2. 对 HTML 文档解析完成后进行首屏渲染,因为项目中对 加了灰色的背景色,因此呈现出灰屏。
  3. 进行文件加载、JS 解析等过程,导致界面长时间出于灰屏中。
  4. 当 Vue 实例触发了 mounted 后,界面显示出大体框架。
  5. 调用 API 获取到时机业务数据后才能展示出最终的页面内容。

由此得出结论,因为要等待文件加载、CSSOM 构建、JS 解析等过程,而这些过程比较耗时,导致用户会长时间出于不可交互的首屏灰白屏状态,从而给用户一种网页很“慢”的感觉。

以上内容节选自美团技术团队前端黑科技:美团网页首帧优化实践

fcp与fp

首屏性能最重要的两个性能指标:

  1. FP -> first paint 首绘
  2. FCP -> first contentful paint 首次有内容绘制

那么如何获取这两个指标呢,我们可以通过chrome的devtools工具Performance或者LightHouse来获取这两个指标.

alt

指标的具体分析可以参考文章前端黑科技:美团网页首帧优化实践

优化方式

SSR服务端渲染

对于CSR客户端渲染而言,直接返回html空节点。后续还要请求相关的路由js文件然后在交由浏览器进行渲染,这其中会造成长时间的白屏。 CSR 渲染架构的特点非常明显:

实现了前后端架构分离,实现了前后端职责分离;

TTFB 时间最小,但由于客户端和服务端会有多次交互(获取静态资源、获取数据)才能进行渲染,实际首屏效果以及 FCP/FMP 时间不够理想。

alt

SSR渲染在服务端完成页面模板、数据预取、填充,并且在服务端就可以将完整的 HTML 内容返回给浏览器。首屏性能高,FMP 比 CSR 和预渲染快。

alt

以我博客为例,传统单页面CSR部署的www.carrotwu.com/homenext.js重构部署的SSR域名ssr.carrotwu.comFCP对比就十分明显。

alt

alt

但SSR也有自身的缺点:

  1. 更复杂的开发,开发的代码需要兼容前后端的runtime
  2. 更复杂的构建和部署
  3. 加重服务器负载

预渲染

什么是预渲染?直接搬vue官方的解释即可:

如果你调研服务器端渲染 (SSR) 只是用来改善少数营销页面(例如 /, /about, /contact 等)的 SEO,那么你可能需要预渲染。无需使用 web 服务器实时动态编译 HTML,而是使用预渲染方式,在构建时 (build time) 简单地生成针对特定路由的静态 HTML 文件。优点是设置预渲染更简单,并可以将你的前端作为一个完全静态的站点。

如果你使用 webpack,你可以使用 prerender-spa-plugin 轻松地添加预渲染。它已经被 Vue 应用程序广泛测试 - 事实上,作者是 Vue 核心团队的成员。

prerender-spa-plugin

prerender-spa-plugin 利用了 Puppeteer 的爬取页面的功能。 Puppeteer 是 Google Chrome 团队官方的无界面(Headless)Chrome 工具,它是一个 Node 库,提供了一个高级的 API 来控制 DevTools 协议上的无头版 Chrome 。prerender-spa-plugin 原理是在 Webpack 构建阶段的最后,在本地启动一个 Puppeteer 的服务,访问配置了预渲染的路由,然后将 Puppeteer 中渲染的页面输出到 HTML 文件中,并建立路由对应的目录。

基本用法如下:

const PrerenderSPAPlugin = require('prerender-spa-plugin');
const Renderer = PrerenderSPAPlugin.PuppeteerRenderer;
const path = require('path');

module.exports = {
    plugins: [new PrerenderSPAPlugin({
        staticDir: path.join(__dirname, 'dist'),
        routes: [ '/' ], // 需要预渲染的路由,
        renderer: new Renderer({
            headless: true, // 开启无头浏览器
            renderAfterDocumentEvent: 'render-event', // 渲染的事件,只有触发了这个事件,插件才会开始爬取html
          }),
    })]
}

缺点:

  1. 不支持动态路由,例如/news/:id这样子的路径
  2. 适用于纯静态数据的页面,对于需要实时数据的页面并不能使用预渲染

骨架屏

骨架屏类似于页面加载中的loading效果,骨架屏可以给人一种页面内容已经渲染出一部分的感觉,相较于传统的 loading 效果,在一定程度上可提升用户体验。尤其在网络较慢、图文信息较多、加载数据流较大的情况下。

简单的说,骨架屏就是在JS代码解析完成之前,先使用一些图形进行占位,等内容加载完成之后用真实的页面把它替换掉。

alt

page-skeleton-webpack-plugin

page-skeleton-webpack-plugin 是饿了么团队开发的一款自动化生成骨架屏的webpack插件,这个插件可以根据不同的页面生成不同的骨架屏页面。

大概原理如下:

alt

page-skeleton-webpack-plugin的使用不再赘述,社区已经有很多成熟的用例.

预渲染+骨架屏

对于预渲染来说,需要实时获取数据的页面并没有很好的办法进行支持。

那么我们换个思路,对于需要获取实时数据的页面来说:

  1. 静态节点可以通过预渲染进行处理。
  2. 需要获取实时数据来更新的节点,我们可以通过骨架屏的形式来进行占位。

骨架屏+预渲染的形式我觉得对于用户体验来说会更加的优雅。因此,在原有prerender-spa-plugin预渲染的基础上我整合了page-skeleton-webpack-plugin的骨架屏渲染功能。自行搞了一个支持骨架屏+预渲染的webpack插件prerender-skeleton-plugin

大概用法如下:


const PrerenderSkeletonPlugin = require('prerender-skeleton-plugin');
const Renderer = PrerenderSkeletonPlugin.PuppeteerRenderer;
const path = require('path');

module.exports = {
    plugins: [
      new PrerenderSkeletonPlugin({
        staticDir: path.join(__dirname, 'build'),
        // puppbter设备
        device: 'iPhone X',
        // 支持配置浏览器cookie
        cookies: '',
        // 路由支持数组功能 并且允许配置是否开启骨架屏
        routes: [
          { path: '/' },
          { path: '/love' },
          {
            path: '/xxx',
            // 骨架屏
            skeleton: true,
            // 骨架屏的节点 内部使用的是querySelectorAll 只支持单节点
            skeletonRoot: '.tag-list',
            // 骨架屏是否开启动画
            animation: true
          },
        ],
        // 下面的配置跟原有prerender-spa-plugin插件一致
        consoleHandler: true,
        renderer: new PuppeteerRenderer({
          injectProperty: '__PRERENDER_INJECTED',
          inject: {
            prerender: true
          },
          headless: true,
          // 这个是监听 document.dispatchEvent 事件,决定什么时候开始预渲染
          // document.dispatchEvent(new Event('render-event'))
          renderAfterDocumentEvent: 'custom-render-trigger'
        }),
        server: {
          // 跨域请求
          proxy: {
            '/api': {
              target: 'xxx',
              secure: false,
              changeOrigin: true,
              pathRewrite: { '/api': '' }
            }
          }
        }
      })
    ]
}

博客列表改造后的效果如下:

alt

上面的导航栏是预渲染,下方的文章列表因为是动态获取我们设置为骨架屏即可。

遇到的坑

路由懒加载导致的客户端覆盖渲染问题

在开发的过程中,遇到了首先显示预渲染的页面。半秒后页面闪烁变白然后又变回完整的页面问题。

问题的本质是因为我们通过预渲染已经填充了当前需要渲染的节点到vue或者react提前定义好的root节点中。对于客户端渲染来说,vue或者react会直接丢弃root节点中的已经渲染好的html内容,直接重新执行一遍客户端渲染。

由于服务器已经渲染好了 HTML,我们显然无需将其丢弃再重新创建所有的 DOM 元素。相反,我们需要"激活"这些静态的 HTML,然后使他们成为动态的(能够响应后续的数据变化)。

vue中提供了data-server-rendered属性,react中提供了reactDom.hydrate。只要直出的html内容跟客户端渲染的html节点一直的话就会直接进行节点复用,重新挂载一些事件。前提是直出的html节点跟客户端渲染的节点一致。

问题就处在这里,如果页面路由时懒加载的。那么客户端渲染的html节点一定是跟直出的html节点是不一致的,这就导致了每次客户端会清除掉已经直出预渲染好的html节点然后重新进行客户端渲染。这就导致了先显示预渲染内容,然后闪烁一下(html不一致root节点被清空白屏),然后又重新进行客户端渲染的效果。

因此换个思路,我们只需要确保如果路由时懒加载的我们先加载懒加载的路由js文件再进行,一下提供react的思路。

  1. 路由懒加载,这里使用了@loadable/component
import React from 'react';
import loadable, { LoadableComponent } from '@loadable/component';
import { matchPath } from 'react-router-dom';
const Home = loadable(() => import('views/home'));
const Post = loadable(() => import('views/post'));
const Blog = loadable(() => import('views/blog'));
const Tag = loadable(() => import('views/tag'));
const TagList = loadable(() => import('views/tagList'));
const Love = loadable(() => import('views/love'));

interface IRoute {
  key: string;
  name: string;
  path: string;
  isCache?: boolean;
  exact?: boolean;
  redirect?: string;
  component?: LoadableComponent<any>;
  render?: (props: any) => React.ReactNode;
}

// 路由配置表
const routerArray: IRoute[] = [
  {
    name: '首页',
    path: '/',
    key: 'index',
    component: Blog,
    exact: true
  },
];

// 获取当前路径匹配到的路由
export function matchRoute(path: string, routeList: IRoute[]) {
  let targetRoute, targetMatch;

  for (let item of routeList) {
    targetMatch = matchPath(path, item);
    if (targetMatch) {
      targetRoute = item; //查找到第一个路由后停止查找
      break;
    }
  }
  return { targetRoute, targetMatch };
}
export default routerArray;
  1. 渲染前判断root节点是否有节点,有的话先获取路由文件再进行hydrate渲染.

const root = document.getElementById('root');
const hasChildNodes = root!.hasChildNodes();

function clientRender() {
  //查找路由
  let matchResult = matchRoute(document.location.pathname, routerArray);
  let { targetRoute } = matchResult;
  // 预渲染的情况之下
  if (targetRoute && isPromise(targetRoute.component) && targetRoute.component.load) {
    // 懒加载的路由 确保先获取在进行渲染
    targetRoute.component.load().then(() => {
      hydrate(<App />, root);
    });
  } else {
    hydrate(<App />, root);
  }
}

//为了防止预渲染时生成的节点直接删除预渲染的节点 这边需要使用hydrate来进行替换 而不是直接删除
if (hasChildNodes) {
  // 在已经预渲染的情况下,执行 hydrate
  clientRender();
} else {
  render(<App />, root);
}

骨架屏下客户端渲染与骨架屏永远不相同导致覆盖渲染的问题

对于静态数据来说使用预渲染,即使是路由懒加载只要保证先加载路由在进行客户端渲染就能够保证预渲染的html节点是能够一致并且进行复用的。

但是对于骨架屏来说,因为prerender-skeleton-plugin插件的骨架屏本质上是在构建时puppeter下获取实时数据修改样式来实现骨架屏的。因此只要使用到了骨架屏那么客户端以及html的节点是用于无法复用的,导致客户端的渲染接口总是会覆盖掉根节点中的骨架屏。因此,涉及到骨架屏的页面一定会闪烁一下然后进行loading数据的状态页面

那换种思路,我们能不能学习一下nuxt或者next的形式通过注水把数据预先保存在页面中呢?结果也是不行。因为nuxt或者next每次渲染时数据都是最新的,因此能够直接拿来客户端进行渲染。

对于prerender-skeleton-plugin插件的骨架屏来说,注水的数据是构建时的获取的数据,数据并不能得到实时更新。

因此我换了一种思路:在客户端渲染的时候把渲染好的骨架屏节点拷贝一份skeletonRoot,把原先生成好的根节点root直接进行隐藏。这样子我们等待客户端清空root节点重新构建最新的dom节点之后。再把skeletonRoot删除把根节点再显示即可

具体实现:

const root = document.getElementById('root')!;
const hasChildNodes = root.hasChildNodes();

function clientRender() {
  //查找路由
  let matchResult = matchRoute(document.location.pathname, routerArray);
  let { targetRoute } = matchResult;
  // 有骨架屏 那么需要复制一个新的子节点
  if (targetRoute && targetRoute.skeleton) {
    const skeletonRoot = root.cloneNode(true);
    // @ts-ignore
    skeletonRoot.id = '__skeleton-root__';
    // 当前节点就是骨架屏节点
    document.body.appendChild(skeletonRoot);
    // 原先的root节点进行隐藏 交给客户端进行渲染 这样子即使清除掉root节点内的内容也无所谓
    // 等待root客户端渲染实时内容后再显示即可
    root.style.display = 'none';
  }
  // 预渲染的情况之下
  if (targetRoute && isPromise(targetRoute.component) && targetRoute.component.load) {
    targetRoute.component.load().then(() => {
      hydrate(<App />, root);
    });
  } else {
    hydrate(<App />, root);
  }
}

//为了防止预渲染时生成的节点直接删除预渲染的节点 这边需要使用hydrate来进行替换 而不是直接删除
if (hasChildNodes) {
  // 在已经预渲染的情况下,执行 hydrate
  clientRender();
} else {
  render(<App />, root);
}


// 页面组件内Home
  // 页面组件内获取到数据渲染完成之后 我们删除拷贝出来的骨架屏节点 显示root节点即可
  useEffect(() => {
    load().then(() => {
      triggerPrerenderEvent(true);
    });
  }, []);

export const triggerPrerenderEvent = (isDeleteSkeletonRoot: boolean = false) => {
  const skeletonRoot = document.getElementById('__skeleton-root__');
  const root = document.getElementById('root')!;
  if (skeletonRoot && isDeleteSkeletonRoot) {
    // 删除用于搞骨架屏的节点
    skeletonRoot.remove();
    // 显示真正的root节点
    root.style.display = 'block';
  }
  document.dispatchEvent(new Event('custom-render-trigger'));
};

alt

这样子就不会有闪络的白屏一下然后loading页面的效果啦

webview下的额外首屏优化

除了运行在传统的浏览器中的h5之外,一些原生app也可以通过webview的方式渲染h5网页。对于webview下的首屏优化其实还有一些额外的优化方法。

离线包

事实上,我们的大部分离线场景将是会在本地独立 app 之中,借助客户端能力,我们可以把 web 代码包提前内置到客户端之中,然后使用一套代码更新机制,前端代码缓存问题可以得到解决。

我们可以先将页面需要的静态资源打包并预先加载到客户端的安装包中,当用户安装时,再将资源解压到本地存储中,当 WebView 加载某个 H5 页面时,拦截发出的所有 http 请求,查看请求的资源是否在本地存在,如果存在则直接返回资源。

alt

对于前端来说,首先需要在前端打包的过程中同时生成离线包,我的思路是 webpack 插件在 emit 钩子时(生成资源并输出到目录之前),通过 compilation 对象(代表了一次单一的版本构建和生成资源)遍历读取 webpack 打包生成的资源,然后将每个资源(可通过文件类型限定遍历范围)的信息记录在一个资源映射的 json 里,具体内容如下:

{
  "packageId": "mwbp",
  "version": 1,
  "items": [
    {
      "packageId": "mwbp",
      "version": 1,
      "remoteUrl": "http://122.51.132.117/js/app.67073d65.js",
      "path": "js/app.67073d65.js",
      "mimeType": "application/javascript"
    },
    ...
  ]
}

其中 remoteUrl 是该资源在静态资源服务器的地址,path 则是在客户端本地的相对路径(通过拦截该资源对应的服务端请求,并根据相对路径从本地命中相关资源然后返回)。最后将该资源映射的 json 文件和需要本地化的静态资源打包成 zip 包,以供后面的流程使用。

我们可以通过cicd在打包完成的阶段把打包好的zip文件通过接口的形式发送一份到部署好的离线包平台中。这样子app端就可以获取所有包括最新的离线包资源