背景
笔者最近在工作中对一个项目进行了性能优化,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 到页面展示主要流程如下:
- 查找缓存。浏览器会向 URL 发起请求以获取页面资源,不过在发起请求前,会先查找本地是否缓存了该资源,如果有且未过期,直接使用,不再发起请求。如果没有,则进入资源请求阶段,然后继续下面的步骤。
- DNS 解析,建立 TCP 连接。进入网络资源请求阶段后,首先是 DNS 解析获取 IP 地址,然后与该主机建立 TCP 连接。
- 发送 HTTP 请求,等待服务端返回资源。
- 接收到 HTML 文件后,开始解析 HTML ,构建 DOM 树等渲染步骤。
我们来分析下,上面四个步骤,有哪些可以优化的点:
-
如果命中缓存,那页面的加载速度将会是质的提升,缓存一直都是性能优化的重要武器,所以网站应该开启资源缓存。目前主流的优化做法是将所有的静态资源文件全部放在 CDN,并开启资源缓存。
-
对于 DNS 解析,我们可以开启 DNS 预解析。对于 TCP 连接,我们可以减少 TCP 连接数,使用 HTTP1.1 的长连接,后者直接使用 HTTP2.0 的多路复用。
-
这一步往往是影响首屏时间的最主要的因素。这一步主要包含三部分部分:网络环境、服务端响应时间、资源大小。在 Network 的 Timing 面板可以看到请求的 TTFB,它代表了网络环境和服务端响应时间,而 Content Download 则代表了网络环境和资源大小。
优化方法主要有:静态资源上 CDN,以减少网络传输距离和提高服务端响应时间;减少资源大小,以减少资源传输时间。所以目前我们能做的就是减少项目资源文件大小(HTML、JS、CSS文件)。
- 在开始解析 HTML 后,遇到 CSS、JS 文件还需要去加载并解析,所以主要优化点还是减少资源文件大小。
经过上述的分析,并结合项目情况,接下来要优化的点就是减少首屏使用文件大小。
笔者项目是 SPA,并且是一个 Monorep 项目,使用技术栈是:vue + view design + vuex + vue-router
减少首屏使用文件大小
优化前首屏使用到的文件 chunk-vendors.js + app.js 的文件大小为(Gzipped) 1695.95 + 343.33 = 2039.28 KiB。
异步加载非首屏业务逻辑代码
我们知道首屏业务逻辑代码会打包在 app.js 文件里,而这个文件的瘦身无非两种方法:
- 优化业务逻辑代码,去掉无效代码,优化写法等,不过这个办法吃力不讨好,效果并不明显。
- 拆分,将非首屏业务逻辑代码实现异步加载,比如每个路由对应的业务都是异步加载;某些大的组件等使用到时再加载等。
在笔者的项目优化中,我们将所有非首屏路由页面做成异步加载:
注意,首屏组件不要设置为异步加载,后面会有分析。
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 分析页面的整个解析和渲染过程。
经过分析可以发现以下几个问题:
-
FCP 时间是右下角 feedback 按钮渲染完时间,而真正首屏出来还需要好长时间,而且这个 feedback 按钮的执行和渲染时间在 110ms 左右。
这是不合理的,这个 FCP 可以视为无效的,因为它不是有意义首屏的一部分,所以它可以推迟到 LCP 之后渲染,避免阻塞正常首屏的渲染。
-
FP 和 FCP 之间,还有一块大耗时的任务,就是组件的注册。这里阻塞了有效首屏的渲染,故也应该推迟到 LCP 之后执行。
-
项目是一个 SPA 页面,首屏路由是 Home。这里问题是路由组件做了异步加载,导致渲染首屏的时候,还要再发起网络请求,获取首屏页面的 chunk.js 和 chunk.css。这会导致首屏路由渲染时间很长,需要发起网络请求、解析 JS、执行 JS 等。所以这里应该把首屏路径改为同步的。
处理
- 首屏路由组件改为同步加载渲染。
import Home from 'pages/home'; // 首屏路由同步加载即可,不要做成异步的
const routes = [
{
path: '/home',
name: '主页',
component: Home,
}
];
- 将组件注册、全局弹框挂载、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 机制。