精读《用 React 做按需渲染》

    技术2026-04-23  16

    1 引言

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

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

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

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

    2 精读

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

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

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

    阻塞组件重渲染

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

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

    inActive 时,任何 props 变化都不会导致组件渲染。

    从 inActive 切换到 active 时,之前作用于组件的 props 要立即生效。

    如果切换到 active 后 props 没有变化,也不应该触发重渲染。

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

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

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

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

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

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

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

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

    class InterpVisibleObserve 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 ('InterpObserver' in window) {       // 最新 InterpObserve 方案       this.actualVisibleObserve = new InterpVisibleObserve(targetDomId, rootDomId, onActiveChange);     } else {       // 兼容的 SetInterval 方案       this.actualVisibleObserve = new SetIntervalVisibleObserve(targetDomId, rootDomId, onActiveChange);     }   }   observe() {     this.actualVisibleObserve.observe();   }   unobserve() {     this.actualVisibleObserve.unobserve();   } }

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

    observe 与 unobserve 阶段都可以无视具体类的实现,直接调用 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。

    计算 root 与 component 长度之和 sumOfWidth 与宽度之和 sumOfHeight。

    计算 root 与 component 长度之和 + 两倍间距 sumOfWidthWithGap 与 宽度之和 + 两倍间距 sumOfHeightWithGap。

    sumOfWidthWithGap - sumOfWidth 的差值就是横向 gap 距离,sumOfHeightWithGap - sumOfHeight 的差值就是横向 gap 距离,两个值都为负数表示在内部。

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

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

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

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

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

    class InterpVisibleObserve extends AVisibleObserve {   /**    * InterpObserver 实例    */   private interpObserver: InterpObserver;   constructor(targetDomId: string, rootDomId: string, onActiveChange: (active?: boolean) => void) {     super(targetDomId, rootDomId, onActiveChange);     this.interpObserver = new InterpObserver(       changes => {         if (changes[0].interpRatio > 0) {           onActiveChange(true);         } else {           onActiveChange(false);           // 因为虚拟 dom 更新导致实际 dom 更新,也会在此触发,判断 dom 丢失则重新监听           if (!document.body.contains(changes[0].target)) {             this.interpObserver.unobserve(changes[0].target);             this.interpObserver.observe(document.getElementById(this.targetDomId));           }         }       },       {         root: document.getElementById(rootDomId),       },     );   }   observe() {     if (document.getElementById(this.targetDomId)) {       this.interpObserver.observe(document.getElementById(this.targetDomId));     }   }   unobserve() {     this.interpObserver.disconnect();   } }

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

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

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

    当元素判断不在可视区域时,也包含了元素被销毁。

    因此通过 body.contains 判断元素是否被销毁,如果被销毁则重新监听新的 DOM 实例。

    3 总结

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

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

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

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

    关于奇舞周刊

    《奇舞周刊》是360公司专业前端团队「奇舞团」运营的前端技术社区。关注公众号后,直接发送链接到后台即可给我们投稿。

    Processed: 0.009, SQL: 9