useSyncExternalStore,一个陌生但重要的 hook

简介: useSyncExternalStore,一个陌生但重要的 hook

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


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

相关文章
|
2月前
|
测试技术 编译器 C#
一篇文章讲明白hook(钩子程序)(转载)
一篇文章讲明白hook(钩子程序)(转载)
31 0
|
3月前
|
缓存 前端开发 JavaScript
常用的hooks都有哪些,说出他们的作用?
这些是常用的 React Hooks,每个 Hook 都有特定的作用,能够方便地处理组件的状态管理、副作用操作、上下文等功能。使用 Hooks 可以使函数组件更易于编写、理解和维护。
45 0
|
3月前
|
关系型数据库 MySQL API
如何使用hook?
如何使用hook?
29 0
|
10月前
|
JavaScript
hooks | 朋友用了都说好的useTable
你是不是还在为了重复性的写表格查询,重置,分页等而烦恼?
119 0
|
前端开发 API
react中hook的作用和用处
react中hook的作用和用处
|
JavaScript 前端开发
react 进阶hook 之 useLayoutEffect hook
useLayoutEffect 其函数签名与 useEffect相同,但它会在所有的 DOM 变更之后同步调用 effect。可以使用它来读取 DOM 布局并同步触发重渲染
react 进阶hook 之 useLayoutEffect hook
|
前端开发
react 进阶hook 之 useImperativeHandle hook
这个hook比较简单,作用: 获取函数组件里面的事件,我们通过 ref 来获取类组件的事件,所以 这个 useImperativeHandle Hook 一般是于 ref 转发一起使用。
react 进阶hook 之 useImperativeHandle hook
|
前端开发 JavaScript API
咱就是说,瞧瞧这React的Hooks的由来
咱就是说,瞧瞧这React的Hooks的由来
119 0
咱就是说,瞧瞧这React的Hooks的由来
|
前端开发
react hook学习1-hook简介
react hook学习1-hook简介
63 0
react hook学习1-hook简介