ice [RFC]框架对流式渲染的支持方案

ws51t4hk  于 2022-12-31  发布在  其他
关注(0)|答案(8)|浏览(215)

理想状态

组件侧利用 react 这个 rfc 中提到的 await 能力, 直接发起数据请求

export default async function Comments() {
  const comments = await getData();

  return (
    <div>
      {comments.map((comment, i) => (
        <p className="comment" key={i}>
          {comment}
        </p>
      ))}
    </div>
  );
}

组件外部,使用 Suspense 包裹, SSR 渲染时,这类组件等待数据请求返回后,以流式的方式返回渲染结果

<Suspense fallback={<Loading />}>
    <Comments />
 </Suspense>

这是 React 和 Next.js 在 Server Component 中流式渲染内容,所使用的方式

面临的问题

1. await 能力尚不可用

react 组件内的 await 能力尚未正式发布,正式版本还无法直接使用

2. 需要 throw promise

由于问题 1 未解决,所以组件需要主动在 async 函数未执行完毕的时候,throw promise 供 Suspense 组件消费,这也是 react 的 lazy 和官方 suspense demo 中的做法。需要框架提供统一解决方案

3. 数据透传问题

不同于 Server Component,只需要下发 vdom,传统 SSR 应用下发 HTML 的同时,还需要下发数据,所以遇到了数据如何透传的 问题

因此组件不能在内部自己直接发起数据请求,自己消费,这样框架就无法获取数据,帮助透传。

4. SSR 降级

在集团内环境,我们需要区分 Server 请求和 Client 端请求两套属性,供 SSR 降级时用,在组件内直接发起数据请求,无法做代码移除

方案

结合上述问题,一种解决思路是对组件的编码规范做出约定

  • 通过导出 suspense = true 声明需要被流式返回
  • 通用导出 Loading 组件,来声明 loading 状态时的 UI
  • 通用导出 Fallback 组件,来声明兜底状态的 UI
  • 通过导出 serverDataLoader 声明 server 端的数据请求
  • 通过导出 dataLoader 声明 Client 端的数据请求
  • 通过 useData 来消费数据
import { useData } from 'ice';

export default function Footer() {
  // 消费数据
  const data = useData();

  return (
    <div>
      <h2>{data.title}</h2>
    </div>
  );
}

// 声明 loading 时的 UI
export function Loading() {
  return (
    <div>loading...</div>
  );
}

export const serverDataLoader = () => {
  // SSR 时的数据请求
};

export const dataLoader = () => {
   // CSR 时的数据请求
};

// 标记需要被 suspense
export const suspense = true;

对于这类声明了 suspense 的组件,框架会在其外层主动包裹 IceSuspense 组件,其职责包括:

  • 帮助组件发起数据请求
  • 数据请求未 ready 时,throw promise 给 React 原始 Suspense 组件消费
  • 透传数据的请求结果,用于 hydrate

demo 见 #5584

基于这个方案的思路,还需要讨论的问题是:

要不要限定路由组件才能使用 Suspense 能力

如果限定路由组件

  • 可以延续 dataLoader 的声明方式
  • 可以自动包裹 Suspense 组件

如果所有组件都可以使用 Suspense 能力

  • 灵活度更高
  • 无法自动处理,需要业务自己调用 IceSuspense 组件进行传参
  • 需要给组件标记唯一 ID,用于透传数据时使用
  • 需要对非路由组件也开启代码移除能力
  • 如果想要代码移除,必须导出 serverDataLoader,即使不被其他组件消费
  • 路由组件不能直接 Suspense,如果路由组件声明了 serverDataLoader,供 Suspense 消费,会和框架默认的数据请求逻辑冲突

考虑到路由组件存在数据请求串行的问题 https://github.com/alibaba/ice/issues/5697, 普通组件也需要支持流式渲染的能力

使用方式如下:

import { Suspense } from 'ice';
import * as Comments from '@/components/Comments';
import * as Footer from '@/components/Footer';
export default function Home() {
  return (
    <div>
      <Suspense module={Comments} id="comments" />
      <Suspense module={Footer} id="footer" />
    </div>
  );
}
vc9ivgsu

vc9ivgsu1#

组件内的 await 针对的应该是 server component,对于 client 端,官方推荐用 use

biswetbf

biswetbf2#

可以直接是 useSuspenseData -> useData 跟现在保持一致,内部通过上层 Context 信息来决定是 suspense 还是正常后去数据

svgewumm

svgewumm3#

Polyfill for First class support of async/await to react components.

Usage

async function Home() {
  const data = await getData();
  return <h1>home, data is {data}</h1>;
}

export default withPromise(Home);

Note that withPromise can be automaticlly handled by compiling or packer.

Impl for withPromise

const unResolved = Symbol('unresolved');

function withPromise(Component) {
  const fn = (props) => {
    const [instance, setInstance] = useState(unResolved);
    if (instance === unResolved) {
      const ret = Component(props);
      if (isPromise(ret)) {
        ret.then(setInstance);
      } else {
        setInstance(ret);
      }
      return null;
    } else {
      return instance;
    }
  };
  // Used for HMR should have a name for exported function to work.
  Object.defineProperty(fn, 'name', {
    get: () => Component.name,
  });
  return fn;
}

function isPromise(val: any): boolean {
  return val
    && typeof val.then === 'function'
    && typeof val.catch === 'function';
}
nkcskrwz

nkcskrwz4#

对于这类声明了 suspense 的组件,框架会在其外层主动包裹 IceSuspense 组件

这里如果是让开发者自行包装 IOC 呢,会更直接一些

希望是做更少声明参数 - 影响一些黑盒逻辑的事情, 而把做了什么事情暴露给用户

import { suspenseComponent } from 'ice';

function Home(props) {...}

export default suspenseComponent(Home);
p1iqtdky

p1iqtdky5#

async function Home() {
  const data = await getData();
  return <h1>home, data is {data}</h1>;
}

这种方式的限制是:

  • 框架无法获取到具体 data ,进行透传(script 标签那段实现)
  • getData 的实现会有多样性,代码移除比较高(比如想要移除 server only 的实现)
vom3gejh

vom3gejh6#

线下讨论结论是

  • 先对路由组件支持 suspense ,保持现有的数据请求等规范不变
  • 持续研究看是否有更好的方式,支持任意组件 suspense
zhte4eai

zhte4eai7#

线下讨论结论是

  • 先对路由组件支持 suspense ,保持现有的数据请求等规范不变
  • 持续研究看是否有更好的方式,支持任意组件 suspense

确定性的方案 可以直接更新到上述的方案中

jslywgbw

jslywgbw8#

实现过程中暴露了一些问题,比如:

因此,对之前的方案做出了一些调整:

  • 需要在 Document 中调整资源加载顺序
  • 需要关闭 dataLoader,因为流式情况下不需要独立构建 dataLoader,关闭后,可以保证这部分代码被构建入业务 Bundle,使普通组件也可以调用 dataLoader 中声明的方法
  • 普通组件也支持流式渲染,如果需要分为多个片段返回页面内容,不推荐使用嵌套路由,避免请求串行

具体用法如下:

前置条件

document.tsx 中调整 JS 加载顺序

<body>
        <Main />
        <Scripts defer />
      </body>

Scripts 需配置为 async 或 defer,以确保 JS 不会阻塞流式内容的上屏

  • defer 等待所有流式内容返回完成后,再执行 JS
  • async JS 加载过程中,不阻塞内容上屏,JS 加载完成后,优先执行 JS,再渲染后续返回的内容

ice.config.mts 中配置 dataLoader 为 false

import { defineConfig } from '@ice/app';

export default defineConfig({
  dataLoader: false,
});

组件实现

需要被流式渲染的组件,按以下规则导出内容:

  • 通用导出 Loading 组件,来声明 loading 状态时的 UI
  • 通过导出 serverDataLoader 声明 server 端的数据请求
  • 通过导出 dataLoader 声明 Client 端的数据请求
  • 通过 useData 来消费数据
import { useData } from 'ice';

export default function Footer() {
  // 消费数据
  const data = useData();

  return (
    <div>
      <h2>{data.title}</h2>
</div>
  );
}

// 声明 loading 时的 UI
export function Loading() {
  return (
    <div>loading...</div>
  );
}

export const serverDataLoader = () => {
  // SSR 时的数据请求
};

export const dataLoader = () => {
   // CSR 时的数据请求
};

使用 ice 导出的 Suspense 组件包裹需要被流式渲染的内容

import { Suspense } from "ice";
import * as Header from "@/components/Header";
import * as Feeds from "@/components/Feeds";

export default function Home() {
  return (
    <div className={styles.homeContainer}>
      <Suspense module={Header} id="header" />
      <Suspense module={Feeds} id="feeds" />
</div>
  );
}

相关问题