我在真实项目中用到的前端性能优化实战技巧

♫芳妤 工具 阅读 2,979
赞 16 收藏
二维码
手机扫码查看
反馈

谁更灵活?谁更省事?

最近在优化一个后台数据看板,首屏加载时间卡在 2.3s 左右,用户反馈“点进去要等半天”。我扒了一圈 Lighthouse 报告,发现核心问题不是接口慢(后端已压缩到 80ms),而是 JS 打包太大 + 首屏渲染被一堆无关逻辑阻塞。于是我又一次坐下来,把几个主流的“性能优化手段”拉出来重新对了一遍——不是看文档,是翻自己项目里真实改过的 commit、删掉又加回去的代码块、还有那几行被注释了三个月最后还是解开了的 useMemo

我在真实项目中用到的前端性能优化实战技巧

这次重点对比三个我日常高频用、也反复踩坑的方案:

  • Code Splitting(import() 动态导入)
  • React.memo + useMemo 组合拳
  • Intersection Observer + 懒加载组件(配合 Suspense)

不聊 SSR、不聊微前端、也不扯 WebAssembly——那些离我当前业务太远,先搞定眼前这 2.3 秒再说。

先说结论:我基本只用前两个,第三个只在特定场景开个口子

Code Splitting 我几乎每新项目必配;React.memo 是我写组件时的肌肉记忆,但 useMemo 我现在会手抖三秒再敲——真不是它不好,是我自己曾经滥用它导致 rerender 更频繁,折腾了半天发现是依赖数组写错了,还顺带把整个表格卡成 PPT。

Code Splitting:最省心,也最容易“假优化”

我比较喜欢用 import() 配合 React.lazy 做路由级拆分。为什么?因为不用动业务逻辑,一行 const Dashboard = React.lazy(() => import('./Dashboard')) 就能干掉 300KB 的 chunk。实测打包后 vendor.js 直接从 1.4MB 降到 620KB,Lighthouse 的 Performance 分数涨了 18 分。

但这里有个巨坑:如果你没配 Suspense fallback,页面直接白屏。我第一次上线就忘了这个,监控报警一响,我一边喝咖啡一边骂自己。

代码长这样(真实项目精简版):

// routes.jsx
import { lazy, Suspense } from 'react'

const Dashboard = lazy(() => import('./pages/Dashboard'))
const Report = lazy(() => import('./pages/Report'))

function AppRoutes() {
  return (
    <Suspense fallback={<div className="loading">加载中...</div>}>
      <Routes>
        <Route path="/dashboard" element={<Dashboard />} />
        <Route path="/report" element={<Report />} />
      </Routes>
    </Suspense>
  )
}

优点是真的香:Webpack 自动切包,不用手动管理 chunk 名,也不用搞魔法注释(/* webpackChunkName: "xxx" */)。缺点也有——比如你不能在非 async 函数里用 import(),想按需加载某个工具函数?得封装一层 Promise,略烦。另外,如果组件内部有大量 useEffect 初始化逻辑,首屏还是可能卡顿——懒加载只管“下载时机”,不管“执行时机”。

React.memo + useMemo:我的日常防抖开关

我写列表组件必加 React.memo,哪怕只是个 Card。不是为了多快,是怕别人传个新对象进来,整张表重绘。亲测有效:一个 200 行的表格,去掉 memo 后,滚动时 FPS 掉到 30 以下;加上之后稳在 58-60。

useMemo 我现在特别谨慎。上个月修一个搜索页,我给整个过滤后的数据加了 useMemo(filteredData, [searchTerm, filters]),结果发现输入框打字延迟半秒——debug 半小时才发现 filters 是个对象,每次父组件更新都生成新引用,memo 失效,还白跑了深比较。

后来我改成:

// ✅ 正确写法:只依赖可序列化的值
const filteredData = useMemo(() => {
  return data.filter(item =>
    item.name.includes(searchTerm) &&
    item.status === filters.status
  )
}, [data, searchTerm, filters.status]) // 注意:只取 status 字段,不是整个 filters 对象

总结一句:React.memo 是安全带,useMemo 是手动档——好用,但挂错挡会熄火。我现在默认加 memo,但 useMemo 必须问自己一句:“这个计算真的重吗?有没有更轻的替代?”

Intersection Observer + 懒加载:听起来高级,用起来累

这个我试过两次。第一次是首页瀑布流,图片太多,想用 IO 实现“进视区才加载”。代码写完效果是有了,但用户快速滑动时,图片一批批闪现,体验像网速差的视频缓冲。第二次是报表底部的“导出历史”模块,我把它懒加载了,结果 QA 直接提 bug:“点导出按钮没反应”,一看是 IO 还没触发,组件根本没 mount,ref 是 null。

不是技术不行,是它和业务节奏对不上。我们后台用户习惯“打开即用”,不是“慢慢加载”。而且 Intersection Observer 在 iOS Safari 上偶尔有兼容问题(iOS 15.4 之前,rootMargin 算不准),我们还得加 polyfill,体积又回来了。

简单示例(仅展示 IO 核心逻辑):

// useLazyLoad.js
import { useEffect, useRef } from 'react'

export function useLazyLoad(callback) {
  const ref = useRef(null)

  useEffect(() => {
    const observer = new IntersectionObserver(
      ([entry]) => {
        if (entry.isIntersecting) {
          callback()
          observer.unobserve(entry.target)
        }
      },
      { rootMargin: '100px' }
    )

    if (ref.current) {
      observer.observe(ref.current)
    }

    return () => {
      if (ref.current) {
        observer.unobserve(ref.current)
      }
    }
  }, [callback])

  return ref
}

// 组件里用
function ExportHistory() {
  const ref = useLazyLoad(() => {
    fetch('https://jztheme.com/api/export-history')
      .then(res => res.json())
      .then(data => setHistory(data))
  })

  return <div ref={ref}>导出历史内容...</div>
}

这段代码能跑,但真上线?我宁愿多打一个 50KB 的 bundle,换用户点击不懵逼。

我的选型逻辑:能切就切,能 memo 就 memo,别硬上 IO

优先级很明确:

  • 路由/大模块 → import() + lazy(必须配 Suspense
  • 列表项、表单项、卡片类组件 → React.memo(加 areEqual 判断更稳)
  • 计算量大且依赖稳定 → useMemo(但先 profile,别猜)
  • IO?除非产品明确说“这个模块可以晚点加载”,否则免谈

最后说个真实细节:我们上周把所有 import() 加上后,首屏时间确实降到了 1.6s,但 TTI(可交互时间)只提前了 0.2s——因为 JS 执行时间没变。所以现在我在做两件事:一是把部分工具函数抽成 Web Worker(比如 Excel 导出前的数据预处理),二是砍掉一个用了三年没人改的第三方 chart 库,换成轻量 Canvas 实现。性能优化没有银弹,只有组合拳,而且得盯着真实指标改,别光看打包体积。

以上是我踩坑后的总结,希望对你有帮助。有更优的实现方式欢迎评论区交流。

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

暂无评论