React SSR | 從零開始實作 SSR — 基礎篇

Leo Chiu
手寫筆記
Published in
13 min readJul 5, 2020

前言

從實作瞭解原理系列已經寫了一陣子,終於到了實作 React SSR。到目前為止我還沒有碰過 React SSR 常見的框架 — Next.js,因為平常都只是做一些小專案,都是使用 Client side rendering。

所以就趁這個機會先來了解 React SSR 的原理知識,以及框架可能是怎麼實作的,等瞭解原理後再來碰 Next.js 。

這篇文章除了會提到 SSR 基本的原理之外,還會帶大家從零實作 React SSR,然後再搭配 react-router 製作可換頁的簡單應用。

P.S. 我主要是看 Udemy 的課程 Server Side Rendering with React and Redux,他講得非常好,而且實作很清楚,雖然是 2~3 年前的課程,不過推薦可以看看。

CSR VS. SSR

為什麼要學 SSR (Server Side Rendering)?難道不能只用 CSR (Client Side Rendering) 打遍天下嗎?

CSR vs. SSR

我們用上面這張圖來瞭解兩者的差異,如果是 CSR 瀏覽器請求 HTML 後,必須等待請求 JS 檔案,然後再等待 React 把元件 mount 到 DOM上,以及請求 API 還會花費額外的時間。

此外,有些網站很注重搜尋引擎優化 (SEO),搜尋引擎的原理即是爬蟲,而爬蟲通常不包含請求 HTML 後再請求 JS 檔案,所以如果是一般透過前端請求 API 後才載入資料,搜尋引擎便看不到網頁實際上的內容,不利於 SEO。

如果是 SSR,網頁的內容都會在伺服器端處理,爬蟲看到的即是包含完整內容的 HTML,便有利於進行 SEO。同時也有助於減少使用者從請求網頁到看見網頁內容時間,提升使用者體驗 (UX)。

SSR 原理

CSR 與 SSR 主要不一樣的點在於多了一個渲染伺服器渲染伺服器會用於第一次使用者請求 HTML 時,會將內容都事先放到 HTML 中,所以使用者看到的就是一個已經包含完整內容的網頁。

以下包含 React SSR 幾個重點:

  • 擁有一個獨立的伺服器 (後端),提供 API 可以請求資料。
  • 渲染伺服器與瀏覽器端都可以請求 API。
  • 渲染伺服器會在使用者請求 HTML 時,會請求 API 的資料,並將內容都事先放到 HTML 中。
  • 在第一次請求 HTML 後,之後的元件 routing、請求 API 都是在瀏覽器端執行。

事前準備

安裝套件

yarn add express react react-domyarn add -D @babel/core @babel/preset-env @babel/preset-react babel-loader nodemon npm-run-all webpack webpack-cli webpack-node-externals

express

我們會用 express 作為撰寫渲染伺服器的框架,能夠在使用者請求 HTML 時,決定要渲染哪個元件,以及呼叫 API 請求資料,並將資料渲染至 HTML 中,最後以字串回傳 HTML。

webpack + babel

實作 React SSR 時我們不會用 CRA 建立 React APP,因為我們要將 bundle.js 分成 express 與 React APP 用的,所以要客製化 bundle.js。

然而在撰寫 express 時,node.js 會看不懂一些 JavaScript 較新的指令,以及 React 的 JSX 語法,所以我們要搭配 babel 打包程式碼。

nodemon

nodemon is a tool that helps develop node.js based applications by automatically restarting the node application when file changes in the directory are detected.

nodemon 是一個用於取代原本用 node 執行 node.js 程式碼的套件,如果用原本的 node 指令,在每次 bundle.js 有更動時,都必須重新啟動正在執行的伺服器。

nodemon 便是可以解決上述困擾的工具,它可以自動偵測檔案是否改變,並在改變時重新執行程式碼。

nodemon [your node app]

npm-script (package.json)

我們一開始在啟動整個專案時,必須先使用 webpack 打包 client 與 server 的檔案,打包完後再使用 node 啟動伺服器,總共需要三個步驟 (這邊就用 npm-script 代替):

  • dev:server : 為了解決檔案變動時必須手動重新執行 npm-script 的問題,使用 nodemon 監聽 bundle.js 是否有改變,自動執行 bundle.js
  • dev:build:server:使用 webpack 打包 server 端程式碼 (express),並用 --watch監聽程式碼的改變,並自動編譯程式碼。
  • dev:build:client:使用 webpack 打包 client 端程式碼 (react),並用 --watch監聽程式碼的改變,並自動編譯程式碼。

npm-run-all

如果你覺得要開三個 termial 執行 npm-script 很麻煩,可以使用 npm-run-all 解決方案,它可以幫你整合三個指令為一個指令,而且使用上很簡單,一行就可以搞定。

Basic Server Side Rendering

SSR 實際上的做法是當使用者進入某個 URL 時,會跟渲染伺服器請求 HTML,渲染伺服器會將 HTML 以字串的形式回傳給使用者,瀏覽器會解析 HTML 字串並顯示內容。

所以,首先我們先關注如何將一個 React Component 轉成字串。React 官方也有想到這一點,所以除了原本我們熟悉的 reactDOM.render 能夠渲染元件之外,也提供了在 SSR 中可以將元件轉換成 HTML 字串的解決方案。

renderToString()

我們直接看看官方文件怎麼說:「這個方法將一個 React element render 至其初始的 HTML。React 將會回傳一個 HTML string。你可以使用這個方法在伺服器端產生 HTML,並在初次請求時傳遞 markup,以加快頁面載入速度,並讓搜尋引擎爬取你的頁面以達到 SEO 最佳化的效果。」

資料夾結構

│  .babelrc
│ .gitignore
│ package.json
│ webpack.client.js
│ webpack.server.js
└─src
│ index.js
└─client
│ client.js
└─components
Home.js

定義 Home 元件

為了實驗,我們先來定義一個簡單的元件吧!這邊我們除了顯示一些文字內容之外,並額外定義一個 <button>,並在 <button> 定義了一個 onClick 事件,在使用者點擊時,會在 console 顯示 click me 的訊息。

建立 express 服務

express 做為一個渲染伺服器,必須提供能夠回傳 HTML 字串的 API。

我們簡單定義一個 express 服務,express 提供一個 GET API,在使用者進入 localhost:3000 時,會將元件 <Home /> 轉換成 HTML 字串,然後回傳。

.babelrc

平常用 CRA 習慣了,突然要寫 webpack 跟 babel 還真不習慣 😅。

因為我們在 node.js 的環境中會用到 React 的 JSX 語法,而且可能會用到比較新的 JavaScript 語法,所以我們要在 .babelrc 中加入兩個 preset:

  • @babel/preset-env
  • @babel/preset-react

webpack 設定檔

用於編譯 node 程式碼的 webpack.server.js 會長這個樣子,webpack 的設定檔有點多,第一次看肯定會頭昏眼花,不過我們還是加把勁,了解一下 webpack 設定檔長什麼樣子。

主要簡述以下 webpack 設定檔中用到的五個部分:

👉 target 用於描述使用何種環境編譯程式碼,例如以下的範例便是使用 node.js 的環境編譯程式碼。其他環境還有 web、electron等,如果想知道更多 target 的配置選項,可以參考 webpack 的 🔗官方網站

👉 entry 就像是在寫程式時我們經常提到的 main function,也就是程式碼的進入點,同樣地,webpack 也需要知道被編譯的程式碼進入點在哪裡。

👉 output 是 webpack 編譯完程式碼後,打包的檔案存放在哪裡,以及輸出的檔案要叫什麼名字

👉 module 是定義 webpack 如何處理各種不同的 module,包括處理在程式碼種各種不同規範的 import 語句,像是在 ES6 中的 import,在 CommonJS 的 require ,甚至是在 CSS 中的 @import 語句等等。

此外, module 還包括使用 loader 處理各種非 JavaScript 的程式碼,例如 JSX、TypeScript、Sass 等,透過這些 Loader ,webpack 可以將各式各樣的程式碼轉換一包指定環境看得懂的可執行檔案。

👉 externals 用於能夠從 bundle 中排除相依套件,表示相依套件會額外載入到執行的環境中,這個參數能夠縮減 bundle 的檔案大小。在以下的設定檔中用的 webpack-node-externals 就是不要把 node_modules 整包都加入 bundle。

打包並執行 bundle

yarn dev:build:server
yarn dev:server

打包並執行 bundle 後,可以連到 localhost:3000 測試看看。

出大事了,點擊 <button> 沒有反應 😱。

原本我們在 <Home /> 中定義的 <button> 包含了點擊事件,應該會在點擊後顯示 click me 於 console 才對,為什麼不見了?

還記得前面我們說的渲染伺服器是回傳 HTML 字串嗎? renderToString 並不會幫我們處理 event listener 的程式碼,所以現在你不管怎麼點擊按鈕都不會有任何反應。

接下來讓我們來看看怎麼處理這個問題。

注入 event listener

我們再次看到這張圖,這次 SSR 多了後面一小段,也就是接下來要處理的部分。在使用者看到網頁的內容時,只是單純的 HTML 內容,這時必須額外載入 JS,再透過 React 綁定並監聽瀏覽器事件。

這時候你大概知道,我們除了前面用 webpack 打包 express 的程式碼,現在要多一個在使用者看到網頁內容後,用於綁定事件的 JavaScript 檔案。

接下來我們要來處理另一個 bundle

client.js

React 官方在提供 renderToString 這個 API 時,就另外提供了相對應的解決方案,同樣地直接看官方文件:「如果你在一個已經有伺服器端 render markup 的 node 上呼叫 ReactDOM.hydrate()React 將會保留這個 node 並只附上事件處理,這使你能有一個高效能的初次載入體驗。」

官方文件也說到,React 預期在伺服器端與客戶端所渲染的內容是相同的,如果不相同,hydrate 會修復不同的部分,但同時會跳出 Warning,警告 hydrate 前後的 DOM 長得不一樣。不一樣時,hydrate 不保證會修復 attribute,而且會降低 React 的效能。

如果你用 reactDOM.render 渲染元件,你會看到瀏覽器的 console 跑出以下警告:「Warning: render(): Calling ReactDOM.render() to hydrate server-rendered markup will stop working in React v17. Replace the ReactDOM.render() call with ReactDOM.hydrate() if you want React to attach to the server HTML.」

express

從前面的圖中可以看到,在使用者看到網頁內容後,還需要再載入另外的 bundle,處理事件綁定。

👉 所以,我們稍微修改 express 的程式碼,將回傳的 HTML 稍微包裝,並在最後載入一個 bundle.js

👉 別忘記第 8 行程式碼,要加入 express.static 宣告靜態檔案的位置,我們打包的另一包 bundle 會放在 public 這個資料夾中,如此一來在 HTML 最後載入的 bundle.js 才會從 public 這個資料夾讀取。

webpack.client.js

設定 webpack 打包的環節基本上與 server 的 webpack 檔案相同,主要的差別在於:

  • entry 的檔案變成 client.js
  • outputpath 變成 public
  • 刪掉 webpack-node-externals

打包並執行 bundle

yarn dev:build:server
yarn dev:build:client
yarn dev:server
// 如果你有用 npm-run-all 也可以用以下指令
yarn dev

打包並執行 bundle 後,可以連到 localhost:3000 測試看看。

終於正常了,可喜可賀 🎊

結論

👉 實作的程式碼在這邊: 🔗GitHub

這篇文章帶大家實作一個基本的 React SSR,從 webpack、babel 的設定,使用 renderToString 將元件轉換成 HTML 字串格式,最後使用 express 提供 API,讓使用者看到網頁內容。

之後還會整理如何在 React SSR 中加入 react-router、redux,以及像是 Next.js 初始化載入資料的 getInitialProps。

分享就到這邊,如果喜歡我的文章可以幫我拍個幾下手,在閱讀文章時如果有遇到什麼問題,或是有什麼建議,都歡迎留言告訴我,謝謝。 😃

--

--

Leo Chiu
手寫筆記

每天進步一點點,在終點遇見更好的自己。 Instragram 小帳:@leo.web.dev