流式渲染

概述

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

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

相比传统渲染:

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

使用

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 组件中获取数据。

异步获取数据

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 必须接收一个对象类型的参数, 因此, 传入 defer 的参数为:{ data: user }

defer 还可以同时接收异步数据和同步数据。例如:

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,
      });
    }, 200);
  });

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

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

otherData 前加了 await,所以是同步获取的数据,它可以和异步获取的数据 user 同时传入 defer

渲染异步数据

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

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 属性设置的内容。

注意

从 Data Loader 文件导入类型时,需要使用 import type 语法,保证只导入类型信息,这样可以避免 Data Loader 的代码打包到前端产物的 bundle 文件中。

所以,这里的导入方式为:import type { Data } from './page.data';

也可以通过 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, 以判断请求是否来自爬虫。