前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >”渐进式页面渲染“:详解 React Streaming 过程

”渐进式页面渲染“:详解 React Streaming 过程

作者头像
19组清风
发布2023-07-09 15:48:14
7830
发布2023-07-09 15:48:14
举报
文章被收录于专栏:Web Front EndWeb Front End

引言

眨眼距离 React 18.2.0 发布已经过了一年多了。

越来越多的开发者从当初的观望心态,逐步已经将 React18 的新特性投入开发/生产中了,当然,笔者所在的团队也不例外。

今天这篇文章就和大家简单聊聊 React 18 中的 Streaming 。

Streaming

所谓的 Streaming(流式渲染) 的概念,简单来说就是将一整个 HTML 脚本文件通过切成一小段一小段的方式返回给客户端,客户端收到每一段内容时进行分批渲染。

Streaming
Streaming

这样的方式相较于传统的服务端一次性渲染完成整个 HTML 内容进行返回,在视觉上大大减少了 TTFB 以及 FP 的时间,在用户体验上更好。

在 HTTP/1.1 中可以利用的分块传输编码(Chunked transfer encoding)机制实现这一过程。

在 HTTP/2.0 中由于传输内容是基于数据帧的,自然默认内容总是“分块”的。

接下来,我们首先会在 NextJs、Remix 中体验这一特性。

同时在文章的第三个部分,我们会不借助任何框架尝试实现这一过程从而让你更好的理解它。

NextJs

这里,我使用 npx create-next-app@13.4.6 创建了一个初始项目做了简单的修改。

在新版本中,NextJs 引入了一个新的基于服务端组件(RSC)构建的 app 目录,该目录下所有的组件默认为 React Server Compnent

简单来将,RSC 在 React18 中的出现赋予了我们在服务端获取组件数据并在服务端进行渲染组件的能力。

上边的代码中,我将 app/page.tsx 中的原始模版代码修改成为了一段商品展示的业务代码:

代码语言:javascript
复制
// 获取商品评论信息(延迟3s)
function getComments(): Promise<string[]> {
  return new Promise((resolve) =>
    setTimeout(() => {
      resolve(['This is Great.', 'Worthy of recommendation!']);
    }, 3000)
  );
}

export default async function Home() {
  // 获取评论数据
  const comments = await getComments();

  return (
    <main className="flex min-h-screen flex-col items-center justify-between p-24">
      <div>
        <div>商品</div>
        <p>价格</p>
        <div>
          <p>评论</p>
          <input />
          <div>
            {comments.map((comment) => {
              return <p key={comment}>{comment}</p>;
            })}
          </div>
        </div>
      </div>
    </main>
  );
}

当我们启动项目打开页面时,延迟 3 秒之后页面会展示出所有的内容。

next-no-suspense
next-no-suspense

对于商品评论这些非关键性数据来说,打开页面需要因为获取评论数据从而导致页面存在 3 秒白屏时间这无疑是比较糟糕的体验。

在 NextJs 中,我们只要稍作修改就可以非常方便的利用内置的 Server ComponentStreaming 特性来完美解决这一问题:

代码语言:javascript
复制
// components/Comment.tsx
function getComments(): Promise<string[]> {
  return new Promise((resolve) =>
    setTimeout(() => {
      resolve(['This is Great.', 'Worthy of recommendation!']);
    }, 3000)
  );
}

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

  return (
    <div>
      <p>评论</p>
      <input />
      {comments.map((comment) => {
        return <p key={comment}>{comment}</p>;
      })}
    </div>
  );
}
代码语言:javascript
复制
// app/page.tsx
import Comment from '@/components/Comments';
import { Suspense } from 'react';

export default async function Home() {
  return (
    <main className="flex min-h-screen flex-col items-center justify-between p-24">
      <div>
        <div>商品</div>
        <p>价格</p>
        <div>
          {/* Suspense 包裹携带数据请求的 Comment Server 组件 */}
          <Suspense fallback={<div>Loading...</div>}>
            <Comment />
          </Suspense>
        </div>
      </div>
    </main>
  );
}

将评论内容抽离为携带数据请求的服务端组件,同时在父组件中通过 <Suspense /> 进行包裹,即可利用 RSCStreaming 的特性来解决获取评论数据阻塞页面渲染的问题:

with-suspense
with-suspense

你可以点击这里查看代码仓库地址

打开网页地址时,整个页面除了评论部分使用 Loading... 进行占位其余部分会立即进行渲染。

3s 之后,评论组件的内容会替换页面中的 Loading 内容展示给用户,这看来就非常酷,对吧。

接下来,我们尝试在代码中在额外添加一些交互的内容,允许用户在 <input /> 中输入内容并进行提交:

代码语言:javascript
复制
// components/Comment.tsx
import { useRef } from 'react';

function getComments(): Promise<string[]> {
  return new Promise((resolve) =>
    setTimeout(() => {
      resolve(['This is Great.', 'Worthy of recommendation!']);
    }, 3000)
  );
}

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

  const inputRef = useRef<HTMLInputElement>(null);

  const onSubmit = () => {
    alert(`您提交的评论内容:${inputRef.current?.value}`);
  };

  return (
    <div>
      <p>评论</p>
      <input ref={inputRef} />
      <button onClick={onSubmit}>提交评论</button>
      {comments.map((comment) => {
        return <p key={comment}>{comment}</p>;
      })}
    </div>
  );
}

在此刷新页面,不出意外的话你会得到这样的错误:

SFC-Error
SFC-Error

这是因为 React 服务端组件是完全在服务器上进行的渲染,你无法使用任何 hooks Api 以及使用任何浏览器 Api 、事件绑定等。

同样在 Next 中提供了解决方案嵌套组件的方式来为我们来解决这个问题。

我们需要让各个组件各司其职,在服务端组件中配合 Suspense 动态获取数据同时将数据传递给具有交互逻辑的客户端组件,之后在 RSC 中将客户端组件作为子组件进行包裹即可。

代码语言:javascript
复制
// components/Comment.tsx
import EditableComments from './EditableComments';

function getComments(): Promise<string[]> {
  return new Promise((resolve) =>
    setTimeout(() => {
      resolve(['This is Great.', 'Worthy of recommendation!']);
    }, 3000)
  );
}

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

  return (
    <div>
      <p>评论</p>
      {/* RFC 中包裹客户端组件 */}
      <EditableComments comments={comments} />
      {comments.map((comment) => {
        return <p key={comment}>{comment}</p>;
      })}
    </div>
  );
}
代码语言:javascript
复制
// components/EditableComments.tsx
'use client';
import { useRef } from 'react';

export default function EditableComments(props: { comments: string[] }) {
  const inputRef = useRef<HTMLInputElement>(null);

  const onSubmit = () => {
    // 限制评论内容
    if (props.comments.length < 10) {
      alert(`您提交的评论内容为:${inputRef.current?.value}`);
    }
  };

  return (
    <>
      <input ref={inputRef} />
      <button onClick={onSubmit}>提交评论</button>
    </>
  );
}

完整代码在这里

上述的代码可以看到,我们将存在客户端交互逻辑部分抽离成为 EditableComments.tsx 组件。

通过在原有的 Comment.tsx 服务端组件中进行数据获取,当获取完成数据后会将数据传递给客户端组件进行展示。

Client Component
Client Component

一起看起来都完美无误,在 NextJs 中默认 app 目录下的组件都是服务端组件。

当你需要添加客户端逻辑时,需要在该文件的顶层使用 'use client' 显式声明这是一个客户端组件才能添加交互逻辑以及使用浏览器 API。

同时不要忘记服务端组件和客户端组件只能通过嵌套的关系进行相互存在(客户端组件需要服务端数据时,只能通过外层服务端组件获取传入)。

服务端组件和客户端组件差异性
服务端组件和客户端组件差异性

上面这张图是 NextJs 中总结的一些客户端组件和服务端组件的不同用例。

Remix

了解完 NextJs 中如何利用服务端组件配合 Streaming 特性后,我们再来看看 Remix 中是如何处理这一过程的。

Remix 中规定在每个路由页面中可以导出一个名为 loader 的函数用来为渲染时提供数据。

比如:

代码语言:javascript
复制
import type { LoaderFunction } from '@remix-run/node';
import { json } from '@remix-run/node';
import { useLoaderData } from '@remix-run/react';

export const loader: LoaderFunction = () => {
  return json({
    name: '19Qingfeng',
  });
};

export default function Index() {
  const { name } = useLoaderData();

  return (
    <div style={{ fontFamily: 'system-ui, sans-serif', lineHeight: '1.8' }}>
      <h3>Hello {name}!</h3>
    </div>
  );
}

上述是我用 npx create-remix@latest 创建的模板项目,你可以在这里看到源代码

  • 首先,export const loader 表示该页面导出了一个名为 loader 的方法,用于在服务端的页面数据获取。

注意注意的是该方法仅在服务器上运行。在初次打开该页面时,它将向 HTML 文档提供数据。同样在切换为 SPA 模式跳转下,Remix 将从浏览器调用该函数。

该方法仅会在服务器上运行,它会在页面加载组件之前进行执行

  • 其次,导出的 export default function Index 和 NextJs 用法相同。

Remix 规定在指定目录下定义文件的默认导出会渲染成为该路径下的 HTML 页面。 同时,我们可以在任何地方使用 Remix 提供的 useLoaderData hook 获得该页面定义的 loaderFunction 的返回值。

Remix-Page
Remix-Page

这样,我们在 NextJs 中通过服务端组件进行数据获取,同样可以放置在 Remix 的 LoaderFunction 中进行数据获取。

让我们对于上边 NextJS 的代码进行迁移,将获取评论的逻辑迁移到 Remix 项目中:

代码语言:javascript
复制
import type { LoaderFunction } from '@remix-run/node';
import { json } from '@remix-run/node';
import { useLoaderData } from '@remix-run/react';

function getComments(): Promise<string[]> {
  return new Promise((resolve) =>
    setTimeout(() => {
      resolve(['This is Great.', 'Worthy of recommendation!']);
    }, 3000)
  );
}

export const loader: LoaderFunction = async () => {
  const comments = await getComments();

  return json({
    comments,
  });
};

export default function Index() {
  const { comments } = useLoaderData<{ comments: string[] }>();

  return (
    <div style={{ fontFamily: 'system-ui, sans-serif', lineHeight: '1.8' }}>
      <div>
        <div>商品</div>
        <p>价格</p>
        <div>
          <div>
            <p>评论</p>
            {comments.map((comment) => {
              return <p key={comment}>{comment}</p>;
            })}
          </div>
        </div>
      </div>
    </div>
  );
}

你可以在这里下载代码。

Remix-fetchData
Remix-fetchData

输入 URL 后页面会在 3 秒加载后进行渲染所有数据,看起来仍然还是被阻塞了三秒。

这是因为我们在 loaderFunction 中进行了阻塞的加载:

代码语言:javascript
复制
export const loader: LoaderFunction = async () => {
  const comments = await getComments();
  return json({
    comments,
  });
};

loader 方法看起同步的调用了 getComments,等待 getComments 返回的 promise<resolved> 后才返回了获得的评论内容。

页面渲染是依赖于定义的 loaderFunction 返回内容的,自然打开页面后由于服务端数据获取的阻塞特性导致页面加载阻塞。

那么,Remix 中如何像 NextJs 中一样将评论这些非关键性数据进行“分段返回 ”呢?

Remix 中同样提供了更加便捷的 Api 来为我们处理这一场景。

Remix 在服务端提供了一个名为 defer 的方法为我们实现这一过程。

正如它的定义所言,当我们在 Remix 中开启流式渲染(默认行为)后,我们可以在 loader 中使用 defer 方法包裹返回值,它的行为完全和 json() 类型,唯一不同的是这个方法可以将 promise 传输到 UI 组件中,比如:

代码语言:javascript
复制
export const loader: LoaderFunction = async () => {
  const comments = getComments();
  // 使用 defer 传输 getComments 返回的 Promise 
  return defer({
    comments,
  });
};


export default function Index() {
  // 使用 loaderFunction 获取中传递的 Promise 
  const { comments } = useLoaderData<{ comments: Promise<string[]> }>();
  
  // ...
}

关于 defer 方法的实现,有兴趣的小伙伴可以查阅 @remix-server-runtime/responses.ts

  • <Await /> 同时,Remix 中提供了一个 <Await /> 组件用来负责解析从 loaderFunction 中返回的 promise。 它类似于 React Error Boundaries 的简单包装器,这个组件配合 <Suspense /> 会等待传入的 promise 完成之前一直使用 <Suspense /> 进行占位,直到传入的 promise 完成后会展示真实内容。

我们来稍微使用这两个 Api 来做一些改造:

代码语言:javascript
复制
import type { LoaderFunction } from '@remix-run/node';
import { defer } from '@remix-run/node';
import { Await, useLoaderData } from '@remix-run/react';
import { Suspense } from 'react';

function getComments(): Promise<string[]> {
  return new Promise((resolve) =>
    setTimeout(() => {
      resolve(['This is Great.', 'Worthy of recommendation!']);
    }, 3000)
  );
}

export const loader: LoaderFunction = async () => {
  const comments = getComments();

  return defer({
    comments,
  });
};

export default function Index() {
  const { comments } = useLoaderData<{ comments: string[] }>();

  return (
    <div style={{ fontFamily: 'system-ui, sans-serif', lineHeight: '1.8' }}>
      <div>
        <div>商品</div>
        <p>价格</p>
        <div>
          <div>
            <p>评论</p>
            <Suspense fallback={<div>Loading...</div>}>
              <Await<string[]> resolve={comments}>
                {(comments) => {
                  return comments.map((comment) => {
                    return <p key={comment}>{comment}</p>;
                  });
                }}
              </Await>
            </Suspense>
          </div>
        </div>
      </div>
    </div>
  );
}

你可以在这里下载到这段代码

Remix-Suspense
Remix-Suspense

一起看起来和 NextJs 展示的效果一模一样对吧,这便是如何在 Remix 中利用 Streaming 特性进行数据获取。

至于 NextJs 也好,Remix 也罢这两种框架对于配合 Streaming Data 的处理都是开箱即用的。

因为 NextJs 中基于 Server Component 的机制来配合实现流式渲染,所以在代码组织上的限制显得稍微有些掣肘。

而 Remix 内部实现这一过程和 RSC 并无关系,所以它的代码风格上相较于 NextJs 更加贴近传统前端代码编写习惯。

就个人来说,我自己比较喜欢 Remix 这种没有任何心智负担的代码组织风格。

Manual

聊完 Next 以及 Remix 中如何使用 Streaming 进行数据请求后,我们来尝试自己实现这一过程。

上边我们也提到过无论 Next、Remix 或者是其他框架每种框架的实现思路是不一样的,后续我也会单独和大家聊聊 Remix 是如何通过 loaderFunction 将 Promise 从服务端传递到客户端的,这里我们按照 React 提案中的一些方式来实现这一过程。

模版搭建

工欲善其事,必先利其器。首先,我们会创建一个简易的 SSR 项目,避免麻烦这部分基础代码大家可以从这里进行下载。

项目目录如下

代码语言:javascript
复制
.
├── README.md                   描述文件,如何安装和启动
├── build                       客户端产物存放文件
│   └── index.js
├── package.json
├── pnpm-lock.yaml
├── public                      静态资源存放目录
│   └── index.css
├── rollup.config.mjs rollup    配置文件
├── server 
│   └── render.js               服务端渲染方法
├── server.entry.js             服务端入口文件
└── src
    ├── App.jsx                 页面入口组件
    ├── html.jsx                页面 HTML 组件,用于 Server Side 生生成 HTML
    └── index.jsx               客户端入口文件

整体项目非常简单在 package.json 中存在以下两个脚本:

代码语言:javascript
复制
{
  ...
  "scripts": {
      "dev": "npm-run-all --parallel \"dev:*\"",
      "dev:server": "cross-env NODE_ENV=development babel-node server.entry.js",
      "dev:client": "cross-env NODE_ENV=development rollup -c -w"
  }
}

"dev:server" 利用 babel-node 来执行服务端脚本,当请求首次来到时会执行 server.entry.js:

代码语言:javascript
复制
const express = require('express');
const render = require('./server/render').default;

const app = express();

app.use(express.static('build'));
app.use(express.static('public'));

app.get('/', (req, res) => {
  render(res);
});

app.listen(3000, () => {
  console.log(`Server on Port: 3000`);
});

server.entry.js 通过 express 启动了一个 NodeServer,监听到来自 localhost:3000 的方法时会调用 server/render 中导出的方法:

代码语言:javascript
复制
import React from 'react';
import App from '../src/App';
import HTML from '../src/html';
import { renderToString } from 'react-dom/server';

function getComments() {
  return new Promise((resolve) =>
    setTimeout(() => {
      resolve(['This is Great.', 'Worthy of recommendation!']);
    }, 3000)
  );
}

export default async function render(res) {
  const comments = await getComments();
  res.send(
    renderToString(
      <HTML comments={comments}>
        <App comments={comments} />
      </HTML>
    )
  );
}

server.js 中导出的 render 方法中做的事情也非常简单:

代码语言:javascript
复制
1. 在服务器上请求获取评论数据,这个方法同样会在 3s 后返回。
2. 获得数据后调用 `renderToString` 方法传递给 `response` 从而实现服务端渲染。

接下来还有一些 src/App.jsxsrc/HTML.jsx 他们都非常简单,我就直接将代码罗列下来了:

代码语言:javascript
复制
// src/html.jsx
import React from 'react';

export default ({children,comments}) => {
  return <html>
    <head>
      <link ref="stylesheet" href="/index.css"></link>
    </head>
    <body>
      <div id='root'>{children}</div>
    </body>
  </html>
}
代码语言:javascript
复制
// src/App.jsx
import React, { useRef } from "react";

export default function Index({comments}) {

  const inputRef = useRef(null)

  const onSubmit = () => {
    if(inputRef.current) {
      alert(`添加评论内容:${inputRef.current?.value}`)
    }
  }

  return (
    <div style={{ fontFamily: 'system-ui, sans-serif', lineHeight: '1.8' }}>
      <div>
        <div>商品</div>
        <p>价格</p>
        <input ref={inputRef} />
        <button onClick={onSubmit}>添加评论</button>
        <div>
          <div>
            <p>评论</p>
            {
              Array.isArray(comments) &amp;&amp; comments.map(comment => {
                return  <p key={comment}>{comment}</p>;
              })
            }
          </div>
        </div>
      </div>
    </div>
  );
}

需要注意的是,我们在 src/index.js 中时客户端的入口文件,换而言之我们需要将 src/index.js 中的内容最终打包成为浏览器可以执行的代码进行返回从而实现注水(hydrate)的过程:

代码语言:javascript
复制
import React, { startTransition } from 'react'
import { hydrateRoot } from 'react-dom/client'
import App from './App'

startTransition(() => {
  hydrateRoot(document.getElementById('root'),<App />)
})

上述的 "dev:client" 命令也正是将这个文件作为入口文件构建到 build/index.js 中。

解下来,我们运行 npm run dev 打开页面即可看到渲染的页面:

custom-ssr
custom-ssr

细心的小伙伴会发现页面上点击评论并没有任何交互效果出现,这是因为我们还没有在服务器上的 html 返回中加入任何 js 脚本的嵌入。

到这里,基础的项目结构我们已经满足了,接下来我们继续。

客户端数据交互

上一步我们已经创建好了基础的项目结构,只不过项目中未添加任何 JavaScript 脚本。

接下来我们移动到 src/html.jsx 中,在 html 组件中添加上构建出的客户端 JS 脚本:

代码语言:javascript
复制
import React from 'react';

export default ({children,comments}) => {
  return <html>
    <head>
      <link ref="stylesheet" href="/index.css"></link>
    </head>
    <body>
      <div id='root'>{children}</div>
       {/* 添加 JS 脚本注入 */}
      <script src="/index.js"></script>
    </body>
  </html>
}

之后重新运行 npm run dev:

diy-ssr
diy-ssr

此时,点击页面 button 已经可以正常执行客户端逻辑。

不过,除了浏览器控制台的一堆错误外,我们发现在服务器上获取的评论数据也没有同步到客户端进行渲染。

没有同步客户端渲染的原因非常简单:浏览器中无法拿到服务器上获取的评论数据。

代码语言:javascript
复制
import React, { startTransition } from 'react'
import { hydrateRoot } from 'react-dom/client'
import App from './App'

startTransition(() => {
  // 客户端发生 hydrate 的 <App /> 组件并没有任何 comments 传入
  hydrateRoot(document.getElementById('root'),<App />)
})

简单来说,我们在服务器上调用了 renderToString 等待评论接口返回后渲染的 HTML 模版是具有评论的 HTML 内容的,服务器将这份数据返回给客户端。

之后,客户端加载到返回的 HTML 后。因为要动态进行一个所谓的注水(hydrate)过程,为服务端返回的模版添加事件交互和补充状态。

此时,客户端会在此执行src/index.js中的hydrateRoot的逻辑,在此调用根组件获得 VDom 和服务端发下的模版进行比对(如何标签相同就复用标签添加事件交互,如果不相同则会重新在客户端渲染该 Dom)。

所以,仔细观察上述的过程实际上页面加载的过程中会发生闪烁。

一次渲染为服务端下发携带评论数据的 HTML 模版,另一次为客户端 hydrate 失败后回退到客户端渲染没有评论数据的页面。

Render-Frames
Render-Frames

左侧为服务端下发的渲染,右侧为客户端执行 JS 重新渲染后的页面。

自然,页面上的报错也就是客户端hydrateRoot执行时,HTML 结构双端不匹配的 error。

那么,如何解决这一问题呢?首先,这个问题的本质即是在服务端渲染模版时已经获取的评论数据如何传递到客户端浏览器 JS 脚本中。

我们来用一种最简单直接的方式来实现:服务端获取完成数据后,下发的 HTML 中通过 window 注入已获取的内容从而实现在客户端 JS 执行时动态获取这部分数据。

此时,客户端 JS 在执行时即可正常获取这部分数据进行渲染。

在上边的 server/render.js 中已经通过 <Html comments={comments} /> 在服务端已经为 HTML 组件传递过获取到的评论信息。

之后,我们进入 src/html.jsx 中修改下发的 HTML 内容,**在客户端 JS 执行之前通过 script 标签的形式为 window 上添加 window.__diy_ssr_context

代码语言:javascript
复制
import React from 'react';

export default ({children,comments}) => {
  return <html>
    <head>
      <link ref="stylesheet" href="/index.css"></link>
    </head>
    <body>
      <div id='root'>{children}</div>
      <script dangerouslySetInnerHTML={{
        __html: `window.__diy_ssr_context=${JSON.stringify(comments)}`
      }}></script>
      <script src="/index.js">
      
      </script>
    </body>
  </html>
}

之后,在此回到客户端的入口文件中,仅仅在客户端逻辑执行时通过获取 window.__diy_ssr_context 从而获取到服务端请求到的数据进行传入即可:

代码语言:javascript
复制
import React, { startTransition } from 'react'
import { hydrateRoot } from 'react-dom/client'
import App from './App'

startTransition(() => {
  hydrateRoot(document.getElementById('root'),<App comments={window.__diy_ssr_context} />)
})
with-client-js
with-client-js

这时,控制台的报错内容全部消失了,同时页面上也正常展示了从服务器中获取的评论数据。

直接通过 window 进行注入,这种方式起来非常原始化对吧。

不过现阶段无论是任何框架 Next 也好 Remix 也罢都是通过这种方式进行服务端数据和客户端数据的同步。

renderToPipeableStream

React18 中提供了一个 renderToPipeableStreamApi

它将会替换之前的 renderToString 方法,这个方法会将传入的 ReactTree 转化为 HTML 从而通过 NodeStream 的方式返回给客户端,这个 Api 正是实现流式渲染(Streaming)的核心。

代码语言:javascript
复制
import React from 'react';
import App from '../src/App';
import HTML from '../src/html';
import { renderToPipeableStream } from 'react-dom/server';

function getComments() {
  return new Promise((resolve) =>
    setTimeout(() => {
      resolve(['This is Great.', 'Worthy of recommendation!']);
    }, 3000)
  );
}

export default async function render(res) {
  const comments = await getComments();
  
  // renderToPipeableStream replace renderToString
  const { pipe } = renderToPipeableStream(
    <HTML comments={comments}>
      <App comments={comments} />
    </HTML>,
    {
      onShellReady() {
        pipe(res);
      },
    }
  );
}
streaming
streaming

只要将 server/render.js 中的 renderToString 替换为 renderToPipeableStream 即可实现这一效果。

不过,HTML 的确是通过 Stream 进行分段传输了。但是页面仍然因为评论接口会导致 3s 的白屏时间。

接下来,我们就尝试解决如何将服务端请求的 Promise 配合 streaming 进行流式渲染。

use hook

React 在未来的版本有一个 use hooks 的提案:RFC: First class support for promises and async/await

React 提供了一个特殊的 use Hook。您可以将use其视为和 React-Query 类似的解决方案。

大多数情况下,我们在 React 中为了获取数据请求都会编写过这样的代码:

代码语言:javascript
复制
import React, { useEffect, useState } from 'react';

function getSomeData() {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve();
    }, 3000);
  });
}

function Demo() {
  const [data, setData] = useState();

  useEffect(() => {
    getSomeData().then((data) => setData(data));
  });

  return (
    <div>
      <h3>Title</h3>
      {data &amp;&amp; <div>{data}</div>}
    </div>
  );
}

我们想要从远程接口返回的 Promise 状态完成之后更新页面数据,绝大多数情况我们都在客户端使用 useEffect 配合 then 方法来进行数据更新。

这种情况下,通常我们需要在代码处理不同状态的 Promise 从而在模版中进行不同的渲染。

在即将到来的 React 版本之中,React 团队提供了一种更加便捷的处理方式: use hook 。

利用 use 我们可以可以读取已完成的 Promise 的值,它会将加载时状态以及错误处理委托给最近的 Suspense。

这种架构的好处显而易见:允许将组件分组到上下文中,这些上下文仅在所有组件加载数据时才准备好呈现。

代码语言:javascript
复制
import React, { Suspense, use } from 'react';

function getSomeData() {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve('hello demo');
    }, 3000);
  });
}

export default function Demo() {
  // 使用 use hook 传递需要等待的 Promise,并且同步的方式直接获取数据
  const data = use(getSomeData());

  return (
    <div>
      <h3>Title</h3>
      <>{data}</>
    </div>
  );
}

export function DemoWrapper() {
  return  <Suspense fallback={<div>Loading Demo</div>}>
    {/* 调用 Suspense 直接包裹 Demo 组件  */}
    <Demo />
  </Suspense>
}

上述的代码我们使用 use hook 轻松的处理了需要等待 Promise 状态的地方。

<Demo /> 组件中的 data 会根据传入的 getSomeData() 返回的 promise 状态来决定最外层 <Suspense /> 的状态。

当 promise 仍然为 pending 状态会渲染 fallback 作为占位符,一旦组件内部 promise 变为 fulfilled 自然会渲染 Demo 组件。

趁热打铁,我们尝试用 use 来改造下刚刚的例子:

代码语言:javascript
复制
// server/render.ts
import React from 'react';
import App from '../src/App';
import HTML from '../src/html';
import { renderToPipeableStream } from 'react-dom/server';

function getComments() {
  return new Promise((resolve) =>
    setTimeout(() => {
      resolve(['This is Great.', 'Worthy of recommendation!']);
    }, 3000)
  );
}

export default async function render(res) {
  const comments = getComments();
  // server 端
  const stream = renderToPipeableStream(
    <HTML comments={comments}>
      <App comments={comments} />
    </HTML>,
    {
      onShellReady() {
        stream.pipe(res);
      },
    }
  );
}

首先,我们将服务端的逻辑稍作修改:将原本需要 await getComments()的异步阻塞逻辑停止 await 直接传递返回的 Promise 给 <HTML /> 以及 <App />

代码语言:javascript
复制
import React from 'react';

export default ({children,comments}) => {
  return <html>
    <head>
      <meta http-equiv="Content-Type" content="text/htmL; charset=utf-8" />
      <link ref="stylesheet" href="/index.css"></link>
    </head>
    <body>
      <div id='root'>{children}</div>
      {/* <script src="/index.js" /> */}
    </body>
  </html>
}

其次,由于我们仅仅在服务端处理 getCommones,所以我们将 <Html /> 组件中注入的客户端脚本先注释掉。

代码语言:javascript
复制
// src/App.tsx
import React, { useRef, use, Suspense } from "react";

function Comments({ comments }) {

  const commentsResult = use(comments)

  return Array.isArray(commentsResult) &amp;&amp; commentsResult.map(comment => {
      return  <p key={comment}>{comment}</p>;
    })
}

export default function Index({comments}) {

  const inputRef = useRef(null)


  const onSubmit = () => {
    if(inputRef.current) {
      alert(`添加评论内容:${inputRef.current?.value}`)
    }
  }

  return (
    <div style={{ fontFamily: 'system-ui, sans-serif', lineHeight: '1.8' }}>
      <div>
        <div>商品</div>
        <p>价格</p>
        <input ref={inputRef} />
        <button onClick={onSubmit}>添加评论</button>
        <div>
          <div>
            <p>评论</p>
            <Suspense fallback={<div>Loading</div>}>
              <Comments comments={comments} />
          </Suspense>
          </div>

        </div>
      </div>
    </div>
  );
}

最后,我们对于 src/App.jsx 稍微修改。

将原本的评论内容抽离成为一个单独的组件,在评论组件内部使用 use 来包裹传入的 getComments() 返回的 Promise 对象。

在外层 <Index /> 组件中使用 Suspense 包裹了内部使用 use<Comments /> 组件。

Suspense & use hook
Suspense & use hook

在此刷新页面,评论内容在获取数据时并不会使用阻塞任何页面渲染。当 3s 过后,getCommonets() 返回的 Promise 状态变化时,页面正常渲染了商品的评论内容。

即将到来的 React 18.3 中会提供 use hook 这一特性为我们创建更加方便的客户端 Promise 处理。

那么利用 use 如何和客户端交互呢?

上边我们提到过,通常在服务端渲染的页面中服务器中获取的数据提供给客户端使用时目前只能通过以全局变量的形式来获取。

而这次我们在服务端相当于需要传递一个 Promise 给浏览器来记录他的状态,在服务端序列化一个 Promise 传递给客户端这明显是不太可能的。

代码语言:javascript
复制
// src/html.jsx
import React from 'react';

export default ({children,comments}) => {
  return <html>
    <head>
      <meta http-equiv="Content-Type" content="text/htmL; charset=utf-8" />
      <link ref="stylesheet" href="/index.css"></link>
    </head>
    <body>
      <div id='root'>{children}</div>
      <script src="/index.js" />
    </body>
  </html>
}

此时,我们放开服务端 HTML 中的客户端 /index.js

再次刷新页面,3s 后页面会有一大堆错误:

use error
use error

错误原因可想而知:

当在 Server 端进行渲染时会为 <Comments /> 传递了 props.comments 渲染出正确的模版从而返回。

再次执行客户端 hydrate 逻辑时,由于客户端在再次调用 <Comments /> 时,客户端并未传递任何内容,自然也会产生错误。

那么关键问题就在于,我们如何在服务端传递一个有状态的 Promise 传递给客户端呢?

显然,从服务器上将当前 Promise 序列化传递给客户端的方案明显行不通。那么,我们只好在客户端创建一个所谓的 Promise 。

代码语言:javascript
复制
// src/index.tsx
import React, { startTransition } from 'react'
import { hydrateRoot } from 'react-dom/client'
import App from './App'

// 目前看来永远不会被 resolve 的 Promise
const clientPromise = new Promise((resolve) => {
  window.__setComments_data = (comments) => resolve(comments)
})

startTransition(() => {
  hydrateRoot(document.getElementById('root'),<App comments={clientPromise} />)
})

我们在客户端脚本执行之前,构造了一个 clientPromise,同时在 window 上构造了一个 __setComments_data 的方法。

当调用 __setComments_data 方法时, clientPromise 才会 fullfilled。

之后,我们将客户端构建的这个 clientPromise 传递给需要在客户端执行渲染的 <App /> 组件中。

这一步,我们已经可以确保**<App comments={clientPromise} /> 中的 comments props 接受到的实实在在的一个 Promise。

之后,我们仅仅需要在服务端的 commentPromise 完成时,通知客户端调用 window.__setComments_data 完成客户端的 commentPromise 即可。

代码语言:javascript
复制
// src/html.jsx
import React, { Suspense , use} from 'react';

function CommentsScript({ comments: commentsPromise }) {
  const comments = use(commentsPromise)

  return <script dangerouslySetInnerHTML={{
    __html: `window.__setComments_data(${JSON.stringify(comments)})`
  }}></script>
}

export default ({children,comments}) => {
  return <html>
    <head>
      <meta http-equiv="Content-Type" content="text/htmL; charset=utf-8" />
      <link ref="stylesheet" href="/index.css"></link>
    </head>
    <body>
      <div id='root'>{children}</div>
      <script src="/index.js" />
      <Suspense>
        <CommentsScript comments={comments}></CommentsScript>
      </Suspense>
    </body>
  </html>
}

由于 <Html /> 是在服务端进行的渲染,我们在 <Html /> 上稍微修改。

  1. 纯服务端渲染的 <Html /> 组件会接受到 comments 请求返回的 Promise。
  2. <Html /> 中,我们额外定义了一个所谓的 <CommentsScript />

利用 use hook 配合 Suspense,当服务器上请求的评论接口返回时会替换为一段 script 脚本。

当服务器上的 comments 的状态为 fuilled 后:

代码语言:javascript
复制
<Suspense>
        <CommentsScript comments={comments}></CommentsScript>
</Suspense>

<CommentsScript /> 会进行渲染,从而执行 window.__setComments_data 方法通知客户端 Promise 完成并且获得服务端对应评论数据。

diy-suspense-done
diy-suspense-done

至此,无论是服务端还是客户端逻辑已经可以满足我们的需求并且实现了自定义的流式数据渲染。

当然,实现这种机制的方式并不至此一种。

比如上述我们讲到过 Remix 中在 React18.2 并不存在 use hook 时也可实现异步的数据 Streaming ,有兴趣的同学可以关注我之后的文章我会详细和你聊聊 Remix 中是如何处理 Streaming Data 的方式。

实现机制

也许你会好奇究竟是如何使用 Streaming 实现 HTML “流式渲染” 的,接下来 我们来稍微聊聊这部分。

所谓的“流式渲染” Streaming 也只是实现了 html 脚本内容在网络层面的分段式传输,它并不存在什么神奇魔法可以动态修改你的 html 内容。

通常修改页面 HTML 最直接的方式往往还是通过 JavaScript 去动态操纵 Dom,自然看起来非常高大上的 “流式渲染” 实现渐进式的页面加载也离不开 JavaScript 脚本的帮助。

我们以刚才 diy 的 Demo 来举例:

curl-streaming
curl-streaming

执行 curl --no-buffer localhost:3000 后,我们发现控制台中会立即返回前半段 HTML 内容。

稍后,3s 之后控制台会在此再次打印出剩余的内容。自然,这 3s 恰恰是我们之前定义的 CommentsPromise 评论接口返回的时间差。

代码语言:javascript
复制
<!-- 3s 前,上半段返回内容 -->
<!DOCTYPE html>
<html>

<head>
  <meta http-equiv="Content-Type" content="text/htmL; charset=utf-8" />
  <link href="/index.css" />
</head>

<body>
  <div id="root">
    <div style="font-family:system-ui, sans-serif;line-height:1.8">
      <div>
        <div>商品</div>
        <p>价格</p><input /><button>添加评论</button>
        <div>
          <div>
            <p>评论</p><!--$?--><template id="B:0"></template>
            <div>Loading</div><!--/$-->
          </div>
        </div>
      </div>
    </div>
  </div>
  <script src="/index.js"></script><!--$?--><template id="B:1"></template><!--/$-->

可以看到上半段(3s 前)返回的 HTML 内容仅仅是包含一些静态资源以及静态模版的 HTML 脚本。

页面中存在两个被注释掉的重要节点:

  • 利用 <Suspense /> 包裹的 <Comments /> 组件。

这部分内容展示了评论内容在加载中时使用 fallback 属性占位的 loading 内容,同时使用 <!--$?--> 注释节点包裹两部分内容分别为 fallback 占位的 HTML 节点以及 <template id="B:0"></template>

  • 除了正常应该返回的客户端脚本 index.js 外,额外返回了一个 <template id="B:1"></template> 节点。

留意每一个 <template> 标签都存在一个独一无二的 id 属性。

不同的 id 字符前缀代表不同的节点类型,比如这里的 B: 1 中的 B 代表的是 Boundary (Suspense),S 代表的是 Segment(要插入的有效片段):S:,一般是div,表格,数学公式,SVG 会用对应的元素。

同时不同的占位注释节点也代表不同的状态,上述的节点 <!--$?--> 表示加载中(pending)状态。

而当页面整体加载完毕后,再次打开浏览器控制台你会发现会变为 <!--$--> ,它表示加载完成(Completed)。

3s 之后,控制台中会返回剩余的 Html 脚本内容:

代码语言:javascript
复制
<!DOCTYPE html>
<html>

<head>
  <meta http-equiv="Content-Type" content="text/htmL; charset=utf-8" />
  <link href="/index.css" />
</head>

<body>
  <div id="root">
    <div style="font-family:system-ui, sans-serif;line-height:1.8">
      <div>
        <div>商品</div>
        <p>价格</p><input /><button>添加评论</button>
        <div>
          <div>
            <p>评论</p><!--$?--><template id="B:0"></template>
            <div>Loading</div><!--/$-->
          </div>
        </div>
      </div>
    </div>
  </div>
  <script src="/index.js"></script><!--$?--><template id="B:1"></template><!--/$-->
  <div hidden id="S:0">
    <p>This is Great.</p>
    <p>Worthy of recommendation!</p>
  </div>
  <script>$RC = function (b, c, e) { c = document.getElementById(c); c.parentNode.removeChild(c); var a = document.getElementById(b); if (a) { b = a.previousSibling; if (e) b.data = "$!", a.setAttribute("data-dgst", e); else { e = b.parentNode; a = b.nextSibling; var f = 0; do { if (a &amp;&amp; 8 === a.nodeType) { var d = a.data; if ("/$" === d) if (0 === f) break; else f--; else "$" !== d &amp;&amp; "$?" !== d &amp;&amp; "$!" !== d || f++ } d = a.nextSibling; e.removeChild(a); a = d } while (a); for (; c.firstChild;)e.insertBefore(c.firstChild, a); b.data = "$" } b._reactRetry &amp;&amp; b._reactRetry() } }; $RC("B:0", "S:0")</script>
  <div hidden id="S:1">
    <script>window.__setComments_data(["This is Great.", "Worthy of recommendation!"])</script>
  </div>
  <script>$RC("B:1", "S:1")</script>
</body>

</html>

首先,3s 后数据请求完毕服务端脚本中正常返回所有的评论内容:

代码语言:javascript
复制
 <div hidden id="S:0">
    <p>This is Great.</p>
    <p>Worthy of recommendation!</p>
 </div>

React 会在所有正常返回的脚本内容使用一个标记为 hidden 的 div 来进行包裹。

如果一个元素设置了 hidden 属性,它就不会被显示。

同时,每一个从服务端返回携带 hidden 属性的 HTML 片段同时也会携带一个独一无二的 id 属性。

那么,接下来自然是使用服务端中返回的这段 HTML 片段去替换 <Suspense />fallback 的 HTML 内容。

代码语言:javascript
复制
$RC = function (b, c, e) {
  c = document.getElementById(c);
  c.parentNode.removeChild(c);
  var a = document.getElementById(b);
  if (a) {
    b = a.previousSibling;
    if (e) (b.data = '$!'), a.setAttribute('data-dgst', e);
    else {
      e = b.parentNode;
      a = b.nextSibling;
      var f = 0;
      do {
        if (a &amp;&amp; 8 === a.nodeType) {
          var d = a.data;
          if ('/$' === d)
            if (0 === f) break;
            else f--;
          else ('$' !== d &amp;&amp; '$?' !== d &amp;&amp; '$!' !== d) || f++;
        }
        d = a.nextSibling;
        e.removeChild(a);
        a = d;
      } while (a);
      for (; c.firstChild; ) e.insertBefore(c.firstChild, a);
      b.data = '$';
    }
    b._reactRetry &amp;&amp; b._reactRetry();
  }
};

3s 后,整个页面数据请求结束,服务端会返回这段脚本给客户端。

核心替换脚本就在上述这段 RC 的内嵌 JS 脚本中,这个脚本定义了 RC 全局方法,方法定义结束后理解调用

上述 $RC 方法我就不具体去一行一行描述了,这个方法的核心思想就是实现 Suspense 前后元素的替换从而实现所谓”渐进式 HTML“的效果。

当然还有所谓的 RX、 RX 和

结尾

恰好笔者所在的商旅大前端部门目前已有大部分前端应用切入 Remix,我们在 Remix 的基础上进行了一些改动以及二次适配商旅现有业务达到了开箱即用的效果。

刚好配合 React18 中 Steaming 这一特性,在页面性能方面以及用户体验效果上达到了极大的收益。

当然,关于切换为 Remix 遇到的技术难点以及带来的性能收益和用户体验,有关这部分内容我们也会在之后和大家一起进行分享和讨论。

最后,这篇文章更多是希望通过一种抛砖引玉的形式来和大家聊聊 Steaming 的基本原理,希望文章中的内容会对大家日常业务中有所启发、有所帮助。

本文参与 腾讯云自媒体分享计划,分享自作者个人站点/博客。
原始发表:2023-06-25,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 作者个人站点/博客 前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体分享计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 引言
  • Streaming
  • NextJs
  • Remix
  • Manual
    • 模版搭建
      • 客户端数据交互
        • renderToPipeableStream
          • use hook
            • 实现机制
            • 结尾
            领券
            问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档