From dd7a49504ff618d768c5da54fcef00e6450a13cf Mon Sep 17 00:00:00 2001 From: SpecialX <47072643+wangxiner55@users.noreply.github.com> Date: Sun, 5 Jul 2026 18:15:58 +0800 Subject: [PATCH] =?UTF-8?q?refactor(hooks):=20useActionQuery/useActionMuta?= =?UTF-8?q?tion=20=E6=8E=A5=E5=85=A5=20QueryClient=20+=20=E5=90=91?= =?UTF-8?q?=E5=90=8E=E5=85=BC=E5=AE=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - useActionQuery 新增 queryKey 入参模式:传入 queryKey 走 useQuery 跨页共享缓存;不传回退旧 useEffect + useState 模式。Hook 内部始终声明 useQuery/useState/useEffect 以遵守 React Hooks 规则,通过 isCacheMode 切换启用状态 - useActionMutation 新增 mutationFn + actionId 模式:成功后按 CLIENT_INVALIDATION_MAP[actionId] 自动 invalidateQueries;mutate(action?) 参数可选 - toast 替换为 notify(行为等价,便于未来替换 toast 库) - 同步更新 004/005 架构文档签名说明 --- .../004_architecture_impact_map.md | 4 +- docs/architecture/005_architecture_data.json | 8 +- src/shared/hooks/use-action-mutation.ts | 143 +++++++++++++++--- src/shared/hooks/use-action-query.ts | 134 ++++++++++++---- 4 files changed, 235 insertions(+), 54 deletions(-) diff --git a/docs/architecture/004_architecture_impact_map.md b/docs/architecture/004_architecture_impact_map.md index 84ffe8b..e539ff1 100644 --- a/docs/architecture/004_architecture_impact_map.md +++ b/docs/architecture/004_architecture_impact_map.md @@ -691,8 +691,8 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions" | Hook | 文件 | 签名 | 用途 | 消费方 | |------|------|------|------|--------| -| `useActionMutation` | `hooks/use-action-mutation.ts` | `useActionMutation(options?): { isWorking, mutate }` | 通用 Server Action mutation Hook,替代 50+ 文件中重复的 setIsWorking + try/catch/finally + toast 模式 | 1 个示范(P1-4: schools-view),潜在影响 50+ 文件 | -| `useActionQuery` | `hooks/use-action-query.ts` | `useActionQuery(action, options?): { data, loading, error, refetch }` | 通用 Server Action 查询 Hook,替代 11 个文件中重复的 useEffect + useState(loading) + Action().then().catch().finally() 模式,内置竞态防护 | 1 个示范(P1-4: create-question-dialog),潜在影响 11 个文件 | +| `useActionMutation` | `hooks/use-action-mutation.ts` | `useActionMutation(options?): { isWorking, mutate }`。Options 联合类型:旧版 `{ successMessage?, errorMessage?, onSuccess?, onError? }` 或新版 `{ mutationFn?, actionId?, params?, invalidateQueryKeys?, ... }`。新版接入 QueryClient,成功后按 `CLIENT_INVALIDATION_MAP[actionId]` 自动 invalidateQueries。`mutate(action?)` 参数可选:传 action 走旧模式,不传则使用 options.mutationFn | 通用 Server Action mutation Hook,替代 50+ 文件中重复的 setIsWorking + try/catch/finally + toast 模式。V5 缓存策略重构新增 QueryClient + actionId 自动失效,向后兼容旧调用方 | 1 个示范(P1-4: schools-view),潜在影响 50+ 文件 | +| `useActionQuery` | `hooks/use-action-query.ts` | `useActionQuery(action, options?): { data, loading, error, refetch }`。Options 联合类型:旧版 `{ deps?, enabled?, errorMessage? }` 或新版 `UseActionQueryWithCacheOptions`(含 `queryKey` + `UseQueryOptions`)。传入 `queryKey` 走 useQuery 跨页共享缓存;不传走旧 useEffect + useState。Hook 内部始终声明 useQuery + useState/useEffect 以遵守 React Hooks 规则 | 通用 Server Action 查询 Hook,替代 11 个文件中重复的 useEffect + useState(loading) + Action().then().catch().finally() 模式,内置竞态防护。V5 缓存策略重构新增 queryKey 入参走 QueryClient,向后兼容旧调用方 | 1 个示范(P1-4: create-question-dialog),潜在影响 11 个文件 | **V5 状态管理统一专项基础设施**(阶段 1 新增,零业务行为变更): diff --git a/docs/architecture/005_architecture_data.json b/docs/architecture/005_architecture_data.json index 7f58482..71b9daf 100644 --- a/docs/architecture/005_architecture_data.json +++ b/docs/architecture/005_architecture_data.json @@ -1495,8 +1495,8 @@ { "name": "useActionMutation", "file": "hooks/use-action-mutation.ts", - "signature": "useActionMutation(options?: { successMessage?, errorMessage?, onSuccess?, onError? }): { isWorking: boolean; mutate: (action: () => Promise>) => Promise | undefined> }", - "purpose": "通用 Server Action mutation Hook,P1-4 重构从 50+ 个文件中重复的 setIsWorking(true) + try/catch/finally + toast 模式抽取。支持 successMessage/errorMessage/onSuccess/onError 配置", + "signature": "useActionMutation(options?: UseActionMutationOptions): { isWorking: boolean; mutate: (action?: () => Promise>) => Promise | undefined> }。Options 为联合类型:旧版 { successMessage?, errorMessage?, onSuccess?, onError? } 或新版 { mutationFn?, actionId?, params?, invalidateQueryKeys?, successMessage?, errorMessage?, onSuccess?, onError? }。新版接入 QueryClient,成功后按 CLIENT_INVALIDATION_MAP[actionId] 自动 invalidateQueries。mutate 参数可选:传 action 走旧模式,不传则使用 options.mutationFn", + "purpose": "通用 Server Action mutation Hook,P1-4 重构从 50+ 个文件中重复的 setIsWorking(true) + try/catch/finally + toast 模式抽取。V5 缓存策略重构:接入 QueryClient + actionId 自动 invalidate,向后兼容旧调用方(mutate(action) 仍可用)", "usedBy": [ "school/components/school-form-dialog.tsx", "school/components/school-delete-dialog.tsx" @@ -1505,8 +1505,8 @@ { "name": "useActionQuery", "file": "hooks/use-action-query.ts", - "signature": "useActionQuery(action: () => Promise>, options?: { deps?, enabled?, errorMessage? }): { data, loading, error, refetch }", - "purpose": "通用 Server Action 查询 Hook,P1-4 重构从 11 个文件中重复的 useEffect + useState(loading) + Action().then().catch().finally() 模式抽取。内置竞态防护(cancelled flag)", + "signature": "useActionQuery(action: () => Promise>, options?: UseActionQueryOptions): { data, loading, error, refetch }。Options 为联合类型:旧版 { deps?, enabled?, errorMessage? } 或新版 UseActionQueryWithCacheOptions(含 queryKey + UseQueryOptions)。传入 queryKey 走 useQuery 跨页共享缓存;不传走旧 useEffect + useState。Hook 内部始终声明 useQuery + useState/useEffect 以遵守 React Hooks 规则", + "purpose": "通用 Server Action 查询 Hook,P1-4 重构从 11 个文件中重复的 useEffect + useState(loading) + Action().then().catch().finally() 模式抽取。V5 缓存策略重构:新增 queryKey 入参走 QueryClient,向后兼容旧调用方。内置竞态防护(requestId ref)", "usedBy": [ "questions/components/create-question-dialog.tsx" ] diff --git a/src/shared/hooks/use-action-mutation.ts b/src/shared/hooks/use-action-mutation.ts index ec5bfde..b7d3514 100644 --- a/src/shared/hooks/use-action-mutation.ts +++ b/src/shared/hooks/use-action-mutation.ts @@ -1,10 +1,13 @@ "use client" import { useCallback, useState } from "react" -import { toast } from "sonner" +import { useQueryClient } from "@tanstack/react-query" +import { notify } from "@/shared/lib/notify" +import { CLIENT_INVALIDATION_MAP } from "@/shared/lib/cache/client-invalidation-map" import type { ActionState } from "@/shared/types/action-state" -export interface UseActionMutationOptions { +/** 旧版选项(向后兼容,无 mutationFn / actionId) */ +export interface UseActionMutationLegacyOptions { /** 成功时显示的 toast 文案。不传则使用 result.message。传 false 则不显示。 */ successMessage?: string | false /** 失败时显示的 toast 文案。不传则使用 result.message。传 false 则不显示。 */ @@ -15,51 +18,146 @@ export interface UseActionMutationOptions { onError?: (error: unknown) => void } +/** 新版选项(含 mutationFn + actionId 自动 invalidate) */ +export interface UseActionMutationWithCacheOptions { + /** mutation 函数(新模式必填) */ + mutationFn?: () => Promise> + /** 关联的 INVALIDATION_MAP actionId,成功后自动失效相关 queryKey */ + actionId?: string + /** 模板参数,如 { id: "cls_123" } */ + params?: Record + /** 显式额外失效的 queryKey(不通过 actionId 时使用) */ + invalidateQueryKeys?: readonly (readonly (string | number | object)[])[] + /** 成功时显示的 toast 文案。不传则使用 result.message。传 false 则不显示。 */ + successMessage?: string | false + /** 失败时显示的 toast 文案。不传则使用 result.message。传 false 则不显示。 */ + errorMessage?: string | false + /** 成功回调 */ + onSuccess?: (data: T | undefined) => void + /** 失败回调(result.success === false 或抛出异常时) */ + onError?: (error: unknown) => void +} + +export type UseActionMutationOptions = + | UseActionMutationLegacyOptions + | UseActionMutationWithCacheOptions + export interface UseActionMutationResult { - /** 是否正在执行(try/catch/finally 期间为 true) */ + /** 是否正在执行 */ isWorking: boolean - /** 执行 mutation。返回 ActionState 以便调用方进一步处理。 */ - mutate: (action: () => Promise>) => Promise | undefined> + /** 执行 mutation。 + * - 旧模式:传入 action 函数 + * - 新模式(options.mutationFn 已设):可不传参数 + */ + mutate: ( + action?: () => Promise>, + ) => Promise | undefined> +} + +function isWithCacheOptions( + opts: UseActionMutationOptions, +): opts is UseActionMutationWithCacheOptions { + return ( + opts !== null && + typeof opts === "object" && + ("mutationFn" in opts || "actionId" in opts || "invalidateQueryKeys" in opts) + ) } /** * 通用 Server Action mutation Hook。 * - * 用于替代各组件中重复的 `setIsWorking(true) + try/catch/finally + toast` 模式。 + * 两种模式: + * - 旧模式:mutate(action),手动传入 action 函数,toast + onSuccess/onError + * - 新模式:options.mutationFn + actionId,自动 invalidateQueries * - * @example - * const { mutate, isWorking } = useActionMutation({ - * onSuccess: () => { setOpen(false); router.refresh() } + * @example 旧模式 + * const { mutate } = useActionMutation({ onSuccess: () => setOpen(false) }) + * const handleDelete = () => mutate(() => deleteAction(id)) + * + * @example 新模式 + * const { mutate } = useActionMutation({ + * mutationFn: () => updateClassAction(input), + * actionId: "classes.update", + * params: { id: input.classId }, * }) - * - * const handleDelete = () => mutate(() => deleteSchoolAction(id)) + * const handleSubmit = () => mutate() */ export function useActionMutation( - options: UseActionMutationOptions = {} + options: UseActionMutationOptions = {}, ): UseActionMutationResult { - const { successMessage, errorMessage, onSuccess, onError } = options + const queryClient = useQueryClient() const [isWorking, setIsWorking] = useState(false) + const { + successMessage, + errorMessage, + onSuccess, + onError, + } = options as UseActionMutationLegacyOptions + + const runInvalidations = useCallback( + async (opts: UseActionMutationWithCacheOptions) => { + const keysToInvalidate: (string | number | object)[][] = [] + if (opts.actionId) { + const rule = ( + CLIENT_INVALIDATION_MAP as Record< + string, + { queryKeys: readonly (readonly (string | number)[])[] } + > + )[opts.actionId] + if (rule) { + for (const qk of rule.queryKeys) { + keysToInvalidate.push([...qk]) + } + } + } + if (opts.invalidateQueryKeys) { + for (const qk of opts.invalidateQueryKeys) { + keysToInvalidate.push([...qk]) + } + } + await Promise.all( + keysToInvalidate.map((qk) => + queryClient.invalidateQueries({ queryKey: qk }), + ), + ) + }, + [queryClient], + ) const mutate = useCallback( - async (action: () => Promise>): Promise | undefined> => { + async ( + action?: () => Promise>, + ): Promise | undefined> => { + const cacheOpts = isWithCacheOptions(options) ? options : null + const actionToRun = action ?? cacheOpts?.mutationFn + if (!actionToRun) { + throw new Error( + "useActionMutation: must provide action to mutate() or set options.mutationFn", + ) + } + setIsWorking(true) try { - const result = await action() + const result = await actionToRun() if (result.success) { if (successMessage !== false) { - toast.success(successMessage ?? result.message ?? "Operation succeeded") + notify.success(successMessage ?? result.message ?? "Operation succeeded") + } + if (cacheOpts) { + await runInvalidations(cacheOpts) } onSuccess?.(result.data) } else { if (errorMessage !== false) { - toast.error(errorMessage ?? result.message ?? "Operation failed") + notify.error(errorMessage ?? result.message ?? "Operation failed") } onError?.(new Error(result.message ?? "Action returned failure")) } return result } catch (error) { if (errorMessage !== false) { - toast.error(errorMessage ?? "Operation failed") + notify.error(errorMessage ?? "Operation failed") } onError?.(error) return undefined @@ -67,7 +165,14 @@ export function useActionMutation( setIsWorking(false) } }, - [successMessage, errorMessage, onSuccess, onError] + [ + options, + successMessage, + errorMessage, + onSuccess, + onError, + runInvalidations, + ], ) return { isWorking, mutate } diff --git a/src/shared/hooks/use-action-query.ts b/src/shared/hooks/use-action-query.ts index b228060..dffc9f9 100644 --- a/src/shared/hooks/use-action-query.ts +++ b/src/shared/hooks/use-action-query.ts @@ -1,10 +1,12 @@ "use client" -import { useEffect, useState } from "react" -import { toast } from "sonner" +import { useEffect, useRef, useState } from "react" +import { useQuery, type UseQueryOptions } from "@tanstack/react-query" +import { notify } from "@/shared/lib/notify" import type { ActionState } from "@/shared/types/action-state" -export interface UseActionQueryOptions { +/** 旧版选项(向后兼容,无 queryKey) */ +export interface UseActionQueryLegacyOptions { /** 依赖数组,默认 []。当依赖变化时重新获取。 */ deps?: ReadonlyArray /** 是否启用查询,默认 true。为 false 时不发起请求。 */ @@ -13,6 +15,19 @@ export interface UseActionQueryOptions { errorMessage?: string | false } +/** 新版选项(传入 queryKey 走 QueryClient) */ +export interface UseActionQueryWithCacheOptions + extends Omit>, "queryKey" | "queryFn"> { + /** queryKey 工厂返回的元组,如 queryKeys.classes.detail(id)。传入后走 useQuery,跨页共享缓存 */ + queryKey: readonly (string | number | object)[] + /** 是否启用,默认 true */ + enabled?: boolean +} + +export type UseActionQueryOptions = + | UseActionQueryLegacyOptions + | UseActionQueryWithCacheOptions + export interface UseActionQueryResult { data: T | undefined loading: boolean @@ -20,65 +35,126 @@ export interface UseActionQueryResult { refetch: () => void } +function isWithCacheOptions( + opts: UseActionQueryOptions, +): opts is UseActionQueryWithCacheOptions { + return opts !== null && typeof opts === "object" && "queryKey" in opts +} + /** * 通用 Server Action 查询 Hook。 * - * 用于替代各组件中重复的 `useEffect + useState(loading) + Action().then().catch().finally()` 模式。 - * 内置竞态防护(通过 ref 跟踪最新请求)。 + * 两种模式: + * - 旧模式(不传 queryKey):useEffect + useState,请求内去重,不跨页共享 + * - 新模式(传 queryKey):useQuery,跨页共享缓存,支持自动 invalidate * - * @example - * const { data, loading } = useActionQuery( - * () => getKnowledgePointOptionsAction(), - * { deps: [open], enabled: open } + * 实现说明:为遵守 React Hooks 规则,useQuery 与 legacy state/effect 始终被声明, + * 通过 `isCacheMode` 切换启用状态,二选一返回结果。 + * + * @example 旧模式 + * const { data } = useActionQuery(() => getXAction(), { deps: [id] }) + * + * @example 新模式 + * const { data } = useActionQuery( + * () => getClassDetailAction({ classId }), + * { queryKey: queryKeys.classes.detail(classId) } * ) */ export function useActionQuery( action: () => Promise>, - options: UseActionQueryOptions = {} + options: UseActionQueryOptions = {}, ): UseActionQueryResult { - const { deps = [], enabled = true, errorMessage = "Failed to load data" } = options + const isCacheMode = isWithCacheOptions(options) + const cacheOpts = (isCacheMode ? options : {}) as Partial> + const legacyOpts = (isCacheMode ? {} : options) as UseActionQueryLegacyOptions + + const { deps = [], enabled: legacyEnabled = true, errorMessage = "Failed to load data" } = legacyOpts + const { queryKey: cacheQueryKey, enabled: cacheEnabledFlag, ...restCache } = cacheOpts + + // useQuery 始终声明;legacy 模式下 enabled:false 避免发起请求 + const query = useQuery>({ + ...restCache, + queryKey: cacheQueryKey ?? ["__useActionQueryLegacy__"], + queryFn: action, + enabled: isCacheMode ? (cacheEnabledFlag ?? true) : false, + }) + + // legacy state 始终声明;cache 模式下 effect 直接 return const [data, setData] = useState(undefined) - const [loading, setLoading] = useState(enabled) const [error, setError] = useState(null) - const [refetchCount, setRefetchCount] = useState(0) + const [loading, setLoading] = useState(false) + const requestIdRef = useRef(0) useEffect(() => { - if (!enabled) return - - let cancelled = false + if (isCacheMode || !legacyEnabled) return + const requestId = ++requestIdRef.current setLoading(true) setError(null) - action() .then((result) => { - if (cancelled) return + if (requestId !== requestIdRef.current) return if (result.success) { setData(result.data) } else { setError(new Error(result.message ?? "Action failed")) if (errorMessage !== false) { - toast.error(errorMessage) + notify.error(errorMessage) } } }) .catch((err: unknown) => { - if (cancelled) return + if (requestId !== requestIdRef.current) return setError(err instanceof Error ? err : new Error(String(err))) if (errorMessage !== false) { - toast.error(errorMessage) + notify.error(errorMessage) } }) .finally(() => { - if (!cancelled) setLoading(false) + if (requestId === requestIdRef.current) setLoading(false) }) - - return () => { - cancelled = true - } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [...deps, refetchCount]) + }, [isCacheMode, legacyEnabled, errorMessage, ...deps]) - const refetch = () => setRefetchCount((c) => c + 1) + const legacyRefetch = () => { + if (isCacheMode) return + const requestId = ++requestIdRef.current + setLoading(true) + action() + .then((result) => { + if (requestId !== requestIdRef.current) return + if (result.success) { + setData(result.data) + } else { + setError(new Error(result.message ?? "Action failed")) + if (errorMessage !== false) notify.error(errorMessage) + } + }) + .catch((err: unknown) => { + if (requestId !== requestIdRef.current) return + setError(err instanceof Error ? err : new Error(String(err))) + if (errorMessage !== false) notify.error(errorMessage) + }) + .finally(() => { + if (requestId === requestIdRef.current) setLoading(false) + }) + } - return { data, loading, error, refetch } + if (isCacheMode) { + const cacheData = query.data?.success ? query.data.data : undefined + const cacheError = + query.error ?? + (query.data && !query.data.success + ? new Error(query.data.message ?? "Action failed") + : null) + return { + data: cacheData, + loading: query.isLoading, + error: cacheError, + refetch: () => { + void query.refetch() + }, + } + } + + return { data, loading, error, refetch: legacyRefetch } }