🌙 核心理念:抽象与封装
最佳实践的核心在于不要在每个组件中重复编写 fetch 逻辑。我们应该将数据请求的通用逻辑(如添加认证头、错误处理)抽象到一个中心位置。
我们将分三步实现这个目标:
- 创建一个中心化的
fetcher
函数:处理所有 API 请求的通用逻辑。 - 使用
SWRConfig
进行全局配置:让应用内的所有useSWR
Hook 默认使用我们的fetcher
。 - 创建一个自定义 Hook (
useApi
):进一步封装useSWR
,提供更强的类型支持和更简洁的组件内调用。
🌙 第 1 步:创建中心化的 Fetcher
这是最关键的一步。这个函数将是我们所有 API 请求的入口。
在你的项目中创建一个文件,例如 lib/fetcher.ts
。
// lib/fetcher.ts
/**
* 一个增强的 fetcher 函数,用于 SWR。
* 它会自动处理:
* 1. Token 认证:从 localStorage 读取 token 并添加到 Authorization header。
* 2. 自定义 Headers:允许在调用时传入额外的 header。
* 3. JSON 解析:自动解析响应体为 JSON。
* 4. 错误处理:当 API 返回非 2xx 状态码时,抛出包含状态和消息的错误。
*/
export async function fetcher<T>(
url: string,
options: RequestInit = {}
): Promise<T> {
// 1. 准备 Headers
const headers = new Headers(options.headers);
headers.append('Content-Type', 'application/json');
// 2. Token 认证
// 注意:在客户端组件中,我们可以安全地访问 localStorage。
// 如果你的 token 存储在 cookie 中,你需要使用不同的方式来获取它。
if (typeof window !== 'undefined') {
const token = localStorage.getItem('authToken');
if (token) {
headers.append('Authorization', `Bearer ${token}`);
} else {
console.warn("No auth token found in localStorage.");
// 如果没有 token,可以选择是否抛出错误或继续请求
// throw new Error("No auth token found.");
// 是否需要 重新获取 token 或其他处理逻辑取决于业务需求
// window.location.href = '/login'; // 例如:重定向到登录页面
}
} else {
// 如果在服务端环境中(如 SSR),可以考虑从请求头中获取 token
// 例如:headers.append('Authorization', `Bearer ${req.headers.authorization}`);
console.warn("Running in a non-browser environment, token will not be added.");
}
// 3. 发起请求
const response = await fetch(url, {
...options,
headers,
});
// 4. 健壮的错误处理
if (!response.ok) {
let errorMessage = `An error occurred: ${response.status}`;
try {
// 尝试解析 API 返回的错误信息
const errorData = await response.json();
errorMessage = errorData.message || JSON.stringify(errorData);
} catch (e) {
// 如果响应体不是 JSON 或为空,则使用状态文本
errorMessage = response.statusText;
}
const error = new Error(errorMessage);
// @ts-ignore // 附加额外信息到错误对象上
error.status = response.status;
// 401 或 403 状态码通常表示认证失败或权限不足
if (response.status === 401 || response.status === 403) {
// 可以在这里处理 token 失效的逻辑,比如重定向到登录页面
console.warn("Token invalid or access denied, redirecting to login...");
// window.location.href = '/login'; // 重定向到登录页面
}
throw error;
}
// 5. 解析并返回 JSON 数据
// 如果 API 可能返回空内容(如 204 No Content),则需要处理
const contentType = response.headers.get('content-type');
if (contentType && contentType.includes('application/json')) {
return response.json();
}
// 处理 event-stream 或其他文本类型的响应
if (contentType && contentType.includes('text/event-stream')) {
// 处理 Server-Sent Events (SSE) 的特殊情况
const reader = response.body?.getReader();
if (!reader) {
throw new Error("Response body is not readable.");
}
const decoder = new TextDecoder();
let result = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
result += decoder.decode(value, { stream: true });
}
// 这里可以根据需要进一步处理 SSE 数据
return result as unknown as T; // 将 SSE 文本转换为 T 类型
}
if (contentType && contentType.includes('text/plain')) {
const text = await response.text();
return text as unknown as T; // 将文本转换为 T 类型
}
// 对于没有返回体的成功响应,返回 null 或 true
return null as T;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
why?
- 中心化: 所有认证和 Header 逻辑都在一个地方,修改和维护非常方便。
- 健壮的错误处理: SWR 依赖
fetcher
抛出错误来触发error
状态。这段代码确保了任何非成功的 HTTP 响应都会被正确捕获。 - 可扩展:
options
参数让我们可以在需要时轻松传递额外的 Header 或其他fetch
选项。
🌙 第 2 步:使用 SWRConfig
全局配置
现在我们有了 fetcher
,我们不希望在每次调用 useSWR
时都手动传入它。SWRConfig
Provider 就是为此而生的。
在你的主布局文件 app/layout.tsx
中配置它。
// app/layout.tsx (或你选择的 provider 封装层)
'use client'; // SWRConfig 需要在客户端组件中使用
import { SWRConfig } from 'swr';
import { fetcher } from '@/lib/fetcher'; // 确保路径正确
export default function AppProviders({ children }: { children: React.ReactNode }) {
return (
<SWRConfig
value={{
// 全局配置我们的 fetcher
fetcher,
// 其他可选的全局配置
revalidateOnFocus: false, // 可以在此禁用或配置 SWR 的行为
onError: (error, key) => {
// 全局错误日志记录或上报
console.error(`SWR Error for key ${key}:`, error);
if (error.status === 401 || error.status === 403) {
// Token 失效或无权限,可以触发全局登出逻辑
// 例如: window.location.href = '/login';
console.log("Token invalid, redirecting to login...");
}
},
}}
>
{children}
</SWRConfig>
);
}
// 然后在你的根 layout.tsx 中使用这个 Provider
// app/layout.tsx (根布局)
// import AppProviders from './AppProviders'
// export default function RootLayout({ children }) {
// return (
// <html>
// <body>
// <AppProviders>{children}</AppProviders>
// </body>
// </html>
// )
// }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
why?
- DRY (Don't Repeat Yourself): 你只需配置一次,应用中的所有
useSWR
调用都会自动继承这个fetcher
。 - 全局错误处理:
onError
回调是一个处理通用错误(如 401 未授权)的绝佳位置。
🌙 第 3 步:(推荐) 创建自定义 Hook
虽然前两步已经很好了,但我们可以通过自定义 Hook 让组件内的代码更干净、类型更安全。
创建一个文件 hooks/useApi.ts
。
// hooks/useApi.ts
import useSWR, { SWRConfiguration, SWRResponse } from 'swr';
// 导入你的 fetcher
import { fetcher } from '@/lib/fetcher';
// 定义自定义 Hook 返回的类型,增强 SWRResponse
interface UseApiResponse<Data> extends SWRResponse<Data, Error> {
isLoading: boolean;
}
/**
* 封装了 SWR 的自定义 Hook,专注于 API 调用。
* @param path API 路径 (e.g., '/api/user')。如果为 null,则不发起请求。
* @param swrOptions SWR 的额外配置。
* @param fetchOptions fetch API 的额外配置 (e.g., method, body)。
*/
export function useApi<Data = any>(
path: string | null,
swrOptions: SWRConfiguration = {},
fetchOptions: RequestInit = {}
): UseApiResponse<Data> {
// SWR 的 key。当 fetchOptions 变化时,SWR 会重新请求。
// 我们将 fetchOptions 序列化后作为 key 的一部分,确保请求的唯一性。
const key = path ? [path, JSON.stringify(fetchOptions)] : null;
const { data, error, mutate, isValidating, isLoading } = useSWR<Data, Error>(
key,
// SWR 会将 key 数组作为参数传递给 fetcher
([url]: [string, string]) => fetcher(url, fetchOptions),
swrOptions
);
return {
data,
error,
mutate,
isValidating,
// isLoading 是 SWR 2.0+ 的新特性,表示首次加载
// 如果你使用的是旧版本,可以用 `!data && !error` 来模拟
isLoading: isLoading,
};
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
why?
- 极致简洁: 在组件中,你不再需要关心
fetcher
或复杂的 SWR key。 - 类型安全:
<Data>
泛型让你在调用时就能指定返回数据的类型,获得完美的 TypeScript 智能提示。 - 易于传递参数:
fetchOptions
参数使得发送POST
请求或传递自定义 Header 变得非常简单明了。
🌙 最终实践:在组件中使用
现在,让我们看看在组件中使用我们的 useApi
Hook 是多么优雅。
场景 1: GET 请求获取用户资料
// components/UserProfile.tsx
'use client';
import { useApi } from '@/hooks/useApi';
interface UserProfile {
id: string;
name: string;
email: string;
}
export function UserProfile() {
const { data: user, error, isLoading } = useApi<UserProfile>('/api/user/profile');
if (isLoading) return <div>Loading profile...</div>;
if (error) return <div>Error: {error.message}</div>;
if (!user) return <div>No profile data.</div>;
return (
<div>
<h1>{user.name}</h1>
<p>{user.email}</p>
</div>
);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
场景 2: POST 请求更新数据,并传递一个自定义 Header
// components/UpdatePost.tsx
'use client';
import { useMutation } from '@/hooks/useMutation';
import { useState } from 'react';
import { useSWRConfig } from 'swr'; // SWR 提供的全局 mutate 函数
export function UpdatePost({ postId }: { postId: string }) {
const [title, setTitle] = useState('');
// 1. 获取全局 mutate 函数,用于在成功后刷新其他 SWR-managed 数据
const { mutate } = useSWRConfig();
// 2. 实例化我们的突变 Hook
const postUrl = `/api/posts/${postId}`;
const [triggerUpdate, { isMutating, error }] = useMutation(postUrl, 'POST');
const handleUpdate = async () => {
try {
// 3. 调用 trigger 函数执行突变
await triggerUpdate({ body: { title } });
console.log('Post updated successfully!');
// 4. 成功后,手动触发 SWR 重新验证以更新UI
// 我们告诉 SWR,与 postUrl 关联的数据已经“过时”,需要重新获取。
// 这会刷新任何使用 useApi(postUrl) 的组件。
mutate(postUrl);
} catch (e) {
// 错误已在 Hook 中 console.error,这里可以做一些 UI 反馈
console.log("Caught error in component, could show a toast notification.");
}
};
return (
<div>
<input
value={title}
onChange={(e) => setTitle(e.target.value)}
disabled={isMutating} // 在提交时禁用输入框
/>
<button onClick={handleUpdate} disabled={isMutating}>
{isMutating ? 'Updating...' : 'Update Title'}
</button>
{error && <p style={{ color: 'red' }}>Failed to update: {error.message}</p>}
</div>
);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
专门用于处理更新操作的 Hook:useMutation.ts
:
// hooks/useMutation.ts
import { useState } from 'react';
import { fetcher } from '@/lib/fetcher'; // 复用我们强大的 fetcher
// TBody 是请求体(body)的类型,TData 是成功响应数据的类型
type MutationOptions<TBody> = Omit<RequestInit, 'body'> & {
body?: TBody;
};
interface MutationResult<TData> {
data: TData | null;
error: Error | null;
isMutating: boolean;
}
// 返回一个元组,第一个是触发函数,第二个是状态对象
type UseMutationReturn<TBody, TData> = [
(options?: MutationOptions<TBody>) => Promise<TData | undefined>,
MutationResult<TData>
];
/**
* 一个用于处理 POST, PUT, DELETE 等突变操作的自定义 Hook。
* 它不会自动执行,而是返回一个可以被手动调用的 trigger 函数。
*
* @param url API 端点
* @param method HTTP 方法,默认为 'POST'
*/
export function useMutation<TBody = any, TData = any>(
url: string,
method: 'POST' | 'PUT' | 'DELETE' | 'PATCH' = 'POST'
): UseMutationReturn<TBody, TData> {
const [result, setResult] = useState<MutationResult<TData>>({
data: null,
error: null,
isMutating: false,
});
const trigger = async (options?: MutationOptions<TBody>): Promise<TData | undefined> => {
setResult({ data: null, error: null, isMutating: true });
try {
const body = options?.body ? JSON.stringify(options.body) : undefined;
const response = await fetcher<TData>(url, {
method,
...options,
body,
});
setResult({ data: response, error: null, isMutating: false });
return response;
} catch (err) {
const error = err as Error;
console.error(`Mutation failed for ${method} ${url}:`, error);
setResult({ data: null, error, isMutating: false });
// 重新抛出错误,以便调用方可以在其自己的 try/catch 中处理
throw error;
}
};
return [trigger, { ...result }];
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
🌙 总结:最佳实践清单
- ✅ 中心化
fetcher.ts
:统一处理认证、Header、错误和 JSON 解析。 - ✅ 在
fetcher
中抛出错误:确保response.ok
为false
时throw new Error()
,以便 SWR 能够捕获。 - ✅ 使用
SWRConfig
全局配置:在根布局中设置默认fetcher
和全局onError
处理器。 - ✅ 创建自定义
useApi
Hook:封装useSWR
,提供简洁的调用方式和强大的类型支持。 - ✅ 在组件中处理
isLoading
和error
状态:为用户提供清晰的 UI 反馈。 - ✅ 善用
mutate
:在执行POST
,PUT
,DELETE
等操作后,调用mutate
来更新相关GET
请求的数据,保持 UI 同步。