React Context 有哪些性能问题?怎样避免不必要的重新渲染?
Provider 的 value 用 Object.is 比较出变化后,所有读这个 Context 的组件都会重新渲染,memo 也挡不住,React 不会只通知用到某个字段的组件。常见改法是用 useMemo、useCallback 稳定 value,按变化频率拆分 Context,把 state 和 dispatch 放进两个 Context;高频数据改放外部 store,用 useSyncExternalStore 让组件只订阅自己用到的那一部分。
简答
比如把
{ user, theme, cart }放在一个AppContext里,购物车数量每变一次,只读theme的导航栏、只读user的头像也全都跟着重新渲染。原因是 React 只比较 Provider 的value整体变没变,变了就让所有读这个 Context 的组件重新渲染,它不知道组件只用了其中哪个字段。这个更新连
memo也挡不住。memo只在父组件传来的 props 没变时跳过渲染,React 文档写明,组件用到的 Context 变了,被memo包住的组件照样重新渲染。改法按情况选。value 是每次渲染新建的对象时,用
useMemo、useCallback让它在数据没变时保持同一个引用;变化频率不同的数据拆成几个 Context;useReducer的 state 和 dispatch 分开放,只发 action 的组件就不受 state 变化影响。数据变化很频繁、组件只关心一小部分时,就把数据放到 React 外面的 store 里,组件用useSyncExternalStore只订阅自己用到的值。要注意,这些都是按需做的优化。React 文档说,小应用里 value 每次都新建并不是问题;Context 适合放主题、语言、登录用户这类不常变的数据,输入框内容、拖拽坐标这种高频数据不适合放进去。
一个 Context 装太多东西会怎样
先回顾 Context 的更新规则,基本用法见《useContext 怎样用?Context 变化为什么会让所有消费组件重新渲染?》。React 文档的说法是:从收到不同 value 的 Provider 开始,React 会重新渲染下面所有使用这个 Context 的组件,新旧 value 用 Object.is 比较。
const AppContext = createContext(null);
function App() {
const [user] = useState({ name: '小明' });
const [theme, setTheme] = useState('light');
const [cartCount, setCartCount] = useState(0);
const value = useMemo(
() => ({ user, theme, cartCount, setTheme, setCartCount }),
[user, theme, cartCount],
);
return (
<AppContext value={value}>
<NavBar />
<ProductList />
</AppContext>
);
}
const NavBar = memo(function NavBar() {
const { theme } = useContext(AppContext);
return <nav className={theme}>...</nav>;
});
这里已经用 useMemo 稳定了 value,可是购物车加一件商品,cartCount 变了,value 就得是一个新对象。NavBar 只用了 theme,也会重新渲染。
为什么外面包了 memo 还没用?因为 memo 管的是 props。NavBar 没有从父组件拿任何 props,它的更新来自 useContext。React 文档写得很直接:就算组件被 memo 包住,它使用的 Context 变了,它仍然会重新渲染,记忆化只跟父组件传来的 props 有关。
第一步:别让 value 无故变化
最常见的问题其实比上面更简单:value 写成了 value={{ user, login }},Provider 所在组件每渲染一次,对象和函数都是新的,Object.is 永远判定为不同。这时数据根本没变,所有消费组件却都在跟着渲染。
const login = useCallback((response) => {
setCurrentUser(response.user);
}, []);
const contextValue = useMemo(() => ({ currentUser, login }), [currentUser, login]);
用 useCallback 固定函数,用 useMemo 固定对象,只有 currentUser 真的变了,value 才会变。这一步只能消除“数据没变却重新渲染”,数据真的变了,所有消费组件还是会更新,所以还要看下面的拆分。useMemo 和 memo 的区别见《memo、useMemo、useCallback 各自优化什么?》。
第二步:按变化频率拆分 Context
既然 React 按“整个 Context”通知,那就让每个 Context 只装一类数据。上面的例子可以拆成 ThemeContext、UserContext、CartContext,NavBar 只读 ThemeContext,购物车变化就不会再影响它。
如果只是一个组件想读 Context 里的一部分,React 的 memo 文档给了另一种办法:把组件拆成两层,外层读 Context,把用到的那个字段作为 prop 传给一个被 memo 包住的内层组件。外层每次还会渲染,但它很轻;内层只在这个字段变了时才渲染。
function NavBarOuter() {
const { theme } = useContext(AppContext);
return <NavBarInner theme={theme} />;
}
const NavBarInner = memo(function NavBarInner({ theme }) {
return <nav className={theme}>...</nav>;
});
第三步:state 和 dispatch 分开放
useReducer 配合 Context 时,React 文档的做法是建两个 Context:
const TasksContext = createContext(null);
const TasksDispatchContext = createContext(null);
function TasksProvider({ children }) {
const [tasks, dispatch] = useReducer(tasksReducer, initialTasks);
return (
<TasksContext value={tasks}>
<TasksDispatchContext value={dispatch}>{children}</TasksDispatchContext>
</TasksContext>
);
}
为什么要分开?因为 useReducer 返回的 dispatch 的引用是稳定的(React 文档称它有 stable identity),TasksDispatchContext 的 value 永远不变。“添加任务”按钮只读 dispatch,任务列表怎么变,它都不会因为 Context 重新渲染。要是把 { tasks, dispatch } 放在一个对象里,任务一变,按钮也得跟着渲染。这种组合的适用范围见《Context 与 useReducer 适合管理什么状态?》。
数据变化很频繁时:外部 store 加 useSyncExternalStore
拆分也有尽头。比如一个协作编辑器,文档状态有几十个字段,每秒变好几次,每个组件只关心其中一两个。拆成几十个 Context 不现实,放在一个 Context 里又会每次全部重新渲染。
这时的思路是把数据放到 React 外面,自己管理订阅,让每个组件只在“自己关心的那部分”变了时才更新。下面用纯 JavaScript 模拟这个机制:watch 相当于一个组件,它记住自己选出来的值,store 通知变化时,用 Object.is 比较新旧两次选出的值,不同才算一次重新渲染。
function createStore(initialState) {
let state = initialState;
const listeners = new Set();
return {
getState: () => state,
setState(patch) {
state = { ...state, ...patch };
listeners.forEach((listener) => listener());
},
subscribe(listener) {
listeners.add(listener);
return () => listeners.delete(listener);
},
};
}
const store = createStore({ count: 0, theme: 'light' });
const renders = { counter: 0, themeLabel: 0 };
function watch(name, selector) {
let last = selector(store.getState());
store.subscribe(() => {
const next = selector(store.getState());
if (!Object.is(last, next)) {
last = next;
renders[name] += 1;
}
});
}
watch('counter', (state) => state.count);
watch('themeLabel', (state) => state.theme);
store.setState({ count: 1 });
store.setState({ count: 2 });
console.log(renders.counter); // 2
console.log(renders.themeLabel); // 0
store.setState({ theme: 'dark' });
console.log(renders.themeLabel); // 1
console.log(Object.is({ count: 2 }, { count: 2 })); // false
count 改了两次,counter 更新了两次,只读 theme 的 themeLabel 一次都没更新;改 theme 以后它才更新一次。这正是 Context 做不到的:Context 只能按整个 value 通知,这里每个订阅者按自己选出的值判断。
最后一行说明为什么选出来的值要是原始值或者 store 里原有的引用。两个内容相同的新对象,Object.is 也判定为不同,选择函数要是每次都拼一个新对象返回,就等于每次都更新。
在 React 里,把这个 store 接进组件的官方方式是 useSyncExternalStore:
function useStore(selector) {
return useSyncExternalStore(store.subscribe, () => selector(store.getState()));
}
function Counter() {
const count = useStore((state) => state.count);
return <p>{count}</p>;
}
React 文档说明,store 变化时调用 subscribe 传进来的回调,React 会重新调用 getSnapshot,返回值用 Object.is 比较,不同才重新渲染这个组件。所以 Counter 只在 count 变了时更新。getSnapshot 在数据没变时必须返回同一个值,原因见《useSyncExternalStore 为什么要求稳定的快照?》。Zustand、Redux 这类支持选择器的状态库,也是让组件只订阅自己用到的字段,选型见《Zustand、Jotai 和 Redux Toolkit 应该怎样选择?》。
不过 React 文档也建议,能用 useState、useReducer 解决的就优先用内置状态,useSyncExternalStore 主要用于接入已有的非 React 代码。所以先拆 Context、稳定 value,确实不够再换外部 store。
常见追问
为什么 memo 挡不住 Context 引起的重新渲染?
memo只比较父组件传来的 props,props 没变就跳过这次渲染。Context 的更新不经过 props,React 会直接找到读了这个 Context 的组件,让它重新渲染。React 文档写明,被memo包住的组件,用到的 Context 变了照样重新渲染。想让它只在某个字段变了时更新,就拆成两层:外层读 Context,把字段当 prop 传给memo包住的内层。
为什么 dispatch 要单独放一个 Context?
因为
useReducer返回的dispatch引用是稳定的,单独放在一个 Context 里,这个 Context 的 value 就从来不变。只发 action、不读 state 的组件只订阅它,state 怎么变都不受影响。要是 state 和 dispatch 放在同一个对象里,state 一变对象就是新的,这些组件也会跟着渲染。
Context 的问题是不是说明不该用它?
不是。主题、语言、当前登录用户这类数据变化不频繁、很多组件都要用,放 Context 很合适,偶尔整体重新渲染一次没有影响。问题出在把高频变化的数据放进一个大 Context。这类数据放局部 state,或者放进能按字段订阅的外部 store。