最近比较有空就打算把看板的性能优化做一下,打开项目用chrome的performance记录了一下一个组件比较多的看板。这个结果还是比较让人惊讶,原本以为性能瓶颈主要在图表(G2的性能堪忧),没想到看板本身的性能也如此糟糕。

优化前的性能
优化前的性能

本地开发环境,相比生成环境耗时多了40%,本文主要看同一环境下的对比

接下来看看如何通过这个结果进行优化。下面的章节是按必要性进行的排序。

首屏不要渲染没必要的组件

我先看了下耗时接近7s的那一帧里,什么组件占了大头。发现主要是两个:即席查询的编辑组件和CMDB选择器。

测试用的看板
测试用的看板

即席查询的编辑组件不应该被渲染,因为图表的需要依赖编辑组件里请求的数据进行渲染,但是因此把这么多组件的编辑组件给渲染出来就非常影响性能了。

这里我们把组件需要的数据移到更上一层去请求,然后除了编辑模式下不再去渲染编辑组件。

关于CMDB选择器的优化见下一节

所有组件都应该在独立的帧内渲染

看板里的所有组件应该关心自己的性能,因为组件可能会是成倍出现的,渲染耗时会被成倍放大。如果很多组件碰巧在相近的时间(或者在比较长的一帧内)请求结束并去渲染结果的话,就会将一帧拖得很长。在这帧内页面会处于假死状态,用户无法滚动和交互

在我们的看板里就有这个问题

  1. 看板组件的渲染,尤其是Chart和Table都需要对大量结果进行处理。
  2. CMDB选择器会将所有CMDB数据组织成树状结构。(虽然这里可以改为每次只请求一级来优化,但是这样需要的改动太多了)

怎么优化呢?这里我们可以参考react fiber的实现方式,一个简单的方案就是使用requestIdleCallback这个API来将每个组件请求结束后的更新拆散到每一帧内。这样就不会出现多个组件同时更新而“撞车”的情况了。

requestIdleCallback(() => {
  // update component with response data
}, { timeout: 2000 });

我们可以同时应用到组件和CMDB选择器的上,但是我们又希望组件更新能早于CMDB选择器,因为比较重要。不过目前requestIdleCallback没有优先级的概念,不过可以自己实现一个。

React Concurrent Mode

React其实提供了一个类似的优化方案:concurrent mode,了解更多可以查看这篇文章

但是我在使用concurrent mode时碰到一些问题所以暂时没用:

  • 默认使用Strict Mode,用了一些组件库会有一堆警告
  • 不知道是不是和旧API不兼容,用在项目里时看板组件就抛错
    app.5793fd45.js:6977 Uncaught TypeError: callback is not a function
        at flushFirstCallback (app.5793fd45.js:6977)
        at flushWork (app.5793fd45.js:7075)
        at MessagePort.channel.port1.onmessage (app.5793fd45.js:6817)
    
  • 目前没有详细文档,遇到问题也不好解决

Mobx batch update

因为在看板里用到了mobx所以顺便提一下,我在组件更新图表数据时使用了reaction函数,这个函数会自动对第二个参数里调用update进行batch

reaction(() => toJS(this.fetchResult), () => {
  // auto batch all update here
  store.updateSource(this.fetchResult);
  store.updateConfig(this.fetchResult);
});

但是如果将更新放到requestIdleCallback里的话,这里就会触发两次更新了。需要手动加上action才能使其batch

reaction(() => toJS(this.fetchResult), () => {
  // you must use action to enable batch
  //                         ↓↓↓↓↓↓
  window.requestIdleCallback(action(() => {
    store.updateSource(this.fetchResult);
    store.updateConfig(this.fetchResult);
  }), { timeout: 3000 });
});

懒加载

经过前两步的优化后,看板加载起来已经顺畅很多了,但是看板内容(就是测试看板图上的看板组件部分)初始化时还是有1.4s的时间。

这里其实没有哪块特别耗时了,所以需要其他方法来减少渲染耗时,那么自然就想到懒加载了。就上面展示的看板在首屏时底下有一堆是看不到的,那么我们就不需要在一开始就渲染这些组件。

一个懒加载组件的简单实现

function isElementInViewport(el: Element) {
  const rect = el.getBoundingClientRect();
  // 因为看板没有左右滚动所以不需要判断垂直坐标
  return rect.top < (window.innerHeight || document.documentElement.clientHeight)
    && rect.bottom > 0;
}

const RenderUntilInViewport: React.FC<{
  wrapper: React.ReactElement<any>;
}> = ({ wrapper, children }) => {
  const ref = React.useRef<HTMLDivElement>(null);
  const [display, setDisplay] = React.useState(false);
  React.useEffect(() => {
    function displayIfInViewport() {
      if (!display && ref.current && isElementInViewport(ref.current)) {
        setDisplay(true);
        return true;
      }
      return false;
    }
    if (displayIfInViewport()) {
      return;
    }
    window.addEventListener('scroll', displayIfInViewport, { capture: true });
    return () => {
      window.removeEventListener('scroll', displayIfInViewport, { capture: true });
    };
  }, []);
  return React.cloneElement(wrapper, { ref }, display && children);
};

export default RenderUntilInViewport;

如果使用的是react-grid-layout的话,需要把初始动画关闭,不然组件的初始坐标都是(0, 0),会使懒加载失效。关闭方法,不要使用useCSSTransforms={false},这项会比较影响性能(测试看板+500ms的负收益)。

不过懒加载在测试看板的layout性能时带来的收益不大,大概能快100ms左右,或许用好折叠条组件(可以将一些组件折叠,默认不会去渲染)的话也没必要做吧。

组件延迟渲染(optional)

同样在看板初始化阶段,这段时间里有很大一部分处于react-sizeme计算宽高然后触发组件更新所致,如果将组件真正渲染放到布局完成的话可能可以减少几次组件更新的消耗。

我们修改一下上面的懒加载组件,将第13行代码放到requestIdleCallback里。

// ...codes
window.requestIdleCallback(() => {
  setDisplay(true);
}, { timeout: 1000 });
// ...codes

可以看到看板布局的时间1300ms+ -> 800ms+,虽然看起来提升了很多,但是看板真正展现信息还是要等组件渲染完,所以看板直到可用的等待时间相比原来可能还会增加。因此这节的优化点看实际情况去做吧。

结语

虽然上面写了一大堆,但是基本不需要改多少代码就使页面变得不卡了。

看一下线上的前后对比,同样是上面的测试看板。

优化前
优化前
优化后(滚动页面加载完所有组件)
优化后(滚动页面加载完所有组件)

这里空闲时间比较多是因为请求完成的时间差导致