
场景回放:我见过太多开发者盲目使用
React.memo、useCallback、useMemo,却从未真正衡量过性能收益。他们优化的是"感觉",而不是"实际"。今天咱们来扒一扒 React 渲染优化背后那些被忽视的真相。
如果你已经在用 React.memo 和 useCallback,但从未用过 React DevTools Profiler,那你大概率在做无用功。本文从源码角度出发,告诉你什么时候优化才是真正有效的,什么时候优化纯粹是在浪费开发效率。
React 的渲染 ≠ DOM 更新。这是最被误解的地方。
┌─────────────────────────────────────────┐
│ React 渲染周期 │
├─────────────────────────────────────────┤
│ 1. Render Phase (可中断) │
│ ├─ 执行函数组件 │
│ ├─ 调用 hooks │
│ └─ 生成新的 JSX 树 │
│ │
│ 2. Commit Phase (不可中断) │
│ ├─ DOM 更新 │
│ ├─ 副作用执行 │
│ └─ 浏览器重排/重绘 │
└─────────────────────────────────────────┘
关键洞察:在 Render Phase,React 仅仅是执行函数和对比虚拟 DOM。这个阶段通常很快(毫秒级)。真正拖垮性能的是 Commit Phase 的 DOM 操作和浏览器的重排/重绘。
很多开发者优化的其实是"虚拟 DOM 对比"这一步,但如果最后 DOM 没有变化,这个优化毫无意义。
看这个常见的代码:
// 假设这是你的组件
function Parent() {
const handleClick = () =>console.log('clicked');
return<Child onClick={handleClick} />;
}
const Child = React.memo(({ onClick }) => (
<button onClick={onClick}>点我</button>
));
每次 Parent 渲染,handleClick 都是新的函数引用。但这真的是问题吗?
答案是:通常不是。
为什么?因为 <button> 元素本身没有改变,所以即使 onClick 引用变了,DOM 也不会更新。React 很聪明,它不会因为你传入的函数引用变了就重排网页。
真正的问题出现在这里:
function Parent() {
const handleClick = () => console.log('clicked');
return (
<div>
<Child
onClick={handleClick}
data={someHeavyObject} // ← 这是新对象引用
/>
{/* 100 个其他组件 */}
</div>
);
}
如果 someHeavyObject 是个大对象,每次创建新引用都会导致 Child 及其所有子组件重新渲染。现在才是真问题。
很多人还在用 React 16 时代的优化思维。但 React 18+ 已经帮你做了很多事情:
// React 16: 两次 DOM 更新
setState1(); // 触发更新
setState2(); // 触发更新
// React 18: 一次 DOM 更新(自动批处理)
setState1();
setState2();
这意味着一些曾经必需的优化现在是冗余的。
React.memo() - 场景限制很严格何时有效:
何时无效:
// ❌ 这样做 React.memo 形同虚设
function Parent() {
const config = { timeout: 5000 }; // 每次都是新对象
return <Child config={config} />; // Child 始终被重新渲染
}
const Child = React.memo(({ config }) => <div>{config.timeout}</div>);
成本分析:
我的建议:先用 Profiler 检测,不要盲目用。
useCallback() - 最容易被滥用很多人把 useCallback 当成"保险"来用,但这是个大坑:
// ❌ 常见的无用优化
function Form() {
const [value, setValue] = useState('');
const handleChange = useCallback((e) => {
setValue(e.target.value);
}, []); // 每个输入都会触发这个回调
return (
<>
<input onChange={handleChange} />
<Child callback={handleChange} />
</>
);
}
这里的 useCallback 能防止什么?什么都防不了。因为 Form 组件本身的 state 变了,它总会重新渲染。handleChange 的引用稳定并改变不了这个事实。
真正的应用场景:
// ✅ useCallback 的正确用法
function DataTable() {
const [filter, setFilter] = useState('');
// 这个函数很重,包含复杂的业务逻辑
const memoizedSort = useCallback(
(data) => complexSortAlgorithm(data, filter),
[filter] // 只在 filter 变化时重新创建
);
return (
<>
<TableHeader onSort={memoizedSort} /> // 传给子组件
<TableContent sortFn={memoizedSort} />
</>
);
}
关键点:这个函数被多个 memo 组件使用,且引用稳定能产生实际的性能收益。
useMemo() - 有用但成本容易被忽视useMemo 在这两个场景有明确价值:
场景A:过滤/映射大数组
// ❌ 每次都重新计算(假设列表有 10,000 项)
function UserList({ users, searchQuery }) {
const filtered = users.filter(u =>
u.name.toLowerCase().includes(searchQuery)
); // O(n) 每次都跑
return <div>{filtered.map(u => <User key={u.id} user={u} />)}</div>;
}
// ✅ 只在 users 或 searchQuery 变化时重新计算
function UserList({ users, searchQuery }) {
const filtered = useMemo(() =>
users.filter(u => u.name.toLowerCase().includes(searchQuery)),
[users, searchQuery]
);
return <div>{filtered.map(u => <User key={u.id} user={u} />)}</div>;
}
但这里有个隐藏成本:
单次渲染性能对比(10,000 项列表):
方案1(直接过滤):
Render Phase: 2ms (过滤)
Commit Phase: 15ms (DOM 更新)
总计: 17ms
方案2(useMemo):
Render Phase: 0.5ms (检查依赖 + useMemo hook 开销)
Commit Phase: 15ms (DOM 更新,因为列表引用变了)
总计: 15.5ms
节省: ~1.5ms/次
但如果列表不变?
Render Phase: 0.5ms (检查依赖,复用缓存)
Commit Phase: 0ms (列表引用未变)
总计: 0.5ms
节省: 16.5ms/次 ✅
场景B:复杂的对象/数组创建
// ❌ 每次创建新对象,导致子组件重新渲染
function Dashboard() {
const chartConfig = { // ← 每次都是新对象
type: 'line',
axes: { x: 'date', y: 'value' },
colors: ['#1f77b4', '#ff7f0e'],
};
return<Chart config={chartConfig} />;
}
const Chart = React.memo(({ config }) => {
// 即使 config 的值没变,React 也认为它变了
return<div>{config.type}</div>;
});
// ✅ 用 useMemo 稳定对象引用
function Dashboard() {
const chartConfig = useMemo(() => ({
type: 'line',
axes: { x: 'date', y: 'value' },
colors: ['#1f77b4', '#ff7f0e'],
}), []); // 一次创建,永久复用
return<Chart config={chartConfig} />;
}
这不是微优化,这是架构级别的优化。
传统 React:所有代码都在浏览器运行
网络传输
┌─────────────────────────┐
↓ ↑
┌─────────────────────────────┴──────────────┐
│ 浏览器(Bundle: 250KB) │
│ ├─ React 运行时 │
│ ├─ 业务代码 │
│ ├─ 数据库查询 (重复) │
│ └─ 机密密钥 (危险) │
└──────────────────────────────────────────────┘
RSC 模型:智能分离客户端和服务器代码
网络传输(仅结果)
┌──────────────────┐
│ RSC Payload │ (HTML + 交互标记)
└──────────────────┘
↑
┌──────────┴──────────┬─────────────────────┐
│ 服务器 │ 浏览器 │
│ ├─ 数据库查询 │ ├─ React 运行时 │
│ ├─ 身份验证 │ ├─ 交互组件 │
│ └─ 私密逻辑 │ └─ 事件处理 │
│ │ (Bundle: 50KB) │
└─────────────────────┴─────────────────────┘
实际数据(来自 Vercel 的基准测试):
指标 | 传统 CSR | 使用 RSC |
|---|---|---|
初始 JS Bundle | 250KB | 50KB |
FCP (First Contentful Paint) | 2.3s | 0.8s |
TTI (Time to Interactive) | 4.1s | 1.2s |
页面大小 | 350KB | 120KB |
这才是真正有价值的优化。小对象和函数引用的优化根本没法比。
如果你的列表有 1,000+ 项,虚拟化是唯一的选择,没有之一。
直观对比:
原生渲染 10,000 项:
├─ 创建 10,000 个 DOM 节点
├─ 初始化时间: 3-5s
├─ 内存占用: 50-100MB
└─ 滚动卡顿: 肉眼可见 ❌
虚拟化渲染 10,000 项:
├─ 只创建可见项 (~20 个)
├─ 初始化时间: 100ms
├─ 内存占用: 2-3MB
└─ 滚动流畅: 60fps ✅
代码示例(用 TanStack Virtual,比 react-window 更灵活):
import { useVirtualizer } from'@tanstack/react-virtual';
function LargeList({ items }: { items: Item[] }) {
const parentRef = useRef<HTMLDivElement>(null);
const virtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => parentRef.current,
estimateSize: () =>35, // 每项高度 35px
overscan: 10, // 渲染可见外 10 项(平滑滚动)
});
const virtualItems = virtualizer.getVirtualItems();
const totalSize = virtualizer.getTotalSize();
return (
<div ref={parentRef} style={{ height: '600px', overflow: 'auto' }}>
<div style={{ height: `${totalSize}px`, position: 'relative' }}>
{virtualItems.map((virtualItem) => (
<div
key={virtualItem.key}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: `${virtualItem.size}px`,
transform: `translateY(${virtualItem.start}px)`,
}}
>
<Item item={items[virtualItem.index]} />
</div>
))}
</div>
</div>
);
}
为什么虚拟化这么有效?
因为它从根本上减少了 DOM 节点数量,而 DOM 操作是最昂贵的。没有 memo 或 useCallback 能比得上减少 99% 的 DOM 节点。
很多人用 Context 存储全局状态,然后惊讶地发现整个应用都在闪烁:
// ❌ Context Hell
const ThemeContext = createContext();
const UserContext = createContext();
const NotificationContext = createContext();
function App() {
const [theme, setTheme] = useState('light');
const [user, setUser] = useState(null);
const [notifications, setNotifications] = useState([]);
return (
<ThemeContext.Provider value={{ theme, setTheme }}>
<UserContext.Provider value={{ user, setUser }}>
<NotificationContext.Provider value={{ notifications, setNotifications }}>
<MainApp />
</NotificationContext.Provider>
</UserContext.Provider>
</ThemeContext.Provider>
);
}
// MainApp 中的任何组件,只要 Context value 变化,都会重新渲染
// 即使那个组件只用了 Context 的一部分
为什么这是问题?
当 user 变化时:
UserContext.Provider 的 value 对象变了
→ 所有消费这个 Context 的组件都重新渲染
→ 包括那些只需要 notifications 的组件
→ 级联效应 ❌
解决方案:分离和选择器模式
// ✅ 方案1:分离 Context
const ThemeContext = createContext();
const UserContext = createContext();
const NotificationContext = createContext();
function App() {
return (
<ThemeProvider>
<UserProvider>
<NotificationProvider>
<MainApp />
</NotificationProvider>
</UserProvider>
</ThemeProvider>
);
}
// ✅ 方案2:用 Zustand 替代 Context
import { create } from 'zustand';
import { useShallow } from 'zustand/react/shallow';
const useAppStore = create((set) => ({
theme: 'light',
user: null,
notifications: [],
setTheme: (theme) => set({ theme }),
setUser: (user) => set({ user }),
setNotifications: (notifications) => set({ notifications }),
}));
// 只订阅需要的字段,避免级联重新渲染
function Header() {
const { theme, setTheme } = useAppStore(
useShallow((state) => ({
theme: state.theme,
setTheme: state.setTheme,
}))
);
return <div style={{ color: theme === 'dark' ? '#fff' : '#000' }}>...</div>;
}
我建议:除非你有超过 5 个 Context,否则直接用 Zustand 或 Redux,别自己搭。
有些开发者为了"优化",把事件监听器放在根元素上:
// ❌ 这样做反而更慢
function List({ items }) {
const handleClick = (e) => {
if (e.target.dataset.itemId) {
// 处理点击
}
};
return (
<ul onClick={handleClick}> // 所有点击都冒泡到这里
{items.map(item => (
<li key={item.id} data-item-id={item.id}>
{item.name}
</li>
))}
</ul>
);
}
问题:
dataset对于列表项较少的情况(< 100 项),直接在列表项上绑定事件更快。
很多 Effect 之间有隐藏的依赖关系,导致级联更新:
// ❌ Effect 链
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [posts, setPosts] = useState([]);
const [comments, setComments] = useState([]);
// Effect 1: 获取用户
useEffect(() => {
fetchUser(userId).then(setUser);
}, [userId]);
// Effect 2: 当用户变化时,获取文章
useEffect(() => {
if (user) {
fetchPosts(user.id).then(setPosts);
}
}, [user]);
// Effect 3: 当文章变化时,获取评论
useEffect(() => {
if (posts.length) {
fetchComments(posts[0].id).then(setComments);
}
}, [posts]);
}
// 更新流:userId → user → posts → comments
// 总共 3 次网络请求 + 3 次重新渲染
更好的做法:
// ✅ 集中管理异步逻辑
function UserProfile({ userId }) {
const [state, setState] = useState({
user: null,
posts: [],
comments: [],
loading: true,
});
useEffect(() => {
let cancelled = false;
(async () => {
const user = await fetchUser(userId);
const posts = await fetchPosts(user.id);
const comments = await fetchComments(posts[0].id);
if (!cancelled) {
setState({ user, posts, comments, loading: false });
}
})();
return() => { cancelled = true; };
}, [userId]);
// 一次更新,一次重新渲染
}
别再凭感觉优化了。这是完整的检测流程:
// 标记测量区间
export function App() {
return (
<Profiler id="app" onRender={onRenderCallback}>
<MainContent />
</Profiler>
);
}
function onRenderCallback(
id, // "app"
phase, // "mount" 或 "update"
actualDuration, // 真实渲染时间 (ms)
baseDuration, // 未优化时的估计时间 (ms)
startTime, // React 开始渲染的时间
commitTime // React 提交更新的时间
) {
console.log(`${id} (${phase}) took ${actualDuration}ms`);
}
运行这个,你会看到真实数据,而不是猜测。
Frame Rate Timeline
标记渲染阶段:
[Render 2ms] [Commit 15ms] [Paint 8ms] [Composite 1ms]
如果总时间 > 16ms,那就会掉帧(60fps 的阈值)
import { getCLS, getFID, getFCP, getLCP, getTTFB } from 'web-vitals';
getCLS(console.log); // Cumulative Layout Shift
getFID(console.log); // First Input Delay
getFCP(console.log); // First Contentful Paint
getLCP(console.log); // Largest Contentful Paint
getTTFB(console.log); // Time to First Byte
只关心这 5 个指标。其他的都是虚的。
根据实际项目经验,我总结了优化的优先级:
redux-toolkit 的 entityAdapterReact.memouseCallbackuseMemo 缓存大数据结构我曾优化过公司的内部管理系统,列出几千条员工记录。
优化前:
优化历程:
第一步:加虚拟化
├─ 时间成本: 2 小时
├─ 代码行数: 50 行
└─ 效果: 5.2s → 1.8s (65% 改进) ✅
第二步:分离 Context
├─ 时间成本: 1.5 小时
├─ 效果: 1.8s → 1.6s (11% 改进)
└─ 主要是消除级联重新渲染
第三步:加 React.memo
├─ 时间成本: 3 小时
├─ 效果: 1.6s → 1.5s (6% 改进)
└─ 收益微小,但稳定性更好
第四步:用 Zustand 替代 Redux
├─ 时间成本: 4 小时(重构)
├─ 效果: 1.5s → 1.5s (0% 改进)
└─ 但代码更清晰,更容易维护
最终效果:
5.2s → 1.5s (71% 总体改进)
但关键是:
- 虚拟化贡献了 65%
- Context 分离贡献了 11%
- 其他都是鸡毛蒜皮
这个案例的启示:
useCallback 和 useMemo 上React 性能优化没有银弹。但有原则:
✅ 该做的:
❌ 别做的:
React.memo、useCallback、useMemo最扎心的真相:大多数 React 性能问题根本不是 React 的问题,而是架构设计的问题。换个思路,往往胜过优化一个小时。
欢迎关注我的微信公众号 《前端达人》!这里有:
如果这篇文章对你有启发,请:
让我们一起拒绝无效优化,做真正有效率的开发。