React 渲染性能的微观优化:从 useMemo 误用到 Fiber 调和机制的实战剖析
写在前面
useMemo 和 useCallback 的文档读了三遍,项目里 memo 包裹了所有组件,怎么页面还是卡?因为性能优化的本质不是「多套一层缓存」,而是「让 React 少干活」。这篇文章从 Fiber 调和的角度,拆几个实际场景里容易踩的坑。
陷阱一:props 里的「新对象」
// ❌ 每次父组件渲染,style 都是新引用 → Child 必定重渲染
function Parent() {
const [count, setCount] = useState(0);
return (
<div>
<button onClick={() => setCount(c => c + 1)}>+1</button>
<Child style={{ color: 'red' }} /> {/* 这行就是性能杀手 */}
</div>
);
}
const Child = memo(({ style }: { style: React.CSSProperties }) => {
console.log('Child rendered'); // count 变化也会触发
return <div style={style}>I'm child</div>;
});
React 的 memo 用 Object.is 做浅比较。{ color: 'red' } 每次渲染都是新对象 → 浅比较失败 → memo 形同虚设。
// ✅ 用 useMemo 稳定引用
function Parent() {
const [count, setCount] = useState(0);
const childStyle = useMemo(() => ({ color: 'red' } as const), []);
return (
<div>
<button onClick={() => setCount(c => c + 1)}>+1</button>
<Child style={childStyle} />
</div>
);
}
更激进的做法——如果 Child 不依赖 Parent 的任何状态,直接把它提到组件外部或拆成独立子树。
陷阱二:Context 的「核弹级重渲染」
// ❌ 一个 Context 塞了三个不相关的值
const AppContext = createContext<{
theme: 'light' | 'dark';
user: User | null;
notifications: number;
}>(null!);
function AppProvider({ children }: { children: ReactNode }) {
const [theme, setTheme] = useState<'light' | 'dark'>('light');
const [user, setUser] = useState<User | null>(null);
const [notifications, setNotifications] = useState(0);
return (
<AppContext.Provider value={{ theme, user, notifications }}>
{children}
</AppContext.Provider>
);
}
// NavBar 只读 notifications,但 theme 变化时它也重渲染
function NavBar() {
const { notifications } = useContext(AppContext);
return <span>未读: {notifications}</span>;
}
Context value 变化 → 所有 useContext(AppContext) 的消费者全部重渲染。没有中间件,没有 selector。
// ✅ 按变更频率拆分 Context
const ThemeContext = createContext<'light' | 'dark'>('light');
const UserContext = createContext<User | null>(null);
const NotificationContext = createContext(0);
function AppProvider({ children }: { children: ReactNode }) {
const [theme, setTheme] = useState<'light' | 'dark'>('light');
const [user, setUser] = useState<User | null>(null);
const [notifications, setNotifications] = useState(0);
return (
<ThemeContext.Provider value={theme}>
<UserContext.Provider value={user}>
<NotificationContext.Provider value={notifications}>
{children}
</NotificationContext.Provider>
</UserContext.Provider>
</ThemeContext.Provider>
);
}
拆分后,theme 变化只触发 ThemeContext 消费者。Fiber 遍历时子树范围从全量缩小到一个分支。
陷阱三:列表 key 不只是为了消除 warning
// ❌ index 做 key,列表头插时灾难级重渲染
function TodoList() {
const [items, setItems] = useState(['A', 'B', 'C']);
const addFirst = () => setItems(prev => ['D', ...prev]);
return (
<ul>
{items.map((item, index) => (
<TodoItem key={index}>{item}</TodoItem> {/* index 做 key */}
))}
</ul>
);
}
['D','A','B','C'] 插入后,React 对比 Fiber:旧 index 0 → A,新 index 0 → D → 内容变了,更新 DOM。旧 index 1 → B,新 index 1 → A → 内容变了,更新 DOM。结果 4 个节点全部 DOM 更新。
// ✅ 用稳定的业务 ID 做 key
{items.map(item => (
<TodoItem key={item.id}>{item.text}</TodoItem>
))}
用 id 后,React 能找到旧 Fiber 0 (A) 对应的新位置 (index 1),只需插入 D 并更新索引,其余节点复用。
陷阱四:派生状态 vs 冗余状态
// ❌ 同时维护源数据和计算结果 → 同步地狱
function SearchResults({ query }: { query: string }) {
const [results, setResults] = useState<Item[]>([]);
const [filteredCount, setFilteredCount] = useState(0); // 冗余状态
useEffect(() => {
fetchResults(query).then(data => {
setResults(data);
setFilteredCount(data.filter(item => item.active).length);
});
}, [query]);
return <div>活跃: {filteredCount}</div>;
}
filteredCount 完全由 results 决定。每次更新都要同步两个 state —— 一次渲染周期内触发两次 setState,浪费一个 render。
// ✅ 计算值直接用 useMemo 派生
function SearchResults({ query }: { query: string }) {
const [results, setResults] = useState<Item[]>([]);
const filteredCount = useMemo(
() => results.filter(item => item.active).length,
[results]
);
useEffect(() => {
fetchResults(query).then(setResults);
}, [query]);
return <div>活跃: {filteredCount}</div>;
}
陷阱五:事件回调的引用漂移
// ❌ 内联箭头函数 → memo 失效
function Parent() {
const [count, setCount] = useState(0);
return (
<ExpensiveChild
onAction={() => console.log(count)} // 每次渲染新函数引用
/>
);
}
// ❌ 更隐蔽:useCallback 依赖了高频变化的状态
function Parent() {
const [count, setCount] = useState(0);
const onAction = useCallback(() => {
console.log(count);
}, [count]); // count 变化 → 回调引用变化 → 跟没 memo 一样
return <ExpensiveChild onAction={onAction} />;
}
这里 count 每次变化都需要新的回调,useCallback 无法稳定引用。解法是用 useRef 存储最新值,用 useCallback 稳定函数引用:
// ✅ ref 存值 + callback 稳引用
function Parent() {
const [count, setCount] = useState(0);
const countRef = useRef(count);
countRef.current = count;
const onAction = useCallback(() => {
console.log(countRef.current);
}, []); // 空依赖,引用永远不变
return <ExpensiveChild onAction={onAction} />;
}
思考:什么时候该停止优化
并不是每个 memo 都值得加。memo 本身也有成本——浅比较遍历 props 对象的时间。如果组件的 render 开销小于浅比较开销,加 memo 反而慢了。
// 这种情况就别 memo 了
const Text = ({ text }: { text: string }) => <span>{text}</span>;
性能优化的天花板公式:你的 render 时间 × 重渲染次数 ≤ 16ms。超过这个阈值用户才感知到掉帧。React DevTools Profiler 里看到 render duration 和 commit duration 再决定优化方向,别凭直觉盲铺 memo。
评论区
登录 后参与评论