一次真实的性能测试实践从工具选型到瓶颈定位

甜雅 前端 阅读 1,655
赞 28 收藏
二维码
手机扫码查看
反馈

优化前:卡得不行

上周上线一个数据看板页,加载完要等5秒以上才能点按钮,滚动都掉帧。用户反馈说“点进去像在等重启”,我打开 Chrome DevTools 一录 Performance,直接傻眼:主线程从 TTFB 开始就被 JS 堵死,长任务堆成山,其中三个 300ms+ 的 JS 执行块全来自同一个 React 组件——DataDashboard。不是渲染慢,是初始化阶段就干了太多事。

一次真实的性能测试实践从工具选型到瓶颈定位

更离谱的是,这个页面首屏其实只用展示前10条数据,但初始化时它偷偷 fetch 了全部 2000 条,还一股脑塞进 useState,然后用 .map() 渲染了所有 DOM(哪怕被 display: none 盖着)。这不是性能问题,这是行为艺术。

找到瘼颈了!

没急着改代码,先确认瓶颈在哪。我关掉所有 console.log,清缓存,开隐身模式,跑三遍 Lighthouse(移动端模拟),平均得分 28,FCP 4.2s,TTI 6.8s。再切到 Performance 面板,手动 reload,捕获 10s 范围的录制 —— 果然,initData() 这个函数占了 1.7s 主线程时间,里面嵌了两个 for 循环 + 一次 JSON.parse(JSON.stringify()) 深拷贝(对,你没看错,是深拷贝原始响应数据,就为了防“万一有人改了”)。

顺手跑了个 console.time('parse'),发现光解析后端返回的 2000 条 JSON 就花了 420ms;再加一层 map 处理字段映射,又干掉 610ms;最后 render 阶段,React 把这 2000 个对象全 reconcile 了一遍,哪怕只挂载了 10 个 DOM 节点。

定位完,心里有数了:不是框架慢,是我写的初始化逻辑太莽。

优化后:流畅多了

试了几种方案:

  • 方案一:用 React.memo + useMemo 包一堆子组件 —— 效果微乎其微,因为卡点根本不在渲染层,而在数据准备阶段。
  • 方案二:把请求拆成两步,先拉 10 条,滚动再加载 —— 行,但后端接口不支持分页,改 API 成本高,排期要下周。
  • 方案三:我动手砍逻辑 —— 15 分钟改完,当天下午就上了预发。

核心就三点:

  1. 懒解析:后端返回的 data 字段是字符串(为了兼容老字段),但我一开始就 JSON.parse 全量转对象。改成只 parse 当前需要展示的前 10 条,其余存 raw string,用到再 parse。
  2. 懒处理:字段映射逻辑(比如把 user_iduserId)封装成函数,但只对可见数据调用,不用提前 map 全量。
  3. 懒挂载:用 virtualized list(React Window)替掉原生 map,但这次我没上完整库,就抄了最简版:只渲染可视区域 ±2 行,其他用 height 占位。

下面是关键代码对比(优化前 vs 优化后):

// ❌ 优化前:初始化就干完所有事
function initData(rawResponse) {
  const allData = JSON.parse(rawResponse.data); // 2000 条,420ms
  const mapped = allData.map(item => ({
    userId: item.user_id,
    name: item.full_name?.trim() || 'N/A',
    lastLogin: new Date(item.last_login_ts * 1000),
  })); // 又 610ms
  setData(mapped);
}
// ✅ 优化后:按需解析、按需处理、按需挂载
function useDashboardData(rawResponse) {
  const [rawData, setRawData] = useState(rawResponse.data); // 存字符串
  const [visibleCount, setVisibleCount] = useState(10);

  // 只解析 visibleCount 条
  const parsedData = useMemo(() => {
    const arr = JSON.parse(rawData);
    return arr.slice(0, visibleCount).map(item => ({
      userId: item.user_id,
      name: item.full_name?.trim() || 'N/A',
      lastLogin: new Date(item.last_login_ts * 1000),
    }));
  }, [rawData, visibleCount]);

  // 滚动到底部时增加 visibleCount(简化版)
  const handleScroll = useCallback((e) => {
    if (e.target.scrollTop + e.target.clientHeight >= e.target.scrollHeight - 100) {
      setVisibleCount(prev => Math.min(prev + 20, 2000));
    }
  }, []);

  return { parsedData, handleScroll };
}

HTML 结构也配合改了,去掉 div.map,换成固定高度容器 + 动态 top 偏移:

<div class="list-container" onscroll="handleScroll">
  <div style="height: calc(60px * 2000)"></div>
  <div class="visible-items" style="position: absolute; top: 0px;">
    <!-- 只渲染 parsedData.length 个 item -->
  </div>
</div>

这里注意我踩过好几次坑:一开始用 getBoundingClientRect().top 算可视位置,结果在 iOS Safari 上 scroll event 触发不及时,导致白屏。最后换回原生 scrollTop + scrollHeight 判断,稳定多了。

性能数据对比

上线后跑了五轮实测(同一台 iPhone 13,4G 网络,清除所有缓存):

  • FCP:从 4.2s → 0.83s(提升 80%)
  • TTI:从 6.8s → 1.2s(主线程空闲快了 5.6s)
  • Lighthouse 性能分:28 → 89
  • 内存占用峰值:从 142MB → 48MB

最直观的是用户反馈:“这次点进去秒出,还以为我网好了。” —— 不是网好了,是我不再让 JS 在那瞎忙活了。

当然没 100% 完美:比如滚动到第 1900 条时,第一次展开那个 item 还会卡一下(因为要 parse 单条),但已经比原来“全量卡死”强太多了。而且真到那种深度使用场景,用户大概率已经在用桌面端了。

最后说两句

这次优化没碰 webpack、没搞 code-splitting、没上 SSR,就盯着一个函数砍了三刀:懒解析、懒处理、懒挂载。效果立竿见影。

性能测试不是为了刷分,是帮你看清代码里哪些地方在“假装努力”。很多所谓“慢”,根本不是技术限制,就是我们写的时候图省事,把所有事塞进一个 useEffect 里,觉得“反正用户看不见”。可 DevTools 看得见,Lighthouse 看得见,用户手指头更看得见。

以上是我踩坑后的总结,希望对你有帮助。如果你有更好的方案(比如怎么优雅地做“按需深拷贝”或者轻量级 virtual list 实现),欢迎评论区交流 —— 我真想学学。

本文章不代表JZTHEME立场,仅为作者个人观点 / 研究心得 / 经验分享,旨在交流探讨,供读者参考。
发表评论

暂无评论