[前端筆記]關於SSR,我們在討論什麼

NextJS的服務端渲染策略

Posted by 李定宇 on Monday, April 1, 2024

前言

基本上,關於SSR(server side render)和CSR(client side render)的比較,在網路上已經有很多資源了。本文主要是想以code demo的方式,釐清ssr和stream ssr的差別。

基本SSR

如果只是單純的服務端渲染

相較於React SPA的client side render,server side render可以有效減少到客戶端的js bundle size,直接傳已經渲染好的html。這部分的程式碼demo在此 [repo folder]()。同時,以下會略過有關webpack編譯的過程,只著重在跟SSR有關的邏輯。

以下是基於React + NodeJS express的SSR demo:

  • 新增一個 app/page.js,寫一個React組件
  • import React, { useState } from "react";
    export default function MyApp() {
    const [count, setCount] = useState(0);
    
    return (
        <div>
            <h1>Hello SSR, Counters {count} times</h1>
            <button onClick={() => setCount(count + 1)}>Click me</button>
        </div>
    );
    }
    
  • 新增 server.js,撰寫SSR相關的運行邏輯。第一段主要是引入了 renderToString 這個函數,它用於將React Element render成HTML,可以在SSR時拼接到HTML文本中

    import express from "express";
    import React from "react";
    import { renderToString } from "react-dom/server";
    import App from "./app/page";
    
    const content = renderToString(<App />);
    
  • 將轉換好的react element拼接到HTML文本中

    `
    <html>
        <head>
            <title>Tiny React SSR</title>
        </head>
        <body>
            <div id='root'>
                ${content}
            </div>
        </body>
        <script src="/client.bundle.js"></script>
    </html>
    `
    
  • 這樣一來,server side就可以以簡單的 Express api server來返回 HTML文本

    const app = express();
    
    app.get("/", (req, res) =>
    res.send(`
        <html>
            <head>
                <title>Tiny React SSR</title>
            </head>
            <body>
                <div id='root'>
                    ${content}
                </div>
            </body>
        </html>
    `)
    );
    

不過,在瀏覽器中可以看到,請求回來的HTML文本中,如果點解button,是沒有辦法觸發任何邏輯。因為在目前的SSR邏輯中,只有把React Element轉化爲實體DOM、但並沒有進行事件綁定。

引入client.bundle.js,進行事件綁定

如果要進行事件綁定,最簡單的方式,就是在客戶端再渲染一次React DOM、直接替代SSR中的節點:

  • 新增client.js

    import React from "react";
    import { createRoot } from "react-dom/client";
    import App from "./app/page";
    
    const root = createRoot(document.getElementById('root'));
    root.render(<App />);
    
  • webpack.client.js 會將 client.js 輸出成 client.bundle.js,讓瀏覽器可以讀取

  • 接著只要修改server.js中的HTML模板,將client.bundle.js引入

    `
    <html>
        <head>
            <title>Tiny React SSR</title>
        </head>
        <body>
            <div id='root'>
                ${content}
            </div>
        </body>
        <script src="/client.bundle.js"></script>
    </html>
    `
    

這樣一來,就可以在瀏覽器中觸發點擊事件了。當然,這個做法的缺點也是顯而易見:server 渲染了<MyApp/>一次,然後到了瀏覽器中又渲染一次<MyApp/>

Hydrate

react-dom/server 的hydrateRoot ,便是可以複用已有的DOM節點、而不需要重新重複掛載。

  • 修改 client.js

    import React from "react";
    import { hydrateRoot } from "react-dom/client";
    import App from "./app/page";
    
    hydrateRoot(document.getElementById("root"), <App />);
    

這樣一來,就可以不用完全重新渲染、就可以綁定事件,減少了browser的渲染開銷。

Stream SSR

雖然SSR相較於CSR,可以事先在服務端渲染好再傳送到客戶端,白屏時間減少。不過如果組件過大,也會造成服務端響應耗時過久、讓用戶等待的情形。而 Stream SSR,可以流式傳送已經渲染好的組件、進而加快出始加載時間。這部分的程式碼demo在此 [repo folder]()

  • server.js中,引入renderToPipeableStream,此為基於 HTTP/1.1 中的Chunked transfer encoding機制

    import { renderToPipeableStream } from 'react-dom/server';
    
  • 在 html字符串中,依據<div id="root"></div> 切分前後,作為初始渲染和渲染結束的頭與尾。

    const [head, tail] = htmlData.split('<div id="root"></div>');
    
  • 主要 stream ssr的邏輯部分

    res.write(head + '<div id="root">'); // 寫入頭
    const stream = renderToPipeableStream(<App />, {
    onShellReady() {
        res.statusCode = 200;
        stream.pipe(res, {end: false}); // 開始傳輸
    },
    onShellError(err) {
        console.error(err);
        res.statusCode = 500;
        res.send('Server Error');
        res.end();
    },
    onAllReady() {
        res.write('</div>' + tail); // 寫入尾
        res.end();
    },
    });
    

在demo中,簡單寫了3個包含10000個item的list組件,在console中可以看出頁面是在不斷加載、但頁面有先掛載出已經渲染的部分、並沒有一直等待。

可以從瀏覽器的network項目中,查看到網頁是先掛載了主要元素、然後不停渲染流式傳入的dom節點。(此爲啟動了chrome disable cacheslow 3G 的設置選項,為了突出stream ssr的優點)

image

ChangeLog

  • 20240401-初稿