React SSR

2023-06-18 21:00:33

React SSR - 寫個 Demo 一學就會

今天寫個小 Demo 來從頭實現一下 reactSSR,幫助理解 SSR 是如何實現的,有什麼細節。

什麼是 SSR

SSRServer Side Rendering 伺服器端渲染,是指將網頁內容在伺服器端中生成並行送到瀏覽器的技術。相比於使用者端渲染(CSR),SSR 一般用於以下場景:

  1. SEO (搜尋引擎優化):由於部分搜尋引擎對 CSR 內容支援不佳,所以 SSR 可以提升網站在搜尋引擎結果中的排名。
  2. 首屏載入速度:由於 SSR 可以在伺服器端生成完整的 HTML 頁面,使用者開啟網頁時能夠更快地看到內容,不會看到長時間的白屏,可以提升使用者體驗。
  3. 隱藏某些資料:由於 CSR 需要從伺服器將資料下載下來進行動態渲染,所以一些資料很容易被他人獲取,而 SSR 由於資料到渲染的過程在伺服器端實現,所以可以用來隱藏一些不想讓他人輕易獲得的資料。

如何實現

簡單的 SSR 其實實現很簡單,只需要在伺服器端匯入要渲染的元件,然後呼叫 react-dom/server 包中提供的 renderToString 方法將該元件的渲染內容輸出為字串後返回使用者端即可。

Server 端的元件

下面寫一個簡單的例子:

伺服器端程式碼:

import express from 'express';
import React from 'react';
import { renderToString } from 'react-dom/server';

import App from '../ui/App';

const app = express();

app.get('/', (_: unknown, res: express.Response) => {
    res.send(renderToString(<App />));
});

app.listen(4000, () => {
    console.log('Listening on port 4000');
});

此處要注意伺服器端需要支援 jsx 語法的解析,我這裡直接使用 esno 執行 ts 程式碼,在 tsconfig.json 中設定 jsx 即可。

其實看到這裡就能明白為什麼在 SSR 的頁面上使用 windowlocalstorage 等瀏覽器 API 需要放到 useEffect 裡了,因為該頁面的元件都會被 server 端讀取解析,而 server 端並沒有這些 API

然後看下 App 元件的程式碼:

import React, { useCallback } from 'react';

export default () => {
    const log = useCallback(() => {
        console.log('Hello world');
    }, []);

    return (
        <div>
            <p>react ssr demo</p>
            <button onClick={log}>Click me</button>
        </div>
    );
};

啟動伺服器後 server 端就會使用 renderToString<App /> 渲染成 html 字串,然後通過 send 返回給前端,下面就是伺服器端返回的 html 內容:

<div>
    <p>react ssr demo</p>
    <button>Click me</button>
</div>

開啟瀏覽器存取該地址即可看到伺服器端返回了該 html 片段:

hydrate 復活元件

如果你跟著上面的操作很快就會發現問題:為什麼點按鈕沒法操作了?

其實原因很簡單,因為我們只拿到了一個 html 並沒有任何的 js,事件繫結等自然是無法實現的,要復活元件的互動我們還需要很重要的一步 - hydrate 也就是常說的水合。

hydrate 即通過 react 將對應的元件重新渲染到 SSR 渲染的靜態內容上,類似於 render 差異點在於 render 會忽略 root 元素中現有的 domhydrate 則會複用並會進行內容匹配檢查。

Hydration failed because the initial UI does not match what was rendered on the server.

如果遇到上述錯誤即表示在使用者端執行 hydrate 時伺服器端返回的初始的 domhydrate 接收到的需要進行渲染的 dom 不匹配。

說了這麼多我們再來看下程式碼如何編寫,首先要進行 hydrate 我們需要使用者端的程式碼來執行:

import React from 'react';
import { hydrateRoot } from 'react-dom/client';

import App from './App';

hydrateRoot(document.getElementById('root')!, <App />);

然後將該程式碼進行編譯打包,我這裡就直接使用 webpack 進行打包:

const path = require('path');

module.exports = {
    entry: './ui/index.tsx',
    output: {
        path: path.resolve(__dirname, 'static'),
        filename: 'bundle.js'
    },
    resolve: {
        extensions: ['.ts', '.tsx', '.js', '.jsx']
    },
    module: {
        rules: [
            {
                test: /\.(t|j)sx?$/,
                exclude: /node_modules/,
                use: {
                    loader: 'babel-loader',
                    options: {
                        presets: ['@babel/preset-react', '@babel/preset-typescript']
                    }
                }
            }
        ]
    }
};

打包完成後生成一個 bundle.js 即可在使用者端使用它來進行 hydrate

然後我們再修改下 server 端的程式碼:

app.get('/', (_: unknown, res: express.Response) => {
    res.send(
        `
<div id="root">${renderToString(<App />)}</div>
<script src="/bundle.js"></script>
`
    );
});

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

我們在靜態內容的外層套上 root 元素,然後在下方引入我們剛剛編譯的指令碼,然後就可以在使用者端看到我們想要的結果:

可以看到事件可以正常觸發了。

此處還有個注意點,在 server 端要注意將靜態字串包裹在 root 元素中不要新增換行空格等,不然 reacthydrate 時依舊會因為內容不匹配而提示 Hydration failed(僅在 hydrateRoot 時出現,如果使用 hydrate 不會報錯,不過 18 中 hydrate 已經被棄用。)

動態資料

此時有些同學可能發現一些問題:前面的內容所渲染的內容都是靜態的,如果要針對使用者渲染出不同的內容比如使用者資訊等如何是好?

其實很簡單,只需要在伺服器端將對應的資訊作為 props 進行渲染即可,我們下面使用 userName 模擬一下:

app.get('/', (_: unknown, res: express.Response) => {
    const userName = ['張三', '李四', '王五', '趙六'][(Math.random() * 4) | 0];
    res.send(
        `
<div id="root">${renderToString(<App userName={userName} />)}</div>
<script src="/bundle.js"></script>
`
    );
});

可是使用者端要如何與伺服器端匹配呢?此處有兩種解決方案:

  1. 使用者端獲取對應的資訊並在資訊獲取完成後再進行 hydrate 操作。
  2. 伺服器端將獲取到的資訊放在頁面中。

可以看出方案 1 會帶來明顯的延時,所以一般會採用方案 2,實現一般可以使用全域性變數或特定標籤來實現:

app.get('/', (_: unknown, res: express.Response) => {
    const userName = ['張三', '李四', '王五', '趙六'][(Math.random() * 4) | 0];
    res.send(
        `
<div id="root">${renderToString(<App userName={userName} />)}</div>
<script>
window.__initialState = { userName: '${userName}' };
</script>
<script src="/bundle.js"></script>
`
    );
});
import React from 'react';
import { hydrateRoot } from 'react-dom/client';

import App from './App';

hydrateRoot(document.getElementById('root')!, <App {...window.__initialState} />);

總結

  1. React 中的 SSR 可以通過 renderToString 來實現,但是隻能輸出靜態內容,要讓頁面支援互動需要搭配 hydrate 使用。
  2. 實現 SSR 時伺服器端需要支援 jsx 語法的解析,因為伺服器端也需要讀取元件。
  3. hydrate 會檢查伺服器端與使用者端的內容是否匹配。
  4. 要實現動態資料需要在使用者端與伺服器端之間做好如何使用初始 props 的約定。

最後

本文的 demo 程式碼放置在 React SSR Demo 中,可自行取閱。