数据加载
本页内容

数据加载

Remix 的主要功能之一是简化与服务器的交互,以将数据获取到组件中。当你遵循这些约定时,Remix 可以自动

  • 服务器渲染你的页面
  • 在 JavaScript 无法加载时,对网络状况具有弹性
  • 通过仅加载页面变化部分的数据,在用户与你的网站交互时进行优化,使其速度更快
  • 在过渡时并行获取数据、JavaScript 模块、CSS 和其他资源,避免导致 UI 出现卡顿的渲染+获取瀑布
  • 通过在操作后重新验证,确保 UI 中的数据与服务器上的数据同步
  • 在前后点击(甚至跨域)时提供出色的滚动恢复
  • 使用错误边界处理服务器端错误
  • 使用错误边界为“未找到”和“未授权”提供可靠的 UX
  • 帮助你保持 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 只在服务器上运行。这意味着我们硬编码的产品数组不会包含在浏览器捆绑包中,并且在用于数据库、支付处理、内容管理系统等方面的 API 和 SDK 中,使用服务器端代码是安全的

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

路由参数

当你使用 $ 对文件进行命名时,例如 app/routes/users.$userId.tsxapp/routes/users.$userId.projects.$projectId.tsx,动态段(以 $ 开头的段)将从 URL 解析并传递给你的 loader,放在 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 获取数据非常容易。你可以从你的 loader(在服务器上)进行获取,并让 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 Key Value 存储允许您将数据持久化到边缘,就好像它是静态资源一样。

对于 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>
  );
}

如果用户只选择了一个选项:

  • 耐克
  • 阿迪达斯

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

如果用户同时选择了两个选项:

  • 耐克
  • 阿迪达斯

那么 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>
  );
}

以命令式方式设置搜索参数

虽然不常见,但您也可以在任何时间以任何原因以命令式方式设置 searchParams。这里的用例很少,少到我们甚至想不出一个好的例子,但这里有一个愚蠢的例子。

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)}
    />
  );
}

第三个选择:我们说只有两个选择,但还有一个第三个不洁的选择,如果您非常了解 React,可能会诱惑您。您可能希望清除输入并使用 key 属性重新挂载它。虽然很巧妙,但这会导致可访问性问题,因为当 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 中的数据与服务器上的数据同步。
  • 在前后点击(甚至跨域)时提供出色的滚动恢复
  • 使用错误边界处理服务器端错误
  • 使用错误边界为“未找到”和“未授权”提供可靠的 UX
  • 帮助您保持 UI 快乐路径的快乐。

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

Remix 旨在满足您设计的任何用户体验。虽然您需要外部数据库的情况并不常见,但您可能仍然想要使用它,这没有问题!

当你学习 Remix 时,你会发现自己从以客户端状态思考转变为以 URL 思考,而这样做会让你免费获得很多东西。

注意事项

Loader 仅在服务器上通过浏览器中的 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"}'
}

并非所有内容都能成功!Loader 用于数据,而数据需要可序列化。

一些数据库(如 FaunaDB)返回带有方法的对象,你需要谨慎地序列化这些对象,然后才能从你的 loader 返回。通常情况下这不会有问题,但理解你的数据正在通过网络传输很重要。

此外,Remix 会为你调用你的 loader,你永远不要尝试直接调用你的 loader。

这将不起作用

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

export default function RouteComp() {
  const data = loader();
  // ...
}
文档和示例根据 MIT