如何正确优化React中含大量内容的可折叠卡片渲染耗时?
问题
假设当前DOM中挂载了若干卡片,所有卡片初始均处于collapsed(折叠)状态。点击某张卡片的click to expand按钮时,才会渲染该卡片的内容。当前存在的问题是:如果卡片包含大量内容,加载过程会耗时数秒。
在实际应用中,用户点击卡片后,该卡片的状态会被设置为expanded(展开),所有与该卡片关联的实体内容都会在卡片区域内渲染;但如果关联实体数量较多,加载过程就会产生明显等待。
目前考虑过的修复思路:
- 从Redux状态中移除全量实体数据,初始仅渲染每个可用
card_id对应的卡片基础框架,最多只渲染卡片标题 - 当用户点击某张卡片时,再向后端发起请求拉取所有关联实体数据
- 进一步将请求分块:不通过单次请求拉取全量内容/实体,而是拆分为2-4次请求,加载过程中展示spinner(加载动画)或
loading提示文本,直到所有实体数据成功获取完成
但即使是仅渲染虚拟测试内容的示例,渲染过程也需要数秒时间,示例代码如下:
import * as React from "react"; import styles from "./styles"; export default function App() { const [isExpanded, setIsExpanded] = React.useState(false); const handleExpand = React.useCallback( (isExpanded) => () => { setIsExpanded(!isExpanded); }, [] ); return ( <div style={styles.container}> <div style={styles.listContainer}> <div style={styles.button} onClick={handleExpand(isExpanded)} > click to {isExpanded ? "collapse" : "expand"} </div> <div style={styles.list}> {isExpanded && Array.from({ length: 5000 }, (_, index) => ( <div style={styles.content}> <div>some header {index + 1}</div> <input key={index} value={`some value ${index + 1}`} /> <div>some content heading {index + 1}</div> <input value={`some other field ${index + 1}`} /> <input style={{ marginBottom: "1rem" }} value={`and another field ${index + 1}`} /> </div> ))} </div> </div> </div> ); }
注:已调研过虚拟滚动方案,认为其并不适用于当前场景:业务场景并非标准列表,只是若干卡片,当卡片内容数量较多时会出现离屏渲染的情况。
回答
卡顿的根本原因不是接口请求慢,而是一次性生成、挂载过量DOM节点阻塞了浏览器主线程,上述纯本地渲染Demo已经可以验证这点:哪怕所有数据都存在内存中,一次性渲染5000条包含多节点的内容块,浏览器需要完成节点创建、样式计算、布局、绘制全流程,必然会出现数秒的无响应。
之前考虑的分块拉取接口方案只能解决数据传输的等待问题,解决不了前端一次性渲染大量节点的卡顿,哪怕分4次请求,等所有请求返回后一次性渲染全量内容,还是会卡。
可落地优化方案(按改造成本从低到高排序)
- 时间分片增量渲染
不要在点击展开后一次性把全量内容推入React渲染队列,而是把内容拆成多个小批次,利用浏览器空闲时间逐批渲染:- 点击展开后立刻渲染首屏可容纳的内容量(通常20-50条),让用户第一时间看到内容反馈
- 用
requestAnimationFrame或requestIdleCallback逐帧追加渲染下一批内容,每批控制在20-50条,直到所有内容渲染完成
核心实现参考:
这个方案几乎不需要改动现有业务结构,不需要引入额外依赖,用户点击后不会出现数秒无响应的情况,内容会平滑逐步加载,感知体验远好于全局loading等待。// 单卡片组件内逻辑 const BATCH_SIZE = 30; const [isExpanded, setIsExpanded] = useState(false); const [renderedCount, setRenderedCount] = useState(0); const totalCount = cardData.entities.length; // 卡片关联内容总条数 const handleToggleExpand = () => { const nextExpanded = !isExpanded; setIsExpanded(nextExpanded); if (!nextExpanded) { setRenderedCount(0); return; } // 展开时启动增量渲染 const renderNextBatch = () => { setRenderedCount(prev => { const nextCount = Math.min(prev + BATCH_SIZE, totalCount); if (nextCount < totalCount) { requestAnimationFrame(renderNextBatch); } return nextCount; }); }; // 首屏立刻渲染第一批 setRenderedCount(BATCH_SIZE); requestAnimationFrame(renderNextBatch); }; // 渲染逻辑:只渲染已完成调度的内容块 return ( <div className="card"> <div className="card-header" onClick={handleToggleExpand}> click to {isExpanded ? "collapse" : "expand"} </div> <div className="card-content"> {cardData.entities.slice(0, renderedCount).map((entity, idx) => ( <div className="content-item" key={entity.id}> {/* 单条内容结构 */} </div> ))} </div> </div> ); - 离屏内容懒挂载
针对内容量特别大的卡片,不需要等所有内容都渲染到DOM中,用浏览器原生IntersectionObserver监听内容块位置,只有当内容块滚动到距离视口一定阈值(比如200px)时才挂载真实DOM,离屏位置只渲染等高占位容器即可。
这个方案不需要把内容改成标准列表结构,完全适配独立卡片的场景,比虚拟滚动的适配成本低80%以上,能把任意时刻页面上的DOM节点总量控制在几百个以内,从根源上解决渲染卡顿。 - 降低单内容块渲染开销
- 给独立内容项套
React.memo做浅比较缓存,避免卡片状态变化时所有已渲染内容项重复重渲染 - 非编辑状态下的表单输入控件直接用普通文本节点展示,用户触发编辑操作时再替换为真实input/select控件,能减少30%-50%的DOM节点数量
- 避免在内容项渲染函数中创建新的对象/函数作为props,减少不必要的重渲染触发
- 给独立内容项套
补充说明
此前调研的虚拟滚动方案并非完全不适用,只是不需要给整页卡片列表套虚拟滚动,仅针对展开后内容量超过千条的单个卡片,在卡片内容区域内部应用虚拟滚动即可,只是该方案改造成本高于上述两个方案,内容量在万条以内时优先用前两个方案即可。
内容的提问来源于stack exchange,提问作者Mike K
相关产品推荐
相关产品推荐

