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,而且这次它非常出色

简而言之

Remix 的数据和异步 UI 管理几乎所有出色的功能都将引入 React Router。

  • 来自 Remix 的所有数据组件、钩子和细致的异步数据管理都将引入 React Router。
    • 使用 <Route loader /> 加载数据
    • 使用 <Route action /><Form> 进行数据修改
    • 自动处理中断、错误、重新验证、竞争条件等等。
    • 使用 useFetcher 进行非导航数据交互
  • 一个新的包 @remix-run/router 将以视图无关的方式结合 History 中的所有相关功能、React Router 的匹配和 Remix 的数据管理——抱歉——以视图无关的方式。这只是一个内部依赖项,您仍然需要 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 即将要做的事情。通过在嵌套的路由边界处启动获取,请求瀑布链被展平,速度提高了 3 倍。

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,您只需要表单 action 指向的路由上的 action

<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 将为您处理所有这些问题

  • 在表单提交时调用 action(不再有事件处理程序、event.preventDefault() 和全局数据上下文管道)
  • 如果在 action 中抛出任何内容,则渲染异常边界(不再处理每个具有修改的组件中的错误和异常状态)
  • 通过调用页面的加载器来重新验证页面上的数据(不再需要上下文管道、不再需要用于服务器状态的全局存储、不再需要缓存密钥过期、代码少得多)
  • 如果用户点击太快,则处理中断,避免 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 能够将加载器和 action 与路由模块并置,并从文件系统本身构建路由配置。我们期望人们为他们的应用程序创建类似的模式。

这是一个非常简单的示例,说明如何在不费力的情况下并置这些关注点。使用对真实内容的动态导入创建一个“路由模块”。这使您可以进行代码拆分和更简洁的路由配置。

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 服务端组件,并在初始 SSR 的 Suspense 边界处发送 HTML 块。

这些 API 都不是为了启动加载而设计的,而是当数据可用时如何以及在何处渲染。如果您在 Suspense 边界内启动获取,您仍然只是在组件中获取,这与当今 React Router 应用程序中存在的所有相同性能问题相同。

React Router 的新数据加载 API 正是 Suspense 所期望的!当 URL 更改时,React Router 会在渲染之前为每个匹配的路由启动获取。这为这些新的 React 功能提供了它们所需的一切,使其大放异彩✨。

仓库合并

在开发这些功能时,我们的工作跨越了三个仓库:History、React Router 和 Remix。当所有内容都如此相关时,这对我们来说在所有这些仓库中维护工具、问题和 PR 是一个相当糟糕的 DX。社区也很难提供贡献。

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

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

  • 将 Remix 合并到 React Router 仓库中,因为 React Router 是我们所做的一切的主要依赖项。它还在网络上拥有最长的历史,在过去 7 年中具有问题、PR 和反向链接。Remix 只有几个月大。
  • 将 Remix 仓库从 “remix” 重命名并归档为 “remix-archive”
  • 将 “react-router” 仓库重命名为 “remix”,所有包都放在一起
  • 保持以与以前相同的名称在 NPM 上发布所有内容。这只是源代码/项目调整,您的 package.json 不会受到影响

有很多内务工作要做,因此在我们开始合并工作时,预计会看到仓库中的 issue/PR 被移动、合并或关闭。我们将尽力维护每个仓库的 Git 历史记录,因为我们相信每位贡献者都应该在提交记录中署名!

如果您对此有任何疑问或兴奋,请在 DiscordTwitter 上联系我们:)


获取最新的 Remix 新闻

抢先了解 Remix 的新功能、社区活动和教程。