React Concurrent Mode和useSyncExternalStore
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} />
</>
);
}
当我快速的在输入框中输入内容时,可以明显感觉到,当渲染数据量过大时,如果没有开启并发更新,那么渲染长列表耗时很久,此时无法及时响应我们的输入时间,感觉到明显的卡顿,有时候直接卡死页面。
并发更新时
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>
);
}
可以看到当开启了并发更新之后,浏览器能及时响应用户的输入事件,比非并发更新体验好多了
startTransition
通过将特定更新标记为“过渡”
来显著改善用户交互被 startTransition
回调包裹的 setState
触发的渲染被标记为不紧急渲染,这些渲染可能被其他紧急渲染
(高优任务,如输入点击等事件)所抢占
useDeferredValu
返回一个延迟响应的值,可以让一个state
延迟生效,只有当前没有紧急更新时,该值才会变为最新值。useDeferredValue
和 startTransition
一样,都是标记了一次非紧急更新。
useDeferredValue
与 useTransition
十分相似
- 相同:
useDeferredValue
本质上和内部实现与useTransition
一样,都是标记成了延迟更新
任务。 - 不同:
useTransition
是把更新任务变成了延迟更新任务,而useDeferredValue
是产生一个新的值,这个值作为延时状态。(一个用来包装方法,一个用来包装值)
并发更新下的tearing问题
在并发更新下,高优渲染任务打断低优渲染任务后可能会修改公共store状态(比如react-redux
,zustand
),那么之前的低优渲染任务必须重新执行,否则可能会出现先后状态不一致的情况(tearing
撕裂)。
- Synchronous rendering
- Concurrent rendering
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-redux
和zustand
的原理也是一样的)
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来帮助状态管理库的开发者。
可以参考:
- Concurrent React for Library Maintainers · reactwg/react-18 · Discussion #70 · GitHub
- useMutableSource → useSyncExternalStore · reactwg/react-18 · Discussion #86 · GitHub
第三方状态管理库是怎么使用的?
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…
转载自:https://juejin.cn/post/7237425805503201341