---
title: 服务端渲染（SSR）
---

# 服务端渲染（SSR）

通过在服务器端将网页的 HTML 内容渲染成完整的网页（Server-Side Rendering，简称 SSR），然后将生成的网页发送到浏览器端，浏览器端只需要显示网页即可，不需要再进行额外的渲染。

它主要的优势在于：

- 提高首屏加载速度：SSR 可以在服务器端生成完整的网页，浏览器端只需要下载网页内容即可，不需要再进行额外的渲染，从而提高了首屏加载速度。
- 有利于 SEO：SSR 可以生成完整的 HTML 内容，搜索引擎可以直接索引 HTML 内容，从而提高网站的排名。

如果你有以下场景的需求，开发者可以考虑使用 SSR 来渲染你的页面：

1. 对首屏加载速度要求较高的网站，如电商网站、新闻网站等。
2. 对用户体验要求较高的网站，如社交网站、游戏网站等。
3. 对 SEO 要求较高的网站，如企业官网、博客等。

在 Modern.js 中，SSR 也是开箱即用的。开发者无需为 SSR 编写复杂的服务端逻辑，也无需关心 SSR 的运维，或是创建单独的服务。

除了开箱即用的 SSR 服务，为了保证开发者的开发体验，Modern.js 还具备：

- 完备的 SSR 降级策略，保证页面能够安全运行。
- 自动分割子路由，按需加载，减少首屏资源体积。
- 内置缓存系统，解决服务端负载高的问题。

## 开启 SSR

在 Modenr.js 启用 SSR 非常简单，只需要设置 [`server.ssr`](/configure/app/server/ssr) 为 `true` 即可：

```ts title="modern.config.ts"
import { defineConfig } from '@modern-js/app-tools';

export default defineConfig({
  server: {
    ssr: true,
  },
});
```

## 数据获取

:::tip 前置阅读
如果你还不了解 Data Loader 如何使用或 Client Loader 的概念，请先阅读[数据获取](/guides/basic-features/data/data-fetch)。
:::

### 基本用法

Modern.js 中提供了 Data Loader，方便开发者在 SSR、CSR 下同构地获取数据。每个路由模块，如 `layout.tsx` 和 `page.tsx` 都可以定义自己的 Data Loader：

```ts title="src/routes/page.data.ts"
export const loader = () => {
  return {
    message: 'Hello World',
  };
};
```

在组件中可以通过 Hooks API 的方式获取 `loader` 函数返回的数据：

```tsx
import { useLoaderData } from '@modern-js/runtime/router';
export default () => {
  const data = useLoaderData();
  return <div>{data.message}</div>;
};
```


### 使用 Client Loader

:::info
该功能需要 x.36.0 以上版本，推荐使用框架最新版本。
:::

默认情况下，在 SSR 应用中，`loader` 函数只会在服务端执行。但有些场景下，开发者可能期望在浏览器端发送的请求不经过 SSR 服务，直接请求数据源，例如：

1. 在浏览器端希望减少网络消耗，直接请求数据源。
2. 应用在浏览器端有数据缓存，不希望请求 SSR 服务获取数据。

Modern.js 支持在 SSR 应用中额外添加 `.data.client` 文件，同样具名导出 `loader`。此时 SSR 应用在服务端执行 Data Loader 报错降级，或浏览器端切换路由时，会像 CSR 应用一样在浏览器端执行该 `loader` 函数，而不是再向 SSR 服务发送数据请求。

```ts title="page.data.client.ts"
import cache from 'my-cache';

export async function loader({ params }) {
  if (cache.has(params.id)) {
    return cache.get(params.id);
  }
  const res = await fetch('URL_ADDRESS?id={params.id}');
  return {
    message: res.message,
  }
}
```

:::warning
要使用 Client Loader，必须有对应的 Server Loader，且 Server Loader 必须是 `.data` 文件约定，不能是 `.loader` 文件约定。
:::

## SSR 降级

在 Modern.js 中，如果应用在 SSR 过程中出现异常，Modern.js 会自动降级到 CSR 模式，并在 CSR 重新发起数据请求，保证页面能够正常展示。SSR 降级的原因主要分为两种：

1. Data Loader 执行报错
2. React 组件在服务端渲染报错

### Data Loader 执行报错

默认情况下，如果路由对应的 `loader` 函数执行报错，框架会在服务端直接渲染 `<ErrorBoundary>` 组件，并展示错误信息，这也是社区内大多数框架的默认行为。

Modern.js 也支持通过 [`server.ssr`](/configure/app/server/ssr) 配置项中的 `loaderFailureMode` 字段，自定义降级策略。当该字段被配置为 `clientRender` 时，会直接降级到 CSR 模式，并重新发起数据请求。

此时，如果路由中定义了 Client Loader，则会优先使用 Client Loader 发起数据请求。如果重新渲染仍然出错，再展示 `<ErrorBoundary>` 组件。

{/* Todo 补个图 */}

### 组件渲染报错

如果 Data Loader 执行正常，但组件渲染报错时，SSR 渲染将会部分或完全失败，例如以下代码：

```tsx
import { Await, useLoaderData } from '@modern-js/runtime/router';
import { Suspense } from 'react';

const Page = () => {
  const data = useLoaderData();
  const isNode = typeof window === 'undefined';
  const undefinedVars = data.unDefined;
  const definedVars = data.defined;

  return (
    <div>
      {isNode ? undefinedVars.msg : definedVars.msg}
    </div>
  );
};

export default Page;
```

此时，Modern.js 会将页面降级为 CSR，并利用 Data Loader 中已有的数据渲染。如果重新渲染仍然出错，则展示 `<ErrorBoundary>` 组件。

:::tip
组件渲染报错的行为，不会受到 `loaderFailureMode` 的影响，也不会在浏览器端执行 Client Loader。
:::

{/* Todo 补个图 */}

## 日志与监控

import Monitor from '@site-docs/components/ssr-monitor';

<Monitor />

## 页面缓存

Modern.js 中内置了缓存的能力，详细请参考[渲染缓存](/guides/basic-features/render/ssr-cache)。

## 运行环境差异

SSR 应用会同时运行在服务端和浏览器端，两者在运行环境上不完全相同，存在 Web API 和 Node API 的差异。

开启 SSR 时，Modern.js 会用相同的入口，构建出 SSR Bundle 和 CSR Bundle 两份产物。因此，在 SSR Bundle 中存在 Web API，或是在 CSR Bundle 中存在 Node API 时，都可能导致运行出错。出现这类问题的场景主要是两类：

- 应用自身代码存在问题
- 应用依赖的包中存在副作用

### 自身代码问题

这种场景通常出现在应用从 CSR 迁移到 SSR，CSR 应用通常会在代码中引入 Web API。例如应用希望做全局的事件监听：

```tsx
document.addEventListener('load', () => {
  console.log('document load');
});
const App = () => {
  return <div>Hello World</div>;
};
export default App;
```

对于这种场景，你可以直接使用 Modern.js 内置的环境变量 `MODERN_TARGET` 进行判断，在构建时删除无用代码：

```ts
if (process.env.MODERN_TARGET === 'browser') {
  document.addEventListener('load', () => {
    console.log('document load');
  });
}
```

开发环境打包后，SSR 产物和 CSR 产物会被编译成以下内容。因此 SSR 环境中不会再因为 Web API 报错：

```ts
// SSR 产物
if (false) {
}

// CSR 产物
if (true) {
  document.addEventListener('load', () => {
    console.log('document load');
  });
}
```

:::note
更多内容可以查看[环境变量](/guides/basic-features/env-vars)。
:::

### 依赖中的副作用

这类场景是在 SSR 应用中随时可能出现的，因为社区中的包并不都支持在两个运行环境中运行，有些包也无需在两个环境中运行。例如在代码中引入了包 A，它内部有使用了 Web API 的副作用：

```ts title="packageA"
document.addEventListener('load', () => {
  console.log('document load');
});

export const doSomething = () => {}
```

如果直接引用到组件中，会造成 CSR 加载报错，即使你已经使用环境变量进行判断，但仍然无法移除依赖中副作用的执行。

```tsx title="routes/page.tsx"
import { doSomething } from 'packageA';

export const Page = () => {
  if (process.env.MODERN_TARGET === 'browser') {
    doSomething();
  }
  return <div>Hello World</div>
}
```

Modern.js 也支持通过 `.server.` 后缀的文件来区分 SSR Bundle 和 CSR Bundle 产物的打包文件。可以创建同名的 `.ts` 和 `.server.ts` 文件做一层代理：

```ts title="a.ts"
export { doSomething } from 'packageA';
```

```ts title="a.server.ts"
export const doSomething: any = () => {};
```

在文件中直接引入 `./a`，此时 SSR 打包下会优先使用 `.server.ts` 后缀的文件，CSR 打包下会使用 `.ts` 后缀的文件。

```tsx title="routes/page.tsx"
import { doSomething } from './a'

export const Page = () => {
  doSomething();
  return <div>Hello World</div>
}
```

## 常见问题

### 保持渲染一致

SSR 业务需要保证在服务端渲染时的结果和浏览器端 Hydrate 的结果一致，否则很有可能出现不符合预期的渲染结果。这里通过一个例子，演示当 SSR 与 CSR 渲染不一致时出现的问题，在组件中添加以下代码：

```tsx
{
  typeof window !== 'undefined' ? <div>browser content</div> : null;
}
```

启动应用后，访问页面，会发现浏览器控制台抛出警告信息：

```sh
Warning: Expected server HTML to contain a matching <div> in <div>.
```

这是 React hydrate 结果与 SSR 渲染结果不一致造成的。虽然当前页面表现正常，但在复杂应用中，很有可能因此出现 DOM 层级混乱、样式混乱等问题。

:::info
关于 React hydrate 逻辑请参考[这里](https://zh-hans.react.dev/reference/react-dom/hydrate)。

:::

应用需要保持 SSR 与 CSR 渲染结果的一致性，如果存在不一致的情况，说明这部分内容无需在 SSR 中进行渲染。Modern.js 为这类在 SSR 中不需要渲染的内容提供 [`<NoSSR>` 工具组件](/apis/app/runtime/core/use-runtime-context)：

```ts
import { NoSSR } from '@modern-js/runtime/ssr';
```

在不需要进行 SSR 的元素外部，用 `NoSSR` 组件包裹：

```tsx
<NoSSR>
  <div>browser content</div>
</NoSSR>
```

修改代码后，刷新页发现之前的 Waring 消失。打开浏览器开发者工具的 Network 窗口，查看返回的 HTML 文档是不包含 `NoSSR` 组件包裹的内容的。

在实际场景中，有些应用的 UI 展示会和用户设备有关，例如 [UA](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/User-Agent) 信息。Modern.js 也提供了
[`useRuntimeContext`](/apis/app/runtime/core/use-runtime-context) 这类 API，可以在组件中获取完整的请求信息，利用它保证 SSR 与 CSR 的渲染结果一致。

### 关注内存泄漏

:::warning 警告
在 SSR 场景下，开发者需要特别关注内存泄露问题，即使是微小的内存泄露，在大量的访问后也会对服务造成影响。

:::

SSR 时，浏览器的每次请求，都会触发服务端重新执行一次组件渲染逻辑。所以，需要避免在全局定义任何可能不断增长的数据结构，或在全局进行事件订阅，或创建不会被销毁的流。

例如以下代码，使用 [redux-observable](https://redux-observable.js.org/) 时，习惯了 CSR 的开发者通常会在组件中这样编码：

```tsx
/* 代码仅作为示例，不可运行 */
import { createEpicMiddleware, combineEpics } from 'redux-observable';

const epicMiddleware = createEpicMiddleware();
const rootEpic = combineEpics();

export default function Test() {
  epicMiddleware.run(rootEpic);
  return <div>Hello Modern.js</div>;
}
```

在组件外层创建 Middleware 实例 `epicMiddleware`，并在组件内部调用 `epicMiddleware.run`。

在浏览器端，这段代码不会造成任何问题，但是在 SSR 时，Middleware 实例会一直无法被销毁。每次渲染组件，调用 `epicMiddleware.run(rootEpic)` 时，都会在内部添加新的事件绑定，导致整个对象不断变大，最终对应用性能造成影响。

CSR 中这类问题不易被发觉，因此从 CSR 切换到 SSR 时，如果不确定应用是否存在这类隐患，可以对应用进行压测。