React Concurrent Mode和useSyncExternalStore

571 阅读8分钟

React18正式发布了Concurrent Mode(并发模式,React 17可以通过一些实验性的api开启),在并发模式下,React可以让设备保持响应,从而提升用户体验。并发模式将渲染阶段从之前的同步不可中断更新变为了异步可中断更新。React在渲染阶段可以暂停非紧急任务的渲染,穿插高优先级的任务处理,如始终保持及时响应用户的点击事件,避免白屏和卡顿现象。

同步不可中断更新带来的问题?

通常屏幕的刷新率为60Hz,即一秒刷新60次,渲染一帧的时间大概为16.6ms,一帧中包含了处理用户交互、JavaScript代码执行和页面的布局绘制等步骤。当渲染一帧的时长不超过16.6ms时用户肉眼感觉不到卡顿。但是如果页面需要显示一个很长的列表且dom结构十分复杂时,React执行调和(dom diff)的过程可能会耗时很长,那么浏览器执行页面绘制的时机就会推后,导致渲染一帧的时长超过16.6ms,无法刷新页面和响应用户的点击和滚动等事件,从而让用户感觉卡顿(掉帧)。

而并发模式采用了时间切片的方式来交替执行不同的任务,React会为每帧分配5ms来执行调和任务,当超过5ms仍然没有执行完时,React就会将线程控制权交还给浏览器,让浏览器可以及时响应高优先级任务(比如用户点击事件),然后等待下一帧继续执行被中断的任务。

开启并发模式


import ReactDOM from 'react-dom/client';

// 开启并发模式
ReactDOM.createRoot(document.getElementById(root)).render(<App/>);

并发更新

这里需要注意的是在React18中开启了并发模式并不意味着开启了并发更新。我们需要使用一些并发特性才能开启并发更新,比如

  • useTransition
  • startTransition
  • useDeferredValue

举个🌰来感受并发更新和非并发更新的区别。假如页面上有个input输入框,在用户输入字符后会去查询接口返回一大串列表显示在页面上

非并发更新时

import {
  useState,
  useEffect,
  useDeferredValue,
  startTransition,
} from 'react';

function getData(word) {
  return Promise.resolve(new Array(10000).fill(word));
}

function Suggests({ word }) {
  const [list, setList] = useState([]);
  useEffect(() => {
    if (!word) return;
    getData(word).then((r) => {
      // 这里没有开启并发更新
       setList(r);
    });
  }, [word]);

  return (
    <ul>
      {list.map((item, i) => (
        <li key={i}>{item}</li>
      ))}
    </ul>
  );
}

function App() {
  const [word, setWord] = useState('');
  
  // 开启并发更新有两种方式(前提是开启了并发模式)
  return (
    <>
      <input type="text" onChange={(e) => setWord(e.target.value)} />
      <Suggests word={word} />
    </>
  );
}

非并发.gif

当我快速的在输入框中输入内容时,可以明显感觉到,当渲染数据量过大时,如果没有开启并发更新,那么渲染长列表耗时很久,此时无法及时响应我们的输入时间,感觉到明显的卡顿,有时候直接卡死页面。

并发更新时

function Suggests({ word }) {
  const [list, setList] = useState([]);
  useEffect(() => {
    if (!word) return;
    getData(word).then((r) => {
      // 通过 startTransition开启并发更新
      React.startTransition(() => {
        setList(r);
      });
    });
  }, [word]);
  
  // 通过 useDeferredValue 开启并发更新
  // useEffect(() => {
  //   if (!word) return;
  //   getData(word).then((r) => {
  //     setList(r);
  //   });
  // }, [word]);

  // const deferredList = React.useDeferredValue(list);

  return (
    <ul>
      {list.map((item, i) => (
        <li key={i}>{item}</li>
      ))}
    </ul>
  );
}

并发更新.gif

可以看到当开启了并发更新之后,浏览器能及时响应用户的输入事件,比非并发更新体验好多了

startTransition通过将特定更新标记为“过渡”来显著改善用户交互被 startTransition 回调包裹的 setState 触发的渲染被标记为不紧急渲染,这些渲染可能被其他紧急渲染(高优任务,如输入点击等事件)所抢占

useDeferredValu 返回一个延迟响应的值,可以让一个state 延迟生效,只有当前没有紧急更新时,该值才会变为最新值。useDeferredValuestartTransition 一样,都是标记了一次非紧急更新。

useDeferredValueuseTransition 十分相似

  • 相同:useDeferredValue 本质上和内部实现与 useTransition 一样,都是标记成了延迟更新任务。
  • 不同:useTransition 是把更新任务变成了延迟更新任务,而 useDeferredValue 是产生一个新的值,这个值作为延时状态。(一个用来包装方法,一个用来包装值)

并发更新下的tearing问题

在并发更新下,高优渲染任务打断低优渲染任务后可能会修改公共store状态(比如react-reduxzustand),那么之前的低优渲染任务必须重新执行,否则可能会出现先后状态不一致的情况(tearing 撕裂)。

  • Synchronous rendering

image.png

  • Concurrent rendering

image.png

useSyncExternalStore

为了解决并发更新下的撕裂问题,React 提供了 useSyncExternalStore hook。useSyncExternalStore 可以强制同步更新外部数据。

举个使用🌰:

import { useSyncExternalStore } from 'react';

class Store {
  state = 0;
  listeners = new Set();

  subscribe = (l) => {
    this.listeners.add(l);
    return () => this.listeners.delete(l);
  };

  setState(state) {
    this.state = state;
    this.listeners.forEach((l) => l());
  }
  getState = () => this.state;
}

const store = new Store();

export function UseSyncExternalStore() {
  const state = useSyncExternalStore(store.subscribe, store.getState);
  return <button onClick={() => store.setState(state + 1)}>{state}</button>;
}

在这个例子中,我们在组件中使用了外部的store,然后通过useSyncExternalStore 将外部store中的状态实时同步到组件中。每次点击button,能看到页面会实时显示上一个state+1。(其实react-reduxzustand的原理也是一样的)

useSyncExternalStore是React18提供的,如果是React17以下的版本也开启了并发渲染模式,那么可以使用React提供的useSyncExternalStore shim来达到同样的效果

// useSyncExternalStore shim 核心代码
export function useSyncExternalStore(
  subscribe,
  getSnapshot,
) {
  const value = getSnapshot();

  const [{inst}, forceUpdate] = useState({inst: {value, getSnapshot}});

  // Track the latest getSnapshot function with a ref. This needs to be updated
  // in the layout phase so we can access it during the tearing check that
  // happens on subscribe.
  useLayoutEffect(() => {
    inst.value = value;
    inst.getSnapshot = getSnapshot;
    if (checkIfSnapshotChanged(inst)) {
      // Force a re-render.
      forceUpdate({inst});
    }
  }, [subscribe, value, getSnapshot]);

  useEffect(() => {
    // The store changed. Check if the snapshot changed since the last time we
    // read from the store.
    if (checkIfSnapshotChanged(inst)) {
      // Force a re-render.
      forceUpdate({inst});
    }
    const handleStoreChange = () => {
      if (checkIfSnapshotChanged(inst)) {
        // Force a re-render.
        forceUpdate({inst});
      }
    };
    // Subscribe to the store and return a clean-up function.
    return subscribe(handleStoreChange);
  }, [subscribe]);
  return value;
}

function checkIfSnapshotChanged(inst) {
  const latestGetSnapshot = inst.getSnapshot;
  const prevValue = inst.value;
  try {
    const nextValue = latestGetSnapshot();
    return !Object.is(prevValue, nextValue);
  } catch (error) {
    return true;
  }
}

可以看到对于React18以前的兼容处理还是依靠订阅和forceUpdate解决的。先在useLayoutEffect中判断外部状态的最新值与当前渲染的值是否一致,如果不一致则强制更新(useLayoutEffect执行时机在commit阶段,如果发现前后状态不一致会调用setState重新走渲染阶段)。在useEffect中给外部store添加forceUpdate的订阅,当store状态发生变化时就会触发重新渲染。

useSyncExternalStoreWithSelector

React 还提供了一个useSyncExternalStoreWithSelector shim,它与useSyncExternalStore非常相似,但是支持传入selector来pick state和自定义equalityFn来比较前后state是否一致

// useSyncExternalStoreWithSelector shim
import {useRef, useEffect, useMemo} from 'react';

// Same as useSyncExternalStore, but supports selector and isEqual arguments.
function useSyncExternalStoreWithSelector(
  subscribe,
  getSnapshot,
  getServerSnapshot,
  selector,
  isEqual,
) {
  // Use this to track the rendered snapshot.
  const instRef = useRef(null);
  let inst;
  if (instRef.current === null) {
    inst = {
      hasValue: false,
      value: null,
    };
    instRef.current = inst;
  } else {
    inst = instRef.current;
  }

  const [getSelection, getServerSelection] = useMemo(() => {
    // Track the memoized state using closure variables that are local to this
    // memoized instance of a getSnapshot function. Intentionally not using a
    // useRef hook, because that state would be shared across all concurrent
    // copies of the hook/component.
    let hasMemo = false;
    let memoizedSnapshot;
    let memoizedSelection;
    const memoizedSelector = nextSnapshot => {
      if (!hasMemo) {
        // The first time the hook is called, there is no memoized result.
        hasMemo = true;
        memoizedSnapshot = nextSnapshot;
        const nextSelection = selector(nextSnapshot);
        if (isEqual !== undefined) {
          if (inst.hasValue) {
            const currentSelection = inst.value;
            if (isEqual(currentSelection, nextSelection)) {
              memoizedSelection = currentSelection;
              return currentSelection;
            }
          }
        }
        memoizedSelection = nextSelection;
        return nextSelection;
      }

      // We may be able to reuse the previous invocation's result.
      const prevSnapshot = memoizedSnapshot;
      const prevSelection = memoizedSelection;

      if (Object.is(prevSnapshot, nextSnapshot)) {
        // The snapshot is the same as last time. Reuse the previous selection.
        return prevSelection;
      }

      // The snapshot has changed, so we need to compute a new selection.
      const nextSelection = selector(nextSnapshot);
      if (isEqual !== undefined && isEqual(prevSelection, nextSelection)) {
        return prevSelection;
      }

      memoizedSnapshot = nextSnapshot;
      memoizedSelection = nextSelection;
      return nextSelection;
    };
    // Assigning this to a constant so that Flow knows it can't change.
    const maybeGetServerSnapshot =
      getServerSnapshot === undefined ? null : getServerSnapshot;
    const getSnapshotWithSelector = () => memoizedSelector(getSnapshot());
    const getServerSnapshotWithSelector =
      maybeGetServerSnapshot === null
        ? undefined
        : () => memoizedSelector(maybeGetServerSnapshot());
    return [getSnapshotWithSelector, getServerSnapshotWithSelector];
  }, [getSnapshot, getServerSnapshot, selector, isEqual]);

  const value = useSyncExternalStore(
    subscribe,
    getSelection,
    getServerSelection,
  );

  useEffect(() => {
    inst.hasValue = true;
    inst.value = value;
  }, [value]);

  return value;
}

这两个api通常不是给用户使用的,而是给状态管理库使用的。用户通常只会使用React提供的原生API(useState),而原生的API已经解决了并发更新模式下的tearing问题。但是对于状态管理库而言,它们在控制状态时并非直接使用React提供的API(useState),而是自己维护了一个store对象,比如redux和zustand。因此脱离了React的管理,也就无法依靠React自动解决tearing问题。所以React提供了此hook来帮助状态管理库的开发者。

可以参考:

第三方状态管理库是怎么使用的?

react-redux

// useSelector
export function createSelectorHook(context = ReactReduxContext) {
  const useReduxContext =
    context === ReactReduxContext
      ? useDefaultReduxContext
      : () => useContext(context)

  return function useSelector(selector, equalityFn = (a, b) => a === b) {
    const { store, subscription, getServerState } = useReduxContext()
    const selectedState = useSyncExternalStoreWithSelector(
      subscription.addNestedSub,
      store.getState,
      getServerState || store.getState,
      selector,
      equalityFn
    )
    return selectedState
  }
}

export const useSelector = createSelectorHook()

zustand

简单使用例子,详细用法请参考官网

import { create } from 'zustand'

const useBearStore = create((set) => ({
  bears: 0,
  increasePopulation: () => set((state) => ({ bears: state.bears + 1 })),
  removeAllBears: () => set({ bears: 0 }),
}))

function BearCounter() {
  const bears = useBearStore((state) => state.bears)
  return <h1>{bears} around here ...</h1>
}

function Controls() {
  const increasePopulation = useBearStore((state) => state.increasePopulation)
  return <button onClick={increasePopulation}>one up</button>
}

核心源码如下

import { useSyncExternalStoreWithSelector } from 'react'

const createStoreImpl = (createState) => {
  let state
  const listeners = new Set()
  const setState = (partial, replace) => {
    const nextState = typeof partial === 'function' ? partial(state) : partial
    if (!Object.is(nextState, state)) {
      const previousState = state
      state =
        replace ?? typeof nextState !== 'object'
          ? nextState
          : Object.assign({}, state, nextState)
      listeners.forEach((listener) => listener(state, previousState))
    }
  }
  const getState = () => state
  const subscribe = (listener) => {
    listeners.add(listener)
    // Unsubscribe
    return () => listeners.delete(listener)
  }
  const destroy = () => {
    listeners.clear()
  }

  const api = { setState, getState, subscribe, destroy }
  state = createState(setState, getState, api)
  return api
}

const createStore = (createState) =>
  createState ? createStoreImpl(createState) : createStoreImpl

function useStore(api, selector = api.getState, equalityFn) {
  const slice = useSyncExternalStoreWithSelector(
    api.subscribe,
    api.getState,
    api.getServerState || api.getState,
    selector,
    equalityFn
  )
  return slice
}

const createImpl = (createState) => {
  const api =
    typeof createState === 'function' ? createStore(createState) : createState

  const useBoundStore = (selector, equalityFn) =>
    useStore(api, selector, equalityFn)

  Object.assign(useBoundStore, api)
  return useBoundStore
}

export const create = (createState) =>
  createState ? createImpl(createState) : createImpl

更多React18新特性请参考:github.com/facebook/re…