前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >useSyncExternalStore,一个陌生但重要的 hook

useSyncExternalStore,一个陌生但重要的 hook

作者头像
用户6901603
发布2023-12-28 14:03:06
2580
发布2023-12-28 14:03:06
举报
文章被收录于专栏:不知非攻不知非攻

React 知命境」第 30 篇

useSyncExternalStore 是一个大家非常陌生的 hook,因为它并不常用,不过在一些底层库的封装里,它又非常重要。它能够帮助我们构建自己的驱动数据的方式,而不用非得通过 setState

基础语法如下:

代码语言:javascript
复制
const snapshot = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot?)

一、语法理解

如果只是看官方文档的话,这个语法理解起来比较困难。我尽量想办法把他讲明白。

我们知道,状态想要触发 UI 更新,我们必须把状态定义在 state 中。useSyncExternalStore 可以帮助我们做到非 state 的数据变化,也触发 UI 的更新。我们可以在 React 外部定义一个状态。

代码语言:javascript
复制
let store = {
  x: 0,
  y: 0
}

我们继续在组件外部,定义一个方法,用来获取 store。需要注意的是,该方法不能返回新的对象,必须返回已经存在的引用。

代码语言:javascript
复制
function getSnapshot() {
  return store;
  // 请不要返回如下形式,这会导致无限执行
  // return {}
}

接下来我们需要做的事情,就是在组件外部定义一个 subscribe,这个 subscribe 是最难理解的一个方法。他的主要作用是接收一个回调函数 callback 作为参数,并将其订阅到 store 上。我们需要做的事情就是,当 store 发生变化时,callback 需要被执行。这里官方文档没有说明的一个信息,也是造成他理解困难的重要因素,这个信息是:callback 由 react 内部传递而来,他的主要作用是执行内部的 forceStoreRerender(fiber) 方法,以强制触发 UI 的更新。因此基础逻辑为

store 改变 -> callback 执行 -> forceStoreRerender 执行

除此之外,subscribe 还需要返回一个函数用于取消订阅,它在组件销毁时执行

代码语言:javascript
复制
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 上能响应到最新的数据结果。

代码语言:javascript
复制
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 值

代码语言:javascript
复制
// ✅ 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 来监听鼠标点击的位置。代码跟上面的案例差不多

代码语言:javascript
复制
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 中去

代码语言:javascript
复制
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
}

组件内部正常使用它即可

代码语言:javascript
复制
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 的代码。首先定义一个数组用于存储初始化数据。

代码语言:javascript
复制
// 每一个列表的key值
let nextId = 0;
let todos = [{ id: nextId++, text: 'Todo #1' }];

接着,一个理解上的难度点又来了。我们刚才说,在创建 subscribe 时,会接收一个 callback 参数,该 callback 参数是由 react 底层内部传入,最终会执行 forceStoreRerender(fiber),此处的 fiber 对应到每一个使用 useSyncExternalStore 的节点,也就是说,如果有多个组件使用 useSyncExternalStore,那么就会收集到多个 callback,因此,我们需要定义一个数组来存储这些 callback

代码语言:javascript
复制
let listeners = [];

接下来我们要定义 subscribe 方法,该方法主要用来收集 callback,这段逻辑的关键在于我们要理解 callback 是什么含义,我们在上面已经解释过,我们需要将所有的 callback 收集到数组里

代码语言:javascript
复制
subscribe(listener: any) {
  listeners = [...listeners, listener];
  return () => {
    listeners = listeners.filter(l => l !== listener);
  };
},

再定义一个方法触发所有 callback 的执行

代码语言:javascript
复制
function emitChange() {
  for (let listener of listeners) {
    listener();
  }
}

当数据改变时,执行该方法即可

代码语言:javascript
复制
addTodo() {
  todos = [...todos, { id: nextId++, text: 'Todo #' + nextId }]
  emitChange();
},

最后还要定义一个 get 方法获取数据

代码语言:javascript
复制
getSnapshot() {
  return todos;
}

完整代码如下

代码语言:javascript
复制
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 使用即可

代码语言:javascript
复制
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 的影响。现在我们需求不变,只需要稍作修改

项目结构依然为:

代码语言:javascript
复制
+ App
  - index.tsx
  - store.ts
  - Counter01.tsx
  - Counter02.tsx
  - Counter03.tsx
  - Counter04.tsx
  - Counter05.tsx
  - Reset.tsx

在 index.tsx 中将他们组合在一起。

代码语言:javascript
复制
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,你也可以不需要跟我一样。

代码语言:javascript
复制
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)
}

子组件的写法与之前保持一致。

代码语言:javascript
复制
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

代码语言:javascript
复制
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,我也补一个这样的组件

代码语言:javascript
复制
export default function Counter04() {
  console.log('counter05: ')

  return (
    <div>counter 05</div>
  )
}

Reset 由你在调试的时候动态修改,它的目的是为了验证当我在别的组件中操作全局数据时,其他组件是否会同步更改。

代码语言:javascript
复制
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 的产生,这样我们也能够设计出来一套性能非常优异的状态管理库了。

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

本文参与 腾讯云自媒体同步曝光计划,分享自微信公众号。
原始发表:2023-12-22,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 这波能反杀 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 一、语法理解
  • 二、再来一个案例,并封装自定义hook
  • 三、自定义订阅改变外部 store
  • 三、实现上一章的案例
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档