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


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

相关文章
|
4月前
|
存储 安全 Linux
谈谈优雅的钩子--bpftrace
bpftrace是一个内核跟踪工具,简单来说就是在函数上挂个钩子,挂上钩子后就可以将函数的入参和返回值取出来再放入程序进行二次编程,最终能让程序按照我们的意图来对函数进行观测。
|
6月前
|
测试技术 编译器 C#
一篇文章讲明白hook(钩子程序)(转载)
一篇文章讲明白hook(钩子程序)(转载)
290 0
|
6月前
|
存储 数据可视化 API
一篇文章讲明白ffdshow源代码分析
一篇文章讲明白ffdshow源代码分析
70 0
|
监控 Windows
Hook函数三步走(SetWindowsHookEx、UnhookWindowsHookEx、CallNextHookEx)
Hook函数三步走(SetWindowsHookEx、UnhookWindowsHookEx、CallNextHookEx)
759 0
Hook函数三步走(SetWindowsHookEx、UnhookWindowsHookEx、CallNextHookEx)
|
程序员 API Windows
[笔记]Windows核心编程《番外篇》几种Hook类型
[笔记]Windows核心编程《番外篇》几种Hook类型
|
前端开发 C++
前端hook项目moblie总结笔记-打包apk过程
前端hook项目moblie总结笔记-打包apk过程
103 0
前端hook项目moblie总结笔记-打包apk过程
|
存储 Java 编译器
【重学C/C++系列(四)】:函数体hack过程详解
首先来说下**什么是hack**? hack字面意思“**非法入侵**”,那么在C/C++中其实就是**使用反汇编查看C/C++代码对应的汇编代码**
【重学C/C++系列(四)】:函数体hack过程详解
手把手一步步教你使用rpcms的模板Hook
rpcms可以制作模板和插件,有时候做的模板需要自定义一些功能,但又不想通过插件实现,那样用户在使用模板的时候,还必须配套插件才可以。为避免这写繁琐的操作,让用户使用模板更方便,我们可以使用模板的Hook功能。 这个功能使用起来也很方便,只需要在你的模板文件夹中创建Hook.class.php,文件内容如下:
手把手一步步教你使用rpcms的模板Hook
|
存储 前端开发 JavaScript
【高级React技术】Hook在企业项目中的使用心得以及一些组件的原理(State Hook)
【高级React技术】Hook在企业项目中的使用心得以及一些组件的原理(State Hook)