react请求的正确姿势
目标
- 分享 react 官方为什么不推荐使用 useEffect 请求数据。
- 分享 react-router 在解决数据请求上的实践经验。
背景
在 react 中我们习惯性的会在 useEffect 中请求初始的数据,类似下面这样:
useEffect(() => {
fetch('xxx').then(data => setState(data.json()));
}, [])
但是 react 官方并不推荐这么使用,这是 Dan 在 reddit 上给出的回答。既然不推荐这种方式,那这么写会出现什么问题呢?那如果不这么写那正确的请求方式是什么呢?下面带着这些问题去一探究竟。
为什么不推荐使用 useEffect?
请求竞态问题
下面是官方给的一个例子:
function SearchResults({ query }) {
const [results, setResults] = useState([]);
const [page, setPage] = useState(1);
useEffect(() => {
fetchResults(query, page).then(json => {
setResults(json);
});
}, [query, page]);
function handleNextPageClick() {
setPage(page + 1);
}
}
这里有一个展示搜索结果的的组件,接受一个 query 参数,表示搜索内容,当 page 和 query 发生变化时需要重新请求数据。
这个例子可能看起来与事件处理相矛盾,这段逻辑本应该放入事件处理程序中!但是,请考虑到用户的搜索内容并不是获取的主要原因。除了搜索内容,用户可以在不输入的情况下进行切页。
但是这段代码会有一个隐藏的 bug,想象一下假如用户打字的速度很快,那就会发起多个不同的请求,而页面展示哪个数据,取决于哪个请求先返回,这就是请求竞态问题。
而官方为了帮助我们解决这个问题,也给出了解决方案:要修竞态问题,需要添加一个清理函数来忽略过时的响应。
function SearchResults({ query }) {
const [results, setResults] = useState([]);
const [page, setPage] = useState(1);
useEffect(() => {
let ignore = false;
fetchResults(query, page).then(json => {
if (!ignore) {
setResults(json);
}
});
return () => {
ignore = true;
};
}, [query, page]);
function handleNextPageClick() {
setPage(page + 1);
}
}
这确保除了最后请求的响应之外的所有响应都将被忽略。
没有初始数据的缓存
如果用户跳转到另一个页面然后点击浏览器的返回按钮,此时页面是否有数据展示给用户呢?可能没有,因为没有缓存,毕竟之前页面的组件已经被卸载。相反用户看到的可能是一个 loading 状态,因为需要重新执行 useEffect
获取初始数据。
这个问题的本质原因就是没有做初始数据的缓存。
客户端渲染的白屏
在客户端渲染的模式下使用 useEffect 去请求数据时,浏览器必须先下载所有 JavaScript 代码并展示应用,然后才知道现在需要去请求数据,在数据返回之前,页面都是处于白屏状态。
网络瀑布
当在父子组件内部通过 useEffect 获取数据时,会产生所谓的网络瀑布,父子组件会按照顺序获取各自的数据而不是并行获取多个数据,从而减缓页面的加载速度,考虑下面这个例子:
<Routes>
<Route element={<Root />}>
<Route path="projects" element={<ProjectsList />}>
<Route path=":projectId" element={<ProjectPage />} />
</Route>
</Route>
</Routes>
现在考虑每个组件都获取自己的数据:
function Root() {
let data = useFetch("/api/user.json");
if (!data) {
return <BigSpinner />;
}
if (data.error) {
return <ErrorPage />;
}
return (
<div>
<Header user={data.user} />
<Outlet />
<Footer />
</div>
);
}
function ProjectsList() {
let data = useFetch("/api/projects.json");
if (!data) {
return <MediumSpinner />;
}
return (
<div style={{ display: "flex" }}>
<ProjectsSidebar project={data.projects}>
<ProjectsContent>
<Outlet />
</ProjectContent>
</div>
);
}
function ProjectPage() {
let params = useParams();
let data = useFetch(`/api/projects/${params.projectId}.json`);
if (!data) {
return <div>Loading...</div>;
}
if (data.notFound) {
return <NotFound />;
}
if (data.error) {
return <ErrorPage />;
}
return (
<div>
<h3>{project.title}</h3>
</div>
);
}
当用户查看 /projects/123
时会发生什么?
- Root 组件发送请求
/api/user.json
并且渲染 BigSpinner 组件。 - 等待网络响应
- ProjectsList 组件发送请求
/api/project.json
并且渲染 MediumSpinner。 - 等待网络响应
- ProjectPage请求
/apiprojects/123.json
并且渲染 Loading…。 - 等待网络响应
- ProjectPage 完成渲染.
这种数据获取方式会使得应用渲染速度比应有的速度慢得多,组件只有在挂载时才会启动数据的获取,但是父组件自己处于 loading 状态时会阻止子组件渲染,从而阻止子组件数据的获取。
这些问题只有 React有吗?
不,这些问题适用于任何 UI 框架,而不仅仅是 React,之所以在其他框架中这个问题没有凸显出来,仅仅是因为这些框架的作者没有明确的说明这一点,或者只是在文档中展示了一个简单的获取数据的示例,比如下面 vue 的官方示例:
之所以这个问题在 React 中被凸显出来,是因为 React 官方在引导开发者不要用这种形式请求数据,原因有如下两点:
- 在严格模式下,useEffect 会被执行两次,更加突出了请求的竞态问题。
- 考虑程序的性能和UX,比如上面的网络瀑布。
React-Router 是怎么做的?
网络瀑布
为了解决网络瀑布的问题,react-router 将数据的请求和读取操作进行分离,它为每个路由页提供了请求数据的钩子(loader)用于在路由跳转期间请求数据,以及获取数据的 hook (useLoaderData)用于在组件渲染时获取请求返回的数据,当与嵌套路由相结合时,可以并行加载所有的数据。
下面对上面的例子进行改写:
import * as React from "react";
import {
BrowserRouter,
Routes,
Route,
useLoaderData,
} from "react-router-dom";
ReactDOM.render(
<BrowserRouter>
<Routes
fallbackElement={<BigSpinner />} // 统一处理请求loading状态
>
<Route
path="/"
element={<Root />}
errorElement={<Error />} // 捕获loader中抛出的错误并展示对应的Error组件
loader={() => {
return fetch("/api/user.json");
}}
>
<Route
path="projects"
element={<ProjectsList />}
loader={() => {
return fetch("/api/projects.json");
}}
>
<Route
path=":projectId"
element={<Projects />}
loader={async ({ params }) =>
fetch(`/api/projects/${params.projectId}`)
}
/>
</Route>
<Route index element={<Index />} />
</Route>
</Routes>
</BrowserRouter>
);
function Root() {
// 通过useLoaderData获取loader返回的数据
let data = useLoaderData();
return (
<div>
<Header user={data.user} />
<Outlet />
<Footer />
</div>
);
}
如果每次获取需要一秒才能解析,使用 useEffect 时整个页面至少需要三秒才能呈现!这就是为什么许多 React 应用程序加载缓慢且转换缓慢的原因。
react-router 通过并行请求的方式,整个请求链会变得扁平且速度提高 3 倍。
竞态问题
先说结论:React-Router 在6.4+之后会自动取消过时的操作并自动提交新数据。
由于导航操作异步的,如果同时发起多个导航操作,比如同时点击多个跳转链接或者按钮导致多个导航操作同时发生时,就会出现竞态问题,加上每个路由页在 loader 中都有自己的初始数据请求,这将放大这个问题。
下面我们改写了项目详情页的 loader,设置详情请求为 3s
// ProjectPage Route
<Route
path=":projectId"
element={<ProjectPage />}
loader={async ({ params }) => {
console.log('projectLoader start');
const res = await fetch(`/api/projects/${params.projectId}`); // 3s
console.log('projectLoader end');
return res.json()
}}
/>
function ProjectPage() {
const project = useLoaderData();
console.log("project: ", project.title)
return (
<div>
<h3>project: {project.title}</h3>
</div>
);
当我们快速点击多个调转链接时看一下效果:
通过控制台我们看到,初始时展示的是项目 1,当快速点击切换项目时,控制台打印的还是项目 1,直到最后一个项目的数据返回,这有点类似防抖的效果,这其实是 react-router 内部帮我们自动处理的。
小结
react-router 新增的数据加载 api 旨在何时启动数据的获取,能够很好的解决上述的性能问题,同时这也与 react 提供的 Suspense 形成了良好的互补,因为 Suspense 不是为了启动数据加载而设计的,而是为了在数据可用时如何以及在何处呈现,如果我们在 Suspense 内请求数据,我们仍然只是在组件中请求数据,这也就存在性能问题。
总结
本文主要介绍了 React 官方不推荐的数据请求方式以及原因,同时展示 react-router6.4 提供的数据请求 API 是如何解决数据请求中存在的性能问题。对于客户端的数据请求,react 官方也在不断的实践,比如即将推出的 use 原语——处理异步数据,在现在的项目开发中,为了避免出现数据请求的性能问题,可以使用流行的开源解决方案,比如React Query、useSWR 和 React Router6.4+。