跳至主要内容

[note] React SSR

TL;DR

  • Server-side Rendering(SSR)的重點在於使用者看到的 HTML DOM 是由 server 端產生好後才傳給 client。
  • SSR 和 CSR 的最大差異是使用者最終 UI 上看到的 HTML DOM 元素是由 server 產生(SSR)、或是由 client(CSR)所產生
  • SSR 和 SSG 最大的差異是在 HTML 檔案被建立、產生的時間點。SSR 會在每次收到 request 時才產生對應的 HTML,因此每次回傳 client 的 HTML 對 server 來說都是獨立不同的;但 SSG 是在一開始就把所有需要用到的頁面都建立出來,在收到 request 時就直接用相同、一開始就產生好的 HTML 回傳回去。

React SSR 基礎觀念

什麼是 Server-side Rendering (SSR)

SSR 會由 server 產出完整的 HTML 內容後才發送給 client。

傳統的全端框架(例如,Ruby on Rails)就是屬於 SSR,開發者會在 server 端讀取 database 的資料並放入 HTML 後才把頁面傳給 client。

如果畫面上有需要透過 API 才能取得的資料的話,server 則會同時扮演發送 API 的角色,由 server 發送 API 請求得到資料後,把資料放入 HTML 後,才傳給 client(參考 patterns.dev 上的影片)。

SSR vs. CSR

Pre-rendering and Data Fetching @ pjchender.dev

相較於 SSR 會由 server 將 HTML 的內容產生好後才回傳給 client,在 CSR(client-side rendering)中,server 收到 response 時只會回傳一個簡單的 HTML 模板給 client,後續使用者看到的內容則都是由 client 端的 JavaScript 所產生。

要辨別一個網站使用的是 SSR 或 CSR 最簡單的方式就是使用瀏覽器開發者工具的 「View Page Source」 功能(不能用 inspect,因為 inspect 看到的是已經透過 JavaScript 處理過的最終畫面):

  • 如果 View Page Source 中看到的 HTML DOM 元素和實際 UI 上的幾乎一致,表示這個頁面是以 SSR 來處理;
  • 反之,如果 View Page Source 中看到的是非常簡單的 HTML 樣板,和實際 UI 上看到的 DOM 差異很大,表示這個頁面走的是 CSR,UI 上看到的內容大部分都是透過 JavaScript 所產生。

SSR vs. SSG

Pre-rendering and Data Fetching @ pjchender.dev

除了 CSR 之外,還有一種是作法是使用 Static Side Generator(SSG)。SSG 和 SSR 的 HTML 都是由 server 產生好後提供給 client,但這兩者最大的差異在於,SSR 的 HTML 產生的時間點是沒錯 server 收到 request 後才開始製作,因此 server 每次收到 request 時都會需要產生一個獨立的頁面;而 SSG 則是在網頁打包時就把需要用到的 HTML 全部產生出來,server 每次收到 request 是都是回傳「相同的」、「一開始就產生好的」 HTML 檔給 client。

SSR vs. Universal (Isomorphic) JavaScript

在學習 Server Side Rendering 你有時也會聽到 Universal JavascriptIsomorphic Javascript,這幾個專有名詞的差別在於:

  • Server Side Rendering:在伺服器端產出 HTML 後掛載到使用者的瀏覽器上。
  • Universal JavaScriptIsomorphic JavaScript:兩者都是指在伺服器端所寫的部分程式碼,也會在瀏覽器上執行。強調的是在 client side 或 server side 應該有一致的撰寫風格。我們會先將要把 node 執行的程式碼透過 Webpack 和 Babel 重新編譯,因此在伺服器端也可以使用 ES6 的 import 語法。

在 SSR 中有一個非常重要的觀念,只有使用者第一次進來該頁面時是透過 Renderer Server 處理(SSR),除此之外,後續操作的行為和頁面的切換,則都是透過載好在瀏覽器端的 React APP 來處理。

SSR 的優缺點

  • 由於 server 需要花更多資源來產生頁面,因此使用者得到 server response 的時間會長一些些(Time to First Byte, TTFB),特別是當使用者的網路不好、非常多使用者同時大量湧入、server 的程式碼沒寫好導致程式阻塞等情況時。
  • 因為 server 會先產生好 mockup,可以減少 first paint 所需的時間(First Contentful Paint, FCP
  • 因為需要等到 JS 載入完,並執行 hydrate 後才能互動,因此有可能會增加 first interaction 的時間(Time to Interactive, TTI
  • 使用 SSR 對搜尋引擎的爬蟲比較友善,所以一般來說 SEO 會比較好

React SSR 基本實作

伺服器架構:前後端分離

一般來說在做 React SSR 的時候都會建議有兩個 Sever,也就是前後端分離的架構:

  • API Server:用來處理商業邏輯、登入驗證、顯示使用者清單,一般所謂的後端。
  • Renderer Server:用來轉譯應用程式並顯示給使用者,一般所謂的前端。

img

轉譯出 HTML 內容

在 ReactDOM 中除了提供我們 render() 的函式之外,另外提供了 renderToString() 的方法:

  • render():將所有元件(component)建立實例,並掛載(mount)到 DOM 節點上。
  • renderToString():一次轉譯許多元件,並將 HTML 的結果以字串回傳。

程式範例

const express = require('express');
const app = express();
const React = require('react');
const renderToString = require('react-dom/server').renderToString;
const Home = require('./client/components/Home').default;

app.get('/', (req, res) => {
// 使用 renderToString() 的方法將樣板轉為 HTML
const content = renderToString(<Home />);
res.send(content);
});

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

處理 JSX

在過去 Client APP 的時候,把所有的元件都載入到 index.js 中,接著透過 Babel 會把 JSX 轉譯成瀏覽器可看懂得 ES5 語法,最後打包成一支 bundle.js,讓瀏覽器解析。

同樣的原則可以套用 Server Side 上,我們先透過 WebpackBabel 把所有元件打包成 bundle.js 之後,在讓 Node 去執行它:

$ node build/bundle.js

開發過程自動重新打包與重啟伺服器

在開發過程中,當檔案有變動時,可以使用 webpack --watch 的指令,讓 webpack 可以重新打包好一支新的檔案。另外搭配 nodemon重新執行 node 的指令,兩者的功能不同:

// package.json

{
scripts: {
// 讓 nodemon 監控 './build' 資料夾內的檔案,
// 一旦檔案有變更後就執行 ./build/bundle.js 的檔案
'dev:server': 'nodemon --watch build --exec "node build/bundle.js"',

// 透過 --watch 指令來更新 webpack 打包好的檔案
'dev:build:server': 'webpack --config webpack.server.js --watch'
}
};

這種方法會讓 Node 可以自動更新讀取載入好的 bundle 檔案,但還是需要手動按網頁的重新整理才會看到內容。

無法觸發綁定的事件

在一般的 React App 中,我們會把整個 React JS 的程式碼打包好後放到瀏覽器去執行,接著這個打包好的檔案或去轉譯 DOM,並且設置所有相關的事件處理器(event handler)。

在 SSR 中,當使用者向伺服器發送請求時,Express 伺服器只會回傳 HTML 文檔,沒有 JS 的程式碼會被傳送到瀏覽器端執行,因此自然不會綁定好相關的事件處理器。

為了要讓 JavaScript 檔案順利執行,我們必須要把和此 App 有關的 JavaScript 傳送到瀏覽器端。於是,我們會建立兩個打包好的不同檔案,其中一個是在後端執行;另一個則是會丟到瀏覽器去執行,這兩支 webpack 設定檔(webpack.server.js, webpack.client.js)會有不同的進入點(entry point, index.server.js, index.client.js):

SSR 流程圖

綜合上述,我們整個 SSR 的流程圖會像這樣:

  1. 伺服器將 React App 透過 ReactDOM.renderToString() 產生 HTML 樣版。
  2. 把產生好的 HTML 樣版丟到使用者的瀏覽器。
  3. 瀏覽器轉譯此 HTML 樣版,並下載給 client 使用的 bundle.js 檔。
  4. 瀏覽器載入並執行 bundle.js 檔案。
  5. 在相同的 div 中,手動再次轉譯 React App。
  6. React 會在瀏覽器轉譯 App,並比較新的 HTML 和原本的 HTML 樣版有何差異。
  7. React 取代原本的伺服器所轉譯的樣版,綁定相關的事件處理器。

hydration:將 JS 功能重新放回到已經被伺服器所轉譯的 HTML 樣版上的這個過程,稱作 hydration。

React-Router SSR

Server Rendering @ React Router

在 react-router SSR 中,Express server 會放行所有進來的 request,全部交由 react-router 處理,透過 react-router 來決定要顯示哪個畫面

在一般的伺服器上(非 SSR)React-Router(BrowserRouter)會這樣運作:

  1. 瀏覽器發送請求 '/users'
  2. Express 伺服器不會在意請求的路由為何,透過 app.get('*') 全部收進來
  3. Express 回傳 index.html
  4. Express 回傳 bundle.js
  5. bundle.js 檔案執行後,React 和 React Router 會啟動
  6. React-Router 裡面的 BrowserRouter 元件,會去根據瀏覽器網址列來決定要顯示什麼內容

React-Router 中的 BrowserRouter 元件是根據瀏覽器網址列來決定要顯示什麼內容,但在 SSR 中並沒有瀏覽器網址列的存在

為了解決這樣的問題,會使用到 React-Router 中提供的另一個元件,稱作 StaticRouter,它主要就是來處理 SSR 使用的。所以當 App 透過 Server 初次轉譯時,它會使用的是 StaticRouter;接著,當 App 再次透過 JavaScript 轉譯時(hydrate),則會使用 BrowserRouter

因此我們的架構改變成,先建立一支 ./src/client/Routes.js,它會共享伺服器(./src/helpers/renderer.js)和瀏覽器(./src/client/index.client.js)要使用的程式碼:

React SSR with Redux

在搭配 Redux 使用 SSR 的過程中,為了解決下述問題,在伺服器和瀏覽器上的 Redux 設定必須不同:

  1. 伺服器端只會吐回 HTML 樣版,不會觸發 componentDidMount 的生命週期,也沒有發送 API 的時間點;在伺服器端也不知道何時伺服器上所有初始化資料在 action creators 上載入完成
  2. 需要在伺服器上驗證登入訊息
  3. 需要將 state 在瀏覽器上進行 rehydration

從 Sever Side Fetch 資料

我們會先在所有的元件上添加一些函式(即,loadData),用來描述這個元件在轉譯時需要有哪些資料。所以當瀏覽器發出請求時,我們會先檢查這個 URL 想要存取些什麼,接著檢視有哪些元件需要被轉譯,在那些需要被轉譯的元件上,我們將呼叫在這些元件上所添加的 loadData 函式來初始化資料載入

我們並不會在一開始就轉譯初始化的 App,而是當所有相關的元件都回報已經載入資料完畢後,才會整合所得到的資料來轉譯畫面,並傳送給使用者。

這麼做的好處是只需要轉譯 App 一次,並且可以清楚地知道每一個元件在進行 SSR 時需要哪些資料;缺點則是需要額外撰寫很多的程式碼。

為了要達到這樣的目的,不能使用原本 routes 的設定方式,而是要使用 react-router packages 內的 react-router-config 這個套件,透過它提供的 renderRoutes 方法可以幫助我們把 loadData 函式代到路由當中,如此便可以將該函式從前端透過路由傳送到後端。接著透過它提供的 matchRoutes 方法,則可以取得並呼叫 loadData 這個函式。

接著整個獲取資料的流程會像這樣:

  1. index.server.js 去呼叫所有相關元件中的 loadData 方法,即 route.loadData(serverStore)
    1. 在各元件的 loadData 方法中,會手動去 dispatch 相對應的 action creators
    2. 由於使用了 redux-thunk 作為 middleware,當 dispatch 的 action 是函式(async function)而不是慣例的物件時,redux-thunk 會執行傳進來的這個 action,之後回傳出去,而這個 action 是一個 async function
    3. 另外在這個 action (async function) 中,會手動 dispatch(<ACTION>) ,因此可以把 ACTION 傳到 reducer
  2. 由於 async function 執行後會回傳一個 Promise,所以在 index.server.js 執行 loadData 這個函式後,會得到 Promise
  3. async function 會在當裡面的 AJAX request(await function)都執行完後它自己的 Promise 才會被 resolved
    • 在 async function 中會等相對應的 reducers 都已經執行完並得到資料,才。
  4. 因此,使用 Promise.all() 可以等到所有的 loadData 內的 AJAX 請求都完成後(所有的 async function 都 resolver)才繼續執行其他行為。
  5. 這時候透過 Promise.all().then(<RENDER_APP>) 才開始轉譯 App。

其他解決方法

為了解決上述的一個問題,有一種方式是在瀏覽器請求頁面的時候,先回傳一個初始化的 HTML 樣版,接著瀏覽器再繼續向 API 請求資料,等到資料請求完畢後,再重新產生一個 HTML 樣版給瀏覽器。這麼做的好處是不需要寫太多額外的程式碼,但確有兩個嚴重的問題:

  1. 伺服器會重新轉譯兩次頁面,使用者的體驗不好,且消耗伺服器資源。
  2. 只能一次性的請求資料,在碰到像是 OAuth 這種需要來會的資料請求便無法處理。

解決 server-side 和 client-side Redux store 資料不同步的問題

當我們在 Sever-side 也可以透過 API 取得資料時會發生一個問題,就是伺服器端取得的資料,並沒有傳到瀏覽器端,也就是說這兩邊 Redux Store 內的資料狀態是不一樣的,也因此會導致伺服器端和瀏覽器端在初次轉譯時畫面不同而報出錯誤,而且會讓瀏覽器端的 React 在 hydrate 後又重新去要資料,變成畫面會消失一下才又跑出來。

為了要解決這樣的問題,我們必須將 server-side 的 Redux Store 傳到 client-side 的 Redux Store,透過 createStore(reducer, [preloadedState], [enhancer]) 中的第二個參數,把初始化好的狀態傳到 client-side。

整個流程會像這樣:

  1. 伺服器端的透過 loadData() 取得資料,所有資料取得後,透過 renderer() 把資料存入 server 端的 <Provider /> 的 store 中。
  2. 準備開始於伺服器端轉譯頁面。
  3. 將所有取得的資料透過 window.INITIAL_STATE 倒入頁面內。
  4. 伺服器端透過 render() 開始轉譯 HTML 頁面,並傳送給瀏覽器。
  5. 把瀏覽器端要執行的 bundle.js 傳到瀏覽器。
  6. 開始初始化瀏覽器端的 redux store。
  7. 透過先前從伺服器端倒入頁面的資料,開始初始化瀏覽器端的 redux store。
  8. ReactDOM.hydrate()

留意

需要留意的是,由於先前在瀏覽器端的 React Component 內(react-ssr/src/client/pages/UserListPage.js)已經有透過 AJAX 取得資料( this.props.fetchUsers()),因此若我們直接輸入該頁的網址時,會發現 AJAX 發動了兩次,一次是從伺服器端發送,另一次則是從瀏覽器端發送,但我們並不能直接把瀏覽器端的 AJAX 請求拿掉,因為當使用者透過 react-router 在瀏覽頁面時,並不會促發 server-side 重新請求資料,因為 server-side 只負責第一次的初始頁面,之後都是交由 React 處理,這時候使用者會因為沒有請求資料而得到空的頁面。

在 SSR 中有一個非常重要的觀念,就是只有使用者第一次進來該頁面時是透過 Renderer Server 處理,除此之外,後續操作的行為和頁面的切換,則都是透過載好在瀏覽器端的 React APP 來處理。

SSR 時的資料夾結構 - Page Component

  • components: 這個資料夾內放可以重複使用的元件。
  • pages:這個資料夾內放對應到的路由。

SSR 過程中會碰到的問題及解法

React SSR

問題解法
需要將 React 元件轉成 HTML 字串使用 ReactDOM.renderToString()
Server 端無法解讀 JSX先透過 Webpack 把所有 Server 端的程式碼編譯過後(webpack.server.js),如此 JSX 會先被 babel-loader 編譯,接著再讓 node 執行這個編譯過後的檔案。
讓 APP 在開發過程會自動重新整理使用 webpack --watch 搭配 nodemon
Server 回傳的 HTML 並沒有綁定 JS 事件為了能夠綁定相關的事件,需要多建立一支 webpack 設定檔(webpack.client.js)來打包另一支檔案,它和前者會有不同的進入點(index.client.js),這支打包好的檔案會透過 server 傳送到瀏覽器載入並執行。

Clean Code

問題解法
要啟動開發環境時,需要開多個視窗下多個指令使用套件 npm-run-all 的指令,可以在一個視窗執行多個指令。
在不同的 webpack 設定檔中,有相同的設定使用套件 webpack-merge,可以把共通的檔案拆出來後合併。
server 可以直接使用 node_modules 裡的檔案,不需打包到 bundle.js使用 webpack 中的 externals 設定,搭配套件 webpack-node-externals,當套件在 node_modules 中找得到時,就不用打包到 bundle 中。

React Router SSR

問題解法
ReactRouter 中的 BrowserRouter 是根據瀏覽器的網址列來決定要轉譯哪個畫面,但在 SSR 時並沒有網址列存在。ReactRouter 提供另一個 StaticRouter 讓伺服器使用。在初次轉譯頁面時,會先使用 StaticRouter 來決定要顯示的畫面,接著在 ReactDom.hydrate 後,則切換使用 BrowserRouter。

其他

當伺服器端產生的 HTML 和瀏覽器端產生的不同時:server side 缺少內容

當伺服器轉譯初次提供的 HTML 和後來透過 ReactDOM.hydrate 再次轉譯的畫面不同時,會在 console 跳出錯誤訊息:

Warning: Expected server HTML to contain a matching tag <div> in <div>.

當伺服器端產生的 HTML 和瀏覽器端產生的不同時:server side 多出內容

當在 server-side 使用 Redux 並 fetch 完資料後,會使得 server-side 的 store 比起 client-side 的 store 多了更多內容,進而使得 server-side 比起 client-side 轉譯出了更多內容,這時候就會在 console 跳出錯誤訊息:

Warning: Did not expect server HTML to contain a <li> in <ul>

要解決這樣的問題,就需要把 server-side 取得 API 資料後的 store 和 client-side 的 store 同步

解決身份驗證的問題(authentication)

在做 React SSR 的過程中,會碰到的另一個問題是 OAuth 的資料驗證。因為前後端分離的關係(前端:renderer server;後端:API server),一般來說通常是透過後端的 API Server 去向 OAuth 的對象去請求資料(例如,Facebook, Google),在驗證過了之後,OAuth 的對象會發給我們後端的 API Server 一組 Cookie,接著所有的 request 都必須帶有這組 Cookie,但因為前後端分離的關係,後端的網域可能是叫做 api.ourapp.com,而前端的 Renderer Server 則是叫 ourapp.com,這時候這個核發的 Cookie 就會碰到不同網域無法使用的問題

瀏覽器會根據網址,在發送的請求中自動添加 Cookie 。

為了解決這個問題,我們將在前端的 Renderer Server 設定一個代理(Proxy)。每當使用者要透過我們的 App 進行身份驗證時,不是將使用者的瀏覽器直接傳送到後端的 API Server,而是將該使用者傳送到在前端 Renderer API 上的代理(Proxy),這個代理接著進一步將驗證的請求轉送到 API Server 上。

  • Renderer Server 指的是我們用來轉譯前端頁面,進行 React SSR 的伺服器,一般指稱的是前端
  • API Server 則是指處理商業邏輯、和資料庫進行資料存取的伺服器,一般指稱的是後端。

在 Cookie 透過 API Server 核發完成後,這個代理會在將 Cookie 傳送到瀏覽器上,對於瀏覽器來說,它並不知道代理伺服器的存在,因為它都是和 renderer server 溝通,如此瀏覽器會認為這個 Cookie 是透過 Renderer Server 核發的,而非 API Server。因此所有接下來瀏覽器向 renderer server 所發送的請求,都會帶有這個 Cookie;Renderer Server 在手動地將所有發送到 API Server 的請求添加上這組 Cookie。

初次存取頁面 Initial Page Load:由 Renderer Server 處理

在第一次要存取該頁面時,並不會用到代理(Proxy),而是由 Renderer Server 直接拿著瀏覽器帶著的 Cookie 向 API Server 發送請求(即,透過 action creators 內的 axios 向 API Server 發送請求)。

初次載入後的操作 Followup Request:由瀏覽器端的 React App 處理

在初次載入頁面後,所有的行為都將交由在瀏覽器端的 React App 處理,因此這時候向伺服器發送請求的工作,變成由位於瀏覽器端的 React App 進行,這時候瀏覽器端相同的 Action Creator 再透過 axios 發送請求時,需要發送到不同的路由(React Router),同時需要手動將瀏覽器上的 Cookie 代入請求中,再由 Renderer Server 向 API Server 發送請求。

為什麼不用 JWT(JSON Web Token)

我們可能會想為什麼不使用 JWT,在每次發送請求時都帶 JWT 當作驗證訊息就好。

當在做 SSR 的時候,我們預期是當使用者在網址列輸入網址後,Renderer Server 會自動判斷身份驗證後給我們相對應的內容:

然後,當我們使用 JWT 時,必須要透過我們手動將 JWT 添加在 head, URL, 或 body 內,然而,當使用者直接輸入某一需要驗證的網址時,我們並沒有辦法在第一次發送的請求中就直接帶著 JWT;因此,實際上的流程會變成需要先請使用者提供 JWT 之後(例如,按下登入),才能再進一步取得 JWT 後登入,也就是說,使用者無法直接以登入的狀況進入某一需要驗證的路由

相反地,透過 Cookie ,只要是符合該網域的情況下,Cookie 會自動發送,因此它能夠自動地跟著我們的請求被送出。

SEO

react-helmet: 根據不同的路由設定 meta

在使用 react-helmet 的 component 中,使用 <Helmet> 把希望在該元件轉譯到 <head> 的內容寫入:

// ./src/client/pages/HomePage.js
import React from 'react';
import { Helmet } from 'react-helmet';

const Home = ({ auth }) => {
function renderHelmet() {
return (
<Helmet>
<title>{`React SSR - ${auth ? 'login' : 'logout'}`}</title>
<meta property="og:title" content="React SSR" />
</Helmet>
);
}

return (
<div>
{renderHelmet()}
<h3>Welcome</h3>
<p>check out these awesome features</p>
</div>
);
};

在轉譯 html 的 server side 部分,使用 Helmet.renderStatic() 搭配 helmet.meta.toString()helmet.title.toString() 將內容轉譯出來:

// ./src/server_helpers/renderer.js
import { Helmet } from 'react-helmet';

export default (req, store, context) => {
const helmet = Helmet.renderStatic();

const html = `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">

${helmet.title.toString()}
${helmet.meta.toString()}

</head>
<body>
<!-- ... -->
</body>
</html>
`;

return html;
};

react-helmet @ Github

其他

ReactDOMServer

在 SSR 的說明中,我們使用的是 renderToString() 的方法,其他方法還包括:

  • renderToStaticMarkup():適合用在轉譯靜態頁面。
  • renderToNodeStream():使用這種方法可以讓 TTFB(Time to First Byte)的時間非常短,使用者可以很快的就呈現第一個畫面;但卻會有一個嚴重的問題當瀏覽器接收到 Node Stream 後,即表示該傳送狀態是 200,若轉譯後續元件時才發現該使用者並無檢視此頁面的權限時,並沒有辦法將狀態改成 301 或 401。
  • renderToStaticNodeStream()

ReactDOMServer @ ReactJS

參考資料