Dry information | The road of exploration of Ctrip Business Travel’s front-end React Streaming

About the Author

Group 19 Qingfeng, a senior front-end development engineer at Ctrip, is responsible for the construction of the front-end public infrastructure platform for business travel, focusing on NodeJs and researching performance fields;

ZZR is a senior front-end development engineer at Ctrip Business Travel. He is responsible for the basic platform construction of the business travel public platform and is committed to high-efficiency and high-performance development.

I. Introduction

In the blink of an eye, more than a year has passed since the release of React 18.2.0. More and more developers have gradually put the new features of React18 into development/production. Of course, the author is here The team is no exception.

Today’s article will briefly talk to you about Streaming in React 18.

2. Streaming

The concept of so-called streaming (streaming rendering) simply means that an entire HTML script file is returned to the client by cutting it into small pieces, and the client renders each piece of content in batches.

c2d5094f46c6f8111b2c7ed54250a764.png

Compared with the traditional server-side rendering of the entire HTML content at one time and returning it, this method visually greatly reduces the time of TTFB and FP, and provides a better user experience.

The Chunked transfer encoding mechanism available in HTTP/1.1 implements this process.

In HTTP/2.0, since the transmission content is based on data frames, the default content is always "chunked".

Next, we will first experience this feature in NextJs and Remix.

At the same time, in the third part of the article, we will try to implement this process without using any framework so that you can better understand it.

3. NextJs

Here , I used npx [email protected] to create an initial project and made simple modifications.

In the new version, NextJs introduces a new app directory built based on server-side components (RSF). All components in this directory are React Server Component by default .

To put it simply, the emergence of RSF in React18 gives us the ability to obtain component data on the server side and render components on the server side.

In the above code, I modified the original template code in app/page.tsx into a business code for product display:

// 获取商品评论信息(延迟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>
  );
}

When we start the project and open the page, the page will display all the content after a delay of 3 seconds.

ea192bff78509106291ccf984791154f.gif

For non-critical data such as product reviews, opening the page requires obtaining review data, resulting in a 3-second white screen on the page, which is undoubtedly a bad experience.

In NextJs, we can easily use the built-in Server Component and Streaming features to perfectly solve this problem with just a few modifications:

// 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>
  );
}
// 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>
  );
}

By extracting the comment content into a server-side component that carries data requests, and wrapping it with <Suspense /> in the parent component, you can use the features of RSF and Streaming to solve the problem of obtaining comment data blocking page rendering:

29d7bca0564dbbb3813f9a21a19c7cc1.gif

You can click here to view the code warehouse address .

When opening a web page address, the entire page will be rendered immediately except for the comment section, which uses Loading... to take place.

After 3 seconds, the content of the comment component will replace the Loading content on the page and be displayed to the user. This seems very cool, right?

Next, we try to add some additional interactive content to the code, allowing users to enter content in <input /> and submit it:

// 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>
  );
}

Refresh the page here. If nothing else, you will get an error like this:

e759dfa87c2669d9f2cec25627c86305.png

This is because the React server-side component is completely rendered on the server, and you cannot use any hooks API, use any browser API, event binding, etc.

Also in Next, a solution is provided in the form of nested components to solve this problem for us.

We need to let each component perform its own duties, cooperate with Suspense in the server component to dynamically obtain data and pass the data to the client component with interactive logic, and then wrap the client component as a subcomponent in RSF.

// 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>
  );
}
// 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>
    </>
  );
}

The complete code is here .

As you can see from the above code, we extracted the client interaction logic part into the EditableComments.tsx component.

By obtaining data in the original Comment.tsx server component, when the data is obtained, the data will be passed to the client component for display.

9e5f983bf531efb9834fe58551e1428c.gif

Everything looks perfect together. In NextJs, the components in the default app directory are all server-side components.

When you need to add client-side logic, you need to use 'use client' at the top level of the file to explicitly declare that this is a client-side component to add interactive logic and use the browser API.

At the same time, don’t forget that server-side components and client-side components can only exist with each other through a nested relationship (when the client-side component needs server-side data, it can only be passed in through the outer server-side component).

3c27cc7d9cff85a7e1e380ccebb38e9e.png

The picture above summarizes some different use cases of client-side components and server-side components in NextJs.

4. Remix

After understanding how to use server-side components with the Streaming feature in NextJs, let's take a look at how this process is handled in Remix .

Remix stipulates that each routing page can export a function named loader to provide data for rendering.

for example:

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

The above is a template project I created using npx create-remix@latest, you can see the source code here .

  • First, export const loader means that the page exports a method named loader, which is used to obtain page data on the server side.

It should be noted that this method only runs on the server. When the page is first opened, it provides data to the HTML document. Also when switching to SPA mode, Remix will call this function from the browser.

This method will only run on the server, it will be executed before the page loads the component

  • Secondly, the export default function Index has the same usage as NextJs.

Remix specifies that the default export of definition files in a specified directory will be rendered into an HTML page in that path.

At the same time, we can use the useLoaderData hook provided by Remix anywhere to obtain the return value of the loaderFunction defined on the page.

ccdb93048458bb73aea41b8dd2e69a69.gif

In this way, we obtain data through the server-side component in NextJs, and we can also place it in the LoaderFunction of Remix for data acquisition.

Let's migrate the NextJS code above and migrate the logic of getting comments to the Remix project:

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

You can download the code here .

49a21d38d3f5589581b2b639c998e41d.gif

After entering the URL, the page will load and render all data in 3 seconds, but it still seems to be blocked for three seconds.

This is because we have blocking loading in loaderFunction: 

export const loader: LoaderFunction = async () => {
  const comments = await getComments();
  return json({
    comments,
  });
};

The loader method seems to call getComments synchronously, and waits for the promise<resolved> returned by getComments before returning the obtained comment content.

Page rendering relies on the content returned by the defined loaderFunction. Naturally, after opening the page, the page loading is blocked due to the blocking characteristics of server-side data acquisition.

So, how to "return" non-critical data such as comments in pieces in Remix like in NextJs?

Remix also provides a more convenient API to handle this scenario for us.

4.1  defer 

Remix provides a method called defer on the server side to implement this process for us.

As its definition says, when we enable streaming rendering (default behavior) in Remix, we can use the defer method in the loader to wrap the return value. Its behavior is exactly the same as json(). The only difference is this method Promises can be transferred to UI components, such as:

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[]> }>();


  // ...
}

Regarding the implementation of the defer method, interested friends can check @remix-server-runtime/responses.ts.

4.2  <Await />

At the same time, Remix provides an <Await /> component that is responsible for parsing the promise returned from loaderFunction.

It is similar to a simple wrapper of React Error Boundaries. This component works with <Suspense /> and will wait for the incoming promise to be completed. It will always use <Suspense /> to hold the place until the incoming promise is completed and the real content will be displayed.

Let's use these two APIs to make some modifications:

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

You can download this code here .

2afebadb6552f90098485cb2c420c320.gif

It looks exactly like the effect displayed by NextJs, right? This is how to use the Streaming feature to obtain data in Remix.

As for NextJs and Remix, both frameworks are ready to handle Streaming Data out of the box.

Because NextJs is based on the Server Component mechanism to implement streaming rendering, the restrictions on code organization appear to be slightly restrictive.

Remix's internal implementation of this process has nothing to do with RSC, so its coding style is closer to traditional front-end code writing habits than NextJs.

Personally, I prefer Remix's code organization style without any mental burden.

5. Manual

After talking about Next and how to use Streaming for data requests in Remix, let's try to implement this process ourselves.

We also mentioned above that the implementation ideas of each framework are different regardless of Next, Remix or other frameworks. I will also talk to you separately later about how Remix passes Promise from the server to the client through loaderFunction. Here We implement this process in some ways from the React proposal.

6. Template construction

If a worker wants to do his job well, he must first sharpen his tools. First, we will create a simple SSR project to avoid trouble. You can download this basic code from here.

The project directory is as follows:

.
├── 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               客户端入口文件

The overall project is very simple. There are the following two scripts in package.json :

{
  ...
  "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" uses babel-node to execute server-side scripts. When the request comes for the first time, server.entry.js will be executed: 

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 starts a NodeServer through express. When it listens to the method from localhost:3000, it will call the method exported in server/render:

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

What is done in the render method exported in server.js is also very simple:

a. Request comment data on the server. This method will also return after 3 seconds.

b. After obtaining the data, call the `renderToString` method and pass it to `response` to achieve server-side rendering.

Next there are some src/App.jsx and src/HTML.jsx. They are very simple, so I will list the codes directly:

// 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>
}
// 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) && comments.map(comment => {
                return  <p key={comment}>{comment}</p>;
              })
            }
          </div>
        </div>
      </div>
    </div>
  );
}

It should be noted that we are in src/index.js as the entry file of the client. In other words, we need to finally package the content in src/index.js into code that the browser can execute and return it to achieve hydration. process:

import React, { startTransition } from 'react'
import { hydrateRoot } from 'react-dom/client'
import App from './App'
startTransition(() => {
  hydrateRoot(document.getElementById('root'),<App />)
})

The above-mentioned "dev:client" command also builds this file into build/index.js as the entry file.

Next, we run npm run dev to open the page and see the rendered page:

66886a63a06d62d33bd6004b0a6747d9.gif

Careful friends will find that there is no interactive effect when clicking on the comment on the page. This is because we have not included any js script embedding in the HTML return on the server.

At this point, we have satisfied the basic project structure, let's continue.

7. Client data interaction

In the previous step, we have created the basic project structure, but we have not added any JavaScript scripts to the project.

Next we move to src/html.jsx and add the built client JS script in the html component:

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>
}

Then re-run npm run dev:

1dec7277e47f8f2b856dfcc28510dfcf.gif

At this point, clicking the page button can already execute the client logic normally.

However, in addition to a bunch of errors in the browser console, we discovered that the comment data fetched on the server was not synchronized to the client for rendering.

The reason for not synchronizing client-side rendering is very simple: the browser cannot get the comment data obtained from the server .

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

To put it simply, we call renderToString on the server and wait for the comment interface to return. The rendered HTML template has the HTML content of the comment, and the server returns this data to the client.

Afterwards, the client loads into the returned HTML. Because a so-called hydrate process needs to be performed dynamically, event interaction and supplementary status are added to the template returned by the server.

At this time, the client will execute the logic of hydrateRoot in src/index.js here, and call the root component to obtain the VDom and compare it with the template sent by the server (if the tags are the same, reuse the tag to add event interaction. If If they are different, the Dom will be re-rendered on the client).

Therefore, if you carefully observe the above process, flickering will actually occur during the page loading process.

One rendering is for the server to deliver an HTML template carrying comment data, and the other is for the client to hydrate and fall back to the client to render a page without comment data.

4945351b5851126c40d5db9bb27d1a24.png

The left side is the rendering sent by the server, and the right side is the page after the client executes JS and re-renders it.

Naturally, the error reported on the page is the error caused by the mismatch of the HTML structure at both ends when the client hydrateRoot is executed. 

So, how to solve this problem? First of all, the essence of this problem is how to pass the comment data obtained when the server renders the template to the client browser JS script.

Let's implement it in the simplest and most direct way: after the server obtains the data, it injects the obtained content through the window into the HTML sent, so that this part of the data can be dynamically obtained when the client JS is executed.

At this time, the client JS can normally obtain this part of data for rendering when executing.

In the server/render.js above, the obtained comment information has been passed to the HTML component on the server side through <Html comments={comments} />.

After that, we enter src/html.jsx to modify the delivered HTML content, and add window.__diy_ssr_context to the window in the form of a script tag before the client JS is executed.

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>
}

After that, return to the client's entry file and just get the data requested by the server by getting window.__diy_ssr_context when the client logic is executed and pass it in:

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} />)
})

f7dde34c821ecac0813c46440134acde.gif

At this time, all the error messages in the console disappeared, and the comment data obtained from the server was displayed normally on the page.

Injecting directly through the window seems very primitive, right?

However, at this stage, no matter whether it is Next or Remix, any framework synchronizes server data and client data in this way.

八、renderToPipeableStream 

React18 provides a renderToPipeableStream API.

It will replace the previous renderToString method, which will convert the incoming ReactTree into HTML and return it to the client through NodeStream. This API is the core of implementing streaming rendering (Streaming).

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

0a2f161736544ec3676ec0d25de43249.gif

Just replace renderToString with renderToPipeableStream in server/render.js to achieve this effect.

However, HTML is indeed transmitted in segments through Stream. But the page still causes a 3s white screen time because of the comment interface.

Next, we will try to solve how to combine the Promise requested by the server with streaming for streaming rendering.

九、use hook

React has a proposal to use hooks in future versions: R FC: First class support for promises and async/await .

React provides a special use Hook. You can think of using this as a similar solution to React-Query.

In most cases, we have written code like this in React to obtain data requests:

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 && <div>{data}</div>}
    </div>
  );
}

We want to update the page data after the Promise status returned from the remote interface is completed. In most cases, we use useEffect with the then method on the client to update the data.

In this case, we usually need to handle Promise in different states in the code to perform different renderings in the template.

In the upcoming React version, the React team provides a more convenient way of handling: use hook.

Using use we can read the value of the completed Promise, which will delegate the load-time status and error handling to the nearest Suspense.

The benefits of this architecture are obvious: it allows components to be grouped into contexts that are only ready to render when all components have loaded data.

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>
}

In the above code, we use use hook to easily handle the places where we need to wait for the Promise status.

The data in the <Demo /> component will determine the status of the outermost <Suspense /> based on the promise status returned by the incoming getSomeData().

When the promise is still in the pending state, the fallback will be rendered as a placeholder. Once the promise inside the component becomes fulfilled, the Demo component will naturally be rendered.

To strike while the iron is hot, let's try to use use to transform the example just now:

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

First, we slightly modified the server-side logic: the asynchronous blocking logic that originally required await getComments() stopped await and directly passed the returned Promise to <HTML /> and <App />.

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>
}

Secondly, since we only handle getCommones on the server side, we comment out the client script injected in the <Html /> component first.

// src/App.tsx
import React, { useRef, use, Suspense } from "react";
function Comments({ comments }) {
  const commentsResult = use(comments)
  return Array.isArray(commentsResult) && 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>
  );
}

Finally, we make slight modifications to src/App.jsx.

Extract the original comment content into a separate component, and use use inside the comment component to wrap the Promise object returned by the incoming getComments().

Using Suspense in the outer <Index /> component wraps the inner <Comments /> component using use.

a014b378bf952c9cfe7d8d4cacf1a847.gif

Refresh the page here, and the comment content will not block any page rendering when obtaining data. After 3s, when the Promise status returned by getCommonets() changes, the page renders the product review content normally.

The upcoming React 18.3 will provide the use hook feature to create more convenient client-side Promise processing for us.

So how to interact with the client using use?

As we mentioned above, the data obtained from the server in the server-rendered page is currently only available in the form of global variables when provided to the client.

This time we need to pass a Promise to the browser on the server side to record its status. It is obviously impossible to serialize a Promise on the server side and pass it to the client.

// 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>
}

At this point, we let go of client/index.js in the server-side HTML.

Refresh the page again. After 3 seconds, there will be a lot of errors on the page:

8956898fab6df1fb301c527399cf8622.png

The reason for the error can be imagined:

When rendering on the server side, props.comments will be passed to <Comments /> and the correct template will be rendered and returned.

When the client's hydrate logic is executed again, an error will naturally occur because the client does not pass any content when calling <Comments /> again.

So the key question is, how do we pass a stateful Promise on the server to the client?

Obviously, the solution of serializing the current Promise from the server to the client obviously does not work. Then, we have to create a so-called Promise on the client side.

// 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} />)
})

Before the client script is executed, we construct a clientPromise and a __setComments_data method on the window.

The clientPromise will only be fully filled when the __setComments_data method is called.

After that, we pass the clientPromise built by the client to the <App /> component that needs to perform rendering on the client side.

At this step, we can ensure that the comments props in **<App comments={clientPromise} /> receive a real Promise.

After that, we only need to notify the client to call window.__setComments_data to complete the client's commentPromise when the server's commentPromise is completed.

// 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>
}

Since <Html /> is rendered on the server side, we slightly modified <Html />.

a. The purely server-side rendered <Html /> component will receive the Promise returned by the comments request.

b. In <Html />, we additionally define a so-called <CommentsScript />.

Using use hook and Suspense, when the comment interface requested on the server returns, it will be replaced with a script.

When the status of comments on the server is filled: 

<Suspense>
        <CommentsScript comments={comments}></CommentsScript>
</Suspense>

<CommentsScript /> will be rendered, thereby executing the window.__setComments_data method to notify the client that the Promise is completed and obtain the corresponding comment data from the server.

bc7e6169b34c9d070d693566d0de5720.gif

At this point, both server-side and client-side logic can meet our needs and implement custom streaming data rendering.

Of course, there is no single way to implement this mechanism.

For example, we mentioned above that asynchronous data streaming can be implemented in Remix even when use hook does not exist in React 18.2. Interested students can follow my future articles and I will talk to you in detail about how Streaming Data is processed in Remix. The way.

10. Implementation mechanism

Maybe you are curious about how to use Streaming to implement HTML "stream rendering". Let's talk about this part for a little bit.

The so-called "streaming rendering" Streaming only realizes the segmented transmission of html script content at the network level. It does not have any magic that can dynamically modify your html content.

Usually the most direct way to modify the HTML of a page is to dynamically manipulate the DOM through JavaScript. Naturally, the "streaming rendering" that seems very advanced is also inseparable from the help of JavaScript scripts to achieve progressive page loading.

Let’s take the DIY demo just now as an example:

After executing curl --no-buffer localhost:3000, we found that the first half of the HTML content will be returned immediately in the console.

Later, after 3 seconds, the console will print out the remaining content again here. Naturally, these 3 seconds are exactly the time difference returned by the CommentsPromise comment interface we defined previously.

<!-- 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><!--/$-->

You can see that the HTML content returned in the first half (3 seconds ago) is only an HTML script containing some static resources and static templates.

There are two important nodes that are commented out on the page:

  • Utilize the <Comments /> component wrapped in <Suspense />.

This part of the content shows the loading content that uses the fallback attribute to place the comment content when loading, and uses the <!--$?--> comment node to wrap the two parts of the content, which are the fallback placeholder HTML node and <template id= "B:0"></template> .

  • In addition to the client script index.js that should be returned normally, an additional <template id="B:1"></template> node is returned.

Note that each <template> tag has a unique id attribute.

Different id character prefixes represent different node types. For example, the B in B: 1 here represents Boundary (Suspense), and S represents Segment (valid segment to be inserted): S:, usually div, table, For mathematical formulas, SVG will use the corresponding elements.

At the same time, different placeholder annotation nodes also represent different states. The above node <!--$?--> represents the loading (pending) state.

When the entire page is loaded, open the browser console again and you will find that it will change to <!--$-->, which means that the loading is completed (Completed).

After 3s, the remaining Html script content will be returned in the console:

<!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 && 8 === a.nodeType) { var d = a.data; if ("/$" === d) if (0 === f) break; else f--; else "$" !== d && "$?" !== d && "$!" !== d || f++ } d = a.nextSibling; e.removeChild(a); a = d } while (a); for (; c.firstChild;)e.insertBefore(c.firstChild, a); b.data = "$" } b._reactRetry && 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>

First, after 3 seconds the data request is completed and all comments are returned normally in the server script:

<div hidden id="S:0">
    <p>This is Great.</p>
    <p>Worthy of recommendation!</p>
 </div>

React will wrap all normally returned script content with a div marked hidden.

If an element has the hidden attribute set, it will not be displayed.

At the same time, each HTML fragment carrying the hidden attribute returned from the server will also carry a unique id attribute.

Then, the natural next step is to use this HTML fragment returned from the server to replace the HTML content of the fallback in <Suspense />.

$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 && 8 === a.nodeType) {
          var d = a.data;
          if ('/$' === d)
            if (0 === f) break;
            else f--;
          else ('$' !== d && '$?' !== d && '$!' !== d) || f++;
        }
        d = a.nextSibling;
        e.removeChild(a);
        a = d;
      } while (a);
      for (; c.firstChild; ) e.insertBefore(c.firstChild, a);
      b.data = '$';
    }
    b._reactRetry && b._reactRetry();
  }
};

After 3 seconds, the entire page data request ends, and the server will return this script to the client.

The core replacement script is in the embedded JS script of $RC mentioned above. This script defines the $RC global method. After the method definition is completed, it understands and calls $RC("B:0", "S:0") to use the server. The returned HTML content uses JavaScript to replace the original HTML placeholder nodes and perform regional hydrate.

I will not describe the above $RC method line by line in detail. The core idea of ​​this method is to replace the elements before and after Suspense to achieve the so-called "progressive HTML" effect.

Of course there are similar methods called $RX , $RX and $RC . The content involved in this area is relatively trivial, so I will not go into detail with you in this article.

11. Ending

It just so happens that the large front-end department of business travel where the author works currently has most of the front-end applications integrated into Remix. We made some changes on the basis of Remix and adapted it to the existing business travel business to achieve out-of-the-box results.

Just in conjunction with the Steaming feature in React18, it has achieved great benefits in terms of page performance and user experience.

Of course, we will also share and discuss this with you later on regarding the technical difficulties encountered in switching to Remix as well as the performance benefits and user experience it brings.

Finally, this article is more about talking to you about the basic principles of Steaming in a way that inspires others. I hope that the content in the article will inspire and help you in your daily business.

[Recommended reading]

144ba4fc10320e282e2fea35bb31babd.jpeg

 “Ctrip Technology” public account

  Share, communicate, grow

Guess you like

Origin blog.csdn.net/ctrip_tech/article/details/131714108