流式服务端渲染(Streaming SSR)

流式渲染是一种先进的渲染方式,它可以在页面渲染过程中逐步返回内容,从而显著提升用户体验。

在传统的 SSR 渲染方式中,页面的渲染是一次性完成的,需要等待所有数据加载完成后才能返回完整的 HTML。而在流式渲染中,页面的渲染是逐步完成的,可以边渲染边返回,用户能够更快地看到初始内容。

默认模式

Streaming SSR 是 Modern.js SSR 的默认渲染模式。当你启用 SSR 时,无需额外配置即可使用流式渲染。

如果你需要切换到传统的 SSR 模式(等待所有数据加载完成后一次性返回),可以配置 server.ssr.mode'string'。详细说明请参考服务端渲染(SSR)文档。

相比传统 SSR 渲染:

  • 更快感知速度:流式渲染可以在渲染过程中逐步显示内容,能够以最快的速度显示业务首页
  • 更好的用户体验:通过流式渲染,用户可以更快地看到页面上的内容,而不需要等待整个页面都渲染完成后才能交互
  • 更好的性能控制:流式渲染可以让开发者更好地控制页面加载的优先级和顺序,从而更好地优化性能和用户体验
  • 更好的适应性:流式渲染可以更好地适应不同网络速度和设备性能,使得页面在各种环境下都能有更好的表现

开启流式渲染

Modern.js 支持了 React 18+ 的流式渲染。当你启用 SSR 时,流式渲染默认开启:

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

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

如果你需要显式指定流式渲染模式,可以配置:

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

export default defineConfig({
  server: {
    ssr: {
      mode: 'stream', // 显式指定流式渲染模式
    },
  },
});

Modern.js 的流式渲染基于 React Router 实现,主要涉及 API 有:

  • Await:用于渲染 Data Loader 返回的异步数据。
  • useAsyncValue:用于从最近的父级 Await 组件中获取数据。

获取数据

user/[id]/page.data.ts
import { defer, type LoaderFunctionArgs } from '@modern-js/runtime/router';

interface User {
  name: string;
  age: number;
}

export interface Data {
  data: User;
}

export const loader = ({ params }: LoaderFunctionArgs) => {
  const userId = params.id;

  const user = new Promise<User>(resolve => {
    setTimeout(() => {
      resolve({
        name: `user-${userId}`,
        age: 18,
      });
    }, 200);
  });

  return defer({ data: user });
};

user 是一个 Promise 类型的对象,表示需要异步获取的数据,通过 defer 处理需要异步获取的 user。注意,defer 必须接收一个对象类型的参数,不能直接传递 Promise 对象。

另外,defer 还可以同时接收异步数据和同步数据。在下述例子中,我们等待部分耗时较短的请求,在响应后通过对象数据返回,而耗时较长时间的请求,则通过 Promise 返回:

user/[id]/page.data.ts
export const loader = async ({ params }: LoaderFunctionArgs) => {
  const userId = params.id;

  const user = new Promise<User>(resolve => {
    setTimeout(() => {
      resolve({
        name: `user-${userId}`,
        age: 18,
      });
    }, 2000);
  });

  const otherData = new Promise<string>(resolve => {
    setTimeout(() => {
      resolve('some sync data');
    }, 200);
  });

  return defer({
    data: user,
    other: await otherData,
  });
};

这样,应用无需等待最耗时的数据请求响应后才展示页面内容,可以优先展示部分有数据的页面内容

渲染数据

通过 Await 组件,可以获取到 Data Loader 中异步返回的数据,然后进行渲染。例如:

user/[id]/page.tsx
import { Await, useLoaderData } from '@modern-js/runtime/router';
import { Suspense } from 'react';
import type { Data } from './page.data';

const Page = () => {
  const data = useLoaderData() as Data;

  return (
    <div>
      User info:
      <Suspense fallback={<div id="loading">loading user data ...</div>}>
        <Await resolve={data.data}>
          {user => {
            return (
              <div id="data">
                name: {user.name}, age: {user.age}
              </div>
            );
          }}
        </Await>
      </Suspense>
    </div>
  );
};

export default Page;

Await 需要包裹在 Suspense 组件内部,Awaitresolve 传入的是 Data Loader 异步获取的数据,当数据获取完成后, 通过 Render Props 模式,渲染获取到的数据。在数据的获取阶段,将展示 Suspense 组件 fallback 属性设置的内容。

注意

page.data.ts 文件导入类型时,需要使用 import type 语法,保证只导入类型信息,避免 Data Loader 的代码打包到前端产物中。

在组件中,你也可以通过 useAsyncValue 获取 Data Loader 返回的异步数据。例如:

page.tsx
import { useAsyncValue } from '@modern-js/runtime/router';

const UserInfo = () => {
  const user = useAsyncValue();
  return (
    <div>
      name: {user.name}, age: {user.age}
    </div>
  );
};

const Page = () => {
  const data = useLoaderData() as Data;
  return (
    <div>
      User info:
      <Suspense fallback={<div id="loading">loading user data ...</div>}>
        <Await resolve={data.data}>
          <UserInfo />
        </Await>
      </Suspense>
    </div>
  );
};

export default Page;

错误处理

Await 组件的 errorElement 属性,可以用来处理 Data Loader 或者子组件渲染时的报错。例如,我们故意在 Data Loader 函数中抛出错误:

page.loader.ts
import { defer } from '@modern-js/runtime/router';

export default () => {
  const data = new Promise((resolve, reject) => {
    setTimeout(() => {
      reject(new Error('error occurs'));
    }, 200);
  });

  return defer({ data });
};

然后通过 useAsyncError 获取错误,并将用于渲染错误信息的组件赋值给 Await 组件的 errorElement 属性:

page.ts
import { Await, useAsyncError, useLoaderData } from '@modern-js/runtime/router';
import { Suspense } from 'react';

export default function Page() {
  const data = useLoaderData();

  return (
    <div>
      Error page
      <Suspense fallback={<div>loading ...</div>}>
        <Await resolve={data.data} errorElement={<ErrorElement />}>
          {(data: any) => {
            return <div>never displayed</div>;
          }}
        </Await>
      </Suspense>
    </div>
  );
}

function ErrorElement() {
  const error = useAsyncError() as Error;
  return <p>Something went wrong! {error.message}</p>;
}

控制是否等待全部内容再输出

流式传输可以提高用户体验,因为当页面内容可用时,用户可以及时感知到它们。但在部分场景下(例如 SEO 爬虫、特定 AB 实验或合规页面)希望等所有内容完成后再一次性输出。

Modern.js 默认行为的判定优先级为:

Modern.js 使用 isbot 对请求的 user-agent,以判断请求是否来自爬虫。

  1. 请求头 x-should-stream-all(中间件可写)。
  2. 环境变量 MODERN_JS_STREAM_TO_STRING(强制全量)。
  3. isbot 检测 user-agent(爬虫全量)。
  4. 默认流式(先 shell 后内容)。

你可以在自定义中间件里按请求动态写入标记,控制是否等待全部内容:

middleware
export const middleware = async (ctx, next) => {
  const ua = ctx.req.header('user-agent') || '';
  const shouldWaitAll =
    /Lighthouse|Googlebot/i.test(ua) || ctx.req.path === '/marketing';

  // 写入布尔值字符串,true 表示使用 onAllReady,false 表示使用 onShellReady
  ctx.req.headers.set('x-should-stream-all', String(shouldWaitAll));

  await next();
};

相关文档