## 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` 组件轻松实现此功能：

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

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

### 获取组件 active 状态

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

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

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

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

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

### 实现 useActive

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

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

```jsx
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` 这个函数拥有 `observe` 与 `unobserve` 两个 API，分别是启动监听与取消监听，利用 `useEffect` 销毁时执行 return callback 的特性，监听与销毁机制也完成了。

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

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

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

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

利用 `abstract` 创建抽象类 `AVisibleObserve`，实现构造函数并申明两个 public 的重要函数 `observe` 与 `unobserve`：

```jsx
/**
 * 监听元素是否可见的抽象类
 */
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` 实现的新潮方法，由于后者有兼容性要求，前者就作为兜底方案实现。

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

```jsx
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..
  }
}
```

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

```jsx
/**
 * 监听元素是否可见总类
 */
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` 成员变量存放。

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

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

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

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

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

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

下面是完整实现函数：

```jsx
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 距离，两个值都为负数表示在内部。

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

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

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

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

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

```jsx
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 元素被销毁后，导致后续监听失效，因此需要在元素隐藏时加入下面的代码：

```jsx
// 因为虚拟 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](https://github.com/dt-fe/weekly/issues/254)

**如果你想参与讨论，请 [点击这里](https://github.com/dt-fe/weekly)，每周都有新的主题，周末或周一发布。前端精读 - 帮你筛选靠谱的内容。**

> 关注 **前端精读微信公众号**

<img width=200 src="https://img.alicdn.com/tfs/TB165W0MCzqK1RjSZFLXXcn2XXa-258-258.jpg">

> 版权声明：自由转载-非商用-非衍生-保持署名（[创意共享 3.0 许可证](https://creativecommons.org/licenses/by-nc-nd/3.0/deed.zh)）
