精读《用 React 做按需渲染》

1 引言

BI 平台是阿里数据中台团队非常重要的平台级产品,要保证报表编辑与浏览的良好体验,性能优化是必不可少的。

当前 BI 工具普遍是报表形态,要知道报表形态可不仅仅是一张张图表组件,与这些组件关联的筛选条件和联动关系错综复杂,任何一个筛选条件变化就会导致其关联项重新取数并重渲染组件,而报表数据量非常大,一个表格组件加载百万量级的数据稀松平常,为了维持这么大量级数据量下的正常展示,按需渲染是必须要做的功课。

这里说的按需渲染不是指 ListView 无限滚动,因为报表的布局模式有流式布局、磁贴布局和自由布局三套,每种布局风格差异很大,无法用固定的公式计算组件是否可见,因此我们选择初始化组件全量渲染,阻止非首屏内组件的重渲染。因为初始条件下还没有获取数据,全量渲染不会造成性能问题,这是这套方案成立的前提。

所以我今天就专门介绍如何利用 DOM 判断组件在画布中是否可见这个技术方案,从架构设计与代码抽象的角度一步步分解,不仅希望你能轻松理解这个技术方案如何实现,也希望你能掌握这其中的诀窍,学会举一反三。

2 精读

我们以 React 框架为例,做按需渲染的思维路径是这样的:

得到组件 active 状态 -> 阻塞非 active 组件的重渲染。

这里我选择从结果入手,先考虑如何阻塞组件渲染,再一步步推导出判断组件是否可见这个函数怎么写。

阻塞组件重渲染

我们需要一个 RenderWhenActive 组件,支持一个 active 参数,当 active 为 true 时这一层是透明的,当 active 为 false 时阻塞所有渲染。

再具体描述一下,其效果是这样的:

  1. inActive 时,任何 props 变化都不会导致组件渲染。
  2. 从 inActive 切换到 active 时,之前作用于组件的 props 要立即生效。
  3. 如果切换到 active 后 props 没有变化,也不应该触发重渲染。
  4. 从 active 切换到 inActive 后不应触发渲染,且立即阻塞后续重渲染。

目前 Function Component 做不到这一点,我们仍需借助 Class Component 的 shouldComponentUpdate 做到这一点,因为 Class Component 阻塞渲染时,会将最新 props 存储下来,而 Function Component 完全没有内部状态,目前还无法胜任这项工作。

我们可以写一个 RenderWhenActive 组件轻松实现此功能:

class RenderWhenActive extends React.Component {
  public shouldComponentUpdate(nextProps) {
    return nextProps.active;
  }

  public render() {
    return this.props.children
  }
}

获取组件 active 状态

在进一步思考之前,我们先不要掉到 “如何判断组件是否显示” 这个细节中,可以先假设 “已经有了这样一个函数”,我们应该如何调用。

很显然我们需要一个自定义 Hook:useActive 判断组件是否是激活态,并拿到 active 返回值传递给 RenderWhenActive 组件:

const ComponentLoader = ({ children }) => {
  const active = useActive();

  return <RenderWhenActive active={active}>{children}</RenderWhenActive>;
};

这样,渲染引擎利用 ComponentLoader 渲染的任何组件就具备了按需渲染的功能。

实现 useActive

到现在,组件与 Hook 侧的流程已经完整串起来了,我们可以聚焦于如何实现 useActive 这个 Hook。

利用 Hooks 的 API,可以在组件渲染完毕后利用 useEffect 判断组件是否 Active,并利用 useState 存储这个状态:

export function useActive(domId: string) {
  // 所有元素默认 unActive
  const [active, setActive] = React.useState(false);

  React.useEffect(() => {
    const visibleObserve = new VisibleObserve(domId, "rootId", setActive);

    visibleObserve.observe();

    return () => visibleObserve.unobserve();
  }, [domId]);

  return active;
}

初始化时,所有组件 active 状态都是 false,然而这种状态在 shouldComponentUpdate 并不会阻塞第一次渲染,因此组件的 dom 节点初始化仍会渲染出来。

useEffect 阶段注册了 VisibleObserve 这个自定义 Class,用来监听组件 dom 节点在其父级节点 rootId 内是否可见,并在状态变更时通过第三个回调抛出,这里将 setActive 作为第三个参数,可以及时改变当前组件 active 状态。

VisibleObserve 这个函数拥有 observeunobserve 两个 API,分别是启动监听与取消监听,利用 useEffect 销毁时执行 return callback 的特性,监听与销毁机制也完成了。

下一步就是如何实现最核心的 VisibleObserve 函数,用来监听组件是否可见。

监听组件是否可见的准备工作

在实现 VisibleObserve 之前,想一下有几种方法实现呢?可能你脑海中冒出了很多种奇奇怪怪的方案。是的,判断组件在某个容器内是否可见有许多种方案,即便从功能上能找到最优解,但从兼容性角度来看也无法找到完美的方案,因此这是一个拥有多种实现可能性的函数,在不同版本的浏览器采用不同方案才是最佳策略。

处理这种情况的方法之一,就是做一个抽象类,让所有实际方法都继承并实现抽象类,这样我们就拥有了多套 “相同 API 的不同实现”,以便在不同场景随时切换使用。

利用 abstract 创建抽象类 AVisibleObserve,实现构造函数并申明两个 public 的重要函数 observeunobserve

/**
 * 监听元素是否可见的抽象类
 */
abstract class AVisibleObserve {
  /**
   * 监听元素的 DOM ID
   */
  protected targetDomId: string;

  /**
   * 可见范围根节点 DOM ID
   */
  protected rootDomId: string;

  /**
   * Active 变化回调
   */
  protected onActiveChange: (active?: boolean) => void;

  constructor(targetDomId: string, rootDomId: string, onActiveChange: (active?: boolean) => void) {
    this.targetDomId = targetDomId;
    this.rootDomId = rootDomId;
    this.onActiveChange = onActiveChange;
  }

  /**
   * 开始监听
   */
  abstract observe(): void;

  /**
   * 取消监听
   */
  abstract unobserve(): void;
}

这样我们就可以实现多套方案。稍加思索可以发现,我们只要两套方案,一套是利用 setInterval 实现的轮询检测的笨方法,一种是利用浏览器高级 API IntersectionObserver 实现的新潮方法,由于后者有兼容性要求,前者就作为兜底方案实现。

因此我们可以定义两套对应方法:

class IntersectionVisibleObserve extends AVisibleObserve {
  constructor(/**/) {
    super(targetDomId, rootDomId, onActiveChange);
  }

  observe() {
    // balabala..
  }

  unobserve() {
    // balabala..
  }
}

class SetIntervalVisibleObserve extends AVisibleObserve {
  constructor(/**/) {
    super(targetDomId, rootDomId, onActiveChange);
  }

  observe() {
    // balabala..
  }

  unobserve() {
    // balabala..
  }
}

最后再做一个总类作为调用入口:

/**
 * 监听元素是否可见总类
 */
export class VisibleObserve extends AVisibleObserve {
  /**
   * 实际 VisibleObserve 类
   */
  private actualVisibleObserve: AVisibleObserve = null;

  constructor(targetDomId: string, rootDomId: string, onActiveChange: (active?: boolean) => void) {
    super(targetDomId, rootDomId, onActiveChange);

    // 根据浏览器 API 兼容程度选用不同 Observe 方案
    if ('IntersectionObserver' in window) {
      // 最新 IntersectionObserve 方案
      this.actualVisibleObserve = new IntersectionVisibleObserve(targetDomId, rootDomId, onActiveChange);
    } else {
      // 兼容的 SetInterval 方案
      this.actualVisibleObserve = new SetIntervalVisibleObserve(targetDomId, rootDomId, onActiveChange);
    }
  }

  observe() {
    this.actualVisibleObserve.observe();
  }

  unobserve() {
    this.actualVisibleObserve.unobserve();
  }
}

在构造函数就判断了当前浏览器是否支持 IntersectionObserver 这个 API,然而无论何种方案创建的实例都继承于 AVisibleObserve,所以我们可以用统一的 actualVisibleObserve 成员变量存放。

observeunobserve 阶段都可以无视具体类的实现,直接调用 this.actualVisibleObserve.observe()this.actualVisibleObserve.unobserve() 这两个 API。

这里体现的思想是,父类关心接口层 API,子类关心基于这套接口 API 如何具体实现。

接下来我们看看低配版(兼容)与高配版(原生)分别如何实现。

监听组件是否可见 - 兼容版本

兼容版本模式中,需要定义一个额外成员变量 interval 存储 SetInterval 引用,在 unobserve 的时候 clearInterval

其判断可见函数我抽象到了 judgeActive 函数中,核心思想是判断两个矩形(容器与要判断的组件)是否存在包含关系,如果包含成立则代表可见,如果包含不成立则不可见。

下面是完整实现函数:

class SetIntervalVisibleObserve extends AVisibleObserve {
  /**
   * Interval 引用
   */
  private interval: number;

  /**
   * 检查是否可见的时间间隔
   */
  private checkInterval = 1000;

  constructor(targetDomId: string, rootDomId: string, onActiveChange: (active?: boolean) => void) {
    super(targetDomId, rootDomId, onActiveChange);
  }

  /**
   * 判断元素是否可见
   */
  private judgeActive() {
    // 获取 root 组件 rect
    const rootComponentDom = document.getElementById(this.rootDomId);
    if (!rootComponentDom) {
      return;
    }
    // root 组件 rect
    const rootComponentRect = rootComponentDom.getBoundingClientRect();
    // 获取当前组件 rect
    const componentDom = document.getElementById(this.targetDomId);
    if (!componentDom) {
      return;
    }
    // 当前组件 rect
    const componentRect = componentDom.getBoundingClientRect();

    // 判断当前组件是否在 root 组件可视范围内
    // 长度之和
    const sumOfWidth =
      Math.abs(rootComponentRect.left - rootComponentRect.right) + Math.abs(componentRect.left - componentRect.right);
    // 宽度之和
    const sumOfHeight =
      Math.abs(rootComponentRect.bottom - rootComponentRect.top) + Math.abs(componentRect.bottom - componentRect.top);

    // 长度之和 + 两倍间距(交叉则间距为负)
    const sumOfWidthWithGap = Math.abs(
      rootComponentRect.left + rootComponentRect.right - componentRect.left - componentRect.right,
    );
    // 宽度之和 + 两倍间距(交叉则间距为负)
    const sumOfHeightWithGap = Math.abs(
      rootComponentRect.bottom + rootComponentRect.top - componentRect.bottom - componentRect.top,
    );
    if (sumOfWidthWithGap <= sumOfWidth && sumOfHeightWithGap <= sumOfHeight) {
      // 在内部
      this.onActiveChange(true);
    } else {
      // 在外部
      this.onActiveChange(false);
    }
  }

  observe() {
    // 监听时就判断一次元素是否可见
    this.judgeActive();

    this.interval = setInterval(this.judgeActive, this.checkInterval);
  }

  unobserve() {
    clearInterval(this.interval);
  }
}

根据容器 rootDomId 与组件 targetDomId,我们可以拿到其对应 DOM 实例,并调用 getBoundingClientRect 拿到其对应矩形的位置与宽高。

算法思路如下:

设容器为 root,组件为 component。

  1. 计算 root 与 component 长度之和 sumOfWidth 与宽度之和 sumOfHeight
  2. 计算 root 与 component 长度之和 + 两倍间距 sumOfWidthWithGap 与 宽度之和 + 两倍间距 sumOfHeightWithGap
  3. sumOfWidthWithGap - sumOfWidth 的差值就是横向 gap 距离,sumOfHeightWithGap - sumOfHeight 的差值就是横向 gap 距离,两个值都为负数表示在内部。

其中的关键是,从横向角度来看,下面的公式可以理解为宽度之和 + 两倍的宽度间距:

// 长度之和 + 两倍间距(交叉则间距为负)
const sumOfWidthWithGap = Math.abs(
  rootComponentRect.left +
    rootComponentRect.right -
    componentRect.left -
    componentRect.right
);

sumOfWidth 是宽度之和,这之间的差值就是两倍间距值,正数表示横向没有交集。当横纵两个交集都是负数时,代表存在交叉或者包含在内部。

监听组件是否可见 - 原生版本

如果浏览器支持 IntersectionObserver 这个 API 就好办多了,以下是完整代码:

class IntersectionVisibleObserve extends AVisibleObserve {
  /**
   * IntersectionObserver 实例
   */
  private intersectionObserver: IntersectionObserver;

  constructor(targetDomId: string, rootDomId: string, onActiveChange: (active?: boolean) => void) {
    super(targetDomId, rootDomId, onActiveChange);

    this.intersectionObserver = new IntersectionObserver(
      changes => {
        if (changes[0].intersectionRatio > 0) {
          onActiveChange(true);
        } else {
          onActiveChange(false);

          // 因为虚拟 dom 更新导致实际 dom 更新,也会在此触发,判断 dom 丢失则重新监听
          if (!document.body.contains(changes[0].target)) {
            this.intersectionObserver.unobserve(changes[0].target);
            this.intersectionObserver.observe(document.getElementById(this.targetDomId));
          }
        }
      },
      {
        root: document.getElementById(rootDomId),
      },
    );
  }

  observe() {
    if (document.getElementById(this.targetDomId)) {
      this.intersectionObserver.observe(document.getElementById(this.targetDomId));
    }
  }

  unobserve() {
    this.intersectionObserver.disconnect();
  }
}

通过 intersectionRatio > 0 就可以判断元素是否出现在父级容器中,如果 intersectionRatio === 1 则表示组件完整出现在容器内,此处我们的要求是任意部分出现就 active。

有一点要注意的是,这个判断与 SetInterval 不同,由于 React 虚拟 DOM 可能会更新 DOM 实例,导致 IntersectionObserver.observe 监听的 DOM 元素被销毁后,导致后续监听失效,因此需要在元素隐藏时加入下面的代码:

// 因为虚拟 dom 更新导致实际 dom 更新,也会在此触发,判断 dom 丢失则重新监听
if (!document.body.contains(changes[0].target)) {
  this.intersectionObserver.unobserve(changes[0].target);
  this.intersectionObserver.observe(document.getElementById(this.targetDomId));
}
  1. 当元素判断不在可视区域时,也包含了元素被销毁。
  2. 因此通过 body.contains 判断元素是否被销毁,如果被销毁则重新监听新的 DOM 实例。

3 总结

总结一下,按需渲染的逻辑的适用面不仅仅在渲染引擎,但对于 ProCode 场景直接编写的代码中,要加入这段逻辑就显得侵入性较强。

或许可视区域内按需渲染可以做到前端开发框架内部,虽然不属于标准框架功能,但也不完全属于业务功能。

这次留下一个思考题,如果让手写的 React 代码具备按需渲染功能,怎么设计更好呢?

讨论地址是:精读《用 React 做按需渲染》· Issue #254 · dt-fe/weekly

如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。

关注 前端精读微信公众号

版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证

本文章由javascript技术分享原创和收集

发表评论 (审核通过后显示评论):