# V18 - useSyncExternalStore

订阅外部数据源

最新 React 18 中,用 useSyncExternalStore 代替了 useMutableSource 。具体内容可以参考 useMutableSource → useSyncExternalStore (opens new window)

在 concurrent 模式下,render 可能会被执行多次,那么在读取外部数据源的会存在一个问题,比如一个 render 过程中读取了外部数据源状态 1,那么中途遇到更高优先级的任务,而中断了此次更新(挂起),就在此时改变了外部数据源,然后又恢复了此次更新,那么接下来又读取了数据源,由于中途发生了改变,所以这次读取的是外部数据源状态 2,那么一次更新中出现了这种表现不一致的情况。这个问题叫做 tearing 。

可在控制流中调用的 Hook - 实验性 (opens new window)

# useSyncExternalStore

useSyncExternalStore 能够让 React 组件在 concurrent 模式下安全地有效地读取外接数据源,在组件渲染过程中能够检测到变化,并且在数据源发生变化的时候,能够调度更新。当读取到外部状态发生了变化,会触发一个强制更新,来保证结果的一致性。

现在用 useSyncExternalStore 不在需要把订阅到更新流程交给组件处理。如下:

function App(){
     const state = useSyncExternalStore(store.subscribe,store.getSnapshot)
     return <div>...</div>
}
1
2
3
4

如上是通过 useSyncExternalStore 实现的订阅更新,这样减少了 APP 内部组件代码,代码健壮性提升,一定程度上也降低了耦合,最重要的它解决了并发模式状态读取问题。但是这里强调的一点是, 正常的 React 开发者在开发过程中不需要使用这个 api ,这个 hooks 主要是对于 React 的一些状态管理库,比如 redux ,通过它的帮助可以合理管理外部的 store,保证数据读取的一致。

接下来看一下 useSyncExternalStore 使用:

useSyncExternalStore(
    subscribe,
    getSnapshot,
    getServerSnapshot
)
1
2
3
4
5
  • subscribe 为订阅函数,当数据改变的时候,会触发 subscribe,在 useSyncExternalStore 会通过带有记忆性的 getSnapshot 来判别数据是否发生变化,如果发生变化,那么会强制更新数据。
  • getSnapshot 可以理解成一个带有记忆功能的选择器。当 store 变化的时候,会通过 getSnapshot 生成新的状态值,这个状态值可提供给组件作为数据源使用,getSnapshot 可以检查订阅的值是否改变,改变的话那么会触发更新。
  • getServerSnapshot 用于 hydration 模式下的 getSnapshot。

# useSyncExternalStore 应用

用 useSyncExternalStore 配合 redux ,来简单实现订阅外部数据源功能。

import { combineReducers , createStore  } from 'redux'

/* number Reducer */
function numberReducer(state=1,action){
    switch (action.type){
      case 'ADD':
        return state + 1
      case 'DEL':
        return state - 1
      default:
        return state
    }
}

/* 注册reducer */
const rootReducer = combineReducers({ number:numberReducer  })
/* 创建 store */
const store = createStore(rootReducer,{ number:1  })

function Index(){
    /* 订阅外部数据源 */
    const state = useSyncExternalStore(store.subscribe,() => store.getState().number)
    console.log(state)
    return <div>
        {state}
        <button onClick={() => store.dispatch({ type:'ADD' })} >点击</button>
    </div>
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
  • 点击按钮,会触发 reducer ,然后会触发 store.subscribe 订阅函数,执行 getSnapshot 得到新的 number ,判断 number 是否发生变化,如果变化,触发更新。

有了 useSyncExternalStore 这个 hooks ,可以通过外部数据到内部数据的映射,当数据变化的时候,可以通知订阅函数 subscribe 去触发更新。

# useSyncExternalStore 原理

// react-reconciler/src/ReactFiberHooks.new.js
function mountSyncExternalStore(subscribe,getSnapshot){
    /*  创建一个 hooks  */
    const hook = mountWorkInProgressHook();
    /* 产生快照 */
    let nextSnapshot = getSnapshot(); 

    /* 把快照记录下来 */
    hook.memoizedState = nextSnapshot;
    /* 快照记录在 inst 属性上 */
    const inst  = {
        value: nextSnapshot,
        getSnapshot,
    };
    hook.queue = inst;
    
    /* 用一个 effect 来订阅状态 ,subscribeToStore 发起订阅 */
    mountEffect(subscribeToStore.bind(null, fiber, inst, subscribe), [subscribe]);
    
    /* 用一个 useEffect 来监听组件 render ,只要组件渲染就会调用 updateStoreInstance  */
    pushEffect(
        HookHasEffect | HookPassive,
        updateStoreInstance.bind(null, fiber, inst, nextSnapshot, getSnapshot),
        undefined,
        null,
    );
    return nextSnapshot;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28

mountSyncExternalStore 大致流程是这样的:

  • 第一步:创建一个 hooks 。我们都知道 hooks 更新是分两个阶段的,在初始化 hooks 阶段会创建一个 hooks ,在更新阶段会更新这个 Hook。
  • 第二步:调用 getSnapshot 产生一个状态值,并保存起来。
  • 第三步:用一个 effect 来订阅状态 subscribeToStore 发起订阅 。
  • 第四步:用一个 useEffect 来监听组件 render ,只要组件渲染就会调用 updateStoreInstance 。这一步是关键所在,在 concurrent 模式下渲染会中断,那么如果中断恢复 render ,那么这个 effect 就解决了这个问题。当 render 就会触发 updateStoreInstance 。

接下来看一下 subscribeToStore 和 updateStoreInstance 的实现。

subscribeToStore

// react-reconciler/src/subscribeToStore.js
function checkIfSnapshotChanged(inst) {
  const latestGetSnapshot = inst.getSnapshot;
  /* 取出上一次的快照信息 */
  const prevValue = inst.value;
  try {
    /* 最新的快照信息 */
    const nextValue = latestGetSnapshot();
    /* 返回是否相等 */
    return !is(prevValue, nextValue);
  } catch (error) {
    return true;
  }
}
/* 直接发起调度更新  */
function forceStoreRerender(fiber) {
  scheduleUpdateOnFiber(fiber, SyncLane, NoTimestamp);
}

function subscribeToStore(fiber, inst, subscribe) {
  const handleStoreChange = () => {
    /* 检查 state 是否发生变化 */
    if (checkIfSnapshotChanged(inst)) {
       /* 触发更新 */ 
      forceStoreRerender(fiber);
    }
  };
   /* 发起订阅 */
  return subscribe(handleStoreChange);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30

subscribeToStore 的流程如下:

  • 通过 subscribe 订阅 handleStoreChange,当 state 改变会触发 handleStoreChange ,里面判断两次快照是否相等,如果不想等那么触发更新。

updateStoreInstance

// react-reconciler/src/updateStoreInstance.js
function updateStoreInstance(fiber,inst,nextSnapshot,getSnapshot) {
  inst.value = nextSnapshot;
  inst.getSnapshot = getSnapshot;
  /* 检查是否更新 */
  if (checkIfSnapshotChanged(inst)) {
    /* 强制更新 */
    forceStoreRerender(fiber);
  }
}
1
2
3
4
5
6
7
8
9
10
  • updateStoreInstance 很简单就是判断 state 是否发生变化,变化就更新。

通过如上原理分析,我们知道了 useSyncExternalStore 是如何防止 tearing 的了。为了让大家更清楚其流程 ,接下来我们来模拟一个 useSyncExternalStore 的实现。

function useMockSyncExternalStore(subscribe,getSnapshot){
  const [ , forceupdate ] = React.useState(null)
  const inst = React.useRef(null)

  const nextValue = getSnapshot()

  inst.current = {
     value:nextValue,
     getSnapshot
  }
  /* 检测是否更新 */
  const checkIfSnapshotChanged = () => {
    try {
      /* 最新的快照信息 */
      const nextValue = inst.current.getSnapshot();
      /* 返回是否相等 */
      return !inst.value === nextValue
    } catch (error) {
      return true;
    }
  }
  /* 处理 store 改变 */
  const handleStoreChange=()=>{
    if (checkIfSnapshotChanged(inst)) {
      /* 触发更新 */
      forceupdate({})
   }
  }
  React.useEffect(()=>{
    subscribe(handleStoreChange)
  },[ subscribe ])

  /* 注意这个 useEffect 没有依赖项 ,每次更新都会执行该 effect */
  React.useEffect(()=>{
      handleStoreChange()
  })

  return nextValue
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39