自定义 Hook 与外部 Store:共享逻辑还是共享数据
问题:两个组件调用同一个 Hook,会得到同一份 state 吗
自定义 Hook 把 Hook 调用与业务规则提取成可复用函数。每个组件调用它时,仍得到属于该组件的 Hook state。若数据真的存在 React 外部,并能由非 React 代码修改,就需要明确的 subscribe/getSnapshot 协议接入。
完整实验:两个组件订阅同一个外部计数器
import { useState, useSyncExternalStore } from 'react'
function createCounterStore(initialValue) {
let value = initialValue
const listeners = new Set()
return {
getSnapshot() {
return value
},
subscribe(listener) {
listeners.add(listener)
return () => listeners.delete(listener)
},
increment() {
value += 1
listeners.forEach(listener => listener())
},
reset() {
value = initialValue
listeners.forEach(listener => listener())
},
}
}
const counterStore = createCounterStore(0)
function useSharedCount() {
return useSyncExternalStore(
counterStore.subscribe,
counterStore.getSnapshot,
() => 0,
)
}
function SharedCounter({ label }) {
const count = useSharedCount()
return <p>{label} 看到共享值:{count}</p>
}
function useIndependentCount(initialValue) {
const [count, setCount] = useState(initialValue)
return { count, increment: () => setCount(value => value + 1) }
}
function IndependentCounter({ label }) {
const counter = useIndependentCount(0)
return <button onClick={counter.increment}>{label}:{counter.count}</button>
}
export default function App() {
return (
<main>
<h1>共享数据与复用逻辑</h1>
<button onClick={() => counterStore.increment()}>增加外部 Store</button>
<button onClick={() => counterStore.reset()}>重置外部 Store</button>
<SharedCounter label="A" />
<SharedCounter label="B" />
<hr />
<IndependentCounter label="独立 X" />
<IndependentCounter label="独立 Y" />
</main>
)
}点“增加外部 Store”,A 与 B 同时变化,因为两次 Hook 调用订阅同一个模块级 Store。点“独立 X”,Y 不变:自定义 Hook 复用了状态逻辑,却为每个调用创建独立 state。
useSyncExternalStore 的三方契约
subscribe(listener)登记变化通知,并返回退订函数;组件卸载或订阅函数改变时 React 会清理。getSnapshot()返回渲染所需快照。Store 未变化时,重复调用必须返回Object.is相同的值。- 可选
getServerSnapshot()给服务端渲染与 hydration 的首次读取。本例返回0;服务端值与客户端水合时的值必须一致。
subscribe 与 getSnapshot 定义在组件外,函数身份稳定,不会每次渲染都触发重新订阅。数字快照天然不可变;对象/数组快照则必须在数据未变时复用缓存引用,只在 Store 真正变化时创建新对象。
自定义 Hook 的规则没有例外
名称以 use 开头,内部可以调用其他 Hook;它只能在组件或另一 Hook 的顶层调用。自定义 Hook 可以返回任意清晰的接口。若函数根本不调用 Hook,就用普通函数名,不要用 use 暗示它受 Hook 调用顺序约束。
封装不能抹掉所有权。useSharedCount 的名字与实现明确说明数据在外部 Store;useIndependentCount 则明确每个调用独立。如果一个 Hook 同时偷建全局单例、发请求和改 DOM,调用者会很难判断生命周期责任。
改一个条件:每次返回新对象
下面的快照会在每次读取时创建新对象,即使值没变:
function getSnapshot() {
return { value }
}React 会认为快照持续变化,可能产生重复渲染并报告需要缓存。修复方式不是忽略警告,而是让 Store 在变化时生成并保存不可变快照,未变化时返回同一对象。
下面把数字快照改为对象,先作为独立 JavaScript 实验运行:
function createSnapshotStore() {
let snapshot = Object.freeze({ count: 0 })
const listeners = new Set()
return {
getSnapshot() { return snapshot },
subscribe(listener) {
listeners.add(listener)
return () => listeners.delete(listener)
},
increment() {
snapshot = Object.freeze({ count: snapshot.count + 1 })
listeners.forEach(listener => listener())
},
}
}
function check(condition, message) {
if (!condition) throw new Error(message)
}
const store = createSnapshotStore()
const first = store.getSnapshot()
check(store.getSnapshot() === first, '未变化时应复用同一快照')
let notifications = 0
const unsubscribe = store.subscribe(() => { notifications += 1 })
store.increment()
const second = store.getSnapshot()
check(second !== first && second.count === 1, '变化时产生新快照')
check(first.count === 0, '已经发出的旧快照不得被改写')
check(store.getSnapshot() === second && notifications === 1, '新快照也应保持稳定')
unsubscribe()
store.increment()
check(notifications === 1, '退订后不再收到通知')对象只在 increment 真正改变数据时创建。Object.freeze 在这个只有数字字段的实验里阻止消费者修改已发出的快照;若对象有嵌套字段,还要保持嵌套数据不可变,浅冻结并不代替第二章的变化路径复制。接回组件时可以读取返回对象的 count,但不要在 getSnapshot 内再包一层新对象。
本篇模块级 Store 是单个浏览器页面的共享数据。第三个参数返回 0 只演示服务端初始快照契约;它不会自动解决多用户服务端隔离。如果服务端跨请求复用这个可变单例,就可能混合不同请求的数据。服务端应按请求建立所属数据,并把一致的初始快照交给客户端,具体接入放到服务端渲染章节。
什么时候不该建外部 Store
React 官方建议:应用完全由 React 构建时,优先使用 useState 或 useReducer。外部 Store 主要用于整合既有非 React 数据源、浏览器可订阅 API,或必须跨多个 React root/非 React 调用者共享的数据。
Context 运输树内值,外部 Store 拥有 React 外数据,自定义 Hook 复用读取/订阅逻辑。它们可以组合,但职责不能互相冒充。
边界
- 通知 listener 只表示“可能变化”,React 会再次调用
getSnapshot并用Object.is判断是否渲染。 - 修改外部可变对象后仍返回同一引用,React 看不到变化;始终返回新引用又会制造假变化。
- 浏览器 API 在服务端不存在;涉及 SSR 时必须提供可一致的 server snapshot,或明确只在客户端渲染。
- 退订必须移除同一个 listener;不清理会造成卸载后通知和内存泄漏。
练习:外部选择 Store
实现完整 App.jsx,提供 createSelectionStore(initialId)、模块级 selectionStore、useSelection() 和两个消费者。
契约:
- Store 暴露
getSnapshot、subscribe、select(id);只接受red | green | blue,非法 id 抛RangeError; - 重复选择当前 id 不通知 listener;实际变化恰好通知每个 listener 一次;退订后不再通知;
- 快照是字符串,未变化时自然稳定;
useSelection用useSyncExternalStore接入,SSR 快照为red; - 页面中的按钮与两个预览读取同一 Store;模块加载时用抛错断言验证通知和退订契约。
完成后对照答案 03。
下一篇
引用身份与缓存:从稳定快照继续研究 prop 与函数的身份,区分数据正确性和渲染优化。