抛开数据量谈优化就是耍流氓

8 阅读5分钟

多少次面试官问我前端项目中做了哪些性能优化,我看着一个个使用人数寥寥无几的项目,背着网上千篇一律的面试答案,搜肠刮肚有什么可以结合项目谈一谈实际意义,很想实话讲没有什么需要优化的...

某天上线到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 解析、比对、派发上带来巨大开销;而你的实现没有对返回结果做数量裁剪。

优化思路

  • 渲染函数必须保持纯函数:移除 labelRender()中的 setState,回显补齐统一放在 effect 中按“选中值集合”一次性拉取。
  • 批处理状态更新:在回显逻辑中合并写入(一次 setMissValue),避免循环内 setState;移除 setTimeout 强制重渲染的 hack。
  • 对下拉数据做渲染限流:例如最多渲染 200 条,其余提示用户继续输入关键词。
  • 可选:显式开启虚拟滚动及限制 listHeight,进一步减轻大列表渲染负担。

前提还是上述封装的类似于antd的proTable,简化场景是,有个分段控制器,可以在普通分页表格和虚拟列表之间切换,数据量很大时,卡死了

问题分析:

  1. 表格重建:虚拟滚动的开启/关闭会导致整个表格组件重新构建,这是一个重量级操作
  2. 渲染延迟:表格重建需要重新计算所有行的渲染,如果数据量大会造成明显的卡顿
  3. 列结构的重新计算也会增加渲染负担

动作发生在

表格从虚拟滚动模式切换到普通模式,需要重新构建整个表格组件

🔍 为什么从普通模式切换到虚拟滚动没有卡顿,但反过来却卡顿

  1. 从普通模式 → 虚拟滚动(不卡顿)
  • 渲染优化:虚拟滚动启动,只渲染可视区域的行(通常10-20行)
  • 性能提升:虽然有大量数据,实际渲染的DOM元素反而减少了
  1. 从虚拟滚动 → 普通模式(卡顿)
  • DOM爆炸:需要为所有数据行创建真实的DOM元素
  • 内存压力:如果之前有大量数据,现在要全部渲染到DOM中

🎯 关键差异

虚拟滚动的工作原理:

普通模式:1000条数据 = 1000个DOM节点
虚拟滚动:1000条数据 = 只渲染可见的15个DOM节点

切换时的性能影响:

  • 启用虚拟滚动:减少DOM节点 → 性能提升 ✅
  • 禁用虚拟滚动:增加DOM节点 → 性能下降 ❌

为什么我们常用分页模式,而且也不考虑用virtual={true}优化性能呢

  1. 稳态下(分页模式正常渲染)
  • 分页模式本身每页只有 10~50 条数据,DOM 也就几十个节点,确实不用虚拟滚动。
  • 所以“普通模式:20 条数据 = 20 个 DOM 节点,虚拟滚动也还是 20 个”这句话是指稳定渲染后的场景,此时虚拟滚动没有收益。
  1. 切换瞬间为什么会卡
  • 当从(虚拟滚动、一次加载大量分组数据)切到(分页、虚拟滚动被关掉)时,在请求返回并分页前,表格还暂存着上一模式的大量数据。这一帧里:

    • 虚拟滚动被关闭 → 原本被虚拟化的上千行数据尝试一次性挂到 DOM
    • 列配置也会重建、展开行重绘
    • 这一瞬间就会出现卡顿
  • 换言之,卡顿发生在模式切换的过渡帧,而不是分页模式“稳态”下的 20 行。

  1. 为什么 virtual=true 兼容两种模式能缓解
  • 因为虚拟滚动保持开启,不会在切换瞬间把大量数据一口气挂到 DOM,上述“过渡帧”开销被抹平,所以看起来不卡。
  1. 如何避免这类过渡帧卡顿(保持现有业务:全部分页、非全部虚拟

可选措施:

  • 切换前先清空/压缩数据:切换到“全部”时先把表格数据设为空,再等分页请求回来填充。
  • 切换时保持 virtual 开启virtual={true} 两种模式都用虚拟滚动,避免模式切换重建。
  • 强制 loading 遮挡 + 延迟刷新:切换时立即 setIsTableLoading(true),先切模式,再发请求,避免用户看到过渡帧。
  • 复用一套列定义:减少列重建、render/onCell 重建带来的额外开销。