React Router v7 已发布。 查看文档
数据加载
本页内容

数据加载

Remix 的主要功能之一是简化与服务器的交互,将数据加载到组件中。 当您遵循这些约定,Remix 可以自动执行以下操作:

  • 服务端渲染您的页面
  • 在 JavaScript 加载失败时,能够应对网络状况
  • 当用户与您的网站互动时进行优化,通过仅加载页面中变化的部分的数据来提高速度
  • 在过渡期间并行获取数据、JavaScript 模块、CSS 和其他资源,避免导致 UI 卡顿的渲染+获取瀑布
  • 通过在 操作 后重新验证,确保 UI 中的数据与服务器上的数据同步
  • 在后退/前进点击时(甚至跨域)提供出色的滚动恢复
  • 使用 错误边界 处理服务端错误
  • 使用 错误边界 为“未找到”和“未授权”提供可靠的用户体验
  • 帮助您保持 UI 的正常运行

基础知识

每个路由模块都可以导出一个组件和一个 loaderuseLoaderData 会将 loader 的数据提供给您的组件

import { json } from "@remix-run/node"; // or cloudflare/deno
import { useLoaderData } from "@remix-run/react";

export const loader = async () => {
  return json([
    { id: "1", name: "Pants" },
    { id: "2", name: "Jacket" },
  ]);
};

export default function Products() {
  const products = useLoaderData<typeof loader>();
  return (
    <div>
      <h1>Products</h1>
      {products.map((product) => (
        <div key={product.id}>{product.name}</div>
      ))}
    </div>
  );
}

该组件在服务器和浏览器上呈现。 loader *仅在服务器上运行*。 这意味着我们硬编码的 products 数组不会包含在浏览器捆绑包中,并且可以安全地使用仅限服务器的 API 和 SDK,用于数据库、支付处理、内容管理系统等。

如果您的服务器端模块最终出现在客户端捆绑包中,请参阅我们的关于 服务器与客户端代码执行 的指南。

路由参数

当您使用 $ 命名文件时,例如 app/routes/users.$userId.tsxapp/routes/users.$userId.projects.$projectId.tsx,动态片段(以 $ 开头的片段)将从 URL 中解析出来,并以 params 对象的形式传递给您的加载器。

import type { LoaderFunctionArgs } from "@remix-run/node"; // or cloudflare/deno

export const loader = async ({
  params,
}: LoaderFunctionArgs) => {
  console.log(params.userId);
  console.log(params.projectId);
};

给定以下 URL,参数将按如下方式解析:

URL params.userId params.projectId
/users/123/projects/abc "123" "abc"
/users/aec34g/projects/22cba9 "aec34g" "22cba9"

这些参数对于查找数据非常有用。

import type { LoaderFunctionArgs } from "@remix-run/node"; // or cloudflare/deno
import { json } from "@remix-run/node"; // or cloudflare/deno

export const loader = async ({
  params,
}: LoaderFunctionArgs) => {
  return json(
    await fakeDb.project.findMany({
      where: {
        userId: params.userId,
        projectId: params.projectId,
      },
    })
  );
};

参数类型安全

因为这些参数来自 URL 而不是您的源代码,您无法确定它们是否会被定义。这就是为什么参数键的类型是 string | undefined。在 TypeScript 中,为了获得类型安全,在 使用它们之前进行验证是一个好习惯。使用 invariant 可以很容易地做到这一点。

import type { LoaderFunctionArgs } from "@remix-run/node"; // or cloudflare/deno
import invariant from "tiny-invariant";

export const loader = async ({
  params,
}: LoaderFunctionArgs) => {
  invariant(params.userId, "Expected params.userId");
  invariant(params.projectId, "Expected params.projectId");

  params.projectId; // <-- TypeScript now knows this is a string
};

invariant 失败时,您可能会对抛出这样的错误感到不舒服,但请记住,在 Remix 中,您知道用户最终会进入错误边界,在那里他们可以从问题中恢复,而不是出现损坏的 UI。

外部 API

Remix 在您的服务器上填充了 fetch API,因此很容易从现有的 JSON API 中获取数据。您可以从加载器(在服务器上)执行获取操作,并让 Remix 处理其余的事情,而不是自己管理状态、错误、竞争条件等等。

import { json } from "@remix-run/node"; // or cloudflare/deno
import { useLoaderData } from "@remix-run/react";

export async function loader() {
  const res = await fetch("https://api.github.com/gists");
  return json(await res.json());
}

export default function GistsRoute() {
  const gists = useLoaderData<typeof loader>();
  return (
    <ul>
      {gists.map((gist) => (
        <li key={gist.id}>
          <a href={gist.html_url}>{gist.id}</a>
        </li>
      ))}
    </ul>
  );
}

当您已经有一个可用的 API,并且不在乎或不需要直接连接到 Remix 应用中的数据源时,这非常有用。

数据库

由于 Remix 在您的服务器上运行,您可以直接在您的路由模块中连接到数据库。例如,您可以使用 Prisma 连接到 Postgres 数据库。

import { PrismaClient } from "@prisma/client";
const db = new PrismaClient();
export { db };

然后您的路由可以导入它并对其进行查询。

import type { LoaderFunctionArgs } from "@remix-run/node"; // or cloudflare/deno
import { json } from "@remix-run/node"; // or cloudflare/deno
import { useLoaderData } from "@remix-run/react";

import { db } from "~/db.server";

export const loader = async ({
  params,
}: LoaderFunctionArgs) => {
  return json(
    await db.product.findMany({
      where: {
        categoryId: params.categoryId,
      },
    })
  );
};

export default function ProductCategory() {
  const products = useLoaderData<typeof loader>();
  return (
    <div>
      <p>{products.length} Products</p>
      {/* ... */}
    </div>
  );
}

如果您使用 TypeScript,您可以在调用 useLoaderData 时使用类型推断来使用 Prisma Client 生成的类型。这使得在编写使用加载数据的代码时可以获得更好的类型安全性和智能感知。

import type { LoaderFunctionArgs } from "@remix-run/node"; // or cloudflare/deno
import { json } from "@remix-run/node"; // or cloudflare/deno
import { useLoaderData } from "@remix-run/react";

import { db } from "~/db.server";

async function getLoaderData(productId: string) {
  const product = await db.product.findUnique({
    where: {
      id: productId,
    },
    select: {
      id: true,
      name: true,
      imgSrc: true,
    },
  });

  return product;
}

export const loader = async ({
  params,
}: LoaderFunctionArgs) => {
  return json(await getLoaderData(params.productId));
};

export default function Product() {
  const product = useLoaderData<typeof loader>();
  return (
    <div>
      <p>Product {product.id}</p>
      {/* ... */}
    </div>
  );
}

Cloudflare KV

如果您选择 Cloudflare Pages 或 Workers 作为您的环境,Cloudflare 键值存储允许您像持久化静态资源一样在边缘持久化数据。

对于 Pages,要开始本地开发,您需要在 package.json 任务中添加一个带有命名空间名称的 --kv 参数,如下所示:

"dev:wrangler": "cross-env NODE_ENV=development wrangler pages dev ./public --kv PRODUCTS_KV"

对于 Cloudflare Workers 环境,您需要进行其他一些配置

这使您可以在加载器上下文中使用 PRODUCTS_KV(KV 存储由 Cloudflare Pages 适配器自动添加到加载器上下文中)。

import type { LoaderFunctionArgs } from "@remix-run/cloudflare";
import { json } from "@remix-run/cloudflare";
import { useLoaderData } from "@remix-run/react";

export const loader = async ({
  context,
  params,
}: LoaderFunctionArgs) => {
  return json(
    await context.PRODUCTS_KV.get(
      `product-${params.productId}`,
      { type: "json" }
    )
  );
};

export default function Product() {
  const product = useLoaderData<typeof loader>();
  return (
    <div>
      <p>Product</p>
      {product.name}
    </div>
  );
}

未找到

加载数据时,通常会发生记录“未找到”的情况。一旦您知道无法按预期渲染组件,就 throw 一个响应,Remix 将停止在当前加载器中执行代码,并切换到最近的错误边界

export const loader = async ({
  params,
  request,
}: LoaderFunctionArgs) => {
  const product = await db.product.findOne({
    where: { id: params.productId },
  });

  if (!product) {
    // we know we can't render the component
    // so throw immediately to stop executing code
    // and show the not found page
    throw new Response("Not Found", { status: 404 });
  }

  const cart = await getCart(request);
  return json({
    product,
    inCart: cart.includes(product.id),
  });
};

URL 查询参数

URL 查询参数是 URL 中 ? 之后的部分。其他名称包括“查询字符串”、“搜索字符串”或“位置搜索”。您可以通过从 request.url 创建 URL 来访问这些值。

import type { LoaderFunctionArgs } from "@remix-run/node"; // or cloudflare/deno
import { json } from "@remix-run/node"; // or cloudflare/deno

export const loader = async ({
  request,
}: LoaderFunctionArgs) => {
  const url = new URL(request.url);
  const term = url.searchParams.get("term");
  return json(await fakeProductSearch(term));
};

这里有几个 Web 平台类型在起作用:

  • request 对象有一个 url 属性。
  • URL 构造函数 将 URL 字符串解析为一个对象。
  • url.searchParamsURLSearchParams 的一个实例,它是位置搜索字符串的解析版本,可以轻松读取和操作搜索字符串。

给定以下 URL,搜索参数将按如下方式解析:

URL url.searchParams.get("term")
/products?term=stretchy+pants "stretchy pants"
/products?term= ""
/products null

数据重载

当渲染多个嵌套路由并且搜索参数发生更改时,所有路由都将重新加载(而不仅仅是新的或更改的路由)。这是因为搜索参数是一个跨领域的问题,可能会影响任何加载器。如果您想防止某些路由在这种情况下重新加载,请使用shouldRevalidate

组件中的搜索参数

有时您需要从组件而不是加载器和操作中读取和更改搜索参数。根据您的用例,有多种方法可以做到这一点。

设置搜索参数

设置搜索参数最常见的方法是让用户使用表单控制它们。

export default function ProductFilters() {
  return (
    <Form method="get">
      <label htmlFor="nike">Nike</label>
      <input
        type="checkbox"
        id="nike"
        name="brand"
        value="nike"
      />

      <label htmlFor="adidas">Adidas</label>
      <input
        type="checkbox"
        id="adidas"
        name="brand"
        value="adidas"
      />

      <button type="submit">Update</button>
    </Form>
  );
}

如果用户只选择了一个:

  • Nike
  • Adidas

那么 URL 将是 /products/shoes?brand=nike

如果用户选择了两个:

  • Nike
  • Adidas

那么 URL 将是: /products/shoes?brand=nike&brand=adidas

请注意,brand 在 URL 搜索字符串中重复,因为两个复选框都被命名为 "brand"。在您的加载器中,您可以使用 searchParams.getAll 来访问所有这些值。

import type { LoaderFunctionArgs } from "@remix-run/node"; // or cloudflare/deno
import { json } from "@remix-run/node"; // or cloudflare/deno

export async function loader({
  request,
}: LoaderFunctionArgs) {
  const url = new URL(request.url);
  const brands = url.searchParams.getAll("brand");
  return json(await getProducts({ brands }));
}

链接到搜索参数

作为开发人员,您可以通过链接到包含搜索字符串的 URL 来控制搜索参数。该链接会将当前 URL 中的搜索字符串(如果有)替换为链接中的内容。

<Link to="?brand=nike">Nike (only)</Link>

在组件中读取搜索参数

除了在加载器中读取搜索参数外,您通常还需要在组件中访问它们。

import { useSearchParams } from "@remix-run/react";

export default function ProductFilters() {
  const [searchParams] = useSearchParams();
  const brands = searchParams.getAll("brand");

  return (
    <Form method="get">
      <label htmlFor="nike">Nike</label>
      <input
        type="checkbox"
        id="nike"
        name="brand"
        value="nike"
        defaultChecked={brands.includes("nike")}
      />

      <label htmlFor="adidas">Adidas</label>
      <input
        type="checkbox"
        id="adidas"
        name="brand"
        value="adidas"
        defaultChecked={brands.includes("adidas")}
      />

      <button type="submit">Update</button>
    </Form>
  );
}

您可能希望在任何字段更改时自动提交表单,为此,请使用 useSubmit

import {
  useSubmit,
  useSearchParams,
} from "@remix-run/react";

export default function ProductFilters() {
  const submit = useSubmit();
  const [searchParams] = useSearchParams();
  const brands = searchParams.getAll("brand");

  return (
    <Form
      method="get"
      onChange={(e) => submit(e.currentTarget)}
    >
      {/* ... */}
    </Form>
  );
}

命令式地设置搜索参数

虽然不常见,但您也可以随时出于任何原因命令式地设置搜索参数。这里的用例很少,我们甚至想不出一个好的例子,但这里有一个简单的例子:

import { useSearchParams } from "@remix-run/react";

export default function ProductFilters() {
  const [searchParams, setSearchParams] = useSearchParams();

  useEffect(() => {
    const id = setInterval(() => {
      setSearchParams({ now: Date.now() });
    }, 1000);
    return () => clearInterval(id);
  }, [setSearchParams]);

  // ...
}

搜索参数和受控输入

通常,您希望保持某些输入(如复选框)与 URL 中的搜索参数同步。这在使用 React 的受控组件概念时可能会有点棘手。

只有当搜索参数可以通过两种方式设置时才需要这样做,并且我们希望输入与搜索参数保持同步。例如,<input type="checkbox">Link 都可以更改此组件中的品牌。

import { useSearchParams } from "@remix-run/react";

export default function ProductFilters() {
  const [searchParams] = useSearchParams();
  const brands = searchParams.getAll("brand");

  return (
    <Form method="get">
      <p>
        <label htmlFor="nike">Nike</label>
        <input
          type="checkbox"
          id="nike"
          name="brand"
          value="nike"
          defaultChecked={brands.includes("nike")}
        />
        <Link to="?brand=nike">(only)</Link>
      </p>

      <button type="submit">Update</button>
    </Form>
  );
}

如果用户单击复选框并提交表单,则 URL 更新,复选框状态也会更改。但是,如果用户单击链接,则只会更新 URL 而不会更新复选框。这不是我们想要的。您可能熟悉 React 的受控组件,并认为应该将其切换为 checked 而不是 defaultChecked

<input
  type="checkbox"
  id="adidas"
  name="brand"
  value="adidas"
  checked={brands.includes("adidas")}
/>

现在我们遇到了相反的问题:单击链接会更新 URL 和复选框状态,但是复选框不再起作用,因为 React 会阻止状态更改,直到控制它的 URL 更改 - 而它永远不会更改,因为我们无法更改复选框并重新提交表单。

React 希望您使用一些状态来控制它,但我们希望用户控制它直到他们提交表单,然后我们希望 URL 在它更改时控制它。所以我们处于这种“半控制”的状态。

您有两种选择,您选择哪种取决于您想要的用户体验。

第一选择:最简单的事情是在用户单击复选框时自动提交表单。

import {
  useSubmit,
  useSearchParams,
} from "@remix-run/react";

export default function ProductFilters() {
  const submit = useSubmit();
  const [searchParams] = useSearchParams();
  const brands = searchParams.getAll("brand");

  return (
    <Form method="get">
      <p>
        <label htmlFor="nike">Nike</label>
        <input
          type="checkbox"
          id="nike"
          name="brand"
          value="nike"
          onChange={(e) => submit(e.currentTarget.form)}
          checked={brands.includes("nike")}
        />
        <Link to="?brand=nike">(only)</Link>
      </p>

      {/* ... */}
    </Form>
  );
}

(如果您还在表单 onChange 上自动提交,请确保 e.stopPropagation(),以便该事件不会冒泡到表单,否则您将在每次单击复选框时获得双重提交。)

第二选择:如果您希望输入是“半受控的”,其中复选框反映 URL 状态,但用户也可以在提交表单和更改 URL 之前打开和关闭它,您需要连接一些状态。这有点工作,但很简单。

  • 从搜索参数初始化一些状态。
  • 当用户单击复选框时更新状态,以便该框变为“已选中”。
  • 当搜索参数更改(用户提交表单或单击链接)时更新状态,以反映 url 搜索参数中的内容。
import {
  useSubmit,
  useSearchParams,
} from "@remix-run/react";

export default function ProductFilters() {
  const submit = useSubmit();
  const [searchParams] = useSearchParams();
  const brands = searchParams.getAll("brand");

  const [nikeChecked, setNikeChecked] = React.useState(
    // initialize from the URL
    brands.includes("nike")
  );

  // Update the state when the params change
  // (form submission or link click)
  React.useEffect(() => {
    setNikeChecked(brands.includes("nike"));
  }, [brands, searchParams]);

  return (
    <Form method="get">
      <p>
        <label htmlFor="nike">Nike</label>
        <input
          type="checkbox"
          id="nike"
          name="brand"
          value="nike"
          onChange={(e) => {
            // update checkbox state w/o submitting the form
            setNikeChecked(true);
          }}
          checked={nikeChecked}
        />
        <Link to="?brand=nike">(only)</Link>
      </p>

      {/* ... */}
    </Form>
  );
}

您可能希望为此类复选框创建一个抽象。

<div>
  <SearchCheckbox name="brand" value="nike" />
  <SearchCheckbox name="brand" value="reebok" />
  <SearchCheckbox name="brand" value="adidas" />
</div>;

function SearchCheckbox({ name, value }) {
  const [searchParams] = useSearchParams();
  const paramsIncludeValue = searchParams
    .getAll(name)
    .includes(value);
  const [checked, setChecked] = React.useState(
    paramsIncludeValue
  );

  React.useEffect(() => {
    setChecked(paramsIncludeValue);
  }, [paramsIncludeValue]);

  return (
    <input
      type="checkbox"
      name={name}
      value={value}
      checked={checked}
      onChange={(e) => setChecked(e.target.checked)}
    />
  );
}

选项 3:我们说过只有两个选项,但如果您非常了解 React,则可能还有一个第三个不道德的选项会诱惑您。您可能希望使用 key prop 技巧来删除输入并重新安装它。虽然很聪明,但这会导致可访问性问题,因为用户在单击它后 React 从文档中删除节点时会失去焦点。

不要这样做,这会导致可访问性问题。

<input
  type="checkbox"
  id="adidas"
  name="brand"
  value="adidas"
  key={"adidas" + brands.includes("adidas")}
  defaultChecked={brands.includes("adidas")}
/>

Remix 优化

Remix 通过仅加载页面上导航时正在更改的部分的数据来优化用户体验。例如,考虑一下您现在在这些文档中使用的 UI。侧面的导航栏位于一个父路由中,该路由获取了所有文档的动态生成的菜单,而子路由则获取了您现在正在阅读的文档。如果您单击侧边栏中的链接,Remix 知道父路由将保留在页面上 - 但子路由的数据将更改,因为文档的 url 参数将更改。有了这个洞察,Remix 不会重新获取父路由的数据

没有 Remix 的下一个问题是“我如何重新加载所有数据?”。这也内置在 Remix 中。每当调用action(用户提交了表单或您,程序员,从 useSubmit 调用了 submit)时,Remix 将自动重新加载页面上的所有路由,以捕获可能发生的任何更改。

您不必担心过期的缓存或避免在用户与您的应用程序交互时过度获取数据,这都是自动的。

在以下三种情况下,Remix 将重新加载您的所有路由:

  • 在操作之后(表单,useSubmitfetcher.submit)。
  • 如果 url 搜索参数更改(任何加载器都可以使用它们)。
  • 用户单击指向他们已经所在的完全相同的 URL 的链接(这也将替换历史记录堆栈中的当前条目)。

所有这些行为都模拟了浏览器的默认行为。在这些情况下,Remix 对您的代码了解不足以优化数据加载,但您可以使用 shouldRevalidate 自己对其进行优化。

数据库

由于 Remix 的数据约定和嵌套路由,您通常会发现您不需要使用诸如 React Query、SWR、Apollo、Relay、urql 等客户端数据库。如果您主要使用诸如 redux 之类的全局状态管理库来与服务器上的数据进行交互,那么您也可能不需要这些库。

当然,Remix 不会阻止您使用它们(除非它们需要捆绑器集成)。您可以引入任何您喜欢的 React 数据库,并在您认为它们比 Remix API 更能为您的 UI 服务的地方使用它们。在某些情况下,您可以使用 Remix 进行初始服务器渲染,然后在之后切换到您最喜欢的库进行交互。

也就是说,如果您引入外部数据库并绕过 Remix 自己的数据约定,Remix 将无法再自动:

  • 服务端渲染您的页面
  • 在 JavaScript 加载失败时,能够应对网络状况
  • 当用户与您的网站互动时进行优化,通过仅加载页面中变化的部分的数据来提高速度
  • 在过渡期间并行获取数据、JavaScript 模块、CSS 和其他资源,避免导致 UI 卡顿的渲染+获取瀑布
  • 通过在操作后重新验证来确保 UI 中的数据与服务器上的数据同步。
  • 在后退/前进点击时(甚至跨域)提供出色的滚动恢复
  • 使用 错误边界 处理服务端错误
  • 使用 错误边界 为“未找到”和“未授权”提供可靠的用户体验
  • 帮助您保持 UI 的愉快路径畅通无阻。

相反,您需要做额外的工作来提供良好的用户体验。

Remix 的设计旨在满足您设计的任何用户体验。虽然您需要外部数据库是出乎意料的,但您可能仍然想要一个,这没问题!

在您学习 Remix 时,您会发现您将从考虑客户端状态转变为考虑 URL,并且当您这样做时,您将免费获得很多东西。

注意事项

加载器仅在服务器上通过浏览器发出的 fetch 调用,因此您的数据会使用 JSON.stringify 进行序列化,并通过网络发送,然后才会到达您的组件。这意味着您的数据需要是可序列化的。例如

这行不通!

export async function loader() {
  return {
    date: new Date(),
    someMethod() {
      return "hello!";
    },
  };
}

export default function RouteComp() {
  const data = useLoaderData<typeof loader>();
  console.log(data);
  // '{"date":"2021-11-27T23:54:26.384Z"}'
}

不是所有东西都能传递!加载器用于数据,而数据需要是可序列化的。

一些数据库(如 FaunaDB)返回带有方法的对象,您需要小心地在从加载器返回之前对其进行序列化。通常这不是问题,但最好了解您的数据是通过网络传输的。

此外,Remix 将为您调用加载器,在任何情况下您都不应该尝试直接调用加载器

这行不通

export const loader = async () => {
  return json(await fakeDb.products.findMany());
};

export default function RouteComp() {
  const data = loader();
  // ...
}
文档和示例的许可协议为 MIT