一次真实的性能测试实践从工具选型到瓶颈定位
优化前:卡得不行
上周上线一个数据看板页,加载完要等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 分钟改完,当天下午就上了预发。
核心就三点:
- 懒解析:后端返回的 data 字段是字符串(为了兼容老字段),但我一开始就
JSON.parse全量转对象。改成只 parse 当前需要展示的前 10 条,其余存 raw string,用到再 parse。 - 懒处理:字段映射逻辑(比如把
user_id→userId)封装成函数,但只对可见数据调用,不用提前 map 全量。 - 懒挂载:用
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 实现),欢迎评论区交流 —— 我真想学学。

暂无评论