React useSyncExternalStore
问题的起点:外部状态订阅的一致性问题
在 React 18 之前,如果你想在 React 组件里订阅外部状态(比如 Redux store、Zustand store、或者浏览器的某个 API),你可能会这样写:
import { useState, useEffect } from 'react';
import { store } from './my-redux-store';
function Cart() {
const [items, setItems] = useState(store.getState().items);
useEffect(() => {
const unsubscribe = store.subscribe(() => {
const state = store.getState();
setItems(state.items);
});
return () => unsubscribe();
}, []);
return <div>{items.length} items in cart</div>;
}
这个模式在 React 17 中工作得很好,但在 React 18 的并发渲染下,它有可能导致一个微妙的问题:tearing(撕裂)。
什么是 tearing
「撕裂」是指:用户看到的状态和预期的不一致。考虑这个场景:
- 用户点击「结账」按钮,触发了一个状态更新
- React 开始处理这个更新(这可能需要多个渲染阶段)
- 在 React 处理过程中,用户读取了 Redux store(通过
store.getState()) - 此时用户读到的状态是「处理了一半」的状态——这就是「撕裂」
更具体地说:
正常状态序列:
State A -> State B -> State C
在并发渲染下可能出现的撕裂:
State A -> State B (React 开始渲染 <Checkout />) -> 用户读到了 State B
-> <Checkout /> 渲染到一半
-> State C (React 完成渲染)
用户在某个瞬间读到的状态,不是任何一个「完整」的状态——这就是 tearing。
tearing 在什么时候会发生
tearing 只在 React 并发渲染时才会发生。在 React 17 的「同步渲染」模式下,渲染一旦开始,就会一直执行到完成,不会中途被打断,所以不会有 tearing。
但在 React 18,React 可能会「暂停」渲染来响应紧急更新(如用户输入)。如果渲染暂停时组件读取了外部 store,就可能读到「中间状态」。
useSyncExternalStore 的基本 API
useSyncExternalStore 是 React 18 专门为解决外部 store 订阅问题而引入的 API。它的签名是:
const state = useSyncExternalStore(
subscribe, // 订阅函数,返回取消订阅的函数
getSnapshot, // 获取当前状态的快照
getServerSnapshot // 可选,服务端渲染时的快照
);
基本用法
import { useSyncExternalStore } from 'react';
import { store } from './my-store';
function Cart() {
const items = useSyncExternalStore(
store.subscribe, // 订阅函数
store.getSnapshot, // 获取当前状态
() => ({ items: [] }) // 服务端渲染时的默认状态
);
return <div>{items.length} items in cart</div>;
}
三个参数的详细说明
subscribe 函数:
- 当外部状态变化时,React 会调用这个函数来订阅变化
- 返回一个取消订阅的函数
- React 保证在重新渲染之前会调用取消订阅,然后再订阅
const subscribe = useCallback(
(callback) => {
const unsubscribe = store.subscribe(callback);
return () => unsubscribe();
},
[store]
);
getSnapshot 函数:
- 返回当前状态的快照
- 这个快照必须是不可变的(每次调用返回新的引用会触发重新渲染)
- 如果 store 内部使用了不可变数据模式,这会自动工作
const getSnapshot = () => store.getState();
getServerSnapshot 函数(可选):
- 在服务端渲染时使用
- 服务端没有 store 订阅机制,所以需要返回一个「服务端版本」的状态
- 通常返回初始状态或空状态
const getServerSnapshot = () => ({ items: [], total: 0 });
典型场景:Redux/Zustand 的 React 18 兼容
React 18 发布后,Redux 和 Zustand 等状态管理库都更新了实现,使用 useSyncExternalStore 来确保在并发渲染下的安全性。
Redux Toolkit 的实现思路
Redux Toolkit 内部大致是这样使用 useSyncExternalStore 的:
// React-Redux 内部大致实现
function useSelector(selector) {
const store = useContext(StoreContext);
const [_, forceRender] = useReducer(x => x + 1, 0);
const snapshot = useSyncExternalStore(
(callback) => store.subscribe(callback), // 订阅
() => selector(store.getState()), // 获取选择器结果
() => selector(store.getState()) // 服务端快照
);
return snapshot;
}
Zustand 的实现思路
Zustand 的 useStore hook 大致是:
// Zustand 内部大致实现
function useStore(selector) {
const storeRef = useRef(null);
if (!storeRef.current) {
storeRef.current = createStore();
}
const store = storeRef.current;
return useSyncExternalStore(
(callback) => store.subscribe(callback),
() => selector(store.getState()),
() => selector(INITIAL_STATE)
);
}
典型场景:浏览器 API 订阅
useSyncExternalStore 不仅适用于状态管理库,也适用于订阅浏览器 API,比如 window.innerWidth、online 状态等:
订阅窗口宽度
import { useSyncExternalStore } from 'react';
function useWindowWidth() {
const subscribe = useCallback((callback) => {
window.addEventListener('resize', callback);
return () => window.removeEventListener('resize', callback);
}, []);
const getSnapshot = () => window.innerWidth;
const getServerSnapshot = () => 0;
return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
}
function Component() {
const width = useWindowWidth();
return <div>Window width: {width}</div>;
}
订阅网络状态
function useOnlineStatus() {
const subscribe = useCallback((callback) => {
window.addEventListener('online', callback);
window.addEventListener('offline', callback);
return () => {
window.removeEventListener('online', callback);
window.removeEventListener('offline', callback);
};
}, []);
const getSnapshot = () => navigator.onLine;
const getServerSnapshot = () => true;
return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
}
为什么自定义 store 需要 useSyncExternalStore 而不是 useEffect
你可能会问:为什么不用 useEffect 来订阅外部状态?
// 用 useEffect 订阅:有问题!
function Cart() {
const [items, setItems] = useState([]);
useEffect(() => {
const unsubscribe = store.subscribe(() => {
setItems(store.getState().items);
});
return () => unsubscribe();
}, []);
return <div>{items.length} items</div>;
}
这个模式在单线程渲染下没问题,但在并发渲染下有以下问题:
问题一:setState 时机不确定
useEffect 的 setState 发生在浏览器绘制之后。如果在渲染过程中外部状态发生了变化,而 useEffect 的 setState 还没执行,用户会看到过时的状态。
问题二:订阅可能在错误的时间被清理
React 的 concurrent rendering 可能会「暂停」和「恢复」渲染。在这个过程中,useEffect 的清理函数可能会在 React 期望的时间之外被调用,导致订阅行为不一致。
useSyncExternalStore 的解决方案
useSyncExternalStore 是专门为这个问题设计的:
- 同步读取:在渲染开始前,
useSyncExternalStore会同步调用getSnapshot读取状态,确保组件看到的是一致的「快照」 - 可靠的订阅:订阅机制和 React 的渲染周期紧密集成,React 保证在渲染之前订阅是稳定的
- tearing 防护:无论渲染被中断多少次,组件看到的状态始终是一个「完整」的状态
并发渲染下的安全性:tearing 问题详解
React 18 的并发渲染问题
React 18 引入了并发特性,允许渲染被中断、暂停和恢复。考虑这个场景:
1. 用户在搜索框输入 "a"
2. React 开始渲染 <SearchResults query="a" />
3. 这个组件渲染很慢(需要渲染 1000 个结果)
4. 在渲染到第 500 个结果时,用户又输入了 "b"
5. React 暂停当前渲染,开始渲染 <SearchResults query="ab" />
在这个场景中,如果 <SearchResults> 组件使用了 useEffect 来订阅外部 store,React 可能会在「第 500 个结果」的位置暂停渲染,此时组件看到的状态可能是不一致的。
useSyncExternalStore 如何防止 tearing
useSyncExternalStore 的核心保证是:在组件渲染的任何时间点,组件看到的状态都是某个「完整」的快照,而不是「部分更新」的状态。
这是通过以下机制实现的:
- 渲染开始前同步读取:
getSnapshot在渲染开始前被同步调用,React 记住这个值 - 渲染过程中不更新:一旦渲染开始,即使外部 store 变化了,组件不会看到新值,直到下一次渲染
- 渲染结束后重新订阅:React 保证在一次完整的渲染周期结束后,才会对新的状态变化进行订阅
useSyncExternalStoreWithSelector 简化模式
如果只需要订阅 store 的某个部分,可以用 useSyncExternalStoreWithSelector 来避免不必要的重渲染:
import { useSyncExternalStoreWithSelector } from 'react';
function useStore(selector) {
return useSyncExternalStoreWithSelector(
store.subscribe,
() => store.getState(),
() => store.getState(), // 服务端快照
selector
);
}
// 使用时,只在 selectedCount 变化时重渲染
function CartItemCount() {
const selectedCount = useStore(state => state.items.filter(i => i.selected).length);
return <div>{selectedCount} items selected</div>;
}
面试中的表达
面试官问 useSyncExternalStore,通常想确认你理解 React 18 的并发模型和外部状态管理:
useSyncExternalStore 是 React 18 专门为订阅外部 store 设计的 API。在 React 17 的同步渲染模式下,用 useEffect 订阅外部状态是没问题的,因为渲染一旦开始就会执行到完成。但在 React 18 的并发渲染下,渲染可能会被中断和恢复,如果在渲染过程中组件读取了外部 store,就可能读到「中间状态」——这就是 tearing 问题。
useSyncExternalStore 的核心保证是:无论渲染被中断多少次,组件在任何时间点看到的状态都是一个「完整」的快照,而不是「部分更新」的状态。这是通过在渲染开始前同步读取状态,以及 React 和订阅机制的紧密集成来实现的。
Redux、Zustand 这些状态管理库在 React 18 下都使用 useSyncExternalStore 来确保在并发渲染下的安全性。如果你在自己实现一个外部 store 的订阅 hook,也应该使用 useSyncExternalStore。
延展阅读
- React Docs: useSyncExternalStore — 官方 useSyncExternalStore 文档
- React 18 useSyncExternalStore RFC — useSyncExternalStore 的 RFC,包含详细设计决策
- Redux: React 18 Compatibility — Redux 官方文档中关于 React 18 兼容性的说明
- Zustand: React 18 support — Zustand 的 GitHub 仓库和文档