React 18: useSyncExternalStore

React 真是越来越复杂了。

众所周知,React 18 引入了所谓 Concurrent Rendering,简单来讲,组件的渲染将不再是一个同步过程,而是被分片,可能分为多次进行。注意,是一次渲染被分为多个时间分片执行,即在渲染过程中,可能穿插其他任务执行。

这就引入了一个问题,假如在一次渲染过程中,组件依赖的外部数据发生了变化,会导致什么结果?

React 组件的内部数据,例如 useState 等不会有问题,因为 React 内部对这种情况进行了处理,保证了数据的一致性。

这种情况被 React WG 称为撕裂(Tearing),可能会导致 UI 上出现了不同部分呈现的是不同数据的渲染效果。

Tearing refers to visual inconsistency. It means that a UI shows multiple values for the same state[1].

为了解决这种问题,React 引入了一个新的 Hooks:useSyncExternalStore(前身为useMutableSource)。

这个 Hooks 的函数签名是这样的

const state = useSyncExternalStore(subscribe, getSnapshot[, getServerSnapshot]);

一个典型用法如下

import { useSyncExternalStore } from 'react';

const useSelectorByUseSyncExternalStore = (store, selector) => {
    return useSyncExternalStore(
        store.subscribe,
        useCallback(() => selector(store.getState()), [store, selector])
    );
}

其中 subscribe 是 external store 上需要实现的一个方法,用于注册回调函数,当 state 发生变化时通知 React。而 getSnapshot 是一个用于获取 external store 上最新 state 的方法,如只选取 state 中一部分,也是在这一方法中实现。

React 对于这种 Tearing 的情况的处理方法是,中断当前正在进行的 Concurrent Render,重新进行一次 Sync Render。

React 如何知道发生了 external store 数据变化呢?通过以下两种方式:

  1. subscribe 的回调函数被调用时,说明数据发生变化了,此时 React 手动触发 Sync Render
  2. 在 reconcile 和 commit 阶段进行两次一致性检查,使用 getSnapshot 方法获得最新 state 与之前缓存的 state 进行比对(默认使用 Object.is),如不一致,重新进行一次 Sync Render.

注意

由于使用 Object.is 进行比较,所以 getSnapshot 方法返回的应该是一个稳定的引用,并在 state 变化后变化。也就说,大概率要搞一套 immutable 的。

如上,我们可以知道,就算 external store 没有实现 subscribe 功能,只要有能够获得最新 state 的方法,就能使用这个 Hooks 避免这个情况下的 Tearing。

在这样的机制中,最差情况的开销是多进行一次 Sync Render 和两次一致性检查。但是由于 Render 过程中 external store 的 state 发生改变应该是小概率事件,可以接受。

此外,为了方便使用,React 还提供了一个 with selector 的版本

export function useSyncExternalStoreWithSelector<Snapshot, Selection>(
  subscribe: (() => void) => () => void,
  getSnapshot: () => Snapshot,
  getServerSnapshot: void | null | (() => Snapshot),
  selector: (snapshot: Snapshot) => Selection,
  isEqual?: (a: Selection, b: Selection) => boolean,
): Selection

可以用来自定义 selector 函数,以及自定义比较数据是否一致的方法。

在推出 useSyncExternalStore 之前,React 的方案是一个叫做 useMutableSource 的 Hooks,那么为什么做这个变更呢。这就需要回顾一下历史。

useMutableSource 的函数签名是这样的

export default function createMutableSource<Source: $NonMaybeType<mixed>>(
  source: Source,
  getVersion: MutableSourceGetVersionFn,
): MutableSource<Source>

export function useMutableSource<Source, Snapshot>(
  source: MutableSource<Source>,
  getSnapshot: MutableSourceGetSnapshotFn<Source, Snapshot>,
  subscribe: MutableSourceSubscribeFn<Source, Snapshot>,
): Snapshot

首先,需要创建一个 MutableSource,这个 Source 必须有一个 version 属性,只要数据发生变化,version 就必须发生变化。而 useMutableSource 的参数和 useSyncExternalStore 的参数基本一致。

通过查看源码发现其核心实现大概如下(有删减)

function useMutableSource<Source, Snapshot>(
    hook: Hook,
    source: MutableSource<Source>,
    getSnapshot: MutableSourceGetSnapshotFn<Source, Snapshot>,
    subscribe: MutableSourceSubscribeFn<Source, Snapshot>
): Snapshot {
    const getVersion = source._getVersion;
    const version = getVersion(source._source);

    let [snapshot, setSnapshot] = dispatcher.useState(() =>
        readFromUnsubcribedMutableSource(root, source, getSnapshot)
    );

    dispatcher.useEffect(() => {
        const handleChange = () => {
            setSnapshot(latestGetSnapshot(source._source));
        };
        const unsubscribe = subscribe(source._source, handleChange);
        const maybeNewVersion = getVersion(source._source);
        if (!is(version, maybeNewVersion)) {
            const maybeNewSnapshot = getSnapshot(source._source);
            if (!is(snapshot, maybeNewSnapshot)) {
                setSnapshot(maybeNewSnapshot);
            }
        }

        return unsubscribe;
    }, [source, subscribe]);
}

从中我们可以发现,其核心机制就是在数据发生变化时通过 setState 同步到状态上。其中 version 这一设定只在组件读取了外部 store 但还没有 subscribe 的情况下使用。

这样的设计,不能说冗余,至少应该是有些不够紧凑吧?而且还需要先进行创建 store 再使用,多少有点啰嗦。相比较之下 useSyncExternalStore 将更多的工作放到了 React 内部完成,而且去掉了 version 这一设计(及其隐含的约定)。

当然,促使 React 放弃这个方案的原因,并不这么简单,而是因为:

  1. getSnapshot 函数隐含了 selector 的功能,由于 getSnapshot 需要稳定指向(上面源码省略了这一部分,可以参考这里),导致 selector 也需要用 useCallback 等方法使其指向稳定,否则会重新初始化 Hooks。而让所有的 selector 都用 useCallback 包一层是额外的心智,Redux 等库都并不要求这样做(内部有对应处理,可以参考这里)。
  2. useMutableSource 应对 Tearing 的策略并不是 Sync Render,而是试图继续进行 Concurrent Render,这可能导致可见的组件 fallback,甚至导致无关的组件被 fallback(eg. Router 使用了 useMutableSource)。

// TODO:源码中没有体现 useMutableSource 为何会导致这种 fallback,可能需要读一下 React 的源码,而我没看过,先摸了(

useSyncExternalStoreWithSelector 中,由于将 getSnapshotselector 分为两个参数,因此可以在内部对 selector 做 memorize,类似于 Redux 的 selector,但实现不太一样

这篇文章就先到这了,希望能节省一些你的时间,下期估计会写一篇关于 Suspense 的。

React 引入了 Concurrent 模式之后,对于库作者来说,需要额外考虑许多情况,有时候一些边边角角很容易出毛病。对于一般开发者来说,也许有时候碰到的奇怪的 bug 就是因为在 Concurrent 模式中的一些未处理情况,为此,还是做一些了解吧。

React 真是越来越复杂了(again

参考

  1. https://blog.saeloun.com/2021/12/30/react-18-usesyncexternalstore-api
  2. https://github.com/facebook/react/pull/18000

延伸阅读

  1. https://reactjs.org/docs/hooks-reference.html#usesyncexternalstore React 官方文档对于 useSyncExternalStore 的说明
  2. https://juejin.cn/post/7090063329913208868 一篇写得很好的说明文章
  3. https://github.com/reactwg/react-18/discussions/86 useMutableSource -> useSyncExternalStore
  4. https://github.com/reactjs/rfcs/blob/main/text/0147-use-mutable-source.md useMutableSource RFC

发表回复

您的电子邮箱地址不会被公开。 必填项已用*标注