React Router Logo
2022 年 3 月 23 日

重构 React Router

Ryan Florence
联合创始人

React Router 的早期版本实际上有一个异步钩子来帮助数据加载,名为 willTransitionTo。当时几乎没有人知道如何使用 React,我们也不例外。它并不 完美,但至少朝着正确的方向发展了。

无论好坏,我们在 React Router v4 中全面采用了组件,并删除了钩子。随着 willTransitionTo 的消失,以及组件成为我们的主要工具,几乎所有当今的 React Router 应用程序都在组件内部获取数据。

我们已经认识到,在组件中进行数据获取是获得最慢用户体验的最快途径(更不用说通常随之而来的所有内容布局偏移了)。

不仅用户体验会受到影响,开发人员体验也会变得复杂,因为要处理各种上下文管道、全局状态管理解决方案(这些解决方案通常仅仅是服务器端状态的客户端缓存),以及每个具有数据的组件都需要拥有自己的加载、错误和成功状态。很少有愉快的路径!

在构建 Remix 的过程中,我们已经积累了大量经验,利用 React Router 的嵌套路由抽象来一次性解决所有这些问题。今天,我们很高兴地宣布我们已经开始将这些数据 API 带到 React Router,但这一次它 非常棒

tl;dr

Remix 中几乎所有关于数据和异步 UI 管理的优秀功能都将引入 React Router。

  • 所有来自 Remix 的数据组件、钩子和复杂的异步数据管理都将引入 React Router。
    • 使用 <Route loader /> 进行数据加载
    • 使用 <Route action /><Form> 进行数据变异
    • 自动处理中断、错误、重新验证、竞态条件等。
    • 使用 useFetcher 进行非导航数据交互
  • 一个新的包,@remix-run/router 将结合来自 History、React Router 的匹配和 Remix 的数据管理的所有相关功能,以一种与 vue 无关的方式——原谅我——一种 视图 无关的方式。这仅仅是一个内部依赖关系,你仍然需要 npm install react-router-dom

组件获取和渲染获取链

当你从组件内部获取数据时,就会创建我们所谓的 渲染+获取链,这些链会通过 依次 获取多个数据依赖项而不是 并行 获取,从而人为地降低页面加载和过渡速度。

考虑以下路由

<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 时会发生什么?

  1. <Root> 获取 /api/user.json 并渲染 <BigSpinner/>
  2. 网络响应
  3. <ProjectsList> 获取 /api/projects.json 并渲染 <MediumSpinner/>
  4. 网络响应
  5. <ProjectPage> 获取 /api/projects/123.json 并渲染 <div>加载中...</div>
  6. 网络响应
  7. <ProjectPage> 最终渲染,页面完成

像这样在组件中进行数据获取会使你的应用程序比实际速度慢很多。组件在挂载时启动获取,但父组件自身的挂起状态会阻止子组件渲染,因此也阻止子组件获取数据!

这被称为 渲染+获取链。我们示例应用程序中的所有三个获取操作在逻辑上可以并行进行,但它们不能并行进行,因为它们与 UI 层次结构耦合,并被父级加载状态阻塞。

如果每个获取操作需要一秒钟才能完成,整个页面至少需要三秒钟才能渲染!这就是为什么许多 React 应用程序加载速度慢、过渡速度慢的原因。

network diagram showing sequential network requests
将数据获取与组件耦合会导致渲染+获取链

解决方案是将 启动获取操作读取结果 分离。这正是 Remix API 今天所做的,也是 React Router 很快将要做的。通过在嵌套路由边界启动获取操作,请求级联链将被扁平化,速度提高三倍。

network diagram showing parallel network requests
路由获取并行化请求,消除了缓慢的渲染+获取链

但这不仅仅是关于用户体验。这些新 API 一次性解决的大量问题,对代码的简洁性和编码过程中的乐趣都有重大影响。

即将推出

我们仍在讨论一些事物的名称,但你可以期待以下内容

import * as React from "react";
import {
  BrowserRouter,
  Routes,
  Route,
  useLoaderData,
  Form,
} from "react-router-dom";

ReactDOM.render(
  <BrowserRouter>
    <Routes
      // if you're not server rendering, this manages the
      // initial loading state
      fallbackElement={<BigSpinner />}
      // any rendering or async loading and mutation errors will
      // automatically be caught and render here, no more error
      // state tracking or render branching
      exceptionElement={<GlobalErrorPage />}
    >
      <Route
        // Loaders provide data to route component and are initiated
        // when the URL changes
        loader={({ signal }) => {
          // React Router speaks the Web Fetch API, so you can return
          // a web fetch Response and it'll automatically be
          // deserialized with `res.json()`. No more useFetch hooks
          // and messing with their pending states in every component
          // that needs them.
          return fetch("/api/user.json", {
            // It also handles navigation interruptions and (as long as
            // you pass the signal) cancels the actual fetch.
            signal,
          });
        }}
      >
        <Route
          path="projects"
          element={<Projects />}
          // exceptions bubble, so you can handle them in context or
          // just let them bubble to the top, tons of happy paths!
          exceptionElement={<TasksErrorPage />}
          loader={async ({ signal }) => {
            // You can also unwrap the fetch yourself and write
            // simple `async/await` code (try that inside a useEffect 🥺).
            // You don't even have to `fetch`, you can get data from
            // anywhere (localStorage, indexedDB whatever)
            let res = await fetch("/api/tasks.json", { signal });

            // if at any point you can't render the route component
            // based on the data you're trying to load, just `throw` an
            // exception and the exceptionElement will render instead.
            // This keeps your happy path happy, and your exception path,
            // uh, exceptional!
            if (res.status === 404) {
              throw { notFound: true };
            }

            return res.json();
          }}
        >
          <Route
            path=":projectId"
            element={<Projects />}
            // a lot of your loading is gonna be this simple, React
            // Router will handle all the pending states and expose it
            // to you so you can build pending/optimistic UI
            loader={async ({ signal, params }) =>
              fetch(`/api/projects/${params.projectId}`, { signal })
            }
          />
        </Route>
        <Route index element={<Index />} />
      </Route>
    </Routes>
  </BrowserRouter>,
);
function Root() {
  // components access route data with this hook, data is guaranteed
  // to be here, error free, and no pending states to deal with in
  // every component that has a data dependency (also helps with
  // removing Content Layout Shift).
  let data = useLoaderData();

  // the transition tells you everything you need to build pending
  // indicators, busy spinners, optimistic UI, and side effects.
  let transition = useTransition();

  return (
    <div>
      {/* You can put global navigation indicators at the root and
          never worry about loading states in your components again,
          or you can get more granular around Outlets to build
          skeleton UI so the user gets immediate feedback when a link
          is clicked (we'll show how to do that another time) */}
      <GlobalNavSpinner show={transition.state === "loading"} />
      <Header user={data.user} />
      <Outlet />
      <Footer />
    </div>
  );
}

数据变异也!

我们不仅通过这些数据加载 API 来加速你的应用程序,而且我们还找到了如何将数据变异 API 也引入进来!当你拥有一个包含读取和写入功能的路由和数据解决方案时,你可以一次性解决许多问题。

考虑这个“新建项目”表单。

function NewProjectForm() {
  return (
    <Form method="post" action="/projects">
      <label>
        New Project: <input name="title" />
      </label>
      <button type="submit">Create</button>
    </Form>
  );
}

有了 UI 后,你唯一需要的就是表单操作指向的路由上的操作

<Route
  path="projects"
  element={<Projects />}
  // this action will be called when the form submits because it
  // matches the form's action prop, routes can now handle all of
  // your data needs: reads AND writes.
  action={async ({ request, signal }) => {
    let values = Object.fromEntries(
      // React Router intercepted the normal browser POST request and
      // provides it to you here as a standard Web Fetch Request. The
      // formData as serialized by React Router and available to you
      // on the request. Standard HTML and DOM APIs, nothing new.
      await request.formData(),
    );

    // You already know the web fetch API because you've been using it
    // for years like this:
    let res = await fetch("/api/projects.json", {
      signal,
      method: "post",
      body: JSON.stringify(values),
      headers: { "Content-Type": "application/json; utf-8" },
    });

    let project = await res.json();

    // if there's a problem, just throw an exception and the
    // exception element will render, keeping the happy path happy.
    // (there are better things to throw than errors if you keep
    // reading)
    if (project.error) throw new Error(project.error);

    // now you can return from here to render this route or return a
    // redirect (which is really a Web Fetch Response, ofc) to go
    // somewhere else, like the new project!
    return redirect(`/projects/${project.id}`);
  }}
/>

就是这样。你只需要编写 UI 和简单的 async 函数中的实际应用程序特定变异代码。

不需要调度错误或成功状态,不需要担心 useEffect 依赖关系,不需要返回清理函数,也不需要过期缓存键。你只有一个关注点:执行变异,如果出现错误,则抛出异常。异步 UI、变异问题和异常渲染路径已完全解耦。

从那里,React Router 将为你处理所有这些问题

  • 在表单提交时调用操作(不再需要事件处理程序、event.preventDefault() 和全局数据上下文管道)
  • 如果在操作中抛出任何异常,则渲染异常边界(不再需要在每个具有变异的组件中处理错误和异常状态)
  • 通过调用页面的加载程序来重新验证页面上的数据(不再需要上下文管道、不再需要用于服务器状态的全局存储、不再需要缓存键过期,代码量大大减少)
  • 如果用户频繁点击,则处理中断,避免 UI 不同步
  • 处理多个变异和重新验证同时进行时的重新验证竞态条件

因为它为你处理所有这些问题,所以它可以通过一个简单的钩子来公开它所知道的一切:useTransition。这就是你为用户提供反馈以使你的应用程序感觉非常稳定(也是我们最初将 React 放到页面上的原因!)。

function NewProjectForm() {
  let transition = useTransition();

  let busy = transition.state === "submitting";

  // This hook tells you everything--what state the transition is
  // in ("idle", "submitting", "loading"), what formData is being
  // submitted to the server for optimistic UI and more.

  // You can build the fanciest SPA UI your designers can dream up...
  return (
    <Form method="post" action="/projects">
      <label>
        New Project: <input name="title" />
      </label>
      {/* ... or just disable the button 😂 */}
      <button type="submit" disabled={busy}>
        Create
      </button>
    </Form>
  );
}

如果你的应用程序大部分内容都与获取数据和向 API 路由发布数据有关,那么准备好删除很多代码,因为这些代码很快就会发布。

为抽象而构建

许多开发者可能会看到这个 API,并认为它在路由配置中过于复杂。Remix 能够将加载程序和操作与路由模块共同定位,并从文件系统构建路由配置本身。我们希望人们为他们的应用程序创建类似的模式。

以下是一个非常简单的示例,说明如何在不付出太多努力的情况下共同定位这些问题。创建一个“路由模块”,其中包含一个动态导入到真实模块的导入。这不仅可以实现代码拆分,还可以使路由配置更清晰。

export async function loader(args) {
  let actualLoader = await import("./actualModule").loader;
  return actualLoader(args);
}

export async function action(args) {
  let actualAction = await import("./actualModule").action;
  return actualAction(args);
}

export const Component = React.lazy(() => import("./actualModule").default);
import * as Tasks from "./tasks.route";

// ...
<Route
  path="/tasks"
  element={<Tasks.Component />}
  loader={Tasks.loader}
  action={Tasks.action}
/>;

Suspense + React Router = ❤️

React 服务器组件、Suspense 和流式传输,虽然尚未发布,但它们是 React 中正在形成的激动人心的功能。我们在 React Router 中进行这项工作时,已经考虑了这些 API。

这些 React API 是为一个系统设计的,该系统在 渲染 之前启动数据加载。它们不是用来定义你在哪里 启动 获取操作,而是用来定义你在哪里 访问 结果。

  • Suspense 定义了你需要在哪里等待已经启动的获取操作、挂起的 UI,以及何时在流式传输时“刷新” HTML
  • React 服务器组件将数据加载和渲染转移到服务器
  • 流式传输在数据可用时渲染 React 服务器组件,并在 Suspense 边界处发送 HTML 块,以进行初始 SSR。

这些 API 中的任何一个都不是用来 启动 加载,而是用来在数据可用时如何以及在何处渲染。如果你在 Suspense 边界内部启动获取操作,你仍然只是在组件内部进行获取操作,仍然存在于当今 React Router 应用程序中的所有性能问题。

React Router 的新数据加载 API 正是 Suspense 所期望的!当 url 发生更改时,React Router 会在渲染之前为每个匹配的路由启动获取操作。这为这些新的 React 功能提供了它们需要的一切来发挥光彩 ✨。

存储库合并

在开发这些功能的过程中,我们的工作跨越了三个存储库:History、React Router 和 Remix。这对我们来说是一个糟糕的 DX,因为我们必须在所有这些存储库中维护工具、问题和 PR,而所有这些存储库都息息相关。这也使得社区难以做出贡献。

我们一直认为 Remix 只是“React Router 的编译器和服务器”。现在是它们搬到一起的时候了。

从逻辑上讲,这意味着我们将

  • 将 Remix 合并到 React Router 存储库中,因为 React Router 是我们所做的一切的主要依赖项。它在网络上也有最长的历史,在过去七年中积累了大量问题、PR 和反向链接。Remix 只有几个月的时间。
  • 将 Remix 存储库从“remix”重命名并存档到“remix-archive”。
  • 将“react-router”存储库重命名为“remix”,所有包都将共同存在于此。
  • 继续以与之前相同的名称将所有内容发布到 NPM。这只是源代码/项目重排,你的 package.json 将不会受到影响。

有很多家务活要做,所以预计在开始合并工作时,你会看到问题/PR 在存储库中被移动、合并或关闭。我们将尽最大努力维护每个存储库的 git 历史记录,因为我们相信每个贡献者都应该在他们的提交中拥有自己的姓名!

如果你对这些内容中的任何一个或全部有疑问或感到兴奋,欢迎在 DiscordTwitter 上与我们联系 :)


获取有关最新 Remix 新闻的更新

成为第一个了解 Remix 新功能、社区活动和教程的人。