玩轉 React 服務器端渲染
原文鏈接: https://blog.coding.net/blog/React-server-rendering
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';
import { Route } from 'react-router';
import { List, Item } from './components';
// 無狀態(stateless)組件,一個簡單的容器,react-router 會根據 route
// 規則匹配到的組件作為 `props.children` 傳入
const Container = (props) => {
return (
<div>{props.children}</div>
);
};
// route 規則:
// - `/list` 顯示 `List` 組件
// - `/item/:id` 顯示 `Item` 組件
const routes = (
<Route path="/" component={Container} >
<Route path="list" component={List} />
<Route path="item/:id" component={Item} />
</Route>
);
export default routes; 從這里開始,我們通過這個非常簡單的應用來解釋實現服務器端渲染前后端涉及的一些細節問題。
Reducer
Store 是由 reducer 產生的,所以 reducer 實際上反映了 Store 的狀態樹結構
./reducers/index.js
import listReducer from './list';
import itemReducer from './item';
export default function rootReducer(state = {}, action) {
return {
list: listReducer(state.list, action),
item: itemReducer(state.item, action)
};
} rootReducer的state參數就是整個 Store 的狀態樹,狀態樹下的每個字段對應也可以有自己的
reducer,所以這里引入了listReducer和itemReducer,可以看到這兩個 reducer
的 state 參數就只是整個狀態樹上對應的list和item字段。
具體到./reducers/list.js
const initialState = [];
export default function listReducer(state = initialState, action) {
switch(action.type) {
case 'FETCH_LIST_SUCCESS': return [...action.payload];
default: return state;
}
} list 就是一個包含 items 的簡單數組,可能類似這種結構:[{ id: 0, name: 'first item'}, {id: 1, name: 'second item'}],從'FETCH_LIST_SUCCESS'的action.payload獲得。
然后是./reducers/item.js,處理獲取到的 item 數據
const initialState = {};
export default function listReducer(state = initialState, action) {
switch(action.type) {
case 'FETCH_ITEM_SUCCESS': return [...action.payload];
default: return state;
}
} Action
對應的應該要有兩個 action 來獲取 list 和 item,觸發 reducer 更改 Store,這里我們定義fetchList和fetchItem兩個 action。
./actions/index.js
import fetch from 'isomorphic-fetch';
export function fetchList() {
return (dispatch) => {
return fetch('/api/list')
.then(res => res.json())
.then(json => dispatch({ type: 'FETCH_LIST_SUCCESS', payload: json }));
}
}
export function fetchItem(id) {
return (dispatch) => {
if (!id) return Promise.resolve();
return fetch(`/api/item/${id}`)
.then(res => res.json())
.then(json => dispatch({ type: 'FETCH_ITEM_SUCCESS', payload: json }));
}
} isomorphic-fetch 是一個前后端通用的 Ajax 實現,前后端要共享代碼這點很重要。
另外因為涉及到異步請求,這里的 action 用到了 thunk,也就是函數,redux 通過thunk-middleware來處理這類 action,把函數當作普通的 action dispatch 就好了,比如dispatch(fetchList())
Store
我們用一個獨立的./store.js,配置(比如 Apply Middleware)生成 Store
import { createStore } from 'redux';
import rootReducer from './reducers';
// Apply middleware here
// ...
export default function configureStore(initialState) {
const store = createStore(rootReducer, initialState);
return store;
} react-redux
接下來實現<List>,<Item>組件,然后把 redux 和 react 組件關聯起來,具體細節參見 react-redux
./app.js
import React from 'react';
import { render } from 'react-dom';
import { Router } from 'react-router';
import createBrowserHistory from 'history/lib/createBrowserHistory';
import { Provider } from 'react-redux';
import routes from './routes';
import configureStore from './store';
// `__INITIAL_STATE__` 來自服務器端渲染,下一部分細說
const initialState = window.__INITIAL_STATE__;
const store = configureStore(initialState);
const Root = (props) => {
return (
<div>
<Provider store={store}>
<Router history={createBrowserHistory()}>
{routes}
</Router>
</Provider>
</div>
);
}
render(<Root />, document.getElementById('root')); 至此,客戶端部分結束。
Server Rendering
接下來的服務器端就比較簡單了,獲取數據可以調用 action,routes 在服務器端的處理參考 react-router server rendering ,在服務器端用一個match方法將拿到的 request url 匹配到我們之前定義的 routes,解析成和客戶端一致的 props 對象傳遞給組件。
./server.js
import express from 'express';
import React from 'react';
import { renderToString } from 'react-dom/server';
import { RoutingContext, match } from 'react-router';
import { Provider } from 'react-redux';
import routes from './routes';
import configureStore from './store';
const app = express();
function renderFullPage(html, initialState) {
return `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
</head>
<body>
<div id="root">
<div>
${html}
</div>
</div>
<script>
window.__INITIAL_STATE__ = ${JSON.stringify(initialState)};
</script>
<script src="/static/bundle.js"></script>
</body>
</html>
`;
}
app.use((req, res) => {
match({ routes, location: req.url }, (err, redirectLocation, renderProps) => {
if (err) {
res.status(500).end(`Internal Server Error ${err}`);
} else if (redirectLocation) {
res.redirect(redirectLocation.pathname + redirectLocation.search);
} else if (renderProps) {
const store = configureStore();
const state = store.getState();
Promise.all([
store.dispatch(fetchList()),
store.dispatch(fetchItem(renderProps.params.id))
])
.then(() => {
const html = renderToString(
<Provider store={store}>
<RoutingContext {...renderProps} />
</Provider>
);
res.end(renderFullPage(html, store.getState()));
});
} else {
res.status(404).end('Not found');
}
});
}); 服務器端渲染部分可以直接通過共用客戶端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))獲取數據,顯示詳情頁內容。