React SSR: initial data fetching & hydrate

試想你用 React 刻了一個論壇的 Single Page App,但是為了 SEO / Performance 考量,你決定第一畫面的文章列表在 server side 先 render 出來,這時該怎麼做?

realdennis
11 min readMay 8, 2021
server-side fetching data from reddit endpoint

寫在文前

自己組一套 React server side rendering 其實蠻多文章的,也有翻到不錯的手把手教學文(真的很讚),但多數好像都只有教你弄出畫面,然後畫面上有 React 的功能,卻沒有做到封裝、傳入 data fetching / getInitialProps 這段。

這篇不是手把手的教學文,而是以最暴力的方式告訴你現有工具如何去拉+打解決 SSR data fetching / initial props

閱讀之前我們要有個共識:

  • 你碰過、寫過、蹂躪過 SSR 的 framework ,想知道如果自幹的話 markup 與 state/props 怎麼丟給 User client side
  • 你知道 hydrate 是在幹嘛,它長的跟 ReactDOM.render 87% 像,但他負責跟 renderToString打組合拳

以下全部都是虛擬碼,請斟酌閱讀。

更新:我照著自己的脈絡刻出來的專案 https://github.com/realdennis/react-ssr-reddit-meme 可以參考一下。

Step 0— File structure

├── client.js #主要是 hydrate logic
├── package.json
├── server.js
└── src
└── index.js

Step 1—Server-side fetch data

原本我們在 CRA 設計出來的論壇 App 會在 client-side 去 call endpoint 。

大概原本是長這樣:

client side:

// src/index.js
const endpoint = "https://iamendpoint/v1/getPosts";
const App = () => {
// call endpoint
// and do pagination w/ initial data if trigger bottom
return (
<ul>
{data.map({title,author}=>
<li key={title}>{author}/{title}</li>)
}
</ul>
)
}

把他變這樣 server side rendering component:

// src/index.js
const endpoint = "https://iamendpoint/v1/getPosts";
const App = ({initialData={}}) => {
// do pagination w/ initial data when trigger bottom
return (
<ul>
{initialData.map({title,author}=>
<li key={title}>{author}/{title}</li>)
}
</ul>
)
}
App.getInitialData = async ({req})=> {
const response = await fetch(endpoint, {headers: req.headers});
const initialData = await response.json();
return {initialData};
}

Update: refer this:

Step 2— call getInitialData() before renderToString

啊!既然我們不想用框架,又設計了一個一個新的 method getInitialData ,這時候就在 server-side 組裝 html 前,先把他執行好後並塞進去。

通常是在 express 的 server.js,你爽的話也可以 Koa ,反正你只要能執行那串 getInitialData 就可啦。

你想更爽也可以用 Ruby Python Java … 也可,反正概念一樣只是沒有辦法同構 JS & reuse code。

沒執行大概會長的像這樣(沒啥卵用的 SSR)

// server.js
import App from './src/components/App.jsx'
router.get('/', async(req,res)=>{
const ReactAppHTML = ReactDOM.renderToString(<App/>);
const html = `
<div id="root">{ReactAppHTML}</div>
<script src="client.js"></script>
`
//我懶得寫 html body tag
res.status(200).send(html)
})

有先執行再塞進去的大概長這樣(這下有 props 了):

router.get('/', async(req,res)=>{
const {getInitialData} = App;
const props = await getInitialData();
const ReactAppHTML = ReactDOM.renderToString(<App {...props}/>)
//下略
})

Update: refer this

Step 3—hydrate 階段也要有 state

我們都知道單純的 renderToString 射回前端,只不過是一坨沒有功能的 markup (先把水杯、殼先傳過去),所以我們會把 hydrate logic build 成一個 client.js,透過這支 .js ,在 client side 去把 React 的 event/state 掛回去。(再把水杯裝水、裝 React)

Photo by Jacek Dylag on Unsplash

它原本長這樣:

// client.js
import React from 'react';
import {hydrate} from 'react-dom';
import App from './components/App.jsx'
hydrate(<App/>,document.querySelector('#root'));

啊幹 可是他這樣沒狀態啊 這樣會發生什麼事?

你的畫面會瞬間閃一下,然後在前端狀態變回空物件{},直接踩到 pagination 又再 call 了一次 initial data fetching(如果你有防呆的話啦)。

而且你按 F12 點 console ,你會看到 React 靠北你

幹嘞,你前後端的生的 html 長不一樣,搞毛啊?

明明 server side generate 好的東西,使用者卻又要再重打一次,在SEO/Performance audit 上看來絕對是顯著的 Cumulative Layout Shift impact。

問題來了?那我們在 server.js 裡面 call getInitialData 的結果(props) 要怎麼在 client-side call hydrate 的時候塞進去呢?

還記得我們剛剛不是有個地方在刻純手打的 html 嗎?

我們前端仔有個最具 powerful 的小東東叫做 <script>

讓我們回到 server.js

router.get('/', async(req,res)=>{
const {getInitialData} = App;
const props = await getInitialData();
// 這邊拿到 data 惹對吧
const ReactAppHTML = ReactDOM.renderToString(<App/ {...props}>) const html = `
<script>
window.__my__fucking__props__=${JSON.stringify(props)};
</script>
<div id="root">{ReactAppHTML}</div>
<script src="client.js"></script>
`
// again 我懶得寫 html body tag
//下略
})

這時候發生什麼事?我們的 client side 的 window.__my__fucking__props__ 會拿到 initial props 。

讓我們回到 hydrate 階段

// client.js
import App from './src/index.jsx'
hydrate(
<App {...window.__my__fucking__props__}/>,
document.querySelector('#root')
);

大功告成!前後狀態一致,讚。

更新:DEMO https://react-ssr-reddit-meme.herokuapp.com/ 你可以 curl 測試 SSR 的正確性已經點擊觸發 react hooks 測試 react 功能。

react events / hooks testing
curl https://react-ssr-reddit-meme.herokuapp.com/ testing

尾聲

幫大家總結一下,想要弄一套既有 server-side data fetching 的 SSR 其實只需要幾個步驟:

  1. 在 page 組件新增一 initial 執行的 function
  2. 在 server side 渲染期間,將其預先執行,並在 renderToString 時傳入
  3. 在 markup 渲染期間,將狀態丟入 Client-side
  4. 在 hydrate 階段將狀態塞進去 react app 承先啟後

另外想備註幾個翻了許多文章幾乎沒被提到的點:

  1. 文內有提到,任何語言都可以實現 react SSR ,你爽的話也可以用 Python / Ruby 寫 service,最後在 renderToString 與 hydrate 階段把資料介接進去即可,用 JS 的好處在於同構性,reuse code 、 reuse environment。
  2. Server-side render 的好處不只是 SEO ,當第一頁(首屏)畫面的 data fetching 跟 render 在同一台/群機器上,透過 server 做請求比較快也健康。(可以在 server side 控制 data/template cache,減去負擔)
  3. 承上,除去 SEO/Performance 以外,這類的 server-side fetch 更可以透過 forward user identical info 來做到資料個人化。簡單舉例:若 endpoint 也是我所擁有,我可以將 user cookie 傳進去,那麼即便是在 server-side 做的 request 得到個人化內容。
  4. 承上,如果你還記得之前寫的文章《Polyfill 策略與 bundle 大小的爭鬥》裡頭提過,在 server-side render 階段,我們可以動態決定要不要為使用者加載對應的 Polyfill ,更可以整合在 middleware 做更詳細的預處理。
  5. JSON.stringify 沒辦法適用大多數的場景如 functions, regexps, dates, sets, maps,推薦使用這個專案:serialize-javascript
  6. 這個 Demo 完全沒有 performance 考量,可以考慮試著走 stream pipe,但建議若要 extend usage 直接用 OSS framework ,你不會想在開發 feature 的時候修 framework 的,請相信我。

--

--