Menu

React useSyncExternalStore 教程:订阅外部数据

useSyncExternalStore 让组件订阅 React 之外的数据,比如一个小型 store 模块,或者 navigator.onLine 这样的浏览器 API,并在数据变化时重新渲染。学习 subscribe 和 getSnapshot、为什么快照必须缓存,以及 getServerSnapshot。

本页包含可运行的编辑器。编辑、运行,立即查看输出。

useSyncExternalStore 让组件订阅存放在 React 之外的数据,并在数据变化时重新渲染。你给它两个函数:subscribe 告诉 React 如何监听变化,getSnapshot 返回当前的值。

两个 Display 组件之间没有共享任何 props 或 context,却能一起更新,因为它们订阅的是同一个 store。按钮调用的是一个普通函数,而不是 React 的 setter。再加一个 <Display name="Sidebar" />,不用改动任何其他代码,它就会加入进来。

语法

const value = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot?);
  • subscribe(callback) 开始监听,在数据可能发生变化时调用 callback,并返回一个取消订阅的函数。React 在组件挂载后调用它,在卸载时调用它返回的函数。
  • getSnapshot() 返回当前的值。React 在每次渲染期间以及每次收到通知后调用它,然后用 Object.is 把结果与上一次进行比较。值相同,就不渲染。
  • getServerSnapshot()(可选)返回在服务器上以及 hydration 期间使用的值。

把 subscribe 定义在组件外部,或者用 useCallback 让它保持稳定。如果每次渲染都传入一个新的 subscribe 函数,React 每次都会先取消订阅,再重新订阅。

把浏览器 API 当作 store

任何有当前值、并在值变化时触发事件的东西,都符合这种结构。navigator.onLine 配合 online 和 offline 事件就是经典例子。

关掉网络(或者在浏览器开发者工具中切换到 Offline),文字会在不刷新页面的情况下改变。第三个参数表示在服务器上渲染时“假定在线”,因为服务器上没有 navigator。把这个 Hook 调用包装成 useOnlineStatus,它就成了一个任何组件都能使用的自定义 Hook。

窗口宽度也是同样的道理:

添加几个副本,然后调整窗口大小:所有副本在同一时刻显示同一个数字。每个副本都有自己的监听器,并且都在渲染期间读取宽度,所以没有一个会慢一帧。

getSnapshot 必须返回缓存的值

React 会频繁调用 getSnapshot,并按引用比较结果。每次调用都构建一个新对象或新数组的函数,看起来永远都在变化:

// Broken: a new object on every call
function getSnapshot() {
    return { count: store.count, user: store.user };
}

// Also broken: filter returns a new array every time
function getSnapshot() {
    return store.todos.filter((t) => !t.done);
}

React 渲染,调用 getSnapshot,得到一个“不同”的值,再次渲染,如此反复,直到以 "Maximum update depth exceeded" 终止。在开发环境中,React 还会先输出 "The result of getSnapshot should be cached to avoid an infinite loop"。生产构建(比如这里的预览)会跳过这条警告,只用一个简短的错误码报告最终的错误(Minified React error #185)。

修复方法是让 store 中的数据保持不可变:数据变化时替换整个对象,并原样返回保存的引用。

在 add 替换 state 之前,getSnapshot 一直返回同一个 state 对象,所以每次变化组件只渲染一次。筛选发生在组件中,在读取快照之后,这样是安全的。如果想改在 store 内部筛选,就在数据变化时计算出筛选后的数组并保存起来,让 getSnapshot 返回保存的那一份。

getServerSnapshot 与 hydration

服务器上没有 window,没有 navigator,也没有订阅。第三个参数告诉 React 在那里渲染什么:

const width = useSyncExternalStore(
    subscribe,
    () => window.innerWidth, // in the browser
    () => 1024 // on the server, and during hydration
);

React 在浏览器中对服务端 HTML 进行 hydration 时,第一次渲染也会使用 getServerSnapshot,让两边保持一致。hydration 一结束,它就读取 getSnapshot,如果真实值不同,就用它再渲染一次。没有第三个参数时,服务端渲染会抛出这样的错误:"Missing getServerSnapshot, which is required for server-rendered content. Will revert to client rendering."如果组件上方有 <Suspense> 边界,服务器会发送该边界的 fallback,改由浏览器渲染其中的内容;如果没有边界,服务端渲染就会失败。

useSyncExternalStore 与 useEffect 加 useState 对比

你也可以用 Effect 来订阅:

function useOnlineStatus() {
    const [online, setOnline] = useState(true);
    useEffect(() => {
        const update = () => setOnline(navigator.onLine);
        update();
        window.addEventListener('online', update);
        window.addEventListener('offline', update);
        return () => {
            window.removeEventListener('online', update);
            window.removeEventListener('offline', update);
        };
    }, []);
    return online;
}

这样能用,但有两个弱点。第一次渲染总是显示初始的猜测值,真实的值要等 Effect 运行之后,在下一次渲染时才出现。而且在并发渲染中(比如在 transition 期间),React 可能把一次渲染暂停在中途;如果 store 在暂停期间发生变化,暂停前后渲染的组件就可能显示不同的值。这种不一致叫作 tearing(撕裂)。useSyncExternalStore 在渲染期间读取值,如果 store 变了,就让 React 同步地重新渲染,所以每个组件看到的都是同一个值。

当数据存放在 React 之外时就使用它:你自己的 store 模块、浏览器 API、第三方库。大多数状态管理库(Redux、Zustand 等)都会在它们的 Hook 内部替你调用它。对于属于你的组件的数据,useState、useReducer 和 context 仍然是合适的工具。

常见问题

useSyncExternalStore 是用来做什么的?

读取那些不归 React 管、而且会自行变化的数据:在 React 之外编写的 store、第三方状态管理库,或者 navigator.onLine、窗口宽度这样的浏览器值。每当 store 通知 React 数据变了,组件就会重新渲染。

subscribe 和 getSnapshot 分别做什么?

subscribe(callback) 开始监听 store,每次变化时调用 callback,并返回一个停止监听的函数。getSnapshot() 返回当前的值。React 在渲染期间以及每次收到通知后调用 getSnapshot,只有当 Object.is 判断值发生变化时才重新渲染。

为什么 getSnapshot 必须返回缓存的值?

React 会把每次调用 getSnapshot 的结果与上一次进行比较。如果它每次都返回一个新的对象或数组,React 就总会认为有变化,于是重新渲染,再次调用 getSnapshot,如此循环,直到抛出 "Maximum update depth exceeded"。在数据真正变化之前,要一直返回同一个引用。

getServerSnapshot 是什么?

可选的第三个参数。它返回服务端渲染时以及浏览器中 hydration 期间要使用的值,让两边生成相同的 HTML。如果不提供它,组件在服务器上会抛出 "Missing getServerSnapshot",最近的 <Suspense> 边界下的内容会改为在浏览器中渲染。

该用 useSyncExternalStore,还是 useEffect 加 useState?

订阅外部数据时,优先使用 useSyncExternalStore。它在渲染期间读取值,所以第一次渲染就是正确的,而且即使在并发渲染中,所有组件看到的也是同一个值。useEffect 加 useState 会先用过时的值渲染一次,还可能让不同组件短暂地显示不同的值。

Coddy 编程语言插图

和 Coddy 一起学编程

开始