useSyncExternalStore 是一个大家非常陌生的 hook,因为它并不常用,不过在一些底层库的封装里,它又非常重要。它能够帮助我们构建自己的驱动数据的方式,而不用非得通过 setState。
基础语法如下:
const snapshot = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot?)
一、语法理解
如果只是看官方文档的话,这个语法理解起来比较困难。我尽量想办法把他讲明白。
我们知道,状态想要触发 UI 更新,我们必须把状态定义在 state 中。useSyncExternalStore 可以帮助我们做到非 state 的数据变化,也触发 UI 的更新。我们可以在 React 外部定义一个状态。
let store = { x: 0, y: 0 }
我们继续在组件外部,定义一个方法,用来获取 store。需要注意的是,该方法不能返回新的对象,必须返回已经存在的引用。
function getSnapshot() { return store; // 请不要返回如下形式,这会导致无限执行 // return {} }
接下来我们需要做的事情,就是在组件外部定义一个 subscribe,这个 subscribe 是最难理解的一个方法。他的主要作用是接收一个回调函数 callback 作为参数,并将其订阅到 store 上。我们需要做的事情就是,当 store 发生变化时,callback 需要被执行。这里官方文档没有说明的一个信息,也是造成他理解困难的重要因素,这个信息是:callback 由 react 内部传递而来,他的主要作用是执行内部的 forceStoreRerender(fiber) 方法,以强制触发 UI 的更新。因此基础逻辑为
store 改变 -> callback 执行 -> forceStoreRerender 执行
除此之外,subscribe 还需要返回一个函数用于取消订阅,它在组件销毁时执行
function subscribe(callback) { window.addEventListener('resize', (e) => { store = { x: e.currentTarget.outerWidth, y: e.currentTarget.outerHeight } callback() }); return () => { window.removeEventListener('resize', callback); }; }
在组件内部,我们只需要调用 useSyncExternalStore 即可,他会返回 getSnapshot 的执行结果。这个案例中,我们订阅的是 resize 事件,因此当我们改变窗口大小,resize 事件触发,在其回调中,我们修改了 store,并执行了 subscribe 的 callback。此时 UI 强制刷新,对应的节点会重新执行,节点函数执行时,通过 useSyncExternalStore 得到新的 store 快照,因此 UI 上能响应到最新的数据结果。
export default function Demo() { const store = useSyncExternalStore(subscribe, getSnapshot); return ( <div> <div>{store.x}px</div> <div>{store.y}px</div> </div> ) }
这里需要注意的是,当我们改变 store 时,一定要返回新的引用对象,我们要把 store 当成不可变数据来使用,否则最终我们无法得到最新的 store 值
// ✅ good store = { x: e.currentTarget.outerWidth, y: e.currentTarget.outerHeight } // ❌ bad store.x = e.currentTarget.outerWidth store.y = e.currentTarget.outerHeight
useSyncExternalStore 的第三个参数可选 getServerSnapshot:它是一个函数,返回 store 中数据的初始快照。它只会在服务端渲染时,以及在客户端进行服务端渲染内容的 hydration 时被用到。快照在服务端与客户端之间必须相同,它通常是从服务端序列化并传到客户端的。如果你忽略此参数,在服务端渲染这个组件会抛出一个错误
二、再来一个案例,并封装自定义hook
现在我们想要结合 useSyncExternalStore 来监听鼠标点击的位置。代码跟上面的案例差不多
import { useSyncExternalStore } from 'react'; let store = { x: 0, y: 0 } function getSnapshot() { return store; } function subscribe(callback: any) { window.addEventListener('click', (e) => { store = { x: e.x, y: e.y } callback() }); return () => { window.removeEventListener('click', callback); }; } export default function Demo() { const store = useSyncExternalStore(subscribe, getSnapshot); return ( <div> <div>{store.x}px</div> <div>{store.y}px</div> </div> ) }
我们可以将组件外部的逻辑单独封装到一个自定义 hook 中去
import { useSyncExternalStore } from 'react'; let store = { x: 0, y: 0 } function getSnapshot() { return store; } function subscribe(callback: any) { window.addEventListener('click', (e) => { store = { x: e.x, y: e.y } callback() }); return () => { window.removeEventListener('click', callback); }; } export default function usePosition() { const store = useSyncExternalStore(subscribe, getSnapshot); return store }
组件内部正常使用它即可
import usePosition from './usePostion' export default function Demo() { const pos = usePosition() return ( <div> <div>{pos.x}px</div> <div>{pos.y}px</div> </div> ) }
不过一定要注意的是,此时我们存储的 store 在闭包之中,当不同的组件调用 usePosition 时,得到的数据在不同的组件里是共享的,并且当我们在多个组件调用 usePosition,还会存在的弊端是 subscribe 会执行多次,也就意味着会添加多个点击事件的监听。因此在使用时需要注意这个细节。
三、自定义订阅改变外部 store
官方文档中有这样一个案例。有一个组件渲染一个列表,当我们点击按钮时,往列表中添加一项数据。交互效果如下图所示。
scroll.gif
我们创建一个 todoStore.ts 用来管理外部 store 的代码。首先定义一个数组用于存储初始化数据。
// 每一个列表的key值 let nextId = 0; let todos = [{ id: nextId++, text: 'Todo #1' }];
接着,一个理解上的难度点又来了。我们刚才说,在创建 subscribe 时,会接收一个 callback 参数,该 callback 参数是由 react 底层内部传入,最终会执行 forceStoreRerender(fiber),此处的 fiber 对应到每一个使用 useSyncExternalStore 的节点,也就是说,如果有多个组件使用 useSyncExternalStore,那么就会收集到多个 callback,因此,我们需要定义一个数组来存储这些 callback
let listeners = [];
接下来我们要定义 subscribe 方法,该方法主要用来收集 callback,这段逻辑的关键在于我们要理解 callback 是什么含义,我们在上面已经解释过,我们需要将所有的 callback 收集到数组里
subscribe(listener: any) { listeners = [...listeners, listener]; return () => { listeners = listeners.filter(l => l !== listener); }; },
再定义一个方法触发所有 callback 的执行
function emitChange() { for (let listener of listeners) { listener(); } }
当数据改变时,执行该方法即可
addTodo() { todos = [...todos, { id: nextId++, text: 'Todo #' + nextId }] emitChange(); },
最后还要定义一个 get 方法获取数据
getSnapshot() { return todos; }
完整代码如下
let nextId = 0; let todos = [{ id: nextId++, text: 'Todo #1' }]; let listeners: any[] = []; export const todosStore = { addTodo() { todos = [...todos, { id: nextId++, text: 'Todo #' + nextId }] emitChange(); }, subscribe(listener: any) { listeners = [...listeners, listener]; return () => { listeners = listeners.filter(l => l !== listener); }; }, getSnapshot() { return todos; } }; function emitChange() { for (let listener of listeners) { listener(); } }
然后在组件中 结合 useSyncExternalStore 使用即可
import { useSyncExternalStore } from 'react'; import { todosStore } from './todoStore'; export default function TodosApp() { const todos = useSyncExternalStore(todosStore.subscribe, todosStore.getSnapshot); return ( <> <button onClick={todosStore.addTodo}>Add todo</button> <hr /> <ul> {todos.map(todo => ( <li key={todo.id}>{todo.text}</li> ))} </ul> </> ); }
如果你完全理解了 useSyncExternalStore 的使用,会发现它的机制跟我们上一章提到的解决 context re-render 问题方案思考极为相似。因此我们也可以将 useSyncExternalStore 与 context 结合使用。
三、实现上一章的案例
上一章的案例我们是把多个 counter 分散到不同的子组件,去观察当每一个子组件 counter 改变时,对其他子组件 re-render 的影响。现在我们需求不变,只需要稍作修改
项目结构依然为:
+ App - index.tsx - store.ts - Counter01.tsx - Counter02.tsx - Counter03.tsx - Counter04.tsx - Counter05.tsx - Reset.tsx
在 index.tsx 中将他们组合在一起。
import Counter01 from './Counter01'; import Counter02 from './Counter02'; import Counter03 from './Counter03'; import Counter04 from './Counter04'; import Counter05 from './Counter05'; import Reset from './Reset'; export default function App() { return ( <div> <Counter01 /> <Counter02 /> <Counter03 /> <Counter04 /> <Counter05 /> <Reset /> </div> ) }
在 store 里利用 useSyncExternalStore 创建自定义 hook 与子组件交互。至于子组件要如何与 store 中的数据交互,取决于我们如何封装这个自定义的 useSubscribe,你也可以不需要跟我一样。
import { useSyncExternalStore } from 'react'; interface StoreItem { value: any, dispatch: Set<any> } interface Store { [key: string]: StoreItem } let store: Store = { counter01: { value: 0, dispatch: new Set() }, counter02: { value: 0, dispatch: new Set() }, counter03: { value: 0, dispatch: new Set() }, counter04: { value: 0, dispatch: new Set() }, } function getSnapshot() { return store } function _setValue(key: string, value: any) { store[key].value = value console.log(store[key].dispatch) store[key].dispatch.forEach(cb => { cb() }) return {...store} } export function useSubscribe(key: string, value: any = 0) { const store = useSyncExternalStore((callback: () => any) => { store[key].dispatch.add(callback) return () => { store[key].dispatch.delete(callback) } }, getSnapshot) return [store[key].value, (value: any) => _setValue(key, value)] } export function useDispatch(key: string) { return (value: any) => _setValue(key, value) }
子组件的写法与之前保持一致。
import { useSubscribe } from './store'; export default function Counter01() { const [counter, setCounter] = useSubscribe('counter01') console.log('counter01: ', counter) function clickHandle() { setCounter(counter + 1) } return ( <button onClick={clickHandle}> counter01: {counter} </button> ) }
为了验证 memo 的效果,我们给其中一个子组件单独加上 memo
import {memo} from 'react' import { useSubscribe } from './store'; function Counter03() { const [counter, setCounter] = useSubscribe('counter03') console.log('counter03: ', counter) function clickHandle() { setCounter(counter + 1) } return ( <button onClick={clickHandle}> counter03: {counter} </button> ) } export default memo(Counter03)
为了验证无状态的组件是否会 re-render,我也补一个这样的组件
export default function Counter04() { console.log('counter05: ') return ( <div>counter 05</div> ) }
Reset 由你在调试的时候动态修改,它的目的是为了验证当我在别的组件中操作全局数据时,其他组件是否会同步更改。
import { useDispatch } from './store'; export default function Reset() { const setCounter01 = useDispatch('counter01') const setCounter02 = useDispatch('counter02') const setCounter03 = useDispatch('counter04') console.log('reset'); function clickHandle() { setCounter01(0); setCounter02(0); } function clickHandle03() { setCounter03(0) } return ( <div> <button onClick={clickHandle}> Reset01 02 to 0 </button> <button onClick={clickHandle03}> Reset03 </button> </div> ) }
OK,运行,测试之后我们发现
- 1、功能上基本全部实现,达到了全局共享的目标
- 2、当外部 store 发生改变时,所有的组件都会 re-render,包括无状态组件
- 3、使用 memo 可以避免冗余 re-render 的发生
因此,从结果上来说,我这里使用的封装方案比上一章的方案稍微差一些,不过借助 memo 就能够达到一样的结果。在实现原理上,和上一种方案的差别在于,上一章我们是利用 setState 的方式触发组件更新,useSyncExternalStore 是 react 利用底层的 forceStoreRerender 的方式触发更新,也有可能在封装上还有一些改进的空间,只是我还没有想到,这个空间就留给大家一起探寻。不过所幸能够借助 memo 避免冗余 re-render 的产生,这样我们也能够设计出来一套性能非常优异的状态管理库了。
注意:如果你想要将本文中的案例直接运用于项目实践,请一定要结合具体需求进行扩展和打磨,文章案例设计的组件情况相对简单,主要目的在于语法学习和给大家提供一个思路,请勿直接套用。