react请求的正确姿势

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 的官方示例:

image.png
之所以这个问题在 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 应用程序加载缓慢且转换缓慢的原因。

image.png

react-router 通过并行请求的方式,整个请求链会变得扁平且速度提高 3 倍。

image.png

竞态问题

先说结论: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>
  );

当我们快速点击多个调转链接时看一下效果:

u70u9-3rcl4.gif

通过控制台我们看到,初始时展示的是项目 1,当快速点击切换项目时,控制台打印的还是项目 1,直到最后一个项目的数据返回,这有点类似防抖的效果,这其实是 react-router 内部帮我们自动处理的。

小结

react-router 新增的数据加载 api 旨在何时启动数据的获取,能够很好的解决上述的性能问题,同时这也与 react 提供的 Suspense 形成了良好的互补,因为 Suspense 不是为了启动数据加载而设计的,而是为了在数据可用时如何以及在何处呈现,如果我们在 Suspense 内请求数据,我们仍然只是在组件中请求数据,这也就存在性能问题。

总结

本文主要介绍了 React 官方不推荐的数据请求方式以及原因,同时展示 react-router6.4 提供的数据请求 API 是如何解决数据请求中存在的性能问题。对于客户端的数据请求,react 官方也在不断的实践,比如即将推出的 use 原语——处理异步数据,在现在的项目开发中,为了避免出现数据请求的性能问题,可以使用流行的开源解决方案,比如React Query、useSWR 和 React Router6.4+。

参考资料

© 版权声明
THE END
喜欢就支持一下吧
点赞0

Warning: mysqli_query(): (HY000/3): Error writing file '/tmp/MY68pYyH' (Errcode: 28 - No space left on device) in /www/wwwroot/583.cn/wp-includes/class-wpdb.php on line 2345
admin的头像-五八三
评论 抢沙发
头像
欢迎您留下宝贵的见解!
提交
头像

昵称

图形验证码
取消
昵称代码图片