漫谈 React 系列(七) - 一起来学习 useSyncExternalStore

2,694 阅读11分钟

本文使用「署名 4.0 国际 (CC BY 4.0)」 许可协议,欢迎转载、或重新修改使用,但需要注明来源。

作者: 百应前端团队 @0o华仔o0 首发于 漫谈 React 系列(六) - 一起学习 useSyncExternalStore

前言

Concurrent 模式是 React18 中最引人瞩目的新特性。通过使用 useTransitionuseDeferredValue,更新对应的 reconcile 过程变为可中断,不再会因为长时间占用主线程而阻塞渲染进程,使得页面的交互过程可以更加流畅。

不过这种新特性,在给我们带来用户体验提升的同时,也给我们带来了新的挑战。在我们使用诸如 reduxmobx 等第三方状态库时,如果开启了 Concurrent 模式,那么就有可能会出现状态不一致的情形,给用户带来困扰。针对这种情况, React18 提供了一个新的 hook - useSyncExternalStore,来帮助我们解决此类问题。

今天,我们就通过本文,给大家梳理一下状态不一致的情形以及 useSyncExternalStore 的使用情形、内部原理。

本文的目录结构如下:

useSyncExternalStore 初体验

首先说明,useSyncExternalStore 这个 hook 并不是给我们在日常项目中用的,它是给第三方类库如 ReduxMobx 等内部使用的。

我们先来看一下官网是怎么介绍 useSyncExternalStore 的。

useSyncExternalStore is a new hook that allows external stores to support concurrent reads by forcing updates to the store to be synchronous. It removes the need for useEffect when implementing subscriptions to external data sources, and is recommended for any library that integrates with state external to React.

翻译过来就是:useSyncExternalStore 是一个新的钩子,它通过强制的同步状态更新,使得外部 store 可以支持并发读取。它实现了对外部数据源订阅时不在需要 useEffect,并且推荐用于任何与 React 外部状态集成的库。

useSyncExternalStore 这个新的 hook 的用法如下:

const value = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot )

其中,subscribeexternal store 提供的 subscribe 方法;getSnapshotgetServerSnapshot 是用于获取指定 store 状态的方法,类似于 react-redux 中使用 useSelector 时需要传入的 selector,一个用于客户端渲染,一个用于服务端渲染;返回值就是我们在组件中可用的 store 状态。

看完上面的翻译和 api 介绍,大家是不是有点一脸懵逼呢?😂,说实话,我一开始看到这个 hook,也不知道该怎么使用它,翻阅了不少资料之后才知道它的使用正确姿势。接下来,我就结合自己的学习经历,通过几个简单的小 demo,为大家梳理一下 useSyncExternalStore 的用法以及原理:

  • Concurrent 模式下使用 react-redux-7 出现状态不一致的情形;
  • Concurrent 模式下使用 react-redux-8 解决状态不一致;
  • 在自定义 external store 中使用 useSyncExternalStore

Concurrent 模式下使用 react-redux 7

当我们在项目中使用 react-redux-7 版本时,如果开启 Concurrent 模式,并且在 reconcile 过程中断的时候修改 store,那么就有可能会出现状态不一致的情形,示例如下: useSyncExternalStore-react-redux-7

Apr-20-2022 16-55-57.gif

在示例中,我们可以很清楚的看到 store 状态不一致情形。

其中,在组件 TextBox 中,我们使用 startTransition 包裹 store 修改状态的代码, 使得更新采用 Concurrent 模式:

const TextBox = () => {

    const dispatch = useDispatch();

    const handleValueChange = (e) => {

        startTransition(() => {

            dispatch({ type: "change", value: e.target.value });

        });

   };

   return <input onChange={handleValueChange} />;

};

在组件 ShowText 中,我们通过一个 while loop,将组件的 reconcile 过程人为的调整为 > 5ms, 如下:

const ShowText = () => {

    const value = useSelector((state) => state);
    
    const start = performance.now();

    while (performance.now() - start < 20) {}

    return <div>{value}</div>;

};

打开 performance 面板,整个过程如下:

WX20220421-163303@2x.png

更新开始后,有 10ShowText 节点需要 reconcile, 每个节点 reconcile 时需要耗时 20ms 以上,这就导致每个 ShowText reconcile 结束以后都需要中断让出主线程。在协调中断时,修改 store 状态,后续的 ShowText 节点在恢复 reconcile 时,会使用修改以后的 store 状态,导致最后出现状态不一致的情况。

细心的同学可能会好奇上面为什么会出现两次 reconcile,并且最后所有的 ShowText 组件都显示同样的 store 状态。这是因为react 会为每一次更新分配一条 lane,每次 reconcile 只处理指定 lane 的更新。当我们给 TextBox 做第一次 input 时,触发 react 更新, 分配 lane 为 64,然后开始 reconcile。在 reconcile 过程中,又做了两次 input, 触发两次 react 更新, 分配的 lane 为 128、256。lane 为 64 的 reconcile 结束以后,开始处理 lane 为 384(128 + 256, 128 和 256 的优先级一样,一起处理) 的更新,处理时 store 状态为 123, 所有所有的 ShowText 节点在第二次 reconcile 时显示 123。(如果还不理解,可以在留言区留言,到时再给大家详细讲解)。

针对 Concurrent 模式下状态会出现不一致的情形,react-redux 在最新发布的版本 8 中引入了 useSyncExternalStore,修复了这一问题。

Concurrent 模式下使用 react-redux 8

useSyncExternalStore-react-redux-8 中,我们使用了 react-redux 最新发布的 8.0.0 版本,效果如下:

Apr-20-2022 17-09-05.gif

在示例中,我们发现修改 store 状态时,不再出现状态不一致的情形。但是很明显,TextBox 的交互出现了卡顿,不再像 useSyncExternalStore-react-redux-7 中那样的流畅。

这是为什么呢?难道没有触发 Concurrent 模式吗?

打开 performance 面板,整个过程如下:

WX20220421-173640@2x.png

通过上图,我们可以发现 reconcile 过程变为不可中断的。由于 reconcile 过程不可中断,那么 ShowText 节点显示的状态当然就一致了。

通过这个示例,我们可以看到 useSyncExternalStore 解决状态不一致的方式就是将 reconcile 过程变为不可中断。(这一点,你有想到吗?😂 )。

react-redux-8 中是如何使用 useSyncExternalStore 的呢 ?

考虑到 react-redux 的源码实现还是挺复杂的,我们这里通将过一个简单的自定义 external store 来为大家展示 useSyncExternalStore 的用法,方便大家更好的理解这个新的 hook 该怎么样使用。

Concurrent 模式下使用自定义 external store

首先,我们来定义一个非常简单的 external store。类比 reduxreact-redux,这个简单的 external store 也会提供类似 createStoreuseSelectoruseDispatch 的功能。

整个 external store 的核心代码如下:

    const { useState, useEffect } from 'react';
    
    const createStore = (initialState) => {
        let state = initialState;
        const getState = () => state;
        const listeners = new Set();
        // 通过 useDispatch 返回的 dispatch 修改 state 时,会触发 react 更新
        const useDispatch = () => {
            return (newState) => {
                state = { ...state, ...newState }
                listeners.forEach(l => l());
            }
        };
        // 订阅 state 变化
        const subscribe = (listener) => {
            listeners.add(listener);
            return () => {
                listeners.delete(listener)
            };
        }
        
        return { getState, useDispatch, subscribe }
   }
   
   const useSelector = (store, selector) => {
       const [state, setState] = useState(() => selector(store.getState()));
       useEffect(() => {
           const callback = () => setState(selector(store.getState()));
           const unsubscribe = store.subscribe(callback);
           return unsubscribe;
        }, [store, selector]);
        return selector(store.getState());
   }

在这个 external store 中,我们可以通过 useSelector 获取需要的公共状态,然后通过 useDispatch 返回的 dispatch 去修改公共状态,并触发 react 更新。

在这里,我们是基于发布订阅模式来实现修改公共状态来触发 react 更新。使用 useSelector 时,注册 callback;使用 dispatch 时,修改公共状态,遍历并执行注册的 callback,通过执行 useState 返回的 setState 触发 react 更新。

基于这个自定义 external store,我们来实现如 useSyncExternalStore-react-redux-7 效果,示例 external-store 如下:

Apr-20-2022 17-22-55.gif

观察示例,我们可以清楚的看到状态不一致的情形。

针对这种情形,我们可以使用 useSyncExternalStore 来改造 useSelector,过程如下:

import { useSyncExternalStore } from 'react';

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

external-store-useSyncExternalStore 中,我们使用了改造以后的 useSelectorByUseSyncExternalStore,效果如下:

Apr-20-2022 17-33-41.gif

状态不一致问题解决,✌🏻,不过同样的 Concurrent 模式也取消了,😂。

实际上,react-redux-8 中使用 useSyncExternalStore 的方式和我们的自定义 external store 基本类似,在这里就不过多的展开了,感兴趣的同学可以自己去看看 useSelector 的实现过程。

到这里,本节的内容就结束了,相信通过上面的几个 demo,大家能对状态不一致以及 useSyncExternalStore 的用法有了初步的认识了吧。了解了用法之后,我们接下来就来看看为什么 useSyncExternalStore 可以解决状态不一致的问题。

源码分析

其实,在上一节 useSyncExternalStore-react-redux-8 中,我们通过 performance 分析面板,就已经可以知道 useSyncExternalStore 解决状态不一致的方式就是将 reconcile 过程从 Concurrent 模式变为 Sync 模式即同步不可中断。

关于这一点,我们可以看看 useSyncExternalStore 相关源码,看看它是怎么实现的。

首先是 useSyncExternalStoremount 阶段时要执行的 mountSyncExternalStore 方法。

// 挂载阶段,执行 mountSyncExternalStore
function mountSyncExternalStore(subscribe, getSnapshot, getServerSnapshot) {
     // 当前正在处理的 fiber node
    var fiber = currentlyRenderingFiber$1;
    // 挂载阶段,生成 hook 对象
    var hook = mountWorkInProgressHook();
    // store 的快照
    var nextSnapshot;
    // 判断当前协调是否是 hydrate
    var isHydrating = getIsHydrating();
    if (isHydrating) {
        // hydrate, 先不用考虑
        ...
        nextSnapshot = getServerSnapshot();
        ...
    } else {
        // 获取到的新的 store 的值
        nextSnapshot = getSnapshot();
        ...
        var root = getWorkInProgressRoot();
        ...
        if (!includesBlockingLane(root, renderLanes)) {
            // 一致性检查, concurrent 模式下需要进行一致性检查
            pushStoreConsistencyCheck(fiber, getSnapshot, nextSnapshot);
        }
    }
    // hook 对象存储 store 的快照
    hook.memoizedState = nextSnapshot;
    var inst = {
      value: nextSnapshot,
      getSnapshot: getSnapshot
    };
    hook.queue = inst; 
    // 相当于 mount 阶段执行 useEffect(subscribeToStore.bind(null, fiber, inst, subscribe), [subscribe]);
    mountEffect(subscribeToStore.bind(null, fiber, inst, subscribe), [subscribe]);
    ...
    // 标记 Passive$1 副作用,需要在 commit 阶段进行一致性检查,判断store 是否发生变化
    pushEffect(HasEffect | Passive$1, updateStoreInstance.bind(null, fiber, inst, nextSnapshot, getSnapshot), undefined, null);
    return nextSnapshot;
}

mountSyncExternalStore 中,主要做了四件事情:

  • 执行 getSnapshot 方法获取当前 store 状态值,并存储在 hook 中;
  • consistency check - 一致性检查设置,在 render 阶段结束时要进行 store 的一致性检查;
  • 利用 mountEffect,即 useEffectmount 阶段执行的方法,在节点 mount 完成以后执行 store 对外提供的 subscribe 方法进行订阅;
  • 标记 Passive$1 副作用,在 commit 阶段再进行一次 consistency check;

我们再来看一下 subscribeToStorepushStoreConsistencyCheckupdateStoreInstance 的实现:

  • subscribeToStore

    function subscribeToStore(fiber, inst, subscribe) {
        // handleStoreChange 方法在我们通过 store 的 dispatch 方法修改 store 时会触发
        var handleStoreChange = function () {
          if (checkIfSnapshotChanged(inst)) {
            // 如果 store 发生变化,采用阻塞模式渲染
            forceStoreRerender(fiber);
          }
        }; 
        // 使用 store 提供的 subscribe 方法去订阅
        return subscribe(handleStoreChange);
    }
    
    // 用于判断 store 是否发生变化
    function checkIfSnapshotChanged(inst) {
        var latestGetSnapshot = inst.getSnapshot;
        // 之前的 store 值
        var prevValue = inst.value;
    
        try {
          // 新的 store 值
          var nextValue = latestGetSnapshot();
          // 浅比较 prevValue, nextValue
          return !objectIs(prevValue, nextValue);
        } catch (error) {
          return true;
        }
    }
    
    // 使用同步阻塞模式渲染
    function forceStoreRerender(fiber) {
        scheduleUpdateOnFiber(fiber, SyncLane, NoTimestamp);
    }
    
    

    subscribeToStore 中通过 store 提供的 subscribe 方法订阅了 store 状态变化。当我们通过 store 提供的 dispatch 方法修改 store 时,store 会遍历依赖列表,按序执行订阅的 callback。此时 handleStoreChange 方法执行,由于 store 状态发生了变化,执行 forceStoreRerender 方法, 手动触发 Sync 阻塞渲染。

  • pushStoreConsistencyCheck

      // 一致性检查配置,如果是 concurrent 模式,会构建一个 check 对象添加到 fiber node 的 updateQueue 对象的 store 数组中
      function pushStoreConsistencyCheck(fiber, getSnapshot, renderedSnapshot) {
        fiber.flags |= StoreConsistency;
        var check = {
          getSnapshot: getSnapshot,
          value: renderedSnapshot
        };
        var componentUpdateQueue = currentlyRenderingFiber$1.updateQueue;
    
        if (componentUpdateQueue === null) {
          componentUpdateQueue = createFunctionComponentUpdateQueue();
          currentlyRenderingFiber$1.updateQueue = componentUpdateQueue;
          // 收集 check 对象
          componentUpdateQueue.stores = [check];
        } else {
          var stores = componentUpdateQueue.stores;
          // 收集 check 对象
          if (stores === null) {
            componentUpdateQueue.stores = [check];
          } else {
            stores.push(check);
          }
        }
     }
     
     // iber tree 的整个协调过程
     function performConcurrentWorkOnRoot(root, didTimeout) {
        ...
        // 判断采用 concurrent or sync 模式
        var shouldTimeSlice = !includesBlockingLane(root, lanes) && !includesExpiredLane(root, lanes) && ( !didTimeout);
        var exitStatus = shouldTimeSlice ? renderRootConcurrent(root, lanes) : renderRootSync(root, lanes);
    
        if (exitStatus !== RootInProgress) { // 协调结束
          if (exitStatus === RootErrored) {
            // 出现异常
            ...
          }
    
          if (exitStatus === RootFatalErrored) {
            // 出现异常
            ...
          }
    
          if (exitStatus === RootDidNotComplete) {
            // suspense 挂起
            ...
          } else {
            // 协调完成
            var renderWasConcurrent = !includesBlockingLane(root, lanes);
            var finishedWork = root.current.alternate;
            // 如果是 concurrent 模式,需要进行 store 的一致性检查
            if (renderWasConcurrent && !isRenderConsistentWithExternalStores(finishedWork)) {
              // store 状态不一致,采用同步阻塞渲染
              exitStatus = renderRootSync(root, lanes);
              ...
            }
          ...
          finishConcurrentRender(root, exitStatus, lanes);
        }
        ...
      }
      
    

    为了保证 store 的状态一致,react 在 mountSyncExternalStore 方法中,先通过 pushStoreConsistencyCheck 给组件节点配置 check 对象,然后在协调完成以后,再遍历一次 fiber tree,基于节点的 check 对象做状态一致性检查。如果发现 store 状态不一致,那么就通过 renderRootSync 方法重新进行一次 Sync 阻塞渲染。

  • updateStoreInstance

    function updateStoreInstance(fiber, inst, nextSnapshot, getSnapshot) {
        inst.value = nextSnapshot;
        inst.getSnapshot = getSnapshot; 
        if (checkIfSnapshotChanged(inst)) {
          // 在 commit 阶段,检查 store 是否发生变化,如果发生变化,触发同步阻塞渲染
          forceStoreRerender(fiber);
        }
    }
    

    commit 阶段,需要处理 render 阶段收集的 effect。此时,如果发现 store 发生变化,那么在浏览器渲染之前,还要重新进行一次 Sync 阻塞渲染,以保证 store 状态一致。

看完 mountSyncExternalStore 的实现之后,我们再来看一下 useSyncExternalStoreupdate 阶段要执行的 updateSyncExternalStore 的实现。


function updateSyncExternalStore(subscribe, getSnapshot, getServerSnapshot) {
    var fiber = currentlyRenderingFiber$1;
    // 获取 hooke 对象
    var hook = updateWorkInProgressHook(); 
    // 获取新的 store 状态
    var nextSnapshot = getSnapshot();
    ...
    var inst = hook.queue;
    
    updateEffect(subscribeToStore.bind(null, fiber, inst, subscribe), [subscribe]); 

    if (inst.getSnapshot !== getSnapshot || snapshotChanged || // Check if the susbcribe function changed. We can save some memory by
    // checking whether we scheduled a subscription effect above.
    workInProgressHook !== null && workInProgressHook.memoizedState.tag & HasEffect) {
      fiber.flags |= Passive;
      pushEffect(HasEffect | Passive$1, updateStoreInstance.bind(null, fiber, inst, nextSnapshot, getSnapshot), undefined, null); 
      var root = getWorkInProgressRoot();
      
      ...

      if (!includesBlockingLane(root, renderLanes)) {
        // 一致性检查配置
        pushStoreConsistencyCheck(fiber, getSnapshot, nextSnapshot);
      }
    }

    return nextSnapshot;
  }

updateSyncExternalStoremountSyncExternalStore 做的事情差不多,主要做了:

  • 执行 getSnapshot 方法获取当前 store 状态值,并存储在 hook 中;
  • 利用 updateEffect,即 useEffectupdate 阶段执行的方法,在节点更新完成以后执行 store 对外提供的 subscribe 方法(如果 store 提供的 subscribe 方法没有发生变化,这一步不会执行);
  • 标记 Passive$1 副作用,在 commit 阶段进行一致性检查;
  • consistency check - 一致性检查设置,在 render 阶段结束时要进行 store 的一致性检查;

通过上面的源码分析,我们可以了解到 useSyncExternalStore 保证 store 状态一致的手段就是协调采用 Sync 不可中断渲染。

为了达到这个目的,useSyncExternalStore 采用了三道保险:

  • 通过 dispatch 修改 store 状态时,强制使用 Sync 同步不可中断渲染;
  • Concurrent 模式下,协调结束以后会进行一致性检查,如果发现状态不一致,强制重新进行一次 Sync 同步不可中断渲染;
  • commit 阶段时,再进行一次一致性检查,如果发现状态不一致,强制重新进行一次 Sync 同步不可中断渲染;

写在最后

到这里,关于 useSyncExternalStore 的介绍就结束了。相信通过本文,大家能对 useSyncExternalStore 这个新的 hook 有一定的了解了吧。如果大家觉得本文还不错,那就给我点个赞吧,😄。

参考资料