在 react 中如何管理服务端状态?相信很多小伙伴的第一反应就是: 在 useEffect 里发送请求,然后结合 useState 来管理状态。

下面我们来看下,使用这种方式存在什么问题,以及如何解决这些问题,最后我们会看看其他更好的方式。

在useEffect里发送请求,然后结合useState来管理状态

通常我们会这样写:

App.tsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function App() {
const [users, setUsers] = useState([]);
useEffect(() => {
fetch("https://jsonplaceholder.typicode.com/users")
.then((res) => res.json())
.then((data) => setUsers(data));
}, []);
return (
<ul>
{users.map((user) => (
<li key={user.id}>{user.name}</li>
))}
</ul>
);
}

这作为一个 demo 演示代码或许可行,但是以上代码存在如下问题:

  1. 没有处理接口请求中的状态,用户不知道我们在获取数据
  2. 没有处理接口请求中的错误,如果接口发生了异常,用户没有感知

我们修复上面的代码:

App.tsx
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
function App() {
const [users, setUsers] = useState();
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState(null);
useEffect(() => {
setIsLoading(true);
fetch("https://jsonplaceholder.typicode.com/users")
.then((res) => {
if (!res.ok) {
throw new Error("Failed to fetch users");
}
return res.json();
})
.then((data) => {
setUsers(data);
setIsLoading(false);
setError(null);
})
.catch((error) => {
setError(error);
setIsLoading(false);
});
}, []);
if (isLoading) {
return <div>Loading...</div>;
}
if (error) {
return <div>Error: {error.message}</div>;
}
return (
<ul>
{users.map((user) => (
<li key={user.id}>{user.name}</li>
))}
</ul>
);
}

乍看一下,虽然多个 useState 的引入,让代码变得更加复杂,但是你可能还是能接受的。 但你的老板找到你,想让你加上搜索功能,于是乎你可能会写这样的代码:

App.tsx
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
function App() {
const [users, setUsers] = useState([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState(null);
const [search, setSearch] = useState("");
useEffect(() => {
setIsLoading(true);
fetch(`https://jsonplaceholder.typicode.com/users?q=${search}`)
.then((res) => {
if (!res.ok) {
throw new Error("Failed to fetch users");
}
return res.json();
})
.then((data) => {
setUsers(data);
setIsLoading(false);
setError(null);
})
.catch((error) => {
setError(error);
setIsLoading(false);
});
}, [search]);
if (error) {
return <div>Error: {error.message}</div>;
}
return (
<div>
<input
type="text"
value={search}
onChange={(e)=> setSearch(e.target.value)}
/>
{isLoading ? (
<div>Loading...</div>
) : (
<ul>
{users.map((user) => (
<li key={user.id}>{user.name}</li>
))}
</ul>
)}
</div>
);
}

这段代码虽然看上去还可以,但是其实存在一个问题,当我们每次输入的时候,都会发送一个请求,假设我们先输入 a,然后输入 b,那么就会以 a 和 ab 为查询字符串分别发送两次请求,而服务端接口的速度快慢是不一定的,如果请求 a 响应比 请求 ab 更慢,那么我们的页面上就会先显示 ab 请求对应的结果, 然后再显示 a 请求对应的结果。这里存在如下问题:

  1. 最终结果与 ui 不符,我们希望看到的是 ab 请求对应的结果。

如何解决这个问题呢?

你可能会想到,我们能不能对请求做个 debounce,即在输入时延迟一会儿再发送请求,这样就不会发送两次请求了。但 debounce 解决不了根本的问题, 因为服务端的接口快慢是不一定的,debounce 之后,用户输入稍微有些停顿,可能还是会发送两次请求,还是会造成多个请求竞争的问题。

而事实上我们要做的是,只要有新的请求发出,那么就取消之前的请求或者忽略之前的请求。这样就不会出现多个请求竞争的问题了。我们可以从 react 的官网上找到类似的解决方案。

App.tsx
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
function App() {
const [users, setUsers] = useState();
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState(null);
const [search, setSearch] = useState("");
useEffect(() => {
let ignore = false;
setIsLoading(true);
fetch(`https://jsonplaceholder.typicode.com/users?q=${search}`)
.then((res) => {
if (!res.ok) {
throw new Error("Failed to fetch users");
}
return res.json();
})
.then((data) => {
if (ignore) return;
setUsers(data);
setIsLoading(false);
setError(null);
})
.catch((error) => {
if (ignore) return;
setError(error);
setIsLoading(false);
});
return () => {
ignore = true;
};
}, [search]);
if (error) {
return <div>Error: {error.message}</div>;
}
return (
<div>
<input
type="text"
value={search}
onChange={(e)=> setSearch(e.target.value)}
/>
{isLoading ? (
<div>Loading...</div>
) : (
<ul>
{users.map((user) => (
<li key={user.id}>{user.name}</li>
))}
</ul>
)}
</div>
);
}

由于 react 会在每次 执行 effect 之前去执行 cleanup,所以我们可以在 fetch 的 then 里做个 ignore 判断,如果 ignore 是 true,那么就不会执行 setState。

此时你会发现我们处理请求的代码已经越来越复杂了,而且这段代码里解决的问题,可能是我们每个请求都需要处理的,于是你可能会想到抽出一个公用的自定义 hook。

useQuery.ts
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
export function useQuery({ url }) {
const [data, setData] = useState();
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState(null);
useEffect(() => {
let ignore = false;
setIsLoading(true);
fetch(url)
.then((res) => {
if (!res.ok) {
throw new Error("Failed to fetch users");
}
return res.json();
})
.then((data) => {
if (ignore) return;
setData(data);
setIsLoading(false);
setError(null);
})
.catch((error) => {
if (ignore) return;
setError(error);
setIsLoading(false);
});
return () => {
ignore = true;
};
}, [url]);
return {
data,
isLoading,
error,
};
}
App.tsx
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
import { useQuery } from "./useQuery";
function App() {
const [search, setSearch] = useState("");
const {
data: users,
isLoading,
error,
} = useQuery({
url: `https://jsonplaceholder.typicode.com/users?q=${search}`,
});
if (isLoading) {
return <div>Loading...</div>;
}
if (error) {
return <div>Error: {error.message}</div>;
}
return (
<div>
<input
type="text"
value={search}
onChange={(e)=> setSearch(e.target.value)}
/>
{isLoading ? (
<div>Loading...</div>
) : (
<ul>
{users.map((user) => (
<li key={user.id}>{user.name}</li>
))}
</ul>
)}
</div>
);
}

好了,我们的自定义 hook 完成了,看上去很不错,但是如果你在项目中大量使用它,你就会发现它可能会遇到如下问题:

  1. 请求去重。我们的 useQuery 没有做请求去重,如果我们在多个组件中使用 useQuery, 传递同一个 url 参数,会发送多次请求,这其实是没有必要的,对服务端资源来说是一种浪费。
  2. 请求缓存。我们的 useQuery 没有做缓存。对于一个追求高性能体验的客户端应用程序而言,缓存是必不可少的,因为我们并不是所有场景都要求非常实时的数据。而使用缓存可以帮助我们大大提高用户体验。但是我们知道在程序的世界里有句说法: 缓存和命名 是最难的。
  3. 重新请求的机制。我们是否需要在用户离开页面一端时间再次返回页面之后(也许是去上了个厕所, 喝个咖啡, 锁屏回来重新打开屏幕),重新请求刷新数据?
  4. 我们的 useQuery 内部将 fetch 封装进来了。这个耦合太大了,其实只需要传递一个 queryFn 和 queryKey 就可以了,queryFn 里去返回一个 promise,这里就又会牵扯到 queryKey 结构的问题,因为我们要监听 queryKey 的变化。我们如何高效比对两个 queryKey 是否相同?

使用三方库

好了,说了那么多,无非是想说明,维护服务端请求状态确实是一件很复杂的事情,如果你追求极致的用户体验,那么我的建议是使用三方库来完成这个工作,目前在这个领域使用最广泛的是 react-query, 他提供了如下特性:

  1. 声明式的 api,我们只需声明一个 query,改变 query 的参数,就会触发 query 的重新执行。
  2. 自动处理缓存
  3. 自动处理接口调用去重
  4. 对后端不感知。
  5. 自动重试
  6. 预加载
  7. 请求取消机制
  8. 其他

如果你想要了解更多的细节,可以参考 react-query 的文档

我们使用 react-query 对我们的示例程序改写:

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
import {
QueryClient,
QueryClientProvider,
useQuery,
} from "@tanstack/react-query";

const queryClient = new QueryClient();

export default function App() {
return (
<QueryClientProvider client={queryClient}>
<Example />
</QueryClientProvider>
);
}

function Example() {
const [search, setSearch] = useState("");
const { data, isPending, error } = useQuery({
queryKey: ["users", search],
queryFn: () =>
fetch(`https://jsonplaceholder.typicode.com/users?q=${search}`).then(
(res) => {
if (!res.ok) {
throw new Error("Failed to fetch users");
}
return res.json();
}
),
});
if (error) {
return <div>Error: {error.message}</div>;
}
return (
<div>
<input
type="text"
value={search}
onChange={(e)=> setSearch(e.target.value)}
/>
{isPending ? (
<div>Loading...</div>
) : (
<ul>
{users.map((user) => (
<li key={user.id}>{user.name}</li>
))}
</ul>
)}
</div>
);
}

好了,改造完成,看起来如此优雅的 api,希望在以后的项目中能够有机会去使用它。