我在真实项目中用到的前端性能优化实战技巧
谁更灵活?谁更省事?
最近在优化一个后台数据看板,首屏加载时间卡在 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 实现。性能优化没有银弹,只有组合拳,而且得盯着真实指标改,别光看打包体积。
以上是我踩坑后的总结,希望对你有帮助。有更优的实现方式欢迎评论区交流。

暂无评论