---
title: 数据获取
sidebar_position: 3
---

# 数据获取

Modern.js 中提供了开箱即用的数据获取能力，开发者可以通过这些 API，在项目中获取数据。需要注意的是，这些 API 并不帮助应用发起请求，而是帮助开发者更好地管理数据，提升项目的性能。

## 什么是 Data Loader

:::note
Modern.js v1 项目通过 `useLoader` 获取数据，这已经不是我们推荐的用法，建议迁移到 Data Loader。
:::

Modern.js 推荐使用[约定式路由](/guides/basic-features/routes)做路由的管理，每个路由组件（`layout.ts`，`page.ts` 或 `$.tsx`）都可以有一个同名的 `.data` 文件。这些文件可以导出一个 `loader` 函数，我们称为 Data Loader，它会在对应的路由组件渲染之前执行，为组件提供数据。如下面示例：

```bash
.
└── routes
    ├── layout.tsx
    └── user
        ├── layout.tsx
        ├── layout.data.ts
        ├── page.tsx
        └── page.data.ts
```

在 `routes/user/page.data.ts` 文件中，可以导出一个 `loader` 函数：

```ts title="routes/user/page.data.ts"
export type ProfileData = {
  /*  some types */
};

export const loader = async (): Promise<ProfileData> => {
  const res = await fetch('https://api/user/profile');
  return await res.json();
};
```

:::warning 兼容性
- 在之前的版本中，Data Loader 是定义在 `.loader` 文件中的。当前版本中，我们推荐定义在 `.data` 文件中，同时我们会保持对 `.loader` 文件的兼容。
- 在 `.loader` 文件中，Data Loader 可以默认导出。但在 `data` 文件中，Data Loader 需要以 `loader` 具名导出。
```ts
  // xxx.loader.ts
export default () => {}

// xxx.data.ts
export const loader = () => {}
```
:::


在路由组件中，你可以通过 `useLoaderData` 函数获取数据：

```ts title="routes/user/page.tsx"
import { useLoaderData } from '@modern-js/runtime/router';
import type { ProfileData } from './page.data.ts';

export default function UserPage() {
  const profileData = useLoaderData() as ProfileData;
  return <div>{profileData}</div>;
}
```

:::caution
路由组件和 `.data` 文件共享类型，要使用 `import type` 语法，避免引入预期之外的副作用。
:::

在 CSR 项目中，`loader` 函数会在客户端执行，`loader` 函数内可以使用浏览器的 API（但通常不需要，也不推荐）。

在 SSR 项目中，不管是首屏，还是在客户端的导航，`loader` 函数只会在服务端执行，这里可以调用任意的 Node.js API，同时这里使用的任何依赖和代码都不会包含在客户端的 bundle 中。

:::tip
在以后的版本中，Modern.js 可能会支持在 CSR 环境下，`loader` 函数也在服务端运行，以提高性能和安全性，所以这里建议尽可能地保证 `loader` 的纯粹，只做数据获取的场景。
:::

当在浏览器端导航时，基于[约定式路由](/guides/basic-features/routes)，Modern.js 能够支持所有的 `loader` 函数并行执行（请求）。即当访问 `/user/profile` 时，`/user` 和 `/user/profile` 下的 `loader` 函数都会并行执行（请求），这种方式解决了部分请求、渲染瀑布流的问题，较大的提升了页面性能。

## `loader` 函数

`loader` 函数有两个入参，分别用于获取路由参数和请求信息。

### params

`params` 是当路由为[动态路由](/guides/basic-features/routes#动态路由)时的动态路由片段，会作为参数传入 `loader` 函数：

```tsx title="routes/user/[id]/page.data.ts"
import { LoaderFunctionArgs } from '@modern-js/runtime/router';

// 访问 /user/123 时，函数的参数为 `{ params: { id: '123' } }`
export const loader = async ({ params }: LoaderFunctionArgs) => {
  const { id } = params;
  const res = await fetch(`https://api/user/${id}`);
  return res.json();
};
```

### request

`request` 是一个 [Fetch Request](https://developer.mozilla.org/en-US/docs/Web/API/Request) 实例。一个常见的使用场景是通过 `request` 获取查询参数：

```tsx
import { LoaderFunctionArgs } from '@modern-js/runtime/router';

export const loader = async ({ request }: LoaderFunctionArgs) => {
  const url = new URL(request.url);
  const userId = url.searchParams.get('id');
  return queryUser(userId);
};
```

### 返回值

`loader` 函数的返回值**只能是两种数据结构之一**，可序列化的数据对象或者 [Fetch Response](https://developer.mozilla.org/en-US/docs/Web/API/Response) 实例。

```tsx
const loader = async (): Promise<ProfileData> => {
  return {
    message: 'hello world',
  };
};
export default loader;
```

默认情况下，`loader` 返回的响应 `Content-type` 是 `application/json`，`status` 为 200，你可以通过自定义 `Response` 来设置：

```tsx
const loader = async (): Promise<ProfileData> => {
  const data = { message: 'hello world' };
  return new Response(JSON.stringify(data), {
    status: 200,
    headers: {
      'Content-Type': 'application/json; utf-8',
    },
  });
};
```

## 在不同环境使用 Data Loader

`loader` 函数可能会在服务端或浏览器端执行。在服务端执行的 `loader` 函数，我们称为 Server Loader，在浏览器端执行的称为 Client Loader。

在 CSR 应用中，`loader` 函数会在浏览器端执行，即默认都是 Client Loader。

在 SSR 应用中，`loader` 函数只会在服务端执行，即默认都是 Server Loader。在 SSR 渲染时，Modern.js 会直接在服务端调用对应的 `loader` 函数。在浏览器端切换路由时，Modern.js 会发送一个 http 请求到 SSR 服务，同样在服务端触发 `loader` 函数。

:::note
SSR 应用的 `loader` 函数只在服务端执行可以带来以下好处：

- **简化使用方式**：保证 SSR 应用获取数据的方式是同构的，开发者无需根据环境区分 `loader` 函数执行的代码。
- **减少浏览器端 bundle 体积**：将逻辑代码及其依赖，从浏览器端移动到了服务端。
- **提高可维护性**：将逻辑代码移动到服务端，减少了数据逻辑对前端 UI 的直接影响。此外，也避免了浏览器端 bundle 中误引入服务端依赖，或服务端 bundle 中误引入浏览器端依赖的问题。

:::

我们推荐在 `loader` 函数中使用 `fetch` API 发起请求。在 Modern.js 中默认对 `fetch` API 做了 polyfill，允许服务端使用该 API 发起请求，这意味你都可以在 CSR 和 SSR 时同构的获取数据：

```tsx
export async function loader() {
  const res = await fetch('URL_ADDRESS');
  return {
    message: res.message
  }
}
```

## 错误处理

### 基本用法

在 `loader` 函数中，可以通过 `throw error` 或者 `throw response` 的方式处理错误，当 `loader` 函数中有错误被抛出时，Modern.js 会停止执行当前 `loader` 中的代码，并将前端 UI 切换到定义的 [`ErrorBoundary`](/guides/basic-features/routes#错误处理) 组件：

```tsx
// routes/user/profile/page.data.ts
export async function loader() {
  const res = await fetch('https://api/user/profile');
  if (!res.ok) {
    throw res;
  }
  return res.json();
}

// routes/user/profile/error.tsx
import { useRouteError } from '@modern-js/runtime/router';
const ErrorBoundary = () => {
  const error = useRouteError() as Response;
  return (
    <div>
      <h1>{error.status}</h1>
      <h2>{error.statusText}</h2>
    </div>
  );
};

export default ErrorBoundary;
```

### 修改 HTTP 状态码

在 SSR 项目中你可以通过在 `loader` 函数中 `throw response` 的方式，控制页面的状态码，展示对应的 UI。

如以下示例，页面的状态码将与这个 `response` 保持一致，页面也会展示为 `ErrorBoundary` 的 UI:

```ts
// routes/user/profile/page.data.ts
export async function loader() {
  const user = await fetchUser();
  if(!user){
    throw new Response('The user was not found', { status: 404 });
  }
  return user;
}

// routes/error.tsx
import { useRouteError } from '@modern-js/runtime/router';
const ErrorBoundary = () => {
  const error = useRouteError() as { data: string };
  return <div className="error">{error.data}</div>;
};

export default ErrorBoundary;
```

## 获取上层组件的数据

很多场景下，子组件需要获取到上层组件 `loader` 中的数据，你可以通过 `useRouteLoaderData` 方便地获取到上层组件的数据：

```tsx
// routes/user/profile/page.tsx
import { useRouteLoaderData } from '@modern-js/runtime/router';

export function UserLayout() {
  // 获取 routes/user/layout.data.ts 中 `loader` 返回的数据
  const data = useRouteLoaderData('user/layout');
  return (
    <div>
      <h1>{data.name}</h1>
      <h2>{data.age}</h2>
    </div>
  );
}
```

`userRouteLoaderData` 接受一个参数 `routeId`。在使用约定式路由时，Modern.js 会为你自动生成 `routeId`，`routeId` 的值是对应组件相对于 `src/routes` 的路径，如上面的例子中，子组件想要获取 `routes/user/layout.tsx` 中 loader 返回的数据，`routeId` 的值就是 `user/layout`。

在多入口场景下，`routeId` 的值需要加上对应入口的名称，入口名称非指定情况下一般是入口的目录名，如以下目录结构：

```bash
.
└── src
    ├── entry1
    │     └── routes
    │           └── layout.tsx
    └── entry2
          └── routes
                └── layout.tsx
```

如果想获取 `entry1/routes/layout.tsx` 中 `loader` 返回的数据，`routeId` 的值就是 `entry1_layout`。


## Loading UI (Experimental)

:::info Experimental
此功能当前是实验性功能，后续 API 可能有调整。
:::

创建 `user/layout.data.ts`，并添加以下代码：

```ts title="routes/user/layout.data.ts"
import { defer } from '@modern-js/runtime/router';

export const loader = () =>
  defer({
    userInfo: new Promise(resolve => {
      setTimeout(() => {
        resolve({
          age: 1,
          name: 'user layout',
        });
      }, 1000);
    }),
  });
```

在 `user/layout.tsx` 中添加以下代码：

```tsx title="routes/user/layout.tsx"
import { Await, defer, useLoaderData, Outlet } from '@modern-js/runtime/router';

export default function UserLayout() {
  const { userInfo } = useLoaderData() as { userInfo: Promise<UserInfo> };
  return (
    <div>
      <React.Suspense fallback={<p>Loading...</p>}>
        <Await
          resolve={userInfo}
          children={userInfo => (
            <div>
              <span>{userInfo.name}</span>
              <span>{userInfo.age}</span>
              <Outlet />
            </div>
          )}
        ></Await>
      </React.Suspense>
    </div>
  );
}
```

:::tip
`<Await>` 组件的具体用法请查看 [Await](https://reactrouter.com/en/main/components/await)，`defer` 的具体用法请查看 [defer](https://reactrouter.com/en/main/guides/deferred)。
:::

## 数据缓存

在路由导航时，Modern.js 只会加载路由变化的部分的数据。如当前路由是 `a/b`，`a` 路径对应的 Data Loader 已经执行过，当从 `/a/b` 跳转到 `/a/c`时，`a` 路径对应的 Data Loader 不会重新执行，`c` 路径对应的 Data Loader 会执行，并获取了数据。

这种默认的优化策略避免了无效重复数据的请求。此时你可能会疑惑，如何更新 `a` 路径对应 Data Loader 的数据？

在 Modern.js 中，以下几种情况，Modern.js 会重新加载对应路由路径的数据：

1. 在 [Data Action](/guides/basic-features/data/data-write.md) 触发后
2. URL 参数发生变化后
3. 用户点击的链接与当前页面的 URL 相同
4. 在路由组件中定义了 [`shouldRevalidate`](#/shouldrevalidate) 函数，该函数返回 `true`

:::tip
如果你在路由上定义了 [`shouldRevalidate`](#/shouldrevalidate) 函数，会先检查该函数，判断是否需要重新加载数据。
:::

### `shouldRevalidate`

:::warning
目前 `shouldRevalidate` 只会在 CSR 和 Streaming SSR 下生效。
:::

在路由组件（`layout.tsx`，`page.tsx`，`$.tsx`）中，我们可以导出一个 `shouldRevalidate` 函数。每次项目中的路由变化时，这个函数会被触发，该函数可以控制要重新加载哪些路由中的数据。如果这个函数返回 `true`，Modern.js 就会重新加载对应路由的数据。

```ts title="routes/user/layout.tsx"
import type { ShouldRevalidateFunction } from '@modern-js/runtime/router';
export const shouldRevalidate: ShouldRevalidateFunction = ({
  actionResult,
  currentParams,
  currentUrl,
  defaultShouldRevalidate,
  formAction,
  formData,
  formEncType,
  formMethod,
  nextParams,
  nextUrl,
}) => {
  return true;
};
```

:::tip
`shouldRevalidate` 函数的更多信息可以参考 [react-router](https://reactrouter.com/en/main/route/should-revalidate)。
:::

## 错误用法

1. `loader` 中只能返回可序列化的数据，在 SSR 环境下，`loader` 函数的返回值会被序列化为 JSON 字符串，然后在浏览器端被反序列化为对象。因此，`loader` 函数中不能返回不可序列化的数据（如函数）。

:::warning
目前 CSR 下没有这个限制，但我们强烈推荐你遵循该限制，且未来我们可能在 CSR 下也加上该限制。

:::

```ts
// This won't work!
export default () => {
  return {
    user: {},
    method: () => {},
  };
};
```

2. Modern.js 会帮你调用 `loader` 函数，你不应该自己调用 `loader` 函数：

```ts
// This won't work!
export const loader = async () => {
  const res = fetch('https://api/user/profile');
  return res.json();
};

import { loader } from './page.data.ts';
export default function RouteComp() {
  const data = loader();
}
```

3. 不能从路由组件中引入 `loader` 文件，也不能从 `loader` 文件引入路由组件中的变量，如果需要共享类型的话，应该使用 `import type`

```ts
// Not allowed
// routes/layout.tsx
import { useLoaderData } from '@modern-js/runtime/router';
import { ProfileData } from './page.data.ts'; // should use "import type" instead

export const fetch = wrapFetch(fetch);

export default function UserPage() {
  const profileData = useLoaderData() as ProfileData;
  return <div>{profileData}</div>;
}

// routes/layout.data.ts
import { fetch } from './layout.tsx'; // should not be imported from the routing component
export type ProfileData = {
  /*  some types */
};

export const loader = async (): Promise<ProfileData> => {
  const res = await fetch('https://api/user/profile');
  return await res.json();
};
```

4. 在服务端运行时，`loader` 函数会被打包为一个统一的 bundle，所以我们不推荐服务端的代码使用 `__filename` 和 `__dirname`。

## 常见问题

1. `loader` 和 BFF 函数的关系是什么？

在 CSR 项目中，`loader` 在浏览器端执行，在 `loader` 可以直接调用 BFF 函数进行接口请求。

在 SSR 项目中，每个 `loader` 也是一个服务端接口。我们推荐使用 `loader` 替代 http method 为 `get` 的 BFF 函数，避免多一层转发和执行。
