Suspend and streaming server rendering

Posted by axnoran on Wed, 09 Mar 2022 03:43:45 +0100

preface

With the rapid development of Internet technology, the front-end code becomes more and more complex. However, the complexity of the front-end code increases the volume of the client, and users need to download more content to render the page. In order to reduce the first screen rendering time and improve the user experience, front-end engineers have introduced many effective and powerful technologies, and server-side rendering is one of them.

Server rendering

In order to explain what is server-side rendering, let's draw the flow chart of traditional Client Side Render:

It can be seen that the CSR link is very long and needs to go through:

  1. Request html
  2. Request js
  3. Request data
  4. Execute js

Wait at least 4 steps to complete the First Paint
In order to reduce FP time, front-end engineers introduced Server Side Render:

However, although SSR reduces FP time, there is a large amount of non interactive time in FP and Time To Interactive. In extreme cases, users will look confused and force: "Hey, isn't there already content on the page, why can't they click and roll?"

To sum up:

What CSR and SSR have in common is that HTML is returned first, because html is the basis of everything.

After that, CSR returns js first and then data. The page can be interactive before the first rendering.

SSR returns data first and then js. The first rendering of the page is completed before interaction, so that users can see the data faster.

However, whether to return js or data first does not conflict. It should not block serial, but should be parallel.

Their blocking leads to a period of unsatisfactory effect between FP and TTI. In order to make them parallel and further improve the rendering speed, we need to introduce the concept of streaming server side render rendering.

basic thought

To sum up, the ideal streaming server rendering process is as follows:

At the same time, in order to maximize the loading speed, it is necessary to reduce the Time To First Byte. The best way is to reuse the request. Therefore, only two requests need to be sent:

  1. When requesting html, the server will first return the html of the skeleton screen, then return the required data or html with data, and finally close the request.
  2. Request js. After js returns and executes, it can interact.

Why is it called "streaming server rendering"? This is because the corresponding body of the request that returns html is stream. In the stream, the synchronous HTML code such as skeleton screen / fallback will be returned first, and then the corresponding asynchronous HTML code will be returned after the data request is successful. This HTTP connection will not be closed until it is returned.

The advantages are:

  • Request data and request js are parallel, while most previous solutions are serial.
  • In the optimal case, only two requests are sent, which greatly reduces the total time of TTFB

However, the ssr framework usually executes the render function only once. In order to let it know what is the loading state and what is the data state, we need to upgrade it, first of all, lazy and suspend

lazy and suspend

Then we will further study how they serve for streaming server rendering by simply discussing the implementation principle.
The simplest lazy is as follows:

function lazy(loader) {
  let p
  let Comp
  let err
  return function Lazy(props) {
    if (!p) {
      p = loader()
      p.then(
        exports => (Comp = exports.default || exports),
        e => (err = e)
      )
    }
    if (err) throw err
    if (!Comp) throw p
    return <Comp {...props} />
  }
}

The main logic is to load the target component. If the target component is loading, throw the corresponding Promise, otherwise the target component will be rendered normally.

Why is throw chosen here? At the syntax level, only throw can jump out of the logic of multi-layer functions and find the nearest catch to continue to execute. Other process control keywords, such as break, continue and return, are used to schedule the logic in a single function, affecting the statement block.

Readers who often use throw in conjunction with Error may be surprised, but sometimes they need the ability to look beyond common sense.

lazy is usually used together with suspend. A simple suspend is as follows:

function Suspense({ children, fallback }) {
  const forceUpdate = useForceUpdate()
  const addedRef = useRef(false)
  try {
    // Try to render children first, and write it simply for ease of understanding
    return children
  } catch (e) {
    if(e instanceof Promise) {
      if(!addedRef.current) {
        e.then(forceUpdate)
        addedRef.current = true
      }      
      return fallback
    } else {
      throw e
    }
  }
}

The main logic is: try to render children. If children throw Promise, render fallback. When Promise resolve s, rerender.

As for whether this Promise is from lazy or fetch, I don't really care.
However, suspension within the framework is usually not written like this. Its simplest implementation is:

function Suspense({ children }) {
  return children
}

Yes, it's so simple. It's the same as the Fragment code. It just provides a flag bit for scheduling.

In order to improve scalability and robustness, React uses Symbol as flag bit internally, but the principle is the same.

When scheduling this component, if it is interrupted by throw, it will fall back to fallback:

try {
  updateComponent(WIP) // Interrupted by throw
} catch(e) {
  WIP = WIP.parent // Fallback to suspend component
  WIP.child = WIP.props.fallback // Replace the child pointer
}

For some frameworks, such as vue/preact, their underlying data structure is not fiber or linked list. The principle is to set two placeholders to decide which placeholder to render according to the specific state during scheduling

<Suspense> 
  <template #default> 
    <article-info/> 
  </template> 
  <template #fallback> 
    <div>Loading...</div> 
  </template> 
</Suspense>

Since it is not the focus of this time, it will not be carried out here. Interested students can read the relevant source code.

The last building block

After exploring the principles of lazy and suspend, let's put the last building block for streaming server rendering: ssr framework.

app.get("/", (req, res) => {
  res.write("<!DOCTYPE html><html><head><title>My Page</title></head><body>");
  res.write("<div id='root'>"); 
const stream = ReactServerDom.renderToNodeStream (<App />);
stream.pipe(res, { end: false });
stream.on('end', () => {
    res.write("</div></body></html>");
    res.end();
  });
});

In the process of renderToNodeStream, each component is directly put into the stream after rendering. In the view of the browser, the html string you may receive is as follows:

<html>
  <body>
    <div id="root">
      <input />
      <div>some content</

It looks like only half. How do you show it?

Don't worry, modern browsers have excellent fault tolerance for html. Even if there is only half, it can render the half intact. This is the basis of streaming server rendering.

During scheduling, when suspend is encountered and WIP fallback is required, fallback will be put into the stream and Promise will be executed. When Promise resolve s, the corresponding replacement code will be put in. A simple example is as follows:
Render fallback first:

<html>
  <body>
    <div id="root">
      <div className="loading" data-react-id="123" />

After Promise resolve, return:

<div data-react-id="456">{content}</div>
<script>
  // For example, this API is not really available
  React.replace("123", "456")
</script>

Use the js script of inline to replace dom to realize streaming loading.

The overall appearance is as follows:

<html>
  <body>
    <div id="root">
      <div className="loading" data-react-id="123" />
      <!-- synchronization HTML Return to the client after rendering js -->
      <script src="./index.js" />
      <!-- The client uses the "partial hydration" algorithm to the server HTML Virtual with client dom conduct mergeļ¼ŒSkip by Suspense Managed nodes -->

      <!-- After a while -->
      <div data-react-id="456">{content}</div>
      <script>
        // For example, this API is not really available
        React.replace("123", "456")
      </script>
    </div>
  </body>
</html>

epilogue

Streaming server-side rendering opens a new door to reduce rendering time and improve user experience. The fly in the ointment is that it is still in theory. All major frameworks are under research and development, and there is no available demo. Please wait and see.

Original link: https://bytedance.feishu.cn/w...

reference material

The End

Topics: Front-end ssr