如何将 Lighthouse Performance 评分从 20 提高到 96

4,050 阅读15分钟

背景

笔者最近在工作中对一个项目进行了性能优化,Lighthouse Performance 评分从 20 提高到了96,开发和构建的速度也有了大幅度的提高,整个优化过程的思路和方法还是很有参考意义的,比如借鉴 React Fiber 分片思想来优化 TBT (JS 阻塞时间),特此借本文做个记录和总结,也供更多需要的朋友参考。

优化前评分

看到这个分数,还有有点惊讶,知道性能差,不知道这么差了。这个分数也坚定了我们性能优化的决心。根据 Lighthouse 的评分标准,这个分数可以说是 very poor 了,一个优秀、用户体验良好的网站该分数应该在90分以上。

我们明确了优化目标,就是要将 Lighthouse Performance 评分提高到 90 分以上。接下来,在优化之前,我们首先需要分析项目的性能瓶颈并确定优化方向。

了解评分指标

我们的优化目标就是要提高 Lighthouse Performance 的评分,而 Lighthouse Performance 有 6 个维度的评分,显然,我们需要先了解这 6 个维度的评分标准和影响因素。详细文档请参考:Lighthouse performance scoring

下面简单介绍下(Lighthouse v8):

  • FCP(First Contentful Paint)。FCP 测量在用户导航到你的页面后浏览器呈现第一段 DOM 内容所需的时间,也就是页面第一个内容出现的时间。该指标权重为 10% (Lighthouse v8 版本,下同)。
  • SI(Speed Index)。速度指数衡量页面加载期间内容的视觉显示速度。它要求的是页面的渲染过程应该是渐进的,内容一点点出现,而不是开始一段时间一直是空白,然后全部内容一下出现。这个指标跟页面渲染时间和渲染方式有关,如果页面渲染时间很短,页面一下就出来了,那它的得分也会很高。该指标权重为 10%
  • LCP(Largest Contentful Paint)。LCP 测量视口中最大的内容元素何时呈现到屏幕上(通过录制页面 Performance 可以看到最大的内容元素是什么)。这大约是页面的主要内容对用户可见的时间。该指标权重为 25%
  • TTI(Time to Interactive)。TTI 衡量一个页面需要多长时间才能完全交互。主要影响因素就是页面渲染速度和 JS 阻塞时间。该指标权重为 10%
  • TBT(Total Blocking Time)。可以理解为 JS 的阻塞时间,该指标的计算规则就是 LCP 到 TTI(可交互时间) 之间,所有执行耗时大于 50ms 的 Task(宏任务),大于 50ms 那部分时间的总和。比如下图中的 Task 耗时 95.52ms,那这个 Task 就贡献了 45.52ms 的 TBT。该指标要求我们所有的 JS 任务(宏任务,一般是函数)执行时间不要大于 50 ms。该指标权重为 30%

  • CLS(Cumulative Layout Shift)。指网页布局在加载期间的偏移量,普遍用于测量视觉稳定性。得分范围是0-1,其中0表示没有偏移,1表示最大偏移。要求我们在渲染页面过程中,不要频繁发生内容块的偏移。该指标权重为 15%

另外, Lighthouse 评估后也会给出一些优化建议,大家可以参考下,不过处理完那些建议,分数可能也没有提高。笔者的经验是,结合优化建议和评分标准来优化。 Lighthouse 给出的优化建议,如果容易处理的就处理,如果找不到头绪,可以先搁置,然后结合评分标准来分析和判断优化方向。

经过对 Lighthouse Performance 6 个维度指标的了解和分析,接下来我们就需要针对各个指标进行优化。

提高 FCP 评分(降低首屏时间)

分析

想要降低首屏时间,我们需要先分析从输入 URL 到页面展示,发生了什么,然后再对各个阶段进行针对性优化。输入 URL 到页面展示主要流程如下:

  1. 查找缓存。浏览器会向 URL 发起请求以获取页面资源,不过在发起请求前,会先查找本地是否缓存了该资源,如果有且未过期,直接使用,不再发起请求。如果没有,则进入资源请求阶段,然后继续下面的步骤。
  2. DNS 解析,建立 TCP 连接。进入网络资源请求阶段后,首先是 DNS 解析获取 IP 地址,然后与该主机建立 TCP 连接。
  3. 发送 HTTP 请求,等待服务端返回资源。
  4. 接收到 HTML 文件后,开始解析 HTML ,构建 DOM 树等渲染步骤。

我们来分析下,上面四个步骤,有哪些可以优化的点:

  1. 如果命中缓存,那页面的加载速度将会是质的提升,缓存一直都是性能优化的重要武器,所以网站应该开启资源缓存。目前主流的优化做法是将所有的静态资源文件全部放在 CDN,并开启资源缓存。

  2. 对于 DNS 解析,我们可以开启 DNS 预解析。对于 TCP 连接,我们可以减少 TCP 连接数,使用 HTTP1.1 的长连接,后者直接使用 HTTP2.0 的多路复用。

  3. 这一步往往是影响首屏时间的最主要的因素。这一步主要包含三部分部分:网络环境、服务端响应时间、资源大小。在 Network 的 Timing 面板可以看到请求的 TTFB,它代表了网络环境和服务端响应时间,而 Content Download 则代表了网络环境和资源大小。

    优化方法主要有:静态资源上 CDN,以减少网络传输距离和提高服务端响应时间;减少资源大小,以减少资源传输时间。所以目前我们能做的就是减少项目资源文件大小(HTML、JS、CSS文件)。

  1. 在开始解析 HTML 后,遇到 CSS、JS 文件还需要去加载并解析,所以主要优化点还是减少资源文件大小。

经过上述的分析,并结合项目情况,接下来要优化的点就是减少首屏使用文件大小。

笔者项目是 SPA,并且是一个 Monorep 项目,使用技术栈是:vue + view design + vuex + vue-router

减少首屏使用文件大小

测量插件 webpack-bundle-analyzer

优化前首屏使用到的文件 chunk-vendors.js + app.js 的文件大小为(Gzipped) 1695.95 + 343.33 = 2039.28 KiB。

异步加载非首屏业务逻辑代码

我们知道首屏业务逻辑代码会打包在 app.js 文件里,而这个文件的瘦身无非两种方法:

  1. 优化业务逻辑代码,去掉无效代码,优化写法等,不过这个办法吃力不讨好,效果并不明显。
  2. 拆分,将非首屏业务逻辑代码实现异步加载,比如每个路由对应的业务都是异步加载;某些大的组件等使用到时再加载等。

在笔者的项目优化中,我们将所有非首屏路由页面做成异步加载:

注意,首屏组件不要设置为异步加载,后面会有分析。

import Home  from 'pages/home';
const NotFound = () => import('pages/not-found');
const About = () => import('pages/about');

const routes = [
  {
    path: '/home',
    name: '主页',
    component: Home,
  },
  {
    path: '/about',
    name: '关于我们',
    component: About,
  },
  {
    path: '*',
    name: 'not-found',
    component: NotFound,
  },
];

另外,笔者的项目是有多个业务线的,各个业务线又有自己的组件,之前在打包构建时候,会将所有业务线的所有组件打包进去,打开页面时注册所有的交互组件。由于各个业务线组件的增多,无用组件(非当前业务线,使用不到)的加载和注册就造成了一定的性能消耗。

所以这里就有个优化点:只加载和注册当前业务线的组件,并且抽离为异步的。这样不仅可以减少首屏文件大小,还能不让组件注册影响首屏渲染。

使用动态 import 加载指定业务线的交互组件。

// index.js
function installComponents(biz) {
  switch (biz) {
    case 1:
      import('components/1/index').then(({ install }) => install(Vue));
      break;
    case 2:
      import('components/2/index').then(({ install }) => install(Vue));
      break;
    case 3:
      import('components/3/index').then(({ install }) => install(Vue));
      break;
    case 4:
      import('components/4/index').then(({ install }) => install(Vue));
      break;
    case 5:
      import('components/5/index').then(({ install }) => install(Vue));
      break;
  }
}

// components/1/index.js
// 注册当前目录下自定义组件
export function install(_vue) {
  const components = require.context('./', true, /.vue$/);
  components.keys().forEach(key => {
    // 其他逻辑
    // 全局注册组件
    _vue.component(
      componentName, component.default || component
    );
  });
}

经过上面的优化,我们看看改造后首屏文件大小:

可以看到,app.js 文件由优化前的 343.44 KB 减少到了 207.73 KB,减少了 40% ,优化效果还是很明显的。

接下来,我们继续优化首屏使用到的另一个文件 chunk-vendors.js,也就是首屏业务逻辑的依赖包大小。

减少依赖资源大小

极致的 Tree Shaking

我们发现 Monorep 的本地包是无法做 Tree Shaking 的,所以改为使用相对路径引入指定文件:

// 该方式会引入整个 xxxx 包
// import { util } from 'op-xxxx'; // 这是 Monorep 内的共享组件 

// 该方式只引入指定的一个函数
import { util } from '../../../op-xxxx/api';

去掉没有使用的依赖

保密的原因,不能放置我们项目的构建图,下图为 webpack-bundle-analyzer 包的示例图

经过对构建包的可视化分析,我们发现还打包进来了一些共享组件,但是我们项目并没有使用到,所以我们进行了排查,并在这些没有使用到的依赖全部去掉。

去掉无效的逻辑代码

有一些无效的代码,比如下图这个两年前写的这个添加水印的方法,貌似并没有生效,却消耗了 30+ ms 的执行时间,暂时去掉。

去掉重复的依赖

分析 chunk-vendor 包可以看到,iview 打包了两次,经过分析,原因是我们的子项目使用的是 4.x 版本,也就是 view-design,而 Monorep 公共组件下有些包是很早开发的,使用的是 3.x 版本,也就是 iview。

我们子项目:

Monorep 公共组件:

由于基本上 view-design 是兼容 iview 的,所以我们决定统一使用 view-design 。接下来,需要找到哪些依赖包使用了 iview,然后统一改为使用 view-design。

一开始是使用 Webapck Analyse 这个工具,它可以可视化看到各个模块之间的依赖关系,不过现实是模块太多了,根本无法查看分析。

突然灵光乍现,换个思路,直接在 iview.js 内追踪引入路径。

最终找到了这两个文件

因为这两个文件引入的组件都是 view-design 兼容的,直接改为使用 view-design 即可:

重新跑下构建分析,看看是否有效果:

可以看到,我们子项目和 Monorep 公共组件都是使用 view-design 了,不过还是打包了两次,原因是它们使用的 view-design 版本不一致,导致在 Monorep 下 node_module 和子项目下 node_module 都是安装了 view-design 并各自引入了。

处理方式是使用统一的最新的版本。

重新跑下构建分析看看效果:

可以看到,只有一个 view-design 了,且被统一提升到外层 monorep 的 node_module 中了。

优化效果对比

经过上面的优化,我们看看优化效果:

优化前

优化前,全部 chunks 总共大小为 2.59 MB,首屏使用文件大小:2039.63 KB

优化后

优化后,全部 chunks 总共大小为 1.37 MB,首屏使用文件大小:690.94 KB。

总 chunks 大小减少了 47.1%,首屏使用文件大小减少了 66%,优化效果很明显。

经过上面构建包的优化,我们看看 Lighthouse Performance 评分提高了多少:

FCP 从 3.3s 提高到了 1.0s,效果还是很明显,同时其他指标也有了相应的提高,评分也从 20 提高到了 62。可以看出,资源包大小对网站的性能影响还是特别大的。

但是 62 分距离我们的目标 90 分还是有一段距离的,革命尚未成功,还需继续努力。

可以看到,LCP 和 TBT 还是比较高的,特别是 TBT,而且这两个指标的权重是最大的。如果想拿到 90 分,LCP 必须小于 1200ms,TBT 必须小于 150ms。接下来,我们继续优化 LCP 和 TBT。

降低 LCP 时间

Largest Contentful Paint marks the time at which the largest text or image is painted

分析

这里的策略就是,将首屏页面无关的渲染和 JS 逻辑放到首屏渲染完成之后执行。

我们可以录制 Performance 分析页面的整个解析和渲染过程。

经过分析可以发现以下几个问题:

  1. FCP 时间是右下角 feedback 按钮渲染完时间,而真正首屏出来还需要好长时间,而且这个 feedback 按钮的执行和渲染时间在 110ms 左右。

    这是不合理的,这个 FCP 可以视为无效的,因为它不是有意义首屏的一部分,所以它可以推迟到 LCP 之后渲染,避免阻塞正常首屏的渲染。

  1. FP 和 FCP 之间,还有一块大耗时的任务,就是组件的注册。这里阻塞了有效首屏的渲染,故也应该推迟到 LCP 之后执行。

  2. 项目是一个 SPA 页面,首屏路由是 Home。这里问题是路由组件做了异步加载,导致渲染首屏的时候,还要再发起网络请求,获取首屏页面的 chunk.js 和 chunk.css。这会导致首屏路由渲染时间很长,需要发起网络请求、解析 JS、执行 JS 等。所以这里应该把首屏路径改为同步的。

处理

  1. 首屏路由组件改为同步加载渲染。
import Home  from 'pages/home'; // 首屏路由同步加载即可,不要做成异步的

const routes = [
  {
    path: '/home',
    name: '主页',
    component: Home,
  }
];
  1. 将组件注册、全局弹框挂载、feedback 按钮挂载等推迟到首屏组件 mounted 后执行。
// 首屏组件 mounted 后
mounted() {
  // 注册全局组件
  this.installComponents();

  // 注册全局弹框
  this.isRegisterDialogs = true
  ....
},

降低 TBT

最大的痛点在于 TBT,它的权重是 30%,是六个指标中最高的,而我们的得分又很低,所以这是将是主要的优化方向。这也是为什么 Lighthouse Performance 评分上不去的原因。

分析

通过分析发现,项目内全局弹框众多,并且都是一次性执行注册的,这里会耗时400~500ms,大幅度提高了 TBT。

组件注册同样的道理,都是一次性同步执行的,也会消耗100ms左右,之后随着组件的增多,阻塞时间也会越来越长。

另外还有一些其他大组件,也都是一次性同步执行,整个任务的执行时间也是200~300ms。

正是这些"大任务",贡献了高的 TBT,如何降低 TBT 呢?答案就是将每个 Task 的执行时间控制在 50ms 内。

怎么让每个任务执行时间都在 50ms 内?这个不就是分片执行?还是 React Fiber 给了灵感。

优化过程

总的策略是:对于所有执行时间长的组件,其子组件采用分片挂载。

首先,我们定义一个通用的分片执行方法:

/**
 * 分片执行任务列表,避免一次性执行造成 JS 阻塞
 * 优先使用 requestIdleCallback,不支持则使用 setTimeout
 * @param {Array<function>} tasks 任务列表
 */
export function fiberExecute(tasks) {
  const _tasks = [...tasks];
  const fun = () => {
    // 去除第一个任务
    const task = _tasks.shift();
    if (task) {
      // 如果是函数,执行
      typeof task === 'function' && task();
      // 还有任务,继续加入事件循环
      if (_tasks.length) {
        myRequestIdleCallback(fun);
      }
    }
  };
  myRequestIdleCallback(fun);
}

function myRequestIdleCallback(fun) {
  if (window.requestIdleCallback) {
    requestIdleCallback(fun);
    return;
  }
  setTimeout(() => {
    requestIdleCallback(fun);
  }, 0);
}

对全局组件,采用分片挂载:

// 使用 v-if 控制先不挂载
<component-config v-if="isShowComponentConfig" />

// global-dialogs mounted 后分片挂载子弹框
mounted() {
  // 全局弹框太多,一次性渲染需要耗时500ms+,会造成JS阻塞,这里采用分片渲染
  const tasks = this.keys.map(k => () => (this[k] = true));
  fiberExecute(tasks);
}

组件注册分片执行。由于这里单个组件执行时间一般不超过 5ms,为了尽快完成组件挂载,这里采用一次挂载 6 个组件,粒度可以自己控制。也就是分片策略的粒度,我们可以自己控制。

import { fiberExecute } from 'utils/fiber';

export function installComponents(_vue, components) {
  const keys = components.keys();
  // 每次处理个数
  const len = 6;
  const keysArr = [];
  let cur = 0;
  while (cur < keys.length) {
    keysArr.push(keys.slice(cur, cur + len));
    cur += len;
  }

  const tasks = keysArr.map(ks => () => {
    ks.forEach(key => {
      // 注册逻辑
    });
  });
  fiberExecute(tasks);
}

还有插件注册、feedback 按钮挂载时机等所有高耗时的任务都可以使用分片执行策略。

目前的分片执行最小粒度为组件,如果单个组件执行过长,还可以继续对该组件进行拆分。

采用分片执行后,可以看到 JS 任务执行时间基本是均匀,避免了出现大的任务块,一直占用渲染进程的情况。

优化效果

经过前面的优化,我们看看效果:

可以发现,TBT 有大幅度的提升,因为是分片执行,JS 阻塞时间大大减少,达到了很好的优化效果。

评分直接干到了 96,有点意外,不过又在意料之内,知道会提升,没想到会提升这么多。

证明 JS 分片执行策略效果是非常好的,怪不得 React 不惜代价也要引入 Fiber 机制。