React useSyncExternalStore

深入理解 useSyncExternalStore 的基本 API,外部 store 订阅机制,以及为什么在并发渲染下需要这个 API 来避免 tearing 问题。

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

「撕裂」是指:用户看到的状态和预期的不一致。考虑这个场景:

  1. 用户点击「结账」按钮,触发了一个状态更新
  2. React 开始处理这个更新(这可能需要多个渲染阶段)
  3. 在 React 处理过程中,用户读取了 Redux store(通过 store.getState()
  4. 此时用户读到的状态是「处理了一半」的状态——这就是「撕裂」

更具体地说:

正常状态序列:
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.innerWidthonline 状态等:

订阅窗口宽度

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 是专门为这个问题设计的:

  1. 同步读取:在渲染开始前,useSyncExternalStore 会同步调用 getSnapshot 读取状态,确保组件看到的是一致的「快照」
  2. 可靠的订阅:订阅机制和 React 的渲染周期紧密集成,React 保证在渲染之前订阅是稳定的
  3. 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 的核心保证是:在组件渲染的任何时间点,组件看到的状态都是某个「完整」的快照,而不是「部分更新」的状态

这是通过以下机制实现的:

  1. 渲染开始前同步读取getSnapshot 在渲染开始前被同步调用,React 记住这个值
  2. 渲染过程中不更新:一旦渲染开始,即使外部 store 变化了,组件不会看到新值,直到下一次渲染
  3. 渲染结束后重新订阅: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。


延展阅读