当前位置: 首页 > news >正文

[React 进阶系列] useSyncExternalStore hook

[React 进阶系列] useSyncExternalStore hook

前情提要,包括 yup 的实现在这里:yup 基础使用以及 jest 测试

简单的提一下,需要实现的功能是:

  • yup schema 需要访问外部的 storage
  • 外部的 storage 是可变的
  • React 内部也需要访问同样的 storage

基于这几个前提条件,再加上我们的项目已经从 React 17 升级到了 React 18,因此就比较顺利的找到了一个新的 hook:useSyncExternalStore

这个新的 hook 可以监听到 React 外部 store——通常情况下可以是 local storage/session storage 这种——的变化,随后在 React 组件内部去更新对应的状态

官方文档其实解释的比较清楚了,使用 useSyncExternalStore 监听的 store 必须要实现以下两个功能:

  • subscribe

    其作用是一个 subscriber,主要提供的功能在,当变化被监听到时,就会调用当前的 subscriber

    我个人理解,相比于传统的 Consumer/Subscriber 模式,React 提供的这个 hook 是一个弱化的版本,subscriber 的主要目的是为了提示 React 这里有一个状态变化,所以很多情况下还是需要开发手动在 useEffect 中实现对应的功能

    当然,也是可以通过 event emitter 去出发 subscriber 的变化,这点还需要研究一下怎么实现

  • getSnapshot

    这个是会被返回的最新状态

这也是 useSyncExternalStore 必须的两个参数。另一参数是为初始状态,为可选项:

const snapshot = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot?)

实现 store

import { useSyncExternalStore } from "react";export class PrerequisiteStore {private prerequisite: string | undefined;private listeners: Set<() => void> = new Set();private initListeners: Set<() => void> = new Set();private isInitialized = false;subscribe(listener: () => void) {this.listeners.add(listener);return () => {this.listeners.delete(listener);};}getSnapshot() {return this.prerequisite;}setPrerequisite(prerequisite: string | undefined) {this.prerequisite = prerequisite;this.isInitialized = true;this.listeners.forEach((listener) => listener());this.initListeners.forEach((listener) => listener());this.initListeners.clear();}onInitialized(cb: () => void) {if (this.isInitialized) {cb();} else {this.initListeners.add(cb);}}
}const prerequisteStore = new PrerequisiteStore();export const getPrerequisite = () => prerequisteStore.getSnapshot();
export const setPrerequisite = (prerequisite: undefined | string) =>prerequisteStore.setPrerequisite(prerequisite);const subscribe = (cb: () => void) => prerequisteStore.subscribe(cb);
const getSnapshot = () => prerequisteStore.getSnapshot();
const getPrerequisiteSnapshot = getSnapshot;export const onPrerequisiteStoreInitialized = (cb: () => void) =>prerequisteStore.onInitialized(cb);export const usePrerequisiteSyncStore = () => {return useSyncExternalStore(subscribe, getSnapshot, getPrerequisiteSnapshot);
};

这个实现方法是用 class……其主要原因是想要基于一个 singleton 实现,这样全局访问 prerequisteStore 的时候只能访问这一个 store

不过同样的问题似乎也可以使用 object 来解决,就像 React 官方文档实现的那样:

// This is an example of a third-party store
// that you might need to integrate with React.// If your app is fully built with React,
// we recommend using React state instead.let nextId = 0;
let todos = [{ id: nextId++, text: "Todo #1" }];
let listeners = [];export const todosStore = {addTodo() {todos = [...todos, { id: nextId++, text: "Todo #" + nextId }];emitChange();},subscribe(listener) {listeners = [...listeners, listener];return () => {listeners = listeners.filter((l) => l !== listener);};},getSnapshot() {return todos;},
};function emitChange() {for (let listener of listeners) {listener();}
}

而且目前的实现实际上是无法自由绑定 listener 的,所以之后可能会修改一下这部分,而且还是需要花点时间琢磨一下 subscribe 这个功能怎么用

使用 store

错误实现

useEffect(() => {setTimeout(() => {setPrerequisite("A");initDemoSchema();}, 1000);setTimeout(() => {setPrerequisite("C");}, 2000);
}, []);useEffect(() => {console.log(prerequisiteStore, new Date().toISOString());if (prerequisiteStore) {const res = demoSchema.cast({});demoSchema.validate(res).then((res) => console.log(res)).catch((e) => {if (e instanceof ValidationError) {console.log(e.path, ",", e.message);}});}
}, [prerequisiteStore]);

这是 App.tsx 中的变化,实现效果如下:

在这里插入图片描述

这里可以看到有个问题,那就是在 useEffect(() => {}, [prerequisiteStore]) 获取变化的时候,第一个 useEffect 没有获取更新的状态

修正

首先 store 的初始化,在当前的版本不是非常的必须,所以这里可以省略掉,直接保留 subscribe 等即可……不过因为测试代码已经添加了的关系,这里不会继续修改。主要就是修改一下 initDemoSchema:

// 重命名
export const updateDemoSchema = (prerequisite: string | undefined) => {if (prerequisite) {demoSchema = demoSchema.shape({enumField: string().required().default(prerequisite).oneOf(Object.keys(getTestEnum() || [])),});}
};

随后在 App.tsx 中更新:

useEffect(() => {setTimeout(() => {setPrerequisite("A");}, 1000);setTimeout(() => {setPrerequisite("C");}, 2000);
}, []);useEffect(() => {console.log(prerequisiteStore, new Date().toISOString());if (prerequisiteStore) {updateDemoSchema(prerequisiteStore);const res = demoSchema.cast({});demoSchema.validate(res).then((res) => console.log(res)).catch((e) => {if (e instanceof ValidationError) {console.log(e.path, ",", e.message);}});}
}, [prerequisiteStore]);

这样就可以实现正常更新了:

在这里插入图片描述

补充:发现之前没有写 initDemoSchema,之前旧的实现大致上没有特别大的区别,不过 prerequisite 的方式是通过 getPrerequisite 获取的。但是我没注意到的是,这只是一个 reference,同时也没有绑定 subscribe,因此这里返回的永远是最初值,也就是在 initialized 后的值,也就是 A

下一步

下一步想做的就是把 schema 的变化抽离出来,并且尝试使用 todo 案例中的 emitChange,这样 schema 的变化就不局限在 component 层级

虽然目前的业务情况来说,1 个 schema 基本上只会被用在 1 个页面上,不过还是想要将其剥离出来,减少对 react 组建的依赖性,而是直接想办法监听 store 的变化

测试代码

这个测试代码写的就比较含糊,基本上就是测试了一下 subscriber 被调用了几次

相对而言比较复杂的实现功能还是得回到 yup schema 去做……这等到实际上有这个需求再说吧,感觉那个写起来太痛苦了

import { PrerequisiteStore } from "../store/prerequisiteStore";describe("PrerequisiteStore", () => {let store: PrerequisiteStore;beforeEach(() => {store = new PrerequisiteStore();});test("should subscribe and unsubscribe listeners", () => {const listener = jest.fn();const unsubscribe = store.subscribe(listener);store.setPrerequisite("test");expect(listener).toHaveBeenCalledTimes(1);// 这里注意每个 subscribe 会返回的那个函数// 调用后就会 unsubscribe 当前行为unsubscribe();store.setPrerequisite("new test");expect(listener).toHaveBeenCalledTimes(1);});test("should return the current state with getSnapshot", () => {expect(store.getSnapshot()).toBeUndefined();store.setPrerequisite("test");expect(store.getSnapshot()).toBe("test");});test("should notify listeners when state changes", () => {const listener1 = jest.fn();const listener2 = jest.fn();store.subscribe(listener1);store.subscribe(listener2);store.setPrerequisite("test");expect(listener1).toHaveBeenCalledTimes(1);expect(listener2).toHaveBeenCalledTimes(1);});test("should handle initialization correctly", () => {const initListener = jest.fn();store.onInitialized(initListener);store.setPrerequisite("test");expect(initListener).toHaveBeenCalledTimes(1);const anotherInitListener = jest.fn();store.onInitialized(anotherInitListener);expect(anotherInitListener).toHaveBeenCalledTimes(1);});test("should clear initListeners after initialization", () => {const initListener = jest.fn();store.onInitialized(initListener);store.setPrerequisite("test");expect(initListener).toHaveBeenCalledTimes(1);store.setPrerequisite("new test");expect(initListener).toHaveBeenCalledTimes(1);});test("should handle multiple initialization listeners correctly", () => {const initListener1 = jest.fn();const initListener2 = jest.fn();store.onInitialized(initListener1);store.onInitialized(initListener2);store.setPrerequisite("test");expect(initListener1).toHaveBeenCalledTimes(1);expect(initListener2).toHaveBeenCalledTimes(1);});
});

event emitter

这里新增一下 event emitter 的实现:

class EventEmitter {private events: { [key: string]: Set<Function> } = {};on(event: string, listener: Function) {if (!this.events[event]) {this.events[event] = new Set();}this.events[event].add(listener);}off(event: string, listener: Function) {if (!this.events[event]) return;this.events[event].delete(listener);}emit(event: string, ...args: any[]) {if (!this.events[event]) return;for (const listener of this.events[event]) {listener(...args);}}
}const eventEmitter = new EventEmitter();
export default eventEmitter;

调用方法也很简单,在 schema 中实现:

eventEmitter.on("prerequisiteChange", updateDemoSchema);

app 中更新代码如下:

useEffect(() => {console.log("Prerequisite Store changed:",prerequisiteStore,new Date().toISOString());if (prerequisiteStore) {const res = demoSchema.cast({});demoSchema.validate(res).then((validatedRes) => console.log(validatedRes)).catch((e: ValidationError) => {console.log("Validation error:", e.path, e.message);});}
}, [prerequisiteStore]);

这样就可以有效的剥离 data schema 和 react component 之间的关系,而是通过事件触发进行正常的更新

最后渲染结果如下:

在这里插入图片描述

有的时候就不得不感叹 React 和 Angular 越到后面越有种……天下文章一大抄的感觉……

比如说这是之前学习 Angular 的 EventEmitter 的使用:

export class CockpitComponent {@Output() serverCreated = new EventEmitter<Omit<ServerElement, "type">>();@Output() blueprintCreated = new EventEmitter<Omit<ServerElement, "type">>();newServerName = "";newServerContent = "";onAddServer() {this.serverCreated.emit({name: this.newServerName,content: this.newServerContent,});}onAddBlueprint() {this.blueprintCreated.emit({name: this.newServerName,content: this.newServerContent,});}
}

学了一下 Angular 还真有助于理解 18 这个新 hook 的运用和延伸……

我感觉下意识的选择 class 可能也是受到了一点 Angular 的影响……

相关文章:

  • 北京网站建设多少钱?
  • 辽宁网页制作哪家好_网站建设
  • 高端品牌网站建设_汉中网站制作
  • 华为OD机考题(HJ90 合法IP)
  • Laravel Passport:API认证的瑞士军刀
  • python 内置类型简述(4) —— 集合映射类(set、frozenset、dict)
  • 蓝凌OA 文件Copy导致远程代码执行漏洞复现(XVE-2023-18344)
  • MyBatis的原理?
  • Vim(Vi IMproved)
  • 2.设计模式--创建者模式--单例设计模式
  • docker 容器内部UI映射host
  • STM32智能工业自动化监控系统教程
  • 科普文:详解23种设计模式
  • 代码随想录——分割等和子集(Leetcode LCR 101)
  • 【STC89C51单片机】定时器/计数器的理解
  • Lianwei 安全周报|2024.07.15
  • LLM 构建Data Multi-Agents 赋能数据分析平台的实践之④:数据分析之三(数据展示)
  • Jenkins 安装、部署与配置
  • 《Java8实战》-第四章读书笔记(引入流Stream)
  • Debian下无root权限使用Python访问Oracle
  • java8-模拟hadoop
  • js操作时间(持续更新)
  • React中的“虫洞”——Context
  • React组件设计模式(一)
  • 从tcpdump抓包看TCP/IP协议
  • 后端_MYSQL
  • 紧急通知:《观止-微软》请在经管柜购买!
  • 罗辑思维在全链路压测方面的实践和工作笔记
  • 时间复杂度与空间复杂度分析
  • 数据可视化之 Sankey 桑基图的实现
  • 学习笔记:对象,原型和继承(1)
  • kubernetes资源对象--ingress
  • puppet连载22:define用法
  • 如何用纯 CSS 创作一个菱形 loader 动画
  • #include
  • #我与Java虚拟机的故事#连载06:收获颇多的经典之作
  • $forceUpdate()函数
  • (13):Silverlight 2 数据与通信之WebRequest
  • (6)【Python/机器学习/深度学习】Machine-Learning模型与算法应用—使用Adaboost建模及工作环境下的数据分析整理
  • (arch)linux 转换文件编码格式
  • (done) 两个矩阵 “相似” 是什么意思?
  • (el-Transfer)操作(不使用 ts):Element-plus 中 Select 组件动态设置 options 值需求的解决过程
  • (Spark3.2.0)Spark SQL 初探: 使用大数据分析2000万KF数据
  • (web自动化测试+python)1
  • (不用互三)AI绘画工具应该如何选择
  • (二十六)Java 数据结构
  • (附源码)springboot教学评价 毕业设计 641310
  • (附源码)计算机毕业设计SSM疫情下的学生出入管理系统
  • (蓝桥杯每日一题)平方末尾及补充(常用的字符串函数功能)
  • (离散数学)逻辑连接词
  • (区间dp) (经典例题) 石子合并
  • (三)终结任务
  • (三维重建学习)已有位姿放入colmap和3D Gaussian Splatting训练
  • (数据结构)顺序表的定义
  • (一)、软硬件全开源智能手表,与手机互联,标配多表盘,功能丰富(ZSWatch-Zephyr)
  • (已解决)vue+element-ui实现个人中心,仿照原神
  • ***详解账号泄露:全球约1亿用户已泄露
  • .NET Project Open Day(2011.11.13)