玩轉 React 伺服器端渲染
React 提供了兩個方法 renderToString 和 renderToStaticMarkup 用來將組件(Virtual DOM)輸出成 HTML 字元串,這是 React 伺服器端渲染的基礎,它移除了伺服器端對於瀏覽器環境的依賴,所以讓伺服器端渲染變成了一件有吸引力的事情。
伺服器端渲染除了要解決對瀏覽器環境的依賴,還要解決兩個問題:
- 前後端可以共享代碼
- 前後端路由可以統一處理
React 生態提供了很多選擇方案,這裡我們選用 Redux 和 react-router 來做說明。
Redux
Redux 提供了一套類似 Flux 的單向數據流,整個應用只維護一個 Store,以及面向函數式的特性讓它對伺服器端渲染支持很友好。
2 分鐘了解 Redux 是如何運作的
關於 Store:
- 整個應用只有一個唯一的 Store
- Store 對應的狀態樹(State),由調用一個 reducer 函數(root reducer)生成
- 狀態樹上的每個欄位都可以進一步由不同的 reducer 函數生成
- Store 包含了幾個方法比如 dispatch, getState 來處理數據流
- Store 的狀態樹只能由 dispatch(action) 來觸發更改
Redux 的數據流:
- action 是一個包含 { type, payload } 的對象
- reducer 函數通過 store.dispatch(action) 觸發
- reducer 函數接受 (state, action) 兩個參數,返回一個新的 state
- reducer 函數判斷 action.type 然後處理對應的 action.payload 數據來更新狀態樹
所以對於整個應用來說,一個 Store 就對應一個 UI 快照,伺服器端渲染就簡化成了在伺服器端初始化 Store,將 Store 傳入應用的根組件,針對根組件調用 renderToString 就將整個應用輸出成包含了初始化數據的 HTML。
react-router
react-router 通過一種聲明式的方式匹配不同路由決定在頁面上展示不同的組件,並且通過 props 將路由信息傳遞給組件使用,所以只要路由變更,props 就會變化,觸發組件 re-render。
假設有一個很簡單的應用,只有兩個頁面,一個列表頁 /list 和一個詳情頁 /item/:id,點擊列表上的條目進入詳情頁。
可以這樣定義路由,./routes.js
import React from react;nimport { Route } from react-router;nimport { List, Item } from ./components;nn// 無狀態(stateless)組件,一個簡單的容器,react-router 會根據 routen// 規則匹配到的組件作為 `props.children` 傳入nconst Container = (props) => {n return (n <div>{props.children}</div>n );n};nn// route 規則:n// - `/list` 顯示 `List` 組件n// - `/item/:id` 顯示 `Item` 組件nconst routes = (n <Route path="/" component={Container} >n <Route path="list" component={List} />n <Route path="item/:id" component={Item} />n </Route>n);nnexport default routes;n
從這裡開始,我們通過這個非常簡單的應用來解釋實現伺服器端渲染前後端涉及的一些細節問題。
Reducer
Store 是由 reducer 產生的,所以 reducer 實際上反映了 Store 的狀態樹結構
./reducers/index.js
import listReducer from ./list;nimport itemReducer from ./item;nnexport default function rootReducer(state = {}, action) {n return {n list: listReducer(state.list, action),n item: itemReducer(state.item, action)n };n}n
rootReducer 的 state 參數就是整個 Store 的狀態樹,狀態樹下的每個欄位對應也可以有自己的
reducer,所以這裡引入了 listReducer 和 itemReducer,可以看到這兩個 reducer的 state 參數就只是整個狀態樹上對應的 list 和 item 欄位。具體到 ./reducers/list.js
const initialState = [];nnexport default function listReducer(state = initialState, action) {n switch(action.type) {n case FETCH_LIST_SUCCESS: return [...action.payload];n default: return state;n }n}n
list 就是一個包含 items 的簡單數組,可能類似這種結構:[{ id: 0, name: first item}, {id: 1, name: second item}],從 FETCH_LIST_SUCCESS 的 action.payload 獲得。
然後是 ./reducers/item.js,處理獲取到的 item 數據
const initialState = {};nnexport default function listReducer(state = initialState, action) {n switch(action.type) {n case FETCH_ITEM_SUCCESS: return [...action.payload];n default: return state;n }n}n
Action
對應的應該要有兩個 action 來獲取 list 和 item,觸發 reducer 更改 Store,這裡我們定義 fetchList 和 fetchItem 兩個 action。
./actions/index.js
import fetch from isomorphic-fetch;nnexport function fetchList() {n return (dispatch) => {n return fetch(/api/list)n .then(res => res.json())n .then(json => dispatch({ type: FETCH_LIST_SUCCESS, payload: json }));n }n}nnexport function fetchItem(id) {n return (dispatch) => {n if (!id) return Promise.resolve();n return fetch(`/api/item/${id}`)n .then(res => res.json())n .then(json => dispatch({ type: FETCH_ITEM_SUCCESS, payload: json }));n }n}n
isomorphic-fetch 是一個前後端通用的 Ajax 實現,前後端要共享代碼這點很重要。
另外因為涉及到非同步請求,這裡的 action 用到了 thunk,也就是函數,redux 通過 thunk-middleware 來處理這類 action,把函數當作普通的 action dispatch 就好了,比如 dispatch(fetchList())
Store
我們用一個獨立的 ./store.js,配置(比如 Apply Middleware)生成 Store
import { createStore } from redux;nimport rootReducer from ./reducers;nn// Apply middleware heren// ...nnexport default function configureStore(initialState) {n const store = createStore(rootReducer, initialState);n return store;n}n
react-redux
接下來實現 <List>,<Item> 組件,然後把 redux 和 react 組件關聯起來,具體細節參見 react-redux
./app.js
import React from react;nimport { render } from react-dom;nimport { Router } from react-router;nimport createBrowserHistory from history/lib/createBrowserHistory;nimport { Provider } from react-redux;nimport routes from ./routes;nimport configureStore from ./store;nn// `__INITIAL_STATE__` 來自伺服器端渲染,下一部分細說nconst initialState = window.__INITIAL_STATE__;nconst store = configureStore(initialState);nconst Root = (props) => {n return (n <div>n <Provider store={store}>n <Router history={createBrowserHistory()}>n {routes}n </Router>n </Provider>n </div>n );n}nnrender(<Root />, document.getElementById(root));n
至此,客戶端部分結束。
Server Rendering
接下來的伺服器端就比較簡單了,獲取數據可以調用 action,routes 在伺服器端的處理參考 react-router server rendering,在伺服器端用一個 match 方法將拿到的 request url 匹配到我們之前定義的 routes,解析成和客戶端一致的 props 對象傳遞給組件。
./server.js
import express from express;nimport React from react;nimport { renderToString } from react-dom/server;nimport { RoutingContext, match } from react-router;nimport { Provider } from react-redux;nimport routes from ./routes;nimport configureStore from ./store;nnconst app = express();nnfunction renderFullPage(html, initialState) {n return `n <!DOCTYPE html>n <html lang="en">n <head>n <meta charset="UTF-8">n </head>n <body>n <div id="root">n <div>n ${html}n </div>n </div>n <script>n window.__INITIAL_STATE__ = ${JSON.stringify(initialState)};n </script>n <script src="/static/bundle.js"></script>n </body>n </html>n `;n}nnapp.use((req, res) => {n match({ routes, location: req.url }, (err, redirectLocation, renderProps) => {n if (err) {n res.status(500).end(`Internal Server Error ${err}`);n } else if (redirectLocation) {n res.redirect(redirectLocation.pathname + redirectLocation.search);n } else if (renderProps) {n const store = configureStore();n const state = store.getState();nn Promise.all([n store.dispatch(fetchList()),n store.dispatch(fetchItem(renderProps.params.id))n ])n .then(() => {n const html = renderToString(n <Provider store={store}>n <RoutingContext {...renderProps} />n </Provider>n );n res.end(renderFullPage(html, store.getState()));n });n } else {n res.status(404).end(Not found);n }n });n});n
伺服器端渲染部分可以直接通過共用客戶端 store.dispatch(action) 來統一獲取 Store 數據。另外注意 renderFullPage 生成的頁面 HTML 在 React 組件 mount 的部分(<div id="root">),前後端的 HTML 結構應該是一致的。然後要把 store 的狀態樹寫入一個全局變數(__INITIAL_STATE__),這樣客戶端初始化 render 的時候能夠校驗伺服器生成的 HTML 結構,並且同步到初始化狀態,然後整個頁面被客戶端接管。
最後關於頁面內鏈接跳轉如何處理?
react-router 提供了一個 <Link> 組件用來替代 <a> 標籤,它負責管理瀏覽器 history,從而不是每次點擊鏈接都去請求伺服器,然後可以通過綁定 onClick 事件來作其他處理。
比如在 /list 頁面,對於每一個 item 都會用 <Link> 綁定一個 route url:/item/:id,並且綁定 onClick 去觸發dispatch(fetchItem(id)) 獲取數據,顯示詳情頁內容。
Live Demo:CodeInsight
更多參考
- Universal (Isomorphic)
- isomorphic-redux-app
> 本文作者:CODING 工程師 劉輝
推薦閱讀:
※X-Ray Vision
※木地板材質真沒你想像的那麼難啊
※Unity5 PBR如何實現天氣系統的雪景效果
※為什麼我看壞arnold渲染器的發展
※FFT Ocean