一次真实项目中的前端性能优化实战经验分享

绍懿 框架 阅读 2,455
赞 28 收藏
二维码
手机扫码查看
反馈

先看效果,再看代码

上周上线一个数据看板页,用户反馈“点按钮卡顿”“下拉半天没反应”。我打开 Performance 面板一录,主线程直接飙到 120ms 帧耗——不是动画卡,是点击后 JS 处理逻辑拖垮了整个交互。查了一圈,罪魁祸首居然是 useEffect 里反复调用的 setState + 没加防抖的 window.addEventListener('resize'),再加上一个没 memo 的 map 渲染组件。不是框架不行,是我写得太糙。

一次真实项目中的前端性能优化实战经验分享

今天就直接甩出我在项目里**正在用、已上线、没翻车**的几招性能优化实操,不讲大道理,只说“怎么改、为什么这么改、改完啥效果”。

这个场景最好用:列表渲染卡顿?别急着换虚拟滚动

很多同学一看到长列表就想着上 react-window,但其实 90% 的情况,只是忘了加 React.memouseCallback。我们有个设备监控页,展示 300+ 行实时状态,原始写法:

function DeviceItem({ device }) {
  return (
    <div className="device-row">
      <span>{device.id}</span>
      <span>{device.status}</span>
      <button onClick={() => updateStatus(device.id)}>更新</button>
    </div>
  );
}

function DeviceList({ devices }) {
  return (
    <div className="device-list">
      {devices.map(device => (
        <DeviceItem key={device.id} device={device} />
      ))}
    </div>
  );
}

问题在哪?每次父组件重渲染(比如顶部搜索框输入),哪怕 devices 数组没变,DeviceItem 也会全部重 render —— 因为 onClick 是内联函数,每次都是新引用,React.memo 直接失效。

亲测有效改法:

const DeviceItem = React.memo(function DeviceItem({ device, onUpdate }) {
  return (
    <div className="device-row">
      <span>{device.id}</span>
      <span>{device.status}</span>
      <button onClick={() => onUpdate(device.id)}>更新</button>
    </div>
  );
});

function DeviceList({ devices }) {
  const handleUpdate = useCallback((id) => {
    // 实际业务逻辑
    updateStatus(id);
  }, []);

  return (
    <div className="device-list">
      {devices.map(device => (
        <DeviceItem
          key={device.id}
          device={device}
          onUpdate={handleUpdate}
        />
      ))}
    </div>
  );
}

就两处改动:① 把 onClick 提成 onUpdate props;② 用 useCallback 包一层。实测首屏渲染时间从 86ms 降到 22ms,滚动也顺了。注意:useCallback 的依赖数组必须写全,漏掉 updateStatus 就会闭包旧值——这里我踩过坑,折腾半天发现状态没更新,最后 console.log 闭包才发现。

踩坑提醒:这三点一定注意

  • useMemo 不是万能的,别滥用:我之前在表格列配置里对整个 columns 数组 useMemo,结果发现每次 filter 变化都触发重计算,反而更慢。后来改成只 memo 单个 column renderer 函数,效果立竿见影。
  • resize 事件不节流,页面直接变幻灯片:某个仪表盘页用了 useEffect(() => { window.addEventListener('resize', handler) }),但没加防抖。用户缩放浏览器窗口时,handler 被调用上百次,CPU 占用瞬间 95%。现在统一用这个 hook:
function useDebouncedResize(callback, delay = 150) {
  useEffect(() => {
    const handleResize = () => {
      clearTimeout(handleResize.timer);
      handleResize.timer = setTimeout(callback, delay);
    };
    handleResize.timer = null;
    
    window.addEventListener('resize', handleResize);
    return () => {
      window.removeEventListener('resize', handleResize);
      clearTimeout(handleResize.timer);
    };
  }, [callback, delay]);
}

然后在组件里:useDebouncedResize(() => updateLayout(), 200) —— 简单粗暴,亲测有效。

  • fetch 请求别堆在 useEffect 里裸奔:有个页面同时发了 5 个 fetch('https://jztheme.com/api/xxx'),全是独立 useEffect,没做并发控制也没 loading 状态管理,网络差的时候 UI 完全无响应。后来改用 Promise.all + 自定义 loading 状态,再加个 abortController 防止组件卸载后还更新 state(又一个我踩过好几次坑的地方):
function useBatchData() {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    const controller = new AbortController();
    
    Promise.all([
      fetch('https://jztheme.com/api/status', { signal: controller.signal }),
      fetch('https://jztheme.com/api/metrics', { signal: controller.signal }),
      fetch('https://jztheme.com/api/alerts', { signal: controller.signal })
    ])
      .then(responses => Promise.all(responses.map(r => r.json())))
      .then(([status, metrics, alerts]) => {
        setData({ status, metrics, alerts });
      })
      .catch(err => {
        if (err.name !== 'AbortError') {
          console.error('Batch fetch failed:', err);
        }
      })
      .finally(() => setLoading(false));

    return () => controller.abort();
  }, []);

  return { data, loading };
}

高级技巧:用 CSS containment 偷懒优化渲染

这个可能很多人没听过,但它真香。我们有个复杂表单页,里面嵌了 4 个独立子模块(每个都有自己的状态和动画),但它们之间完全不通信。以前每次改其中一个字段,整个表单树都 re-render。后来给每个子模块外层加了:

.module-container {
  contain: layout style paint;
}

效果?Chrome DevTools 的 Rendering 面板里,“Paint” 区域明显缩小,滚动时 GPU 占用下降 30%。原理很简单:contain: layout style paint 告诉浏览器“这块区域的内容变化不会影响外部布局”,于是浏览器可以跳过很多计算。不是所有场景都适用(比如模块里有 position: fixed 元素就可能失效),但只要你的模块确实是“独立盒子”,建议直接用这种方式。

最后说句实在话

性能优化没有银弹。我上线的那个看板页,加了 memo、节流、containment 后,Lighthouse 性能分从 42 升到 78,但还有两个小问题:① 切换 Tab 时首次加载稍慢(懒加载 chunk 还没预取);② 某个图表库内部 setState 过频,暂时没动它——因为改它的成本远高于收益,目前用户没投诉,我就先放着。

技术选型也好、优化策略也罢,最终要回归到“用户感知”和“投入产出比”。有些优化写起来爽,但真实世界里根本测不出差别;有些 bug 看似严重,其实用户根本没注意到。

这个技巧的拓展用法还有很多,后续会继续分享这类博客。比如:如何用自定义 Hook 统一管理多个异步请求的状态边界;怎么在 SSR 场景下避免 hydration mismatch 导致的重复渲染;还有——怎么说服产品经理别在首页加自动轮播的 10 张高清 Banner 图(别笑,真有)。

以上是我踩坑后的总结,希望对你有帮助。

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

暂无评论