feat(preferences): add extensible preference store
This commit is contained in:
@@ -0,0 +1,363 @@
|
||||
import React from "react"
|
||||
|
||||
/**
|
||||
* 应用自定义偏好的类型扩展入口。
|
||||
*
|
||||
* 消费方可以使用 TypeScript 模块增强添加偏好,而不需要修改此 package:
|
||||
*
|
||||
* ```ts
|
||||
* import "@workspace/preferences"
|
||||
*
|
||||
* declare module "@workspace/preferences" {
|
||||
* interface PreferencesCustom {
|
||||
* "navigation-density": "comfortable" | "compact"
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export interface PreferencesCustom {}
|
||||
|
||||
/**
|
||||
* Provider 管理的完整偏好集合。
|
||||
*
|
||||
* 独立 package 不预设业务字段;所有字段均由消费方通过 `PreferencesCustom` 声明。
|
||||
* 模块增强后,键、值、更新事件和 hooks 会自动获得相应类型。
|
||||
*/
|
||||
export type Preferences = PreferencesCustom
|
||||
|
||||
/** 完整偏好集合中的任意键。 */
|
||||
export type PreferenceKey = keyof Preferences
|
||||
|
||||
/** 根据偏好键取得对应值类型。 */
|
||||
export type PreferenceValue<Key extends PreferenceKey> = Preferences[Key]
|
||||
|
||||
/**
|
||||
* 单个偏好字段的持久化与运行时校验规则。
|
||||
*
|
||||
* `PreferencesProvider` 本身不读写 Cookie;该定义供服务器加载初始偏好、持久化
|
||||
* 更新和校验未知输入时复用。
|
||||
*/
|
||||
export type PreferenceDefinition<T> = {
|
||||
/** 保存该字段时使用的 Cookie 名称。 */
|
||||
cookie: string
|
||||
/** Cookie 不存在或内容无效时使用的默认值。 */
|
||||
defaultValue: T
|
||||
/** 验证未知输入是否是当前字段允许的值。 */
|
||||
is: (value: unknown) => value is T
|
||||
/** 将 Cookie 字符串解析为偏好值。 */
|
||||
parse: (value: string | undefined) => T
|
||||
/** 将偏好值序列化为可写入 Cookie 的字符串。 */
|
||||
serialize: (value: T) => string
|
||||
}
|
||||
|
||||
/** 确保偏好集合中的每个字段都有相同值类型的定义。 */
|
||||
export type PreferenceDefinitions<TPreferences extends object> = {
|
||||
[K in keyof TPreferences]: PreferenceDefinition<TPreferences[K]>
|
||||
}
|
||||
|
||||
/**
|
||||
* 将偏好集合转换为由 `{ key, value }` 组成的可辨识联合类型。
|
||||
*
|
||||
* 例如 `{ enabled: boolean; mode: "a" | "b" }` 会得到:
|
||||
* `{ key: "enabled"; value: boolean } | { key: "mode"; value: "a" | "b" }`。
|
||||
*/
|
||||
export type PreferenceUpdateFor<TPreferences extends object> = {
|
||||
[K in keyof TPreferences]: { key: K; value: TPreferences[K] }
|
||||
}[keyof TPreferences]
|
||||
|
||||
/** 当前完整偏好集合允许发送给持久化处理器的更新。 */
|
||||
export type PreferenceUpdate = PreferenceUpdateFor<Preferences>
|
||||
|
||||
/**
|
||||
* 外部偏好存储的最小接口。
|
||||
*
|
||||
* 它符合 `useSyncExternalStore` 对订阅、客户端快照和服务端快照的要求。
|
||||
*/
|
||||
export type PreferenceStore<TPreferences extends object = Preferences> = {
|
||||
/** 返回当前最新偏好;内容未变化时必须保持引用稳定。 */
|
||||
getSnapshot: () => TPreferences
|
||||
/** 返回 SSR 与首次 hydration 使用的偏好。 */
|
||||
getServerSnapshot: () => TPreferences
|
||||
/** 更新一个偏好;实际发生变化时返回 `true`。 */
|
||||
setPreference: <K extends keyof TPreferences>(
|
||||
key: K,
|
||||
value: TPreferences[K]
|
||||
) => boolean
|
||||
/** 订阅偏好变化,并返回取消订阅函数。 */
|
||||
subscribe: (listener: VoidFunction) => VoidFunction
|
||||
}
|
||||
|
||||
/** 每个偏好副作用收到的只读上下文。 */
|
||||
export type PreferenceEffectContext = Readonly<{
|
||||
/**
|
||||
* 上一次执行同阶段副作用时的快照。
|
||||
* 首次执行时与 `preferences` 相同,因此无需处理 `undefined`。
|
||||
*/
|
||||
previousPreferences: Preferences
|
||||
/** 当前偏好快照。 */
|
||||
preferences: Preferences
|
||||
/** 可用于事件回调中读取最新偏好的稳定 store 实例。 */
|
||||
store: PreferenceStore
|
||||
}>
|
||||
|
||||
/** 偏好副作用回调;返回函数时,该函数会作为 React effect cleanup 执行。 */
|
||||
export type PreferenceEffectCallback = (
|
||||
context: PreferenceEffectContext
|
||||
) => VoidFunction | void
|
||||
|
||||
/**
|
||||
* 可注入 `PreferencesProvider` 的副作用集合。
|
||||
*
|
||||
* - `layoutEffect` 在 DOM 更新后、浏览器绘制前运行,适合同步 class、属性和 CSS。
|
||||
* - `effect` 在绘制后运行,适合事件监听、媒体查询和其他外部订阅。
|
||||
*
|
||||
* 两种回调都会在偏好或 `effects` 引用变化时重新执行。调用方应传入稳定的
|
||||
* `effects` 数组,例如定义在组件外部或通过 `useMemo` 创建。
|
||||
*/
|
||||
export type PreferenceEffect = Readonly<{
|
||||
effect?: PreferenceEffectCallback
|
||||
layoutEffect?: PreferenceEffectCallback
|
||||
}>
|
||||
|
||||
/**
|
||||
* 创建单个 Provider 私有的外部 store。
|
||||
*
|
||||
* `initialPreferences` 同时作为固定的服务端快照,确保 SSR 与 hydration 读取一致。
|
||||
*/
|
||||
function createPreferenceStore(
|
||||
initialPreferences: Preferences
|
||||
): PreferenceStore {
|
||||
let snapshot = initialPreferences
|
||||
const listeners = new Set<VoidFunction>()
|
||||
|
||||
return {
|
||||
getSnapshot: () => snapshot,
|
||||
getServerSnapshot: () => initialPreferences,
|
||||
setPreference: (key, value) => {
|
||||
// Object.is 可以正确处理 NaN,并避免等值更新触发无意义的渲染和持久化。
|
||||
if (Object.is(snapshot[key], value)) return false
|
||||
|
||||
// 始终生成新对象,使 useSyncExternalStore 能通过引用识别偏好变化。
|
||||
snapshot = { ...snapshot, [key]: value }
|
||||
listeners.forEach((listener) => listener())
|
||||
return true
|
||||
},
|
||||
subscribe: (listener) => {
|
||||
listeners.add(listener)
|
||||
return () => listeners.delete(listener)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 偏好持久化回调。允许同步或异步实现,例如写 Cookie、调用服务端函数或发送请求。
|
||||
*/
|
||||
export type PreferenceChangeHandler = (
|
||||
update: PreferenceUpdate
|
||||
) => Promise<void> | void
|
||||
|
||||
/** Provider 向所有偏好 hooks 暴露的内部上下文。 */
|
||||
interface PreferencesContextValue {
|
||||
onPreferenceChange?: PreferenceChangeHandler
|
||||
store: PreferenceStore
|
||||
}
|
||||
|
||||
const PreferencesContext = React.createContext<PreferencesContextValue | null>(
|
||||
null
|
||||
)
|
||||
|
||||
// 使用模块级空数组保持默认 effects 引用稳定,避免每次渲染重复执行副作用。
|
||||
const noPreferenceEffects: readonly PreferenceEffect[] = []
|
||||
|
||||
/** `PreferencesProvider` 的属性。 */
|
||||
export type PreferencesProviderProps = {
|
||||
children: React.ReactNode
|
||||
/** 按数组顺序执行的偏好副作用。 */
|
||||
effects?: readonly PreferenceEffect[]
|
||||
/**
|
||||
* SSR 与客户端首次渲染使用的完整偏好。
|
||||
* Store 只在 Provider 首次渲染时创建,之后修改该属性不会重置已有偏好。
|
||||
*/
|
||||
initialPreferences: Preferences
|
||||
/** 偏好实际变化后触发的持久化回调。 */
|
||||
onPreferenceChange?: PreferenceChangeHandler
|
||||
}
|
||||
|
||||
/** 提供类型安全的外部偏好存储,并运行与偏好关联的可组合副作用。 */
|
||||
export function PreferencesProvider({
|
||||
children,
|
||||
effects = noPreferenceEffects,
|
||||
initialPreferences,
|
||||
onPreferenceChange,
|
||||
}: PreferencesProviderProps) {
|
||||
const storeRef = React.useRef<PreferenceStore | null>(null)
|
||||
|
||||
if (storeRef.current === null) {
|
||||
storeRef.current = createPreferenceStore(initialPreferences)
|
||||
}
|
||||
|
||||
const store = storeRef.current
|
||||
const preferences = React.useSyncExternalStore(
|
||||
store.subscribe,
|
||||
store.getSnapshot,
|
||||
store.getServerSnapshot
|
||||
)
|
||||
|
||||
// 两个 React effect 的执行时机不同,因此分别保存各自上一次看到的偏好。
|
||||
const previousLayoutPreferencesRef = React.useRef(preferences)
|
||||
const previousPreferencesRef = React.useRef(preferences)
|
||||
|
||||
// 适合在浏览器绘制前,把偏好同步到 document、DOM 或 CSS。
|
||||
React.useLayoutEffect(() => {
|
||||
const previousPreferences = previousLayoutPreferencesRef.current
|
||||
previousLayoutPreferencesRef.current = preferences
|
||||
|
||||
return runPreferenceEffects(effects, "layoutEffect", {
|
||||
previousPreferences,
|
||||
preferences,
|
||||
store,
|
||||
})
|
||||
}, [effects, preferences, store])
|
||||
|
||||
// 适合注册外部订阅;runPreferenceEffects 会统一收集并执行 cleanup。
|
||||
React.useEffect(() => {
|
||||
const previousPreferences = previousPreferencesRef.current
|
||||
previousPreferencesRef.current = preferences
|
||||
|
||||
return runPreferenceEffects(effects, "effect", {
|
||||
previousPreferences,
|
||||
preferences,
|
||||
store,
|
||||
})
|
||||
}, [effects, preferences, store])
|
||||
|
||||
const contextValue = React.useMemo(
|
||||
() => ({ onPreferenceChange, store }),
|
||||
[onPreferenceChange, store]
|
||||
)
|
||||
|
||||
return (
|
||||
<PreferencesContext.Provider value={contextValue}>
|
||||
{children}
|
||||
</PreferencesContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 订阅并返回完整偏好快照。
|
||||
*
|
||||
* 任意字段变化都会使使用该 hook 的组件重新渲染;只需要单个字段时可以使用
|
||||
* `usePreference` 获得更精确的类型和更新 API。当前两个 hook 都订阅完整快照。
|
||||
*/
|
||||
export function usePreferences() {
|
||||
const { store } = usePreferencesContext()
|
||||
|
||||
return React.useSyncExternalStore(
|
||||
store.subscribe,
|
||||
store.getSnapshot,
|
||||
store.getServerSnapshot
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取并更新一个指定偏好,返回值形式与 `useState` 一致。
|
||||
*
|
||||
* setter 同时支持直接值和函数式更新。只有值真正变化时才会通知订阅者并调用
|
||||
* `onPreferenceChange`;异步持久化结果不会阻塞本地更新。
|
||||
*/
|
||||
export function usePreference<Key extends PreferenceKey>(
|
||||
key: Key
|
||||
): [
|
||||
PreferenceValue<Key>,
|
||||
React.Dispatch<React.SetStateAction<PreferenceValue<Key>>>,
|
||||
] {
|
||||
const { onPreferenceChange, store } = usePreferencesContext()
|
||||
const preferences = React.useSyncExternalStore(
|
||||
store.subscribe,
|
||||
store.getSnapshot,
|
||||
store.getServerSnapshot
|
||||
)
|
||||
const value = preferences[key]
|
||||
|
||||
const setPreference = React.useCallback<
|
||||
React.Dispatch<React.SetStateAction<PreferenceValue<Key>>>
|
||||
>(
|
||||
(action) => {
|
||||
const currentValue = store.getSnapshot()[key]
|
||||
const nextValue =
|
||||
typeof action === "function" ? action(currentValue) : action
|
||||
|
||||
// 等值更新由 store 拒绝,因此也不会触发外部持久化处理器。
|
||||
if (!store.setPreference(key, nextValue)) return
|
||||
void onPreferenceChange?.({ key, value: nextValue } as PreferenceUpdate)
|
||||
},
|
||||
[key, onPreferenceChange, store]
|
||||
)
|
||||
|
||||
return [value, setPreference]
|
||||
}
|
||||
|
||||
/** 根据字段定义生成完整的默认偏好对象。 */
|
||||
export function createDefaultPreferences<TPreferences extends object>(
|
||||
definitions: PreferenceDefinitions<TPreferences>
|
||||
): TPreferences {
|
||||
return Object.fromEntries(
|
||||
(Object.keys(definitions) as Array<keyof TPreferences>).map((key) => [
|
||||
key,
|
||||
definitions[key].defaultValue,
|
||||
])
|
||||
) as TPreferences
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据字段定义创建运行时类型守卫,用于校验来自请求、表单或消息通道的未知更新。
|
||||
*/
|
||||
export function createPreferenceUpdateGuard<TPreferences extends object>(
|
||||
definitions: PreferenceDefinitions<TPreferences>
|
||||
) {
|
||||
return (value: unknown): value is PreferenceUpdateFor<TPreferences> => {
|
||||
if (!value || typeof value !== "object") return false
|
||||
|
||||
const update = value as { key?: unknown; value?: unknown }
|
||||
if (
|
||||
typeof update.key !== "string" ||
|
||||
!Object.hasOwn(definitions, update.key)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
const key = update.key as keyof TPreferences
|
||||
return definitions[key].is(update.value)
|
||||
}
|
||||
}
|
||||
|
||||
/** 读取内部上下文,并为 Provider 缺失的情况提供明确错误。 */
|
||||
function usePreferencesContext() {
|
||||
const context = React.useContext(PreferencesContext)
|
||||
if (!context) {
|
||||
throw new Error("Preference hooks must be used within PreferencesProvider")
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
/**
|
||||
* 按声明顺序执行指定阶段的所有副作用,并组合它们返回的 cleanup。
|
||||
* cleanup 使用相反顺序运行,与嵌套资源通常采用的后进先出释放顺序一致。
|
||||
*/
|
||||
function runPreferenceEffects(
|
||||
effects: readonly PreferenceEffect[],
|
||||
phase: "effect" | "layoutEffect",
|
||||
context: PreferenceEffectContext
|
||||
) {
|
||||
const cleanups = effects
|
||||
.map((effect) => effect[phase]?.(context))
|
||||
.filter((cleanup): cleanup is VoidFunction => typeof cleanup === "function")
|
||||
|
||||
if (cleanups.length === 0) return
|
||||
|
||||
return () => {
|
||||
for (let index = cleanups.length - 1; index >= 0; index -= 1) {
|
||||
cleanups[index]?.()
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user