项目背景

在 code_pc 项目中,前端需要使用 rrweb 对老师教学内容进行录制,学员可以进行录制回放。为减小录制文件体积,当前的录制策略是先录制一次全量快照,后续录制增量快照,录制阶段实际就是通过 MutationObserver 监听 DOM 元素变化,然后将一个个事件 push 到数组中。

为了进行持久化存储,可以将录制数据压缩后序列化为 JSON 文件。老师会将 JSON 文件放入课件包中,打成压缩包上传到教务系统中。学员回放时,前端会先下载压缩包,通过 JSZip 解压,取到 JSON 文件后,反序列化再解压后,得到原始的录制数据,再传入 rrwebPlayer 实现录制回放。

发现问题

在项目开发阶段,测试录制都不会太长,因此录制文件体积不大(在几百 kb),回放比较流畅。但随着项目进入测试阶段,模拟长时间上课场景的录制之后,发现录制文件变得很大,达到 10-20 M,QA 同学反映打开学员回放页面的时候,页面明显卡顿,卡顿时间在 20s 以上,在这段时间内,页面交互事件没有任何响应。

页面性能是影响用户体验的主要因素,对于如此长时间的页面卡顿,用户显然是无法接受的。

问题排查

经过组内沟通后得知,可能导致页面卡顿的主要有两方面因素:前端解压 zip 包,和录制回放文件加载。同事怀疑主要是 zip 包解压的问题,同时希望我尝试将解压过程放到 worker 线程中进行。那么是否确实如同事所说,前端解压 zip 包导致页面卡顿呢?

3.1 解决 Vue 递归复杂对象引起的耗时问题

对于页面卡顿问题,首先想到肯定是线程阻塞引起的,这就需要排查哪里出现长任务。

所谓长任务是指执行耗时在 50ms 以上的任务,大家知道 Chrome 浏览器页面渲染和 V8 引擎用的是一个线程,如果 JS 脚本执行耗时太长,就会阻塞渲染线程,进而导致页面卡顿。

对于 JS 执行耗时分析,这块大家应该都知道使用 performance 面板。在 performance 面板中,通过看火焰图分析 call stack 和执行耗时。火焰图中每一个方块的宽度代表执行耗时,方块叠加的高度代表调用栈的深度。

按照这个思路,我们来看下分析的结果:
在这里插入图片描述
可以看到,replayRRweb 显然是一个长任务,耗时接近 18s ,严重阻塞了主线程。

而 replayRRweb 耗时过长又是因为内部两个调用引起的,分别是左边浅绿色部分和右边深绿色部分。我们来看下调用栈,看看哪里哪里耗时比较严重:
在这里插入图片描述
熟悉 Vue 源码的同学可能已经看出来了,上面这些耗时比较严重的方法,都是 Vue 内部递归响应式的方法(右边显示这些方法来自 vue.runtime.esm.js)。

为什么这些方法会长时间占用主线程呢?在 Vue 性能优化中有一条:不要将复杂对象丢到 data 里面,否则会 Vue 会深度遍历对象中的属性添加 getter、setter(即使这些数据不需要用于视图渲染),进而导致性能问题。

那么在业务代码中是否有这样的问题呢?我们找到了一段非常可疑的代码

export default {
  data() {
    return {
      rrWebplayer: null
    }
  },
  mounted() {
    bus.$on("setRrwebEvents", (eventPromise) => {
      eventPromise.then((res) => {
        this.replayRRweb(JSON.parse(res));
      })
    })
  },
  methods: {
    replayRRweb(eventsRes) {
      this.rrWebplayer = new rrwebPlayer({
        target: document.getElementById('replayer'),
        props: {
          events: eventsRes,
          unpackFn: unpack,
          // ...
        }
      })
    }
  }
}

在上面的代码中,创建了一个 rrwebPlayer 实例,并赋值给 rrWebplayer 的响应式数据。在创建实例的时候,还接受了一个 eventsRes 数组,这个数组非常大,包含几万条数据。

这种情况下,如果 Vue 对 rrWebplayer 进行递归响应式,想必非常耗时。因此,我们需要将 rrWebplayer 变为 Non-reactive data(避免 Vue 递归响应式)。

转为 Non-reactive data,主要有三种方法

数据没有预先定义在 data 选项中,而是在组件实例 created 之后再动态定义 this.rrwebPlayer (没有事先进行依赖收集,不会递归响应式);

数据预先定义在 data 选项中,但是后续修改状态的时候,对象经过 Object.freeze 处理(让 Vue 忽略该对象的响应式处理);

数据定义在组件实例之外,以模块私有变量形式定义(这种方式要注意内存泄漏问题,Vue 不会在组件卸载的时候销毁状态);

这里我们使用第三种方法,将 rrWebplayer 改成 Non-reactive data 试一下:

let rrWebplayer = null;export default {
  //...
  methods: {
    replayRRweb(eventsRes) {
      rrWebplayer = new rrwebPlayer({
        target: document.getElementById('replayer'),
        props: {
          events: eventsRes,
          unpackFn: unpack,
          // ...
        }
      })
    }
  }
}

重新加载页面,可以看到这时候页面虽然还卡顿,但是卡顿时间明显缩短到5秒内了。观察火焰图可知,replayRRweb 调用栈下,递归响应式的调用栈已经消失不见了:
在这里插入图片描述

3.2 使用时间分片解决回放文件加载耗时问题

但是对于用户来说,这样仍然是不可接受的,我们继续看一下哪里耗时严重:
图片
可以看到问题还是出在 replayRRweb 这个函数里面,到底是哪一步呢:
在这里插入图片描述
那么 unpack 耗时的问题怎么解决呢?

由于 rrweb 录制回放 需要进行 dom 操作,必须在主线程运行,不能使用 worker 线程(获取不到 dom API)。对于主线程中的长任务,很容易想到的就是通过 时间分片,将长任务分割成一个个小任务,通过事件循环进行任务调度,在主线程空闲且当前帧有空闲时间的时候,执行任务,否则就渲染下一帧。方案确定了,下面就是选择哪个 API 和怎么分割任务的问题。

这里有同学可能会提出疑问,为什么 unpack 过程不能放到 worker 线程执行,worker
线程中对数据解压之后返回给主线程加载并回放,这样不就可以实现非阻塞了吗?

如果仔细想一想,当 worker 线程中进行 unpack,主线程必须等待,直到数据解压完成才能进行回放,这跟直接在主线程中 unpack
没有本质区别。worker 线程只有在有若干并行任务需要执行的时候,才具有性能优势。

提到时间分片,很多同学可能都会想到 requestIdleCallback 这个 API。requestIdleCallback 可以在浏览器渲染一帧的空闲时间执行任务,从而不阻塞页面渲染、UI 交互事件等。目的是为了解决当任务需要长时间占用主进程,导致更高优先级任务(如动画或事件任务),无法及时响应,而带来的页面丢帧(卡死)情况。因此,requestIdleCallback 的定位是处理不重要且不紧急的任务。

requestIdleCallback 不是每一帧结束都会执行,只有在一帧的 16.6ms
中渲染任务结束且还有剩余时间,才会执行。这种情况下,下一帧需要在 requestIdleCallback 执行结束才能继续渲染,所以
requestIdleCallback 每个 Tick 执行不要超过
30ms,如果长时间不将控制权交还给浏览器,会影响下一帧的渲染,导致页面出现卡顿和事件响应不及时。

requestIdleCallback 参数说明:

// 接受回调任务
type RequestIdleCallback = (cb: (deadline: Deadline) => void, options?: Options) => number
// 回调函数接受的参数
type Deadline = {
 timeRemaining: () => number // 当前剩余的可用时间。即该帧剩余时间。
 didTimeout: boolean // 是否超时。
}

我们可以用 requestIdleCallback 写个简单的 demo:

// 一万个任务,这里使用 ES2021 数值分隔符
const unit = 10_000;
// 单个任务需要处理如下
const onOneUnit = () => {
    for (let i = 0; i <= 500_000; i++) {}
}
// 每个任务预留执行时间
1msconst FREE_TIME = 1;
// 执行到第几个任务
let _u = 0;

function cb(deadline) {
// 当任务还没有被处理完 & 一帧还有的空闲时间 > 1ms
    while (_u < unit && deadline.timeRemaining() >FREE_TIME) {
        onOneUnit();
        _u ++;
    }
    // 任务干完
    if (_u >= unit) return;
    // 任务没完成, 继续等空闲执行
    window.requestIdleCallback(cb)
}

window.requestIdleCallback(cb)

这样看来 requestIdleCallback 似乎很完美,能否直接用在实际业务场景中呢?答案是不行。我们查阅 MDN 文档就可以发现,requestIdleCallback 还只是一个实验性 API,浏览器兼容性一般:
在这里插入图片描述
查阅 caniuse 也得到类似的结论,所有 IE 浏览器不支持,safari 默认情况下不启用:
在这里插入图片描述
而且还有一个问题,requestIdleCallback 触发频率不稳定,受很多因素影响。经过实际测试,FPS 只有 20ms 左右,正常情况下渲染一帧时长控制在16.67ms 。

为了解决上述问题,在 React Fiber 架构中,内部自行实现了一套 requestIdleCallback 机制:

  • 使用 requestAnimationFrame 获取渲染某一帧的开始时间,进而计算出当前帧到期时间点;
  • 使用 performance.now() 实现微秒级高精度时间戳,用于计算当前帧剩余时间;
  • 使用 MessageChannel 零延迟宏任务实现任务调度,如使用 setTimeout() 则有一个最小的时间阈值,一般是 4ms;

按照上述思路,我们可以简单实现一个 requestIdleCallback 如下:

// 当前帧到期时间点
let deadlineTime;
// 回调任务
let callback;
// 使用宏任务进行任务调度
const channel = new MessageChannel();
const port1 = channel.port1;
const port2 = channel.port2;
// 接收并执行宏任务
port2.onmessage = () => {
  // 判断当前帧是否还有空闲,即返回的是剩下的时间
  const timeRemaining = () => deadlineTime - performance.now();
  const _timeRemain = timeRemaining();
  // 有空闲时间 且 有回调任务
  if (_timeRemain > 0 && callback) {
    const deadline = {
      timeRemaining,
      didTimeout: _timeRemain < 0,
    };
    // 执行回调
    callback(deadline);
  }
};
window.requestIdleCallback = function (cb) {
  requestAnimationFrame((rafTime) => {
    // 结束时间点 = 开始时间点 + 一帧用时16.667ms
    deadlineTime = rafTime + 16.667;
    // 保存任务
    callback = cb;
    // 发送个宏任务
    port1.postMessage(null);
  });
};

在项目中,考虑到 api fallback 方案、以及支持取消任务功能(上面的代码比较简单,仅仅只有添加任务功能,无法取消任务),最终选用 React 官方源码实现。

那么 API 的问题解决了,剩下就是怎么分割任务的问题。

查阅 rrweb 文档得知,rrWebplayer 实例上提供一个 addEvent 方法,用于动态添加回放数据,可用于实时直播等场景。按照这个思路,我们可以将录制回放数据进行分片,分多次调用 addEvent 添加。

import {
  requestHostCallback, cancelHostCallback,
}
 from "@/utils/SchedulerHostConfig";
export default {
  // ...
  methods: {
    replayRRweb(eventsRes = []) {
      const PACKAGE_SIZE = 100;
      // 分片大小
      const LEN = eventsRes.length;
      // 录制回放数据总条数
      const SLICE_NUM = Math.ceil(LEN / PACKAGE_SIZE);
      // 分片数量
      rrWebplayer = new rrwebPlayer({
        target: document.getElementById("replayer"),
        props: {
          // 预加载分片
          events: eventsRes.slice(0, PACKAGE_SIZE),
          unpackFn: unpack,
        },
      });
      // 如有任务先取消之前的任务
      cancelHostCallback();
      const cb = () => {
        // 执行到第几个任务
        let _u = 1;
        return () => {
          // 每一次执行的任务
          // 注意数组的 forEach 没办法从中间某个位置开始遍历
          for (let j = _u * PACKAGE_SIZE; j < (_u + 1) * PACKAGE_SIZE; j++) {
            if (j >= LEN) break;
            rrWebplayer.addEvent(eventsRes[j]);
          }
          _u++;
          // 返回任务是否完成
          return _u < SLICE_NUM;
        };
      };
      requestHostCallback(cb(), () => {
        // 加载完毕回调
      });
    },
  },
};

注意最后加载完毕回调,源码中不提供这个功能,是本人自行修改源码加上的。

按照上面的方案,我们重新加载学员回放页面看看,现在已经基本察觉不到卡顿了。我们找一个 20M 大文件加载,观察下火焰图可知,录制文件加载任务已经被分割为一条条很细的小任务,每个任务执行的时间在 10-20ms 左右,已经不会明显阻塞主线程了:
在这里插入图片描述
优化后,页面仍有卡顿,这是因为我们拆分任务的粒度是 100 条,这种情况下加载录制回放仍有压力,我们观察 fps 只有十几,会有卡顿感。我们继续将粒度调整到 10 条,这时候页面加载明显流畅了,基本上 fps 能达到 50 以上,但录制回放加载的总时间略微变长了。使用时间分片方式可以避免页面卡死,但是录制回放的加载平均还需要几秒钟时间,部分大文件可能需要十秒左右,我们在这种耗时任务处理的时候加一个 loading 效果,以防用户在录制文件加载完成之前就开始播放。

有同学可能会问,既然都加 loading 了,为什么还要时间分片呢?假如不进行时间分片,由于 JS 脚本一直占用主线程,阻塞 UI 线程,这个 loading 动画是不会展示的,只有通过时间分片的方式,把主线程让出来,才能让一些优先级更高的任务(例如 UI 渲染、页面交互事件)执行,这样 loading 动画就有机会展示了。

进一步优化

使用时间分片并不是没有缺点,正如上面提到的,录制回放加载的总时间略微变长了。但是好在 10-20M 录制文件只出现在测试场景中,老师实际上课录制的文件都在 10M 以下,经过测试录制回放可以在 2s 左右就加载完毕,学员不会等待很久。

假如后续录制文件很大,需要怎么优化呢?之前提到的 unpack 过程,我们没有放到 worker 线程执行,这是因为考虑到放在 worker 线程,主线程还得等待 worker 线程执行完毕,跟放在主线程执行没有区别。但是受到时间分片启发,我们可以将 unpack 的任务也进行分片处理,然后根据 navigator.hardwareConcurrency 这个 API,开启多线程(线程数等于用户 CPU 逻辑内核数),以并行的方式执行 unpack ,由于利用多核 CPU 性能,应该能够显著提升录制文件加载速率。

总结

这篇文章中,我们通过 performance 面板的火焰图分析了调用栈和执行耗时,进而排查出两个引起性能问题的因素:Vue 复杂对象递归响应式,和录制回放文件加载。

对于 Vue 复杂对象递归响应式引起的耗时问题,本文提出的解决方案是,将该对象转为非响应式数据。对于录制回放文件加载引起的耗时问题,本文提出的方案是使用时间分片。

由于 requestIdleCallback API 的兼容性及触发频率不稳定问题,本文参考了 React 17 源码分析了如何实现 requestIdleCallback 调度,并最终采用 React 源码实现了时间分片。经过实际测试,优化前页面卡顿 20s 左右,优化后已经察觉不到卡顿,fps 能达到 50 以上。但是使用时间分片之后,录制文件加载时间略微变长了。后续的优化方向是将 unpack 过程进行分片,开启多线程,以并行方式执行 unpack,充分利用多核 CPU 性能。

参考

· vue-9-perf-secrets

· React Fiber很难?六个问题助你理解

· requestIdleCallback – MDN

· requestIdleCallback – caniuse

· 实现React requestIdleCallback调度能力

详情可点击这里查看

if,size_20,color_FFFFFF,t_70,g_se,x_16)