服务端渲染(SSR)

服务端渲染(Server-Side Rendering,简称 SSR)在服务器端生成完整的 HTML 页面,然后发送到浏览器端直接显示,无需客户端额外渲染。

核心优势

  • 更快首屏:服务端预渲染,浏览器直接显示,无需等待 JavaScript 执行
  • 更好 SEO:搜索引擎可直接索引完整的 HTML 内容
  • 开箱即用:无需编写复杂服务端逻辑,无需单独运维
默认渲染模式

Modern.js 的 SSR 默认使用流式渲染(Streaming SSR),页面会边渲染边返回,用户可以更快看到初始内容。

详细用法请参考流式服务端渲染(Streaming SSR)文档。

如需切换到传统 SSR 模式(等待所有数据加载完成后一次性返回),可配置:

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

export default defineConfig({
  server: {
    ssr: {
      mode: 'string', // 传统 SSR 模式
    },
  },
});

开启 SSR

在 Modern.js 启用 SSR 非常简单,只需要设置 server.ssrtrue 即可:

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

export default defineConfig({
  server: {
    ssr: true, // 默认启用流式渲染
  },
});

数据获取

Modern.js 提供了 Data Loader,支持在 SSR 和 CSR 下同构地获取数据。每个路由模块(如 layout.tsxpage.tsx)都可以定义自己的 Data Loader:

了解更多

以下方式不会有流式渲染效果,要想获得流式渲染效果请参考流式服务端渲染(Streaming SSR)

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

在组件中通过 Hooks API 获取数据:

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

使用 Client Loader

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

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

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

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}`);
  const data = await res.json();
  return {
    message: data.message,
  }
}

SSR 降级

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

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

Data Loader 执行报错

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

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

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

组件渲染报错

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

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。

日志与监控

Tip

SSR 相关的数据监控已整合到 Monitors 模块中,详见 Monitors

页面缓存

Modern.js 中内置了缓存的能力,详细请参考渲染缓存

运行环境差异

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。例如应用希望做全局的事件监听:

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

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

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

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

// SSR 产物
if (false) {
}

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

更多内容可以查看环境变量

依赖中的副作用

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

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

export const doSomething = () => {}

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

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 文件做一层代理:

a.ts
export { doSomething } from 'packageA';
a.server.ts
export const doSomething: any = () => {};

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

routes/page.tsx
import { doSomething } from './a'

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

常见问题

保持渲染一致

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

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

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

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

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

Info

关于 React hydrate 逻辑请参考这里

应用需要保持 SSR 与 CSR 渲染结果的一致性,如果存在不一致的情况,说明这部分内容无需在 SSR 中进行渲染。Modern.js 为这类在 SSR 中不需要渲染的内容提供 <NoSSR> 工具组件

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

在不需要进行 SSR 的元素外部,用 NoSSR 组件包裹:

<NoSSR>
  <div>browser content</div>
</NoSSR>

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

在实际场景中,有些应用的 UI 展示会和用户设备有关,例如 UA 信息。Modern.js 也提供了 use(RuntimeContext) 这类 API,可以在组件中获取完整的请求信息,利用它保证 SSR 与 CSR 的渲染结果一致。详细用法请参考运行时上下文

关注内存泄漏

警告

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

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

例如以下代码,使用 redux-observable 时,习惯了 CSR 的开发者通常会在组件中这样编码:

/* 代码仅作为示例,不可运行 */
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 时,如果不确定应用是否存在这类隐患,可以对应用进行压测。