
文档技术博客教程【免费下载链接】weekly前端精读周刊。帮你理解最前沿、实用的技术。项目地址https://gitcode.com/GitHub_Trending/we/weekly点击查看免费下载本篇技术指南完整拆解「用 React 做按需渲染」这一性能优化方案从 BI 报表场景下的渲染瓶颈出发讲解如何通过RenderWhenActive组件阻塞非激活组件重渲染配合自定义 HookuseActive与抽象类VisibleObserve分别用IntersectionObserver原生 API 与setInterval轮询兜底方案判断组件在画布容器内是否可见。读完本文你将掌握一套与布局方式解耦、可复制的「组件级按需渲染」实现思路并了解它与本仓库可视化搭建系列组件注册与画布渲染、ComponentLoader 与动态组件、keepAlive 模式之间的技术关联。一、为什么报表形态需要按需渲染按需渲染方案的直接应用场景是 BI商业智能平台的报表编辑与浏览。BI 平台是数据中台团队非常重要的平台级产品其报表形态远不止一张张图表组件——与图表组件关联的筛选条件、联动关系错综复杂任何一个筛选条件的变化都会导致其关联项重新取数并重渲染组件。更严峻的是报表的数据量级一个表格组件加载百万量级的数据是稀松平常的参考本仓库 精读《高性能表格》 的描述自助分析表格在滚动、联动等场景下的渲染压力非常可观。为了在如此量级的数据下维持正常展示按需渲染是必须做的基础功课。需要特别澄清的是这里的「按需渲染」不是指 ListView 无限滚动。原因在于报表的布局模式有三套——流式布局、磁贴布局与自由布局每种布局的风格差异很大无法用固定公式计算组件是否可见。磁贴布局的组件位置由碰撞逻辑动态决定参见 精读《磁贴布局 - 功能分析》自由布局的组件可能任意摆放流式布局的组件随内容流动——三者都无法简单套用「第 N 屏之外不渲染」的公式。因此方案设计为初始化阶段组件全量渲染初始时组件还没有获取数据全量渲染不会造成性能问题这是整套方案成立的前提运行阶段阻止非首屏组件的重渲染只让可视区域内active的组件响应 props 变化而重渲染。二、方案总览从 active 状态到阻塞重渲染以 React 为例按需渲染的思维路径非常简洁得到组件active状态 → 阻塞非active组件的重渲染。整条实现链路由四个层次组成从结果反推阻塞层RenderWhenActive组件根据active布尔值决定是否放行子组件渲染状态层自定义 HookuseActive返回当前组件是否处于激活态监听层VisibleObserve总类负责启动/停止对目标 DOM 节点的可见性监听实现层继承抽象类AVisibleObserve的两套具体方案——IntersectionVisibleObserve原生与SetIntervalVisibleObserve兼容兜底。下面逐层展开。三、阻塞组件重渲染RenderWhenActive需要一个RenderWhenActive组件支持一个active参数当active为 true 时这一层是透明的当active为 false 时阻塞所有渲染。其行为约束有四条任何实现都必须满足inActive 时任何 props 变化都不会导致组件渲染从 inActive 切换到 active 时之前作用于组件的 props 要立即生效如果切换到 active 后 props 没有变化也不应该触发重渲染从 active 切换到 inActive 后不应触发渲染且立即阻塞后续重渲染。用React.memo配合自定义比较函数十余行代码即可实现const RenderWhenActive React.memo(({ children }) children, (prevProps, nextProps) ( !nextProps.active ))这里的关键在于React.memo的第二个参数——自定义areEqual比较函数。默认情况下React.memo做浅比较而这里直接返回!nextProps.active当nextProps.active为false时比较结果为trueReact 认为 props 未变化跳过本次渲染任何 props 变化都被阻塞满足约束 1、4当nextProps.active为true时比较结果为falseReact 正常执行渲染流程但此时走的是标准的 props 差异比较逻辑props 无变化时同样不会触发多余渲染满足约束 2、3。{ children }直接透传active 为 true 时组件层透明无感知。四、串联渲染引擎ComponentLoader 与 useActive在深入「如何判断组件是否显示」之前先假设「已经有了这样一个函数」思考如何调用——这种从结果入手、逐步反推的设计思路贯穿全文。4.1 ComponentLoader渲染引擎的接入点需要一个自定义 HookuseActive判断组件是否激活态并拿到active返回值传递给RenderWhenActive组件const ComponentLoader ({ children }) { const active useActive(); return RenderWhenActive active{active}{children}/RenderWhenActive; };这样渲染引擎利用ComponentLoader渲染的任何组件就自动具备了按需渲染能力业务组件无需做任何改造。这与可视化搭建系列中 ComponentLoader 与动态组件 的「组件加载器」思想同源都是通过一个统一入口组件将框架能力那里是画布能力这里是按需渲染能力以对业务透明的方式注入到组件渲染链路中。4.2 useActive利用 Hooks 生命周期管理监听利用 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此处对应React.memo阶段并不会阻塞第一次渲染因此组件的 dom 节点初始化仍会渲染出来——这与第一节「初始全量渲染」的设计前提完全吻合监听生命周期与 Hook 绑定在useEffect阶段注册VisibleObserve实例用来监听组件 dom 节点在其父级节点rootId内是否可见并在状态变更时通过第三个回调参数抛出——这里将setActive作为第三个参数可以及时改变当前组件 active 状态。VisibleObserve暴露observe与unobserve两个 API分别是启动监听与取消监听利用useEffect销毁时执行 return callback 的特性监听与销毁机制被完整封装进 Hook 生命周期组件卸载时监听自然被清理不会产生内存泄漏。五、监听组件是否可见抽象类 AVisibleObserve「判断组件在某个容器内是否可见」有许多种实现方案即便从功能上能找到最优解从浏览器兼容性角度看也无法找到完美方案——因此这是一个拥有多种实现可能性的函数在不同版本的浏览器采用不同方案才是最佳策略。处理这种情况的标准做法是抽象类让所有实际方法都继承并实现抽象类从而拥有多套「相同 API 的不同实现」以便在不同场景随时切换。利用 TypeScript 的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; }三个 protected 成员的含义targetDomId要判断可见性的组件 DOM IDrootDomId可见范围根节点父级容器DOM IDonActiveChange可见性变化回调由useActive注入的setActive承担。抽象类只关心「接口层 API」具体实现交给子类这体现了「父类关心接口层 API子类关心基于这套接口 API 如何具体实现」的设计思想。5.1 总类 VisibleObserve按浏览器能力路由实现实现层只需要两套方案原生方案IntersectionVisibleObserve利用浏览器高级 APIIntersectionObserver实现监听精确、性能好兜底方案SetIntervalVisibleObserve利用setInterval轮询检测的笨方法兼容不支持IntersectionObserver的旧浏览器。最后通过总类VisibleObserve作为统一调用入口在构造函数中完成方案路由/** * 监听元素是否可见总类 */ 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(); } }在构造函数就判断当前浏览器是否支持IntersectionObserverAPI。无论何种方案创建的实例都继承自AVisibleObserve因此可以用统一的actualVisibleObserve成员变量存放observe与unobserve阶段都无视具体类的实现直接调用this.actualVisibleObserve.observe()与this.actualVisibleObserve.unobserve()即可。后续如需新增第三种方案比如requestAnimationFrame驱动检测只需再写一个继承AVisibleObserve的子类并扩展路由条件调用方代码完全不用改动。六、兼容版本基于 getBoundingClientRect 的轮询检测兼容版本SetIntervalVisibleObserve需要定义一个额外成员变量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); } }6.1 矩形包含判断算法详解根据容器rootDomId与组件targetDomId拿到对应 DOM 实例后调用getBoundingClientRect获取矩形的位置与宽高。设容器为 root、组件为 component算法分三步计算 root 与 component 长度之和sumOfWidth、宽度之和sumOfHeight计算 root 与 component 「长度之和 两倍间距」sumOfWidthWithGap、以及「宽度之和 两倍间距」sumOfHeightWithGapsumOfWidthWithGap - sumOfWidth的差值就是横向 gap 距离sumOfHeightWithGap - sumOfHeight的差值就是纵向 gap 距离两个值都为负数表示在内部。关键公式是横向的// 长度之和 两倍间距交叉则间距为负 const sumOfWidthWithGap Math.abs( rootComponentRect.left rootComponentRect.right - componentRect.left - componentRect.right );几何直觉sumOfWidth是两者宽度之和sumOfWidthWithGap相当于「宽度之和 两倍的横向间距」。当两矩形横向相交或包含时间距为负值sumOfWidthWithGap sumOfWidth反之间距为正sumOfWidthWithGap sumOfWidth。当横纵两个交集都「小于等于」时代表存在交叉或包含在内部即组件在容器可视范围内。由于判断基于纯数值计算这套算法天然对三种布局流式、磁贴、自由都有效——这正是它相比固定滚动位置公式的优势。此外该实现有两个细节值得注意checkInterval 1000毫秒作为轮询间隔权衡了检测及时性与性能开销可按实际滚动/联动频率调整judgeActive对rootComponentDom与componentDom都做了空值保护DOM 尚未挂载时直接return避免异常。七、原生版本IntersectionObserver 精确监听如果浏览器支持IntersectionObserverAPI 就好办得多完整代码如下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(); } }7.1 intersectionRatio 语义通过intersectionRatio 0判断元素是否出现在父级容器中intersectionRatio为 0 表示完全不可见为 1 表示组件完整出现在容器内。此处需求是「任意部分出现就 active」因此阈值取 0即可。7.2 React 虚拟 DOM 更新导致的监听失效问题这一点是原生方案与 setInterval 方案最大的差异也是 React 场景下最容易踩的坑由于 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)); }判定逻辑分两步当元素判断不在可视区域时也包含了元素被销毁的情况React 卸载组件节点时同样会触发一次回调通过document.body.contains判断元素是否已被销毁如果被销毁先unobserve旧节点再observe按targetDomId重新查找到的新 DOM 实例从而保证监听始终跟随 React 渲染出来的最新节点。而 setInterval 方案每次轮询都通过document.getElementById(this.targetDomId)现取 DOM天然不受虚拟 DOM 更新影响这也是它作为兜底方案的另一个优势。八、原生方案在 React 生态中的印证react-intersection-observer 源码本文的原生方案与社区成熟的 React 封装react-intersection-observer在原理上高度一致本仓库 源码解读《react-intersection-observer 源码》 对其实现做了完整剖析可作为交叉印证Hook 与 DOM 的绑定方式useInView通过ref回调setRef拿到 DOM 节点旧节点先unobserve、新节点再observe并用ref.current记录当前节点——与本文useActive中「useEffect 挂载监听、卸载清理」的生命周期管理思路一致observe 的多实例合并库内部以getRootId threshold rootMargin生成observerId同一 root 配置复用同一个IntersectionObserver实例OBSERVER_MAP并用INSTANCE_MAP记录每个元素实例的inView状态与回调——说明真实场景下「一个 root 监听 N 个组件」是常态本文简化版的「每组件一个 observer」在组件数量极大时可参考该库的实例复用策略做进一步优化回调判定onChange遍历changes数组、按intersectionRatio与阈值比较得出inView再回写状态并执行回调——与本文changes[0].intersectionRatio 0的判定同构。如果不想从零维护兼容层也可以直接用useInView这类封装替代自研的VisibleObserve但需要注意通用封装默认以浏览器 viewport 为 root而本文方案必须以报表画布容器rootId为 root 判断「画布内可见」需要显式配置root参数。九、方案的适用边界与延伸思考9.1 适用范围按需渲染逻辑的适用面不仅限于渲染引擎。但对于 ProCode 场景下直接手写的业务代码要把这段逻辑加进去侵入性会比较强——业务组件需要主动包裹ComponentLoader或引入useActive。从这个角度看该能力更适合沉淀在可视化搭建框架、渲染引擎这类「组件统一由框架渲染」的体系中与本仓库 可视化搭建 - 组件注册与画布渲染 描述的「组件树 组件元信息」架构天然契合。或许可视区域内的按需渲染可以做到前端开发框架内部——它不属于标准框架功能但也不完全属于业务功能处在框架与业务之间的灰色地带值得设计者思考。9.2 与 keepAlive 模式的互补本仓库 keepAlive 模式 讨论了另一个大组件量场景下的性能问题拖拽改变组件父级导致的 React Remount。其解决思路是用createPortal将 React 实例与 DOM 结构解耦。可以看到可视化搭建场景的性能优化是「组合拳」按需渲染解决「大量组件反复重渲染」的问题keepAlive 解决「组件移动导致 Remount」的问题二者可以同时启用且互不冲突——按需渲染控制的是渲染时机keepAlive 控制的是实例挂载位置。9.3 延伸思考题如果让手写的 React 代码也具备按需渲染功能怎么设计更好呢可以从这几个方向思考是否可以提供类似useVisible(domId, rootId)的通用 Hook让业务组件自行接入而无需框架级ComponentLoader是否可以做成装饰器或 Babel 插件形态在编译期自动包裹组件实现零侵入是否需要把「可见性」进一步细分为「部分可见 / 完全可见 / 已滚出」多档状态以支持更精细的懒加载策略十、总结回顾整个方案核心在于两层抽象渲染控制层RenderWhenActiveReact.memo 自定义比较函数把「是否放行渲染」收敛为active布尔值四条行为约束保证了状态的切换语义正确可见性判定层AVisibleObserve抽象类统一observe/unobserve接口IntersectionVisibleObserve与SetIntervalVisibleObserve两套实现按浏览器能力路由既利用了现代 API 的精确性又保留了旧环境的兜底能力。从「初始化全量渲染可行」的洞察到「判断组件可见」的矩形包含算法与 React 虚拟 DOM 更新陷阱整套方案架构清晰、边界完整可直接迁移到任何以 React 渲染大量组件的产品中——尤其是 BI 报表、可视化搭建画布这类「组件多、布局复杂、数据量大」的场景。本文整理自前端精读周刊第 154 期《用 React 做按需渲染》该系列完整文章目录见仓库 readme.md同类话题可延伸阅读 精读《前端与 BI》、精读《高性能表格》、精读《react-intersection-observer 源码》 与 可视化搭建系列。赞分享文档技术博客教程【免费下载链接】weekly前端精读周刊。帮你理解最前沿、实用的技术。项目地址https://gitcode.com/GitHub_Trending/we/weekly点击查看免费下载相关推荐Chatbox 离线3 步完成本地部署断网照样聊Chatbox 离线3 步完成本地部署断网照样聊 Chatbox 是一款 AI 桌面客户端装上它对话、写代码、角色扮演这些日常活都能干。更关键的一点是AI 应用桌面应用大模型如何利用Supabase-kt在iOS平台构建跨平台应用SwiftUI与Kotlin的完美融合如何利用Supabase kt在iOS平台构建跨平台应用SwiftUI与Kotlin的完美融合 Supabase kt是一个强大的Kotlin多平台客户端库前端精读周刊React性能优化案例分析前端精读周刊React性能优化案例分析 引言为什么你的React应用越来越慢 你是否遇到过这样的情况随着项目迭代React应用越来越卡顿列表滚动不流文档技术博客教程创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考