useSyncExternalStore,一个陌生但重要的 hook

React 知命境」第 30 篇

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 需要被执行。这里官方文档没有说明的一个信息,也是造成他理解困难的重要因素,这个信息是:callbackreact 内部传递而来,他的主要作用是执行内部的 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

官方文档中有这样一个案例。有一个组件渲染一个列表,当我们点击按钮时,往列表中添加一项数据。交互效果如下图所示。

useSyncExternalStore,一个陌生但重要的 hook
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>
        ))}
      </u
l>
    </>
  );
}

如果你完全理解了 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 的产生,这样我们也能够设计出来一套性能非常优异的状态管理库了。

注意:如果你想要将本文中的案例直接运用于项目实践,请一定要结合具体需求进行扩展和打磨,文章案例设计的组件情况相对简单,主要目的在于语法学习和给大家提供一个思路,请勿直接套用。

「React 知命境」 是一本从知识体系顶层出发,理论结合实践,通俗易懂,覆盖面广的精品小册,欢迎关注我的公众号,我会持续更新,购买 React 哲学,或者赞赏本文 30 元,可进入 React 付费讨论群,学习氛围良好,学习进度加倍


原文始发于微信公众号(这波能反杀):useSyncExternalStore,一个陌生但重要的 hook

版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 举报,一经查实,本站将立刻删除。

文章由极客之音整理,本文链接:https://www.bmabk.com/index.php/post/240234.html

(0)
葫芦侠五楼的头像葫芦侠五楼

相关推荐

发表回复

登录后才能评论
极客之音——专业性很强的中文编程技术网站,欢迎收藏到浏览器,订阅我们!