[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 的影响……