Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

一个说新不算新的服务端渲染技术——流式渲染 #48

Open
rottenpen opened this issue Mar 28, 2022 · 1 comment
Open

Comments

@rottenpen
Copy link
Owner

rottenpen commented Mar 28, 2022

前言

这个问题源于上一次面试拼多多被问到的一套组合拳,性能优化 -> 预渲染 -> 骨架屏 -> ssr -> 流式渲染,前面的几个点因为有所接触问题不大,但流式渲染因为很早已经被 ssr 框架们内置,我居然听都没听过👴🏿❓ 结合最近刚上线的 react 18 ,我们来聊聊流式渲染吧。

接下来我们带着这几个问题来探究一下:

  • 基于 renderToString 的 SSR 存在什么问题?
  • 流式渲染渲染的原理是什么,使用了流式渲染之后会有什么优势?
  • react 18 对流式渲染做了什么优化?

什么是服务端渲染

以 React 的 SSR 为例,React 提供了 renderToString 这个包,用于把 reactDOM 转化成 html 的静态代码。(为了让后面的测试效果明显一点,我写了一个渲染 4 万行 markdown 的 demo)

const tempString = fs.readFileSync(path.join(__dirname, './template.md'), 'utf8');

app.get('/react-ssr', (req, res) => {
    // 由于量比较大,这一步会有明显的阻塞
    const app = ReactDOMServer.renderToString(<App source={tempString}/>);
    const html = `
        <html lang="en">
        <head>
        </head>
        <body>
            <div id="root">${app}</div>
        </body>
        </html>
    `
    res.send(html);
});

这样做有什么问题呢?

在浏览器获得这一整个超过4万行的 html 之前,浏览器是不做任何渲染的,这就代表用户会有很长的白屏时间,于是我们就有了下面的要说的流式渲染。

什么是流式渲染

我们都知道浏览器接受到 html 之后的渲染流程:HTML 解析 -> DOM Tree / cssom tree -> 合成渲染树 -> layout + paint
image
但比较幸运的是,浏览器不会等解析完完整的 html 文档后,才进行 layout 和 paint。
我们来跑一个简单的 demo 看看
实际上流式渲染的原理并不是很复杂, 为了让效果更直观我们来看看下面这个写了很多 setTimeout 的 demo:

app.get('/ssr-streaming', async (req, res) => {
    res.write(`
        <html lang="en">
        <head>
        </head>
        <body>
    `)
    setTimeout(() => {
        res.write(`<div style="background: #111222;width: 100vw;height: 100px;">321</div>`)
    }, 100);
    setTimeout(() => {
        res.write(`<div style="background: #123222;width: 100vw;height: 100px;">321</div>`)
    }, 500);
    setTimeout(() => {
        res.write(`<div style="background: #333112;width: 100vw;height: 100px;">321</div>`)
    }, 1000);
    setTimeout(() => {
        res.write(`<div style="background: #332312;width: 100vw;height: 100px;">321</div>`)
    }, 2000);
    setTimeout(() => {
        res.write(`<div style="background: #333432;width: 100vw;height: 100px;">321</div>`)
    }, 3000);
    setTimeout(() => {
        res.write(`<div style="background: #335422;width: 100vw;height: 100px;">321</div>`)
    }, 4000);
    setTimeout(() => {
        res.write(`<div style="background: #673211;width: 100vw;height: 100px;">321</div>`)
    }, 5000);
    setTimeout(() => {
        res.end()
    }, 6000);
});

可以从浏览器的效果中看到,这些 div 是一段一段被渲染出来的。这归功于浏览器强大的兜底能力,就算我们没有提供闭合的 dom 结构,也能根据已经接收到的数据,进行补全和渲染。基于这一点,我们就可以选择更优的渲染方案,提前我们页面的 TTI 的时间了。
Kapture 2022-03-27 at 19 34 29

通过 react 的 renderToNodeStream 实现流式渲染(注,renderToNodeStream 是基于 dom 节点切片的,如果都写到一个 dom 里,无论这个节点的字符串多长,都是一次性返回的)

app.get('/react-streaming', (req, res) => {
    res.write(`<html lang="en">
         <head>
         </head>
         <body>`);
    res.write(`<div id="root">`);
    const stream = ReactDOMServer.renderToNodeStream(<App source={tempString} />)
    stream.pipe(res, { end: false })
    stream.on("end", () => {
        res.write("</div></body></html>");
        res.end();
    })
})

我们可以看到在这个比较极端的 case 里 LCP 的时间明显提前了,这是因为当收到第一段 chunk 之后,浏览器马上进行渲染了。
优化前:
image

优化后:
image

流式渲染那么好,那它有什么缺点呢?

lcp 提前了也不完全是好事,因为 react 的 ssr api 是纯文本的,逻辑层会被剥离(dehydrate)。

  • 把逻辑层剥离的过程被称为 脱水(dehydrate)
  • 把逻辑层重新注入 dom 节点的过程被称为注水(hydrate)
    当我们生成脱水后的 html 之后,需要把逻辑层重新注入代码中,代码才能恢复 event 响应。
// 生成注水代码
import React from 'react';
import ReactDom from 'react-dom';
import { App } from './app';

ReactDom.hydrate(<App source="hydrate xxxxx" />, document.getElementById('root'))

// 用esbuild打包
"scripts": {
    "build": "esbuild client/index.tsx --bundle --outfile=built/index.js",
}

// 在最后插入 index.js
app.get('/react-streaming', (req, res) => {
    res.write(`<html lang="en">
         <head>
         </head>
         <body>`);
    res.write(`<div id="root">`);
    const stream = ReactDOMServer.renderToNodeStream(<App source={tempString} />)
    stream.pipe(res, { end: false })
    stream.on("end", () => {
        res.write(`</div></body>
        <script src="index.js"></script> // 注水
        </html>`);
        res.end();
    })
})

这就意味着,在流式渲染中会先看到画面,但页面处于无法响应的阶段,对用户体验会有一定影响。

我们脑补一下,在业务中如果希望提前响应时间我们会怎么做呢?

image

React 18 的 streaming

在React 18之前,如果应用程序的完整JavaScript代码没有加载进来,hydration就无法启动。对于较大的应用程序,这个过程可能需要一段时间。
但在React 18中,可以让你在子组件加载之前就对应用进行hydration。
通过用包装(warp)组件,你可以告诉React,它们不应该阻止页面的其他部分,甚至是hydration。这意味着你不再需要等待所有的代码加载,以便开始hydration。React可以在加载部分时进行hydration。
这2个Suspense的功能和React 18中引入的其他几个变化极大地加快了初始页面的加载。

@Juaoie
Copy link

Juaoie commented Sep 21, 2022

👍

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

2 participants