服务端渲染(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.ssrtrue 即可:

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

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

数据获取

前置阅读

如果你还不了解 Data Loader 如何使用或 Client Loader 的概念,请先阅读数据获取

基本用法

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

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

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

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 服务发送数据请求。

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 配置项中的 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。

日志与监控

NOTE

敬请期待

页面缓存

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 = () => {}

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

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 也提供了 useRuntimeContext 这类 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 时,如果不确定应用是否存在这类隐患,可以对应用进行压测。