在 React 18 下,如何实现产品级的 SSR 和流式渲染?

·  阅读 5600

背景

参考 React v18.0 – React Blog,React 18 已经发布,而流式渲染是 React 18 的一个非常重要的特性。

本文来探讨在 React 18 下,有什么方式可以实现 SSR 和流式渲染。

React 18 支持的流式渲染能力和 api

首先,我们需要知道 React 18 到底什么 api 支持了流式渲染?

具体来说,就是这几个 api 发生了变化:

Suspenselazy

原本 Suspense 和 lazy 配合,只能支持一个场景:客户端的 js 代码拆分和懒加载。

React 18 扩展了这两个 api 的能力,可以直接在服务端使用他们组合使用,类似在客户端的代码拆分和懒加载,服务端直接使用这种方式的话就可以做到服务端数据的流式渲染。

一个简单的使用 Suspense 把页面分割开实现不同组件流式渲染的例子:

 import { Suspense, lazy } from "react";
 
 // 如果想强行实现流式的话,可以延迟 import 组件
 const Header = lazy(() => {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve(import('./components/')); 
    }, 5000);
  });
});

 // 如果没有其他因素,SideBar 和 Main 是同步渲染的
 const SideBar = lazy(() => import("./components/SideBar")); 
 const Main = lazy(() => import("./components/Main"));
 
 function Homepage() {
   return (
     <div className="App">
       <Suspense fallback={<div className="Header">Loading header...</div>}>
         <Header></Header>
       </Suspense>
       <div className="Content">
         <Suspense fallback={<div className="SideBar">Loading Sidebar...</div>}>
           <SideBar></SideBar>
         </Suspense>
 
         <Suspense fallback={<div className="Main">Loading main...</div>}>
           <Main></Main>
         </Suspense>
       </div>
     </div>
   );
 }

 export default Homepage;
复制代码

如果只是把不同的组件用 Suspense 和 lazy 分割开的话并不会实现流式的数据返回,React 会自动把它们同步返回,我们需要保证这里的各个组件的加载时延迟的,才能正确实现流式的数据返回。

参考:github.com/reactjs/rfc…

renderToPipeableStream

服务端为了配合 Suspense 和 Lazy 拆分的部分流式返回给浏览器,需要使用renderToPipeableStream 来流式返回数据

let didError = false;

const stream = renderToPipeableStream(
  <App assets={assets} />,
  {
    onShellReady() {
      res.statusCode = didError ? 500 : 200;
      res.setHeader("Content-type", "text/html");
      stream.pipe(res);  // 通过 React 的 stream 流式把数据返回
    },
    onError(x) {
      didError = true;
      console.error(x);
    },
  }
);
复制代码

hydrateRoot

为了配合流式返回的数据在服务端动态注水组件,使用这个新的 api 来进行注水,它可以在 React 组件流式返回后就开始注水,无需等待所有的内容都返回后才进行注水。

服务端流式组件数据请求方式

从前面 React 提供的 API 可以看出,React api 只定义了流式渲染组件的格式、如何流式返回数据以及如果动态给流式返回的内容注水。

但是不涉及一个比较重要的能力:如何分模块加载不同的组件和并请求它们的数据

由于服务端的 React 组件不会执行 didMount 之后的生命周期,因此不能用类似常规的客户端开发的方式在 didMount 或者 useEffect 中去发送组件的请求,而需要通过某种方式提前发送请求,让组件渲染的时候可以直接拿到请求的内容开始渲染。

把请求封装 lazy 方法中

基于现有的 api,可以发现等待组件加载的逻辑在 lazy 方法中,因此,我们可以封装和拓展 lazy 方法,在等待了请求返回之后,再正常 import 组件。

 import { Suspense, lazy } from "react";
 
 // 如果想强行实现流式的话,可以延迟 import 组件
 const Header = lazy(() => {
  return new Promise((resolve) => {
    fetchData().then(() => {
      resolve(import('./components/')); 
    });
  });
});

 function Homepage() {
   return (
     <div className="App">
       <Suspense fallback={<div className="Header">Loading header...</div>}>
         <Header></Header>
       </Suspense>
     </div>
   );
 }

 export default Homepage;
复制代码

这样,服务端在渲染组件时,会先通过 Suspense 渲染这个组件的 loading,等到这个组件依赖的 fetch 请求返回后,再流式渲染这个组件的内容。

优点

  • 无需额外的 api 就能支持懒加载组件和它的数据

缺点

  • 服务端和客户端代码异构:服务端需要深度和请求和 lazy 封装起来,但是客户端不能同同样的封装,否则代码拆分的文件也会等到 fetch 之后才会加载,有明显的性能问题。

使用 Suspense 和 data fetching 结合

Suspense for Data Fetching

data fetching 是一个 React 中实验性质的 api,它和 Suspense 结合起来,可以使用一种新的设计模式来编写 React 组件。

一个例子:

const resource = fetchProfileData();  // 1. 提前发送请求

function ProfilePage() {
  return (
    // 3. 使用 Suspense 来维护 loading 状态
    <Suspense fallback={<h1>Loading profile...</h1>}> 
      <ProfileDetails />
      <Suspense fallback={<h1>Loading posts...</h1>}>
        <ProfileTimeline />
      </Suspense>
    </Suspense>
  );
}

function ProfileDetails() {
  const user = resource.user.read(); // 2. 使用 render 方法中直接读请求数据
  return <h1>{user.name}</h1>;
}

function ProfileTimeline() {
  const posts = resource.posts.read();
  return (
    <ul>
      {posts.map(post => (
        <li key={post.id}>{post.text}</li>
      ))}
    </ul>
  );
}
复制代码

data fetching 有三个关键点:

  1. 尽量直接提前发送请求
  2. 对于依赖请求的组件,在 render 方法中,去读取请求的结果。
  3. 使用 Suspense 来维护请求的 loading 状态:如果请求没有返回,该组件的外层 Suspense 会展示 loading 的 fallback,如果请求已经返回了,Suspense 会展示这个组件的内容。

优点

  • 拆分组件的关注点:一个依赖请求的组件,可以直接把请求的内容作为数据内容进行渲染,无需关心请求的过程中 loading 状态如何维护,loading 由 Suspense 来处理
  • 提前发送请求:很低成本地保证请求可以尽早的发出,而不是在 useEffect 等生命周期中才开始发请求,如果要发多个请求,也不需要复杂的 Promise.all 等封装
  • 自动解决竞速问题:Suspense 只会处理最后一个请求的数据并渲染对应组件,无需开发者关心竞速
  • React 的未来发展方向

缺点

  • 试验性质的 api,除了基于 GraphQL 的 Relay,没有其他成熟的框架支持 data fetching

自研支持流式渲染需要解决问题

考虑到 data fetching 可能是 React 未来的发展方向,并且要保证服务端、客户端的同构以减少开发维护成本。

因此自研解决服务端流式组件的数据请求时,推荐使用 Suspense 和 data fetching 结合的方式。在服务端和客户端使用相同的方式进行数据请求。

目前 React 官方没有也没有计划提供官方的 data fetching 框架或者库,在社区提供完整的 data fetching 框架解决方案之前,需要我们自研 data fetching 相关的 SSR 逻辑

我们需要解决的问题有:

把普通请求 promise 转换成 data fetching api

一个用于 demo 的 wrapPromise 方法的例子如下:

function wrapPromise(promise) {
  let status = "pending";
  let result;
  let suspender = promise.then(
    (r) => {
      status = "success";
      result = r;
    },
    (e) => {
      status = "error";
      result = e;
    }
  );
  return {
    read() {
      if (status === "pending") {
        throw suspender;
      } else if (status === "error") {
        throw result;
      } else if (status === "success") {
        return result;
      }
    }
  };
}

const fetchData() {
    return wrapPromise(fetchUser()); // fetchUser 是一个普通的 fetch 方法
}

const user = fetchData(); // 提前发送 fetch 请求

function ProfileDetails() {
  const user = user.read(); // render 中直接 read 数据
  return <h1>{user.name}</h1>;
}
复制代码

Suspense 识别 data fetching 的 loading 状态方式是根据 read 方法的返回值来的:

  • 如果 read 方法 throw 了一个 promise,那它就还在 loading
  • 如果 read 方法返回了数据,那它就 loading 完成了,并返回只是 read 的返回值

实际产品中,这个 wrapPromise 方法会复杂很多,需要处理更多的情况,比如报错等等。

服务端、客户端同构使用 data fetching 发送请求

为了兼容使用 data fetching 和现有的 React 组件使用方式,封装一个高阶组件来处理 data fetching 的逻辑,原本的 React 组件自身不处理请求相关的逻辑。

例子:

// 一个高阶组件来结合组件和 fetching 的逻辑
const withDataFetching = (InnerComponent) => {
  const WrappedComponent = (props) => {
    const resource = useResource();
    const data = InnerComponent.getFetchingProps(resource);
    
    return <InnerComponent {...props} {..data} />;
  };

  return WrappedComponent;
};

// 这是一个纯组件,无需关心 fetching
const Header = ({ header = {} }) => {
  return (
    <div className="Header">
      {header.text}
    </div>
  );
};

// 约定组件上挂载一个获取 fetching 数据的逻辑
Header.getFetchingProps = (resource) => {
  const header = resource.header.read();

  return {
    header,
  };
};

// 对外暴露出被高阶组件包裹的组件,可以直接放到 Suspense 中使用
export default withDataFetching(Header);
复制代码

服务端、客户端请求同构

如果页面在 SSR 的时候依赖一个其他服务的 RPC 或者别的服务端接口,那需要一层 BFF 吧这个依赖的 RPC 映射出一个 http api,保证客户端用相同的逻辑可以访问到这个接口,只是客户端访问接口的链路要长一些。

UML 图.jpg

服务端、客户端分别处理 data fetching 的发请求的逻辑

服务端和客户端发请求的时机是不同的:

  • 服务端:在接收到 SSR 页面请求时就开始发请求
  • 客户端:在 SPA 应用内部切换到指定页面时,发起请求

这部分发请求的时间是没法同构的,因此需要在服务端和客户端单独开发维护不同的发请求逻辑。

万幸是这部分逻辑比较收敛,一个页面支持的请求可以全部维护在同一个地方。

服务端

get('/', () => {
  const resources = fetchDataForUrl(url);

  const stream = renderToPipeableStream(
    <StaticRouter location={url}>
      <DataProvider resources={resources}>
        <App assets={assets} />
      </DataProvider>
    </StaticRouter>,
    {
      ...
    }
  );
  });
复制代码

在 renderToPipeableStream 之前根据当天页面的 url 与发送请求。并作为 Context 传递给 React 组件。

不在组件中发请求是为了最早开始请求,节省请求时间。

客户端

function Homepage() {
  const clientSideResources = fetchDataFromClientSide();

  const content = (
    <div className="App">
      ...
    </div>
  );
  
  return isClientSide ? (
    <DataProvider resources={clientSideResources}>{content}</DataProvider>
  ) : (
    content
  );
}
复制代码

在需要发请求的页面 Router 根节点中,发送请求,并设置 Context。

由于组件是在服务端和客户端都会渲染,因此这里是否要设置请求的 Context 就根据自身在服务端还是客户端判断

保证注水时客户端组件能够拿到正确的 props

按照现在的设计思路,依赖请求数据的组件都是流式渲染的,因此需要在流式渲染的过程中把请求数据一起流式返回,并设置成组件的 props。

解决思路是,在 data fetching 的高阶组件中封装逻辑处理:

  • 服务端渲染时,把请求的数据挂载在一个元素上,和流式渲染的组件一起返回
  • 客户端渲染时:判断流式组件的数据是否在挂载的元素上
    • 数据已经挂载了,直接用对应数据渲染组件
    • 数据未挂载,在客户端 fetching 数据,在渲染组件

对应的代码示例:

  • 扩展 withDataFetching 这个高阶组件处理服务端逻辑服务端逻辑:
    • 通过 useId 生成数据挂载节点的 id
    • 把数据挂载在一个 这个空的数据节点上
const withDataFetching = (InnerComponent) => {
  const WrappedComponent = (props) => {
  // useId 会保证在服务端和客户端渲染同一个组件拿到的 ID 是相同的
    const id = useId();
    const resource = useResource();
    const data = InnerComponent.getFetchingProps(resource);
    
    return (
      <>
        <div id={id} data-server-data={data}></div>
        <InnerComponent {...props} {..data} />
      </>
    )
  };

  return WrappedComponent;
};
复制代码
  • 继续扩展这个高阶组件处理客户端逻辑:
    • 获取挂载节点的数据,如果有数据,直接使用该数据作为 props
    • 如果没有数据,使用和服务端同构的 data fetching 逻辑获取数据
const withDataFetching = (InnerComponent) => {
  const WrappedComponent = (props) => {
    // useId 会保证在服务端和客户端渲染同一个组件拿到的 ID 是相同的
    const id = useId();
    const resource = useResource();
    
    let data;
    
    if (isClientSide) {
        // 客户端上,如果挂载数据节点上存在数据,则直接使用该数据作为 props
        const dataElement = document.querySelector(`#${id}`);
        const serverData = JSON.parse(dataElement.dataset.serverData);

        if (serverData) {
            data = serverData;
        } 
    } 
    
    // 其他 case 下使用 data fetching 获取数据(服务端、客户端是同构的)
    if (!data) {
        data = InnerComponent.getFetchingProps(resource);
    }
    
    return (
      <>
        <div id={id} data-server-data={data}></div>
        <InnerComponent {...props} {..data} />
      </>
    )
  };

  return WrappedComponent;
};
复制代码

参考

加入我们

我们来自字节跳动飞书商业应用研发部(Lark Business Applications),目前我们在北京、深圳、上海、武汉、杭州、成都、广州、三亚都设立了办公区域。我们关注的产品领域主要在企业经验管理软件上,包括飞书 OKR、飞书绩效、飞书招聘、飞书人事等 HCM 领域系统,也包括飞书审批、OA、法务、财务、采购、差旅与报销等系统。欢迎各位加入我们。

扫码发现职位&投递简历

官网投递:job.toutiao.com/s/FyL7DRg

分类:
前端
标签:
收藏成功!
已添加到「」, 点击更改