服务端流式渲染(Streaming SSR)

流式渲染是一种新的渲染方式,它可以在用户与页面交互时,实时地更新页面内容,从而提高用户体验。

在传统的渲染方式中,页面的渲染是一次性完成的,而在流式渲染中,页面的渲染是逐步完成的,在用户与页面交互时,逐步加载数据,而不是一次性加载所有数据。

相比传统渲染:

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

开启流式渲染

Modern.js 支持了 React 18 的流式渲染,可以通过如下配置启用:

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

export default defineConfig({
  server: {
    ssr: {
      mode: 'stream',
    },
  },
});

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

  • defer:在 Data Loader 中使用,用于支持异步获取数据。
  • 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 = ({ 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>;
}

为爬虫等待所有内容加载完毕

流式传输可以提高用户体验,因为当页面内容可用时,用户可以及时感知到它们。

然而,当一个爬虫访问该页面时,它可能需要先加载所有内容,直接输出整个 HTML,而不是渐进式地加载它。

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

相关文档

  1. Deferred Data
  2. New Suspense SSR Architecture in React 18