364 lines
12 KiB
TypeScript
364 lines
12 KiB
TypeScript
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]?.()
|
|
}
|
|
}
|
|
}
|