多少次面试官问我前端项目中做了哪些性能优化,我看着一个个使用人数寥寥无几的项目,背着网上千篇一律的面试答案,搜肠刮肚有什么可以结合项目谈一谈实际意义,很想实话讲没有什么需要优化的...
某天上线到12点,忘记什么原因帮我切了一个数据量超级大的代理权限,我随便点了点我写的表格筛选<类似于自己实现了一个antd版的ProTable>,搜姓名,搜一次卡死一次搜一次卡死一次,当我在数据量很少的人电脑上试丝滑没有任何问题,正如我开发的时候,觉得很完美,但是权限大,匹配的多,一旦搜索下拉列表匹配千百个,就卡死了,终于,终于让我遇到一个真实的性能优化项目
一
问题代码
/**
* 回显逻辑
*/
useDebounceEffect(() => {
const refreshValue = [...missValue];
// @ts-ignore
const newSearchParams = _cloneDeep(elseProps?.searchParams ?? {});
_set(newSearchParams, 'data.param.queryBody.value', refreshValue);
// 如果请求中不进行请求
if (!fetching && refreshValue.length && !!newSearchParams) {
setFetching(true);
newRequest({ cache: true, ...newSearchParams })
.then((newData: any) => {
const optionsData = newData?.data?.data ?? [];
optionsData?.forEach((item: any) => {
refData.current[item['value'] as string] = item;
setMissValue((c: any) => {
return c.filter((e: any) => {
return !refData.current[e];
});
});
setOptions(function (v) {
return [...v, ...optionsData];
});
setTimeout(() => {
setOptions(function (v) {
return v.slice(0, v.length - optionsData.length);
});
}, 1);
});
})
.finally(() => {
setFetching(false);
});
}
}, [JSON.stringify(missValue || [])]);
<Select
options={options}
// 为了解决分享等场景下的回显问题
labelRender={(options) => {
const { label, value } = options ?? {};
const currentLabel = label || refData.current?.[value]?.label;
if (!currentLabel && !_includes(missValue, value)) {
setMissValue([value, ...missValue]);
}
return currentLabel || value;
}}
/>
原因分析
-
渲染阶段触发 setState,导致级联重渲染
- 在
labelRender()里根据每一项的渲染动态调用setMissValue(),这是在渲染期间触发状态更新,会引发二次渲染,数据量大时是指数级放大,容易卡死。
- 在
-
回显逻辑中对大数组进行了 O(n^2) 的状态更新
- 在
useDebounceEffect()中对optionsData使用forEach,循环内多次调用setMissValue()和setOptions(),并且还用setTimeout再次触发一次setOptions(),这样每批结果会触发 2N 次 setState,数据量大时引发长时间阻塞。
- 在
-
下拉数据未做渲染限流,直接把大数组塞进
Select- 虽然 antd Select 支持虚拟滚动,但一次性传入成千上万条
options仍然会在 props 解析、比对、派发上带来巨大开销;而你的实现没有对返回结果做数量裁剪。
- 虽然 antd Select 支持虚拟滚动,但一次性传入成千上万条
优化思路
- 渲染函数必须保持纯函数:移除
labelRender()中的 setState,回显补齐统一放在 effect 中按“选中值集合”一次性拉取。 - 批处理状态更新:在回显逻辑中合并写入(一次
setMissValue),避免循环内 setState;移除setTimeout强制重渲染的 hack。 - 对下拉数据做渲染限流:例如最多渲染 200 条,其余提示用户继续输入关键词。
- 可选:显式开启虚拟滚动及限制 listHeight,进一步减轻大列表渲染负担。
二
前提还是上述封装的类似于antd的proTable,简化场景是,有个分段控制器,可以在普通分页表格和虚拟列表之间切换,数据量很大时,卡死了
问题分析:
- 表格重建:虚拟滚动的开启/关闭会导致整个表格组件重新构建,这是一个重量级操作
- 渲染延迟:表格重建需要重新计算所有行的渲染,如果数据量大会造成明显的卡顿
- 列结构的重新计算也会增加渲染负担
动作发生在
表格从虚拟滚动模式切换到普通模式,需要重新构建整个表格组件
🔍 为什么从普通模式切换到虚拟滚动没有卡顿,但反过来却卡顿
- 从普通模式 → 虚拟滚动(不卡顿)
- 渲染优化:虚拟滚动启动,只渲染可视区域的行(通常10-20行)
- 性能提升:虽然有大量数据,实际渲染的DOM元素反而减少了
- 从虚拟滚动 → 普通模式(卡顿)
- DOM爆炸:需要为所有数据行创建真实的DOM元素
- 内存压力:如果之前有大量数据,现在要全部渲染到DOM中
🎯 关键差异
虚拟滚动的工作原理:
普通模式:1000条数据 = 1000个DOM节点
虚拟滚动:1000条数据 = 只渲染可见的15个DOM节点
切换时的性能影响:
- 启用虚拟滚动:减少DOM节点 → 性能提升 ✅
- 禁用虚拟滚动:增加DOM节点 → 性能下降 ❌
为什么我们常用分页模式,而且也不考虑用virtual={true}优化性能呢
- 稳态下(分页模式正常渲染)
- 分页模式本身每页只有 10~50 条数据,DOM 也就几十个节点,确实不用虚拟滚动。
- 所以“普通模式:20 条数据 = 20 个 DOM 节点,虚拟滚动也还是 20 个”这句话是指稳定渲染后的场景,此时虚拟滚动没有收益。
- 切换瞬间为什么会卡
-
当从(虚拟滚动、一次加载大量分组数据)切到(分页、虚拟滚动被关掉)时,在请求返回并分页前,表格还暂存着上一模式的大量数据。这一帧里:
- 虚拟滚动被关闭 → 原本被虚拟化的上千行数据尝试一次性挂到 DOM
- 列配置也会重建、展开行重绘
- 这一瞬间就会出现卡顿
-
换言之,卡顿发生在模式切换的过渡帧,而不是分页模式“稳态”下的 20 行。
- 为什么 virtual=true 兼容两种模式能缓解
- 因为虚拟滚动保持开启,不会在切换瞬间把大量数据一口气挂到 DOM,上述“过渡帧”开销被抹平,所以看起来不卡。
- 如何避免这类过渡帧卡顿(保持现有业务:全部分页、非全部虚拟)
可选措施:
- 切换前先清空/压缩数据:切换到“全部”时先把表格数据设为空,再等分页请求回来填充。
- 切换时保持 virtual 开启:
virtual={true}两种模式都用虚拟滚动,避免模式切换重建。 - 强制 loading 遮挡 + 延迟刷新:切换时立即
setIsTableLoading(true),先切模式,再发请求,避免用户看到过渡帧。 - 复用一套列定义:减少列重建、render/onCell 重建带来的额外开销。