likes
comments
collection
share

React Hooks 源码解读之 useSyncExternalStore

作者站长头像
站长
· 阅读数 14

react 版本:v18.3.0

1、useSyncExternalStore 是什么

我们的组件通常会从它们的 props、state 以及 context 读取数据。然而,有时一个组件需要从一些 React 之外的 store 读取一些随时间变化的数据,这包括:

  • 在 React 之外持有状态的第三方状态管理库
  • 暴露出一个可变值及订阅其改变事件的浏览器 API

useSyncExternalStore 就是这样一个可以从这些外部store读取数据的 React Hook。

2、Hook 入口

React Hooks 源码解读之Hook入口 一文中,我们介绍了 Hooks 的入口及hook处理函数的挂载,从 hook 处理函数的挂载关系我们可以得到这样的等式:

  • 挂载阶段:

    useSyncExternalStore = ReactCurrentDispatcher.current.useSyncExternalStore = HooksDispatcherOnMount.useSyncExternalStore = mountSyncExternalStore;

  • 更新阶段:

    useSyncExternalStore = ReactCurrentDispatcher.current.useSyncExternalStore = HooksDispatcherOnUpdate.useSyncExternalStore = updateSyncExternalStore;

因此,组件在挂载阶段,执行 useSyncExternalStore,其实执行的是 mountSyncExternalStore,而在更新阶段,则执行的是updateSyncExternalStore 。

3、挂载阶段

组件在挂载阶段,执行 useSyncExternalStore,实际上执行的是 mountSyncExternalStore,下面我们来看看 mountSyncExternalStore 的实现:

3.1 mountSyncExternalStore

// packages/react-reconciler/src/ReactFiberHooks.js

function mountSyncExternalStore<T>(
  // useSyncExternalStore 的第一个参数
  subscribe: (() => void) => () => void, 
  // useSyncExternalStore 的第二个参数,读取 store 中的数据快照
  getSnapshot: () => T,
  // useSyncExternalStore 的第三个参数,读取 store 中数据的初始快照
  getServerSnapshot?: () => T,
): T {
  const fiber = currentlyRenderingFiber;
  // 创建一个新的 hook 对象,并返回当前的 workInProgressHook 对象
  const hook = mountWorkInProgressHook();

  let nextSnapshot;
  const isHydrating = getIsHydrating();
  // 服务端渲染
  if (isHydrating) {
    // 如果是服务端渲染,没有传递 getServerSnapshot 参数,则报错
    if (getServerSnapshot === undefined) {
      throw new Error(
        'Missing getServerSnapshot, which is required for ' +
          'server-rendered content. Will revert to client rendering.',
      );
    }
    // 执行 getServerSnapshot 函数,读取 store 中数据的初始快照
    nextSnapshot = getServerSnapshot();
    if (__DEV__) {
      // getServerSnapshot函数每次调用的返回值都不一样,
      if (!didWarnUncachedGetSnapshot) {
        if (nextSnapshot !== getServerSnapshot()) {
          console.error(
            'The result of getServerSnapshot should be cached to avoid an infinite loop',
          );
          didWarnUncachedGetSnapshot = true;
        }
      }
    }
  } else {
    // 客户端渲染

    // 执行 getSnapshot 函数,读取store 中的数据快照
    nextSnapshot = getSnapshot();
    if (__DEV__) {
      if (!didWarnUncachedGetSnapshot) {
        // getSnapshot 函数每次调用的返回值都不一样
        const cachedSnapshot = getSnapshot();
        if (!is(nextSnapshot, cachedSnapshot)) {
          console.error(
            'The result of getSnapshot should be cached to avoid an infinite loop',
          );
          didWarnUncachedGetSnapshot = true;
        }
      }
    }
    // Unless we're rendering a blocking lane, schedule a consistency check.
    // Right before committing, we will walk the tree and check if any of the
    // stores were mutated.
    //
    // We won't do this if we're hydrating server-rendered content, because if
    // the content is stale, it's already visible anyway. Instead we'll patch
    // it up in a passive effect.
    const root: FiberRoot | null = getWorkInProgressRoot();

    if (root === null) {
      throw new Error(
        'Expected a work-in-progress root. This is a bug in React. Please file an issue.',
      );
    }

    if (!includesBlockingLane(root, renderLanes)) {
      pushStoreConsistencyCheck(fiber, getSnapshot, nextSnapshot);
    }
  }

  // Read the current snapshot from the store on every render. This breaks the
  // normal rules of React, and only works because store updates are
  // always synchronous.
  hook.memoizedState = nextSnapshot; // 缓存从外部 store 读取的数据快照,每次渲染时会从存储中读取当前快照
  // 定义一个快照示例,存储到 hook 的队列上
  const inst: StoreInstance<T> = {
    value: nextSnapshot,
    getSnapshot,
  };
  hook.queue = inst;

  // 执行 effect,订阅外部的store
  // Schedule an effect to subscribe to the store.
  mountEffect(subscribeToStore.bind(null, fiber, inst, subscribe), [subscribe]);

  // Schedule an effect to update the mutable instance fields. We will update
  // this whenever subscribe, getSnapshot, or value changes. Because there's no
  // clean-up function, and we track the deps correctly, we can call pushEffect
  // directly, without storing any additional state. For the same reason, we
  // don't need to set a static flag, either.
  fiber.flags |= PassiveEffect;
  // 将一个 update 添加到 effect 链表
  pushEffect(
    HookHasEffect | HookPassive,
    updateStoreInstance.bind(null, fiber, inst, nextSnapshot, getSnapshot),
    createEffectInstance(),
    null,
  );
// 返回当前store的快照
  return nextSnapshot;
}

从 mountSyncExternalStore 函数的源码中可以看到,如果是服务端渲染,则执行 useSyncExternalStore 的第三个参数 getServerSnapshot 来读取 store 中数据的初始快照,如果是客户端渲染,则执行 useSyncExternalStore 的第二个参数 getSnapshot 来读取 store 的数据快照。如下代码:

  // 服务端渲染
  if (isHydrating) {
    
    // ... 
    
    // 执行 getServerSnapshot 函数,读取 store 中数据的初始快照
    nextSnapshot = getServerSnapshot();
    
    // ... 
    
  } else {
    // 客户端渲染

    // 执行 getSnapshot 函数,读取 store 中的数据快照
    nextSnapshot = getSnapshot();

     // ... 
    
  }

读取到的数据快照,会被存储到 hook 对象的 memoizedState 属性上,在组件更新时会从memoizedState 属性上获取上一次读取到的数据快照,便于与新读取的数据快照作比较。并同时把读取到的数据快照和第二个参数getSnapshot 存储到 hook 对象的queue队列上。如下代码:

// 缓存从外部 store 读取的数据快照,每次渲染时会从存储中读取当前快照
hook.memoizedState = nextSnapshot; 
// 定义一个快照示例,存储到 hook 的队列上
const inst: StoreInstance<T> = {
  value: nextSnapshot,
  getSnapshot,
};
hook.queue = inst;

接下来通过 mountEffect 函数 发起一个 effect,执行 subscribe 函数订阅 store 并返回一个取消订阅的函数。如下代码:

// 执行 effect,订阅外部的store
// Schedule an effect to subscribe to the store.
mountEffect(subscribeToStore.bind(null, fiber, inst, subscribe), [subscribe]);

在开发的过程中,可能会遇到 'The result of getSnapshot should be cached' 的错误,这是因为 getSnapshot 函数每次调用时都返回了一个新的对象,例如下面的代码:

function getSnapshot() {
  // 🔴 getSnapshot 不要总是返回不同的对象
  return {
    todos: myStore.todos
  };
}

在 mountSyncExternalStore 函数的源码中,会将 getSnapshot 读取到的数据快照与上一次读取到的数据快照进行比较,如果不一样,React 会重新渲染组件。这就是为什么,如果总是返回一个不同的值,会进入一个无效循环,并产生这个错误。如下代码:

if (isHydrating) {
  // 服务端渲染
  
  // ...
  if (__DEV__) {
    // getServerSnapshot函数每次调用的返回值都不一样,控制台报错
    if (!didWarnUncachedGetSnapshot) {
      if (nextSnapshot !== getServerSnapshot()) {
        console.error(
          'The result of getServerSnapshot should be cached to avoid an infinite loop',
        );
        didWarnUncachedGetSnapshot = true;
      }
    }
  }
} else {
  // 客户端渲染
  nextSnapshot = getSnapshot();
  if (__DEV__) {
    if (!didWarnUncachedGetSnapshot) {
      // getSnapshot 函数每次调用的返回值都不一样,控制台报错
      const cachedSnapshot = getSnapshot();
      if (!is(nextSnapshot, cachedSnapshot)) {
        console.error(
          'The result of getSnapshot should be cached to avoid an infinite loop',
        );
        didWarnUncachedGetSnapshot = true;
      }
    }
  }
  
  // ...
}

最后返回读取到的数据快照,在组件的渲染逻辑中就可以消费这个外部 store 的数据快照。

3.2 mountWorkInProgressHook

在 mountId() 函数中,使用 mountWorkInProgressHook() 函数创建了一个新的 hook 对象,我们来看看它是如何被创建的:

// packages/react-reconciler/src/ReactFiberHooks.js

// 创建一个新的 hook 对象,并返回当前的 workInProgressHook 对象
// workInProgressHook 对象是全局对象,在 mountWorkInProgressHook 中首次初始化
function mountWorkInProgressHook(): Hook {
  const hook: Hook = {
    memoizedState: null,

    baseState: null,
    baseQueue: null,
    queue: null,

    next: null,
  };
  
   // Hooks are stored as a linked list on the fiber's memoizedState field
  // 将 新建的 hook 对象以链表的形式存储在当前的 fiber 节点memoizedState属性上

  // 只有在第一次打开页面的时候,workInProgressHook 为空
  if (workInProgressHook === null) {
    // This is the first hook in the list
    // 链表上的第一个 hook
    
    // currentlyRenderingFiber: The work-in-progress fiber. I've named it differently to distinguish it fromthe work-in-progress hook.
    
    currentlyRenderingFiber.memoizedState = workInProgressHook = hook;
  } else {
    // Append to the end of the list
    // 已经存在 workInProgressHook 对象,则将新创建的这个 Hook 接在 workInProgressHook 的尾部,形成链表
    workInProgressHook = workInProgressHook.next = hook;
  }
  return workInProgressHook;
}

可以看到,在新建一个 hook 对象时,如果全局的 workInProgressHook 对象不存在 (值为 null),即组件在首次渲染时,将新建的 hook 对象赋值给 workInProgressHook 对象,也同时将 hook 对象赋值给 currentlyRenderingFiber 的 memoizedState 属性,如果 workInProgressHook 不为 null,则将 hook 对象接在 workInProgressHook 的尾部,从而形成一个单向链表。

3.3 subscribeToStore

subscribeToStore 函数用来执行 useSyncExternalStore 的第一个参数 subscribe ,如下代码:

// 订阅外部 store 并返回一个取消订阅的函数
function subscribeToStore<T>(
  fiber: Fiber,
  inst: StoreInstance<T>,
  subscribe: (() => void) => () => void,
): any {
  // 使组件重新渲染
  const handleStoreChange = () => {
    // The store changed. Check if the snapshot changed since the last time we
    // read from the store.
    // 从外部 store 中读取的前后的数据快照不一致,强制组件重新渲染
    if (checkIfSnapshotChanged(inst)) {
      // Force a re-render.
      // 强制组件重新渲染
      forceStoreRerender(fiber);
    }
  };
	// 订阅 store 并返回一个取消订阅的函数
  // Subscribe to the store and return a clean-up function.
  return subscribe(handleStoreChange);
}

在 subscribeToStore 函数中,把回调函数 handleStoreChange 订阅得到 store 上,当 store 发生变化时,该回调函数会被调用,并检查 store 发生变化后读取的数据快照与变化前的数据快照是否一致,如果不一致,则调用 forceStoreRerender 函数强制组件重新渲染。

3.4 checkIfSnapshotChanged

checkIfSnapshotChanged 函数的作用是比较当前读取的数据快照与上一次读取的数据快照是否一致。源码如下:

// 比较当前读取的数据快照与上一次读取的数据快照是否一致
function checkIfSnapshotChanged<T>(inst: StoreInstance<T>): boolean {
  // 获取 useSyncExternalStore hook 传递进来的 getSnapshot 函数,该函数从外部 store 读取数据的快照
  const latestGetSnapshot = inst.getSnapshot;
  // 上一次读取的数据快照
  const prevValue = inst.value;
  try {
    // 执行 getSnapshot 函数,读取 store 的数据快照
    const nextValue = latestGetSnapshot();
    // 使用 Object.is 比较读取的数据快照是否相同,如果不相同,React 会重新渲染组件
    return !is(prevValue, nextValue);
  } catch (error) {
    return true;
  }
}

在 checkIfSnapshotChanged 函数中,首先从参数 inst 中获取读取数据快照的函数 getSnapshot 和上一次读取的数据快照;然后执行 getSnapshot 函数,从 store 中读取新的数据快照,接着使用 Object.is 比较前后两次读取的数据快照,如果不一致,则将比较后的结果取反后返回,从而告诉 React 需要重新渲染组件。

3.5 forceStoreRerender

function forceStoreRerender(fiber: Fiber) {
  const root = enqueueConcurrentRenderForLane(fiber, SyncLane);
  if (root !== null) {
    // 进入任务调度流程
    scheduleUpdateOnFiber(root, fiber, SyncLane);
  }
}

forceStoreRerender 函数的作用就是强制让组件重新渲染,它通过调用 scheduleUpdateOnFiber 函数进入任务调度流程,从而让组件重新渲染。scheduleUpdateOnFiber 函数在《React源码解读之任务调度流程》一文中有详细的讲解。

4、更新阶段

组件在更新阶段,执行 useSyncExternalStore,实际上执行的是 updateSyncExternalStore,下面我们来看看 updateSyncExternalStore 的实现。

4.1 updateSyncExternalStore

function updateSyncExternalStore<T>(
  subscribe: (() => void) => () => void,
  getSnapshot: () => T,
  getServerSnapshot?: () => T,
): T {
  const fiber = currentlyRenderingFiber;
  const hook = updateWorkInProgressHook();
  // Read the current snapshot from the store on every render. This breaks the
  // normal rules of React, and only works because store updates are
  // always synchronous.
  let nextSnapshot;
  const isHydrating = getIsHydrating();
  // 服务端渲染
  if (isHydrating) {
    // Needed for strict mode double render
    // 如果是服务端渲染,没有传递 getServerSnapshot 参数,则报错
    if (getServerSnapshot === undefined) {
      throw new Error(
        'Missing getServerSnapshot, which is required for ' +
          'server-rendered content. Will revert to client rendering.',
      );
    }
    // 执行 getServerSnapshot 函数,读取 store 中数据的初始快照
    nextSnapshot = getServerSnapshot();
  } else {
    // 客户端渲染
    nextSnapshot = getSnapshot();
    if (__DEV__) {
      if (!didWarnUncachedGetSnapshot) {
        // 比较 getSnapshot 函数每次读取的数据快照是否一样,如果不一样,则在控制台报错
        const cachedSnapshot = getSnapshot();
        // 使用 Object.is 浅比较 前后读取的数据快照是佛一样
        if (!is(nextSnapshot, cachedSnapshot)) {
          console.error(
            'The result of getSnapshot should be cached to avoid an infinite loop',
          );
          didWarnUncachedGetSnapshot = true;
        }
      }
    }
  }
  // 从 memoizedState 属性上获取上一次读取的数据快照
  const prevSnapshot = (currentHook || hook).memoizedState;
  // 使用 Object.is 浅比较前后读取的数据快照是否相同
  const snapshotChanged = !is(prevSnapshot, nextSnapshot);
  // 前后读取的数据快照不相同,更新 hook 对象上存储的数据快照
  if (snapshotChanged) {
    hook.memoizedState = nextSnapshot;
    markWorkInProgressReceivedUpdate();
  }
  const inst = hook.queue;
// 执行更新,重新渲染组件
  updateEffect(subscribeToStore.bind(null, fiber, inst, subscribe), [
    subscribe,
  ]);

  // Whenever getSnapshot or subscribe changes, we need to check in the
  // commit phase if there was an interleaved mutation. In concurrent mode
  // this can happen all the time, but even in synchronous mode, an earlier
  // effect may have mutated the store.
  if (
    inst.getSnapshot !== getSnapshot ||
    snapshotChanged ||
    // Check if the subscribe function changed. We can save some memory by
    // checking whether we scheduled a subscription effect above.
    (workInProgressHook !== null &&
      workInProgressHook.memoizedState.tag & HookHasEffect)
  ) {
    fiber.flags |= PassiveEffect;
      // 将一个 update 添加到 effect 链表
    pushEffect(
      HookHasEffect | HookPassive,
      updateStoreInstance.bind(null, fiber, inst, nextSnapshot, getSnapshot),
      createEffectInstance(),
      null,
    );

    // Unless we're rendering a blocking lane, schedule a consistency check.
    // Right before committing, we will walk the tree and check if any of the
    // stores were mutated.
    const root: FiberRoot | null = getWorkInProgressRoot();

    if (root === null) {
      throw new Error(
        'Expected a work-in-progress root. This is a bug in React. Please file an issue.',
      );
    }

    if (!isHydrating && !includesBlockingLane(root, renderLanes)) {
      pushStoreConsistencyCheck(fiber, getSnapshot, nextSnapshot);
    }
  }

  return nextSnapshot;
}

在更新阶段,执行 useSyncExternalStore 和在挂载阶段执行 useSyncExternalStore 一样,也是首先执行其参数 getSnapshot 函数或者 getServerSnapshot 函数来读取外部 store 的数据快照。如下代码:

if (isHydrating) {

  // 服务端渲染

  // ...
  
  // 执行 getServerSnapshot 函数,读取 store 中数据的初始快照
  nextSnapshot = getServerSnapshot();
} else {
  
  // 客户端渲染

  // 执行 getSnapshot 函数,读取 store 的数据快照
  nextSnapshot = getSnapshot();
  
  // ...
  
  }
}

接着从 hook 对象 memoizedState 属性上取出上一次读取的数据快照,使用 Object.is 浅比较新读取的数据快照和上一次读取的快照是否相同,如果不相同,则更新 hook 对象上存储的数据快照。

// 从 memoizedState 属性上获取上一次读取的数据快照
const prevSnapshot = (currentHook || hook).memoizedState;
// 使用 Object.is 浅比较前后读取的数据快照是否相同
const snapshotChanged = !is(prevSnapshot, nextSnapshot);
// 前后读取的数据快照不相同,更新 hook 对象上存储的数据快照
if (snapshotChanged) {
  hook.memoizedState = nextSnapshot;
  markWorkInProgressReceivedUpdate();
}

接下来通过 updateEffect 发起一个 effect,执行 subscribe 函数订阅 store 并返回一个取消订阅的函数。如下代码:

const inst = hook.queue;
// 执行更新,重新渲染组件
updateEffect(subscribeToStore.bind(null, fiber, inst, subscribe), [
  subscribe,
]);

subscribeToStore 函数在上文已介绍过,请前往阅读。👉 3.3 subscribeToStore

5、避免 subscribe 多次订阅

在挂载阶段,会通过 mountEffect 执行 subscribe 订阅 store,如下面的代码:

// 执行 effect,订阅外部的store
// Schedule an effect to subscribe to the store.
mountEffect(subscribeToStore.bind(null, fiber, inst, subscribe), [subscribe]);

在更新阶段,则是通过 updateEffect 执行 subscribe 订阅 store,如下面的代码:

// 执行更新,订阅外部的store,重新渲染组件
updateEffect(subscribeToStore.bind(null, fiber, inst, subscribe), [subscribe]);

mountEffect 和 updateEffect 是 useEffect hook 分别在挂载阶段和更新阶段实际执行的函数,如果 useEffect hook 的 dependencies 发生了变化,就会重新执行它的 setup 函数。因此,在定义 useSyncExternalStore 的 subscribe 函数时,应当避免组件每次渲染时 subscribe 是一个新的函数。

在下面的写法中,subscribe 函数是在组件内部定义的,组件每次渲染时,都会重新定义 subscribe 函数,此时传给 updateEffect 的是一个不同的 subscribe,因此 React 会重新执行 subscribe 订阅 store。

function ChatIndicator() {
  const isOnline = useSyncExternalStore(subscribe, getSnapshot);
  
  // 🚩 总是不同的函数,所以 React 每次重新渲染都会重新订阅
  function subscribe() {
    // ...
  }

  // ...
}

如果想要避免重新订阅,可以把 subscribe 函数移到组件的外面:

function ChatIndicator() {
  const isOnline = useSyncExternalStore(subscribe, getSnapshot);
  // ...
}

// ✅ 总是相同的函数,所以 React 不需要重新订阅
function subscribe() {
  // ...
}

或者,把 subscribe 包在 useCallback 里面以便只在某些参数改变时重新订阅:

function ChatIndicator({ userId }) {
  const isOnline = useSyncExternalStore(subscribe, getSnapshot);
  
  // ✅ 只要 userId 不变,都是同一个函数
  const subscribe = useCallback(() => {
    // ...
  }, [userId]);

  // ...
}

6、useSyncExternalStore 流程图

React Hooks 源码解读之 useSyncExternalStore

7、总结

useSyncExternalStore 是一个可以订阅外部 store 的 React Hook。如果是在服务端渲染中使用它,则会执行它的第三个参数 getServerSnapshot 来读取 store 的初始快照,如果是在客户端渲染中使用它,则会执行它的第二个参数 getSnapshot 来读取 store 的数据快照。并通过 Object.is 方法来判断前后读取的数据快照是否一致,如果不一致,则让组件重新渲染。

在使用的过程中,在 store 不变的情况下,应当避免 getSnapshot/getServerSnapshot 每次调用时返回一个新对象,从而避免不必要的组件重新渲染,造成性能问题。