React服務端渲染(程式碼分割和資料預取)
前幾節已經把專案基本骨架和路由搭建好了,但作為實際專案開發,這些還是不夠的。隨著業務的增大,應用層序程式碼也隨之增大,如果把所有程式碼都打包到一個檔案裡面,首次載入會導致時間相對變長,增加流量(對移動端來說)。應用程式包含很多頁面,某一時刻使用者只會訪問一個頁面,使用者未訪問的頁面程式碼在訪問之前不應該被載入,只有在使用者訪問時才應改載入頁面所需資源。之前搭建好的專案暫不涉及資料互動,業務最核心的東西就是資料,本節將會介紹基於路由的程式碼分割、資料互動和同步
上一節:前後端路由同構
原始碼地址見文章末尾
程式碼分割
路由懶載入
在做程式碼分割的時候有很多解決方案,如 ofollow,noindex">react-loadable , react-async-component , loadable-components ,三者都支援Code Splitting和懶載入,而且都支援服務端渲染。react-loadable和react-async-component在做服務端渲染時,步驟十分繁瑣,loadable-components提供了簡單的操作來支援服務端渲染,這裡選用loadable-components
安裝loadable-components
npm install loadable-components 複製程式碼
將路由配置中的元件改成動態匯入
src/router/index.js
import Loadable from "loadable-components"; const router = [ { path: "/bar", component: Loadable(() => import("../views/Bar")) }, { path: "/baz", component: Loadable(() => import("../views/Baz")) }, { path: "/foo", component: Loadable(() => import("../views/Foo")) }, { path: "/top-list", component: Loadable(() => import("../views/TopList")), exact: true } ]; 複製程式碼
import()
動態匯入是從Webpack2開始支援的語法,本質上是使用了promise,如果要在老的瀏覽器中執行需要 es6-promise 或 promise-polyfill
為了解析 import()
語法,需要配置babel外掛 syntax-dynamic-import ,然後單頁面應用中就可以工作了。這裡使用loadable-components來做服務端渲染,babel配置如下
"plugins": [ "loadable-components/babel" ] 複製程式碼
注意:這裡使用babel6.x的版本
在客戶端使用 loadComponents
方法載入元件然後進行掛載。客戶端入口修改如下
src/entry-client.js
import { loadComponents } from "loadable-components"; import App from "./App"; // 開始渲染之前載入所需的元件 loadComponents().then(() => { ReactDOM.hydrate(<App />, document.getElementById("app")); }); 複製程式碼
服務端呼叫 getLoadableState()
然後將狀態插入到html片段中
src/server.js
const { getLoadableState } = require("loadable-components/server"); ... let component = createApp(context, req.url); // 提取可載入狀態 getLoadableState(component).then(loadableState => { let html = ReactDOMServer.renderToString(component); if (context.url) {// 當發生重定向時,靜態路由會設定url res.redirect(context.url); return; } if (!context.status) {// 無status欄位表示路由匹配成功 // 獲取元件內的head物件,必須在元件renderToString後獲取 let head = component.type.head.renderStatic(); // 替換註釋節點為渲染後的html字串 let htmlStr = template .replace(/<title>.*<\/title>/, `${head.title.toString()}`) .replace("<!--react-ssr-head-->", `${head.meta.toString()}\n${head.link.toString()}`) .replace("<!--react-ssr-outlet-->", `<div id='app'>${html}</div>\n${loadableState.getScriptTag()}`); // 將渲染後的html字串傳送給客戶端 res.send(htmlStr); } else { res.status(context.status).send("error code:" + context.status); } }); 複製程式碼
呼叫 getLoadableState()
傳入根元件,等待狀態載入完成後進行渲染並呼叫 loadableState.getScriptTag()
把返回的指令碼插入到html模板中
服務端渲染需要 modules
選項
const AsyncComponent = loadable(() => import('./MyComponent'), { modules: ['./MyComponent'], }) 複製程式碼
這個選項不需要手動編寫,使用 loadable-components/babel
外掛即可。 import()
語法在node中並不支援,所以服務端還需要配置一個外掛 dynamic-import-node
安裝 dynamic-import-node
npm install babel-plugin-dynamic-import-node --save-dev 複製程式碼
客戶端不需要這個外掛,接下來修改webpack配置,客戶端使用 .babelrc
檔案,服務端通過loader的 options
選項指定babel配置
將 webpack.config.base.js
中的以下配置移到 webpack.config.client.js
中
{ test: /\.(js|jsx)$/, loader: ["babel-loader", "eslint-loader"], exclude: /node_modules/ } 複製程式碼
webpack.config.client.js
rules: [ { test: /\.(js|jsx)$/, loader: ["babel-loader", "eslint-loader"], exclude: /node_modules/ }, ...util.styleLoaders({ sourceMap: isProd ? true : false, usePostCSS: true, extract: isProd ? true : false }) ] 複製程式碼
服務端打包配置修改如下
webpack.config.server.js
rules: [ { test: /\.(js|jsx)$/, use: [ { loader: "babel-loader", options: { babelrc: false, presets: [ "react", [ "env", { "targets": { "node": "current" } } ] ], "plugins": [ "dynamic-import-node", "loadable-components/babel" ] } }, { loader: "eslint-loader" } ], exclude: /node_modules/ }, ...util.styleLoaders({ sourceMap: true, usePostCSS: true, extract: true }) ] 複製程式碼
執行 npm run dev
,開啟瀏覽器輸入 http://localhost:3000
,在network面板中可以看到先下載 app.b73b88f66d1cc5797747.js
,然後下載當前bar頁面所需的js(下圖中的 3.b73b88f66d1cc5797747.js
)

當點選其它路由就會下載對應的js然後執行
Webpack打包優化
實際使用中,隨著應用的迭代更新,打包檔案後的檔案會越來越大,其中主要指令碼檔案 app.xxx.js
包含了第三方模組和業務程式碼,業務程式碼會隨時變化,而第三方模組在一定的時間內基本不變,除非你對目前使用的框架或庫進行升級。 app.xxx.js
中的xxx使用 chunkhash
命名, chunkhash
表示chunk內容的hash,第三方模組的chunk不會變化,我們將其分離出來,便於瀏覽器快取
關於output.filename更多資訊請戳這裡
為了提取第三方模組,需要使用webpack自帶的CommonsChunkPlugin外掛,同時為了更好的快取我們將webpack引導模組提取到一個單獨的檔案中
webpack.config.client.js
plugins: [ ... new webpack.optimize.CommonsChunkPlugin({ name: "vendor", minChunks: function(module) { // 阻止.css檔案資源打包到vendor chunk中 if(module.resource && /\.css$/.test(module.resource)) { return false; } // node_modules目錄下的模組打包到vendor chunk中 return module.context && module.context.includes("node_modules"); } }), // 分離webpack引導模組 new webpack.optimize.CommonsChunkPlugin({ name: "manifest", minChunks: Infinity }) ] 複製程式碼
通過以上配置會打包出包含第三方模組的 vendor.xxx.js
和 manifest.xxx.js
注意:這裡使用webpack3.x的版本,CommonsChunkPlugin在webpack4中已移除。webpack4請使用SplitChunksPlugin
專案中在生產模式下才使用了 chunkhash
,接下來執行 npm run build
打包

修改 src/App.jsx
中的程式碼,再進行打包

可以看到 vender.xxx.js
檔名沒有產生變化, app.xxx.js
變化了,4個非同步元件打包後的檔名沒有變化, mainfest.xxx.js
發生了變化
資料預取和同步
服務端渲染需要把頁面內容由服務端返回給客戶端,如果某些內容是通過呼叫介面請求獲取的,那麼就要提前載入資料然後渲染,再呼叫 ReactDOMServer.renderToString()
渲染出完整的頁面,客戶端渲染出來的html內容要和服務端返回的html內容一致,這就需要保證客戶端的資料和服務端的資料是一致的
資料管理這裡選用Redux,Redux在做服務端渲染時,每次請求都要建立一個新的Store,然後初始化state返回給客戶端,客戶端拿到這個state建立一個新的Store
Redux服務端渲染示例
加入Redux
安裝相關依賴
npm install redux redux-thunk react-redux 複製程式碼
首先搭建Redux基本專案結構

actionTypes.js
export const SET_TOP_LIST = "SET_TOP_LIST"; export const SET_TOP_DETAIL = "SET_TOP_DETAIL"; 複製程式碼
actions.js
import { SET_TOP_LIST, SET_TOP_DETAIL } from "./actionTypes"; export function setTopList(topList) { return { type: SET_TOP_LIST, topList }; } export function setTopDetail(topDetail) { return { type: SET_TOP_DETAIL, topDetail }; } 複製程式碼
reducers.js
import { combineReducers } from "redux"; import * as ActionTypes from "./actionTypes"; const initialState = { topList: [], topDetail: {} } function topList(topList = initialState.topList, action) { switch (action.type) { case ActionTypes.SET_TOP_LIST: return action.topList; default: return topList; } } function topDetail(topDetail = initialState.topDetail, action) { switch (action.type) { case ActionTypes.SET_TOP_DETAIL: return action.topDetail; default: return topDetail; } } const reducer = combineReducers({ topList, topDetail }); export default reducer; 複製程式碼
store.js
import { createStore, applyMiddleware } from "redux"; import thunkMiddleware from "redux-thunk"; import reducer from "./reducers"; // 匯出函式,以便客戶端和服務端根據初始state建立store export default (store) => { return createStore( reducer, store, applyMiddleware(thunkMiddleware) // 允許store能dispatch函式 ); } 複製程式碼
這裡請求資料需要使用非同步Action,預設Store只能dispatch物件,使用 redux-thunk 中介軟體就可以dispatch函數了
接下來在 action.js
中編寫非同步Action建立函式
import { getTopList, getTopDetail } from "../api"; ... export function fatchTopList() { // dispatch由thunkMiddleware傳入 return (dispatch, getState) => { return getTopList().then(response => { const data = response.data; if (data.code === 0) { // 獲取資料後dispatch,存入store dispatch(setTopList(data.data.topList)); } }); } } export function fetchTopDetail(id) { return (dispatch, getState) => { return getTopDetail(id).then(response => { const data = response.data; if (data.code === 0) { const topinfo = data.topinfo; const top = { id: topinfo.topID, name: topinfo.ListName, pic: topinfo.pic, info: topinfo.info }; dispatch(setTopDetail(top)); } }); } } 複製程式碼
上述程式碼中Action建立函式返回一個帶有非同步請求的函式,這個函式中可以dispatch其它action。在這裡這個函式中呼叫介面請求,請求完成後把資料通過dispatch存入到state,然後返回Promise,以便非同步請求完成後做其他處理。在非同步請求中需要同時支援服務端和客戶端,你可以使用 axios 或者在瀏覽器端使用fetch API,node中使用 node-fetch
在這裡使用了QQ音樂的介面作為資料來源,服務端使用 axios ,客戶端不支援跨域使用了jsonp, src/api/index.js
中的程式碼看起來像下面這樣
import axios from "axios"; import jsonp from "jsonp"; const topListUrl = "https://c.y.qq.com/v8/fcg-bin/fcg_myqq_toplist.fcg"; if (process.env.REACT_ENV === "server") { return axios.get(topListUrl + "?format=json"); } else { // 客戶端使用jsonp請求 return new Promise((resolve, reject) => { jsonp(topListUrl + "?format=jsonp", { param: "jsonpCallback", prefix: "callback" }, (err, data) => { if (!err) { const response = {}; response.data = data; resolve(response); } else { reject(err); } }); }); } 複製程式碼
如果你想了解更多QQ音樂介面請戳這裡
讓React展示元件訪問state的方法就是使用 react-redux
模組的 connect
方法連線到Store,編寫容器元件 TopList
src/containers/TopList.jsx
import { connect } from "react-redux" import TopList from "../views/TopList"; const mapStateToProps = (state) => ({ topList: state.topList }); export default connect(mapStateToProps)(TopList); 複製程式碼
在 src/router/index.js
中把有原來的 import("../views/TopList"))
改成 import("../containers/TopList"))
{ path: "/top-list", component: Loadable(() => import("../containers/TopList")), exact: true } 複製程式碼
在展示元件 TopList
中通過props訪問state
class TopList extends React.Component { render() { const { topList } = this.props; return ( <div> ... <ul className="list-wrapper"> { topList.map(item => { return <li className="list-item" key={item.id}> {item.title} </li>; }) } </ul> </div> ) } } 複製程式碼
接下來在服務端入口檔案 entry-server.js
中使用 Provider
包裹 StaticRouter
,並匯出 createStore
函式
src/entry-server.js
import createStore from "./redux/store"; ... const createApp = (context, url, store) => { const App = () => { return ( <Provider store={store}> <StaticRouter context={context} location={url}> <Root setHead={(head) => App.head = head}/> </StaticRouter> </Provider> ) } return <App />; } module.exports = { createApp, createStore }; 複製程式碼
server.js
中獲取 createStore
函式建立一個沒有資料的Store
let store = createStore({}); // 存放元件內部路由相關屬性,包括狀態碼,地址資訊,重定向的url let context = {}; let component = createApp(context, req.url, store); 複製程式碼
客戶端同樣使用 Provider
包裹,建立一個沒有資料的Store並傳入
src/App.jsx
import createStore from "./redux/store"; ... let App; if (process.env.REACT_ENV === "server") { // 服務端匯出Root元件 App = Root; } else { const Provider = require("react-redux").Provider; const store = createStore({}); App = () => { return ( <Provider store={store}> <Router> <Root /> </Router> </Provider> ); }; } export default App; 複製程式碼
預取資料
獲取資料有兩種做法第一種是把載入資料的方法放到路由上,就像下面這樣
const routes = [ { path: "/", component: Root, loadData: () => getSomeData() } ... ]; 複製程式碼
另一種做法就是把載入資料的方法放到對應的元件上定義成靜態方法,這種做法更直觀
本例採用第二種做法在 TopList
元件中定義一個靜態方法 asyncData
,傳入store用來dispatch非同步Action,這裡定義成靜態方法是因為元件渲染之前還沒有被例項化無法訪問 this
static asyncData(store) { return store.dispatch(fatchTopList()); } 複製程式碼
fatchTopList
返回的函式被 redux-thunk
中介軟體呼叫, redux-thunk
中介軟體會把呼叫函式的返回值當作dispatch方法的返回值傳遞
現在需要在請求的時候獲取路由元件的 asyncData
方法並呼叫,react-router在 react-router-config 模組中為我們提供了 matchRoutes
方法,根據路由配置來匹配路由
為了在服務端使用路由匹配,路由配置要從 entry-server.js
中匯出
src/entry-server.js
import { router } from "./router"; ... module.exports = { createApp, createStore, router }; 複製程式碼
在 server.js
中獲取 router
路由配置,當所有非同步元件載入完成後呼叫 matchRoutes()
進行路由匹配,呼叫所有匹配路由的 asyncData
方法後進行渲染
let promises; getLoadableState(component).then(loadableState => { // 匹配路由 let matchs = matchRoutes(router, req.path); promises = matchs.map(({ route, match }) => { const asyncData = route.component.Component.asyncData; // match.params獲取匹配的路由引數 return asyncData ? asyncData(store, Object.assign(match.params, req.query)) : Promise.resolve(null); }); // resolve所有asyncData Promise.all(promises).then(() => { // 非同步資料請求完成後進行服務端render handleRender(); }).catch(error => { console.log(error); res.status(500).send("Internal server error"); }); ... } 複製程式碼
上述程式碼中使用 route.component
獲取的是loadable-components返回的非同步元件, route.component.Component
才是真正的路由元件,必須在呼叫 getLoadableState()
後才能獲取。如果元件存在 asyncData
方法就放到 promises
陣列中,不存在就返回一個resolve好的Promise,然後將所有Promise resolve。有些url類似 /path/:id
, match.params
就是用來獲取該url中的 :id
表示的引數,如果某些引數以?形似傳遞,可以通過 req.query
獲取,合併到 match.params
中,傳給元件處理
注意:matchRoutes中第二個引數請用 req.path
, req.path
獲取的url中不包含query引數,這樣才能正確匹配
同步資料
服務端預先請求資料並存入Store中,客戶端根據這個state初始化一個Store例項,只要在服務端載入資料後呼叫 getState()
獲取到state並返回給客戶端,客戶端取到這個這個state即可
在 server.js
中獲取初始的state,通過 window.__INITIAL_STATE__
儲存在客戶端
src/server.js
let preloadedState = {}; ... // resolve所有asyncData Promise.all(promises).then(() => { // 獲取預載入的state,供客戶端初始化 preloadedState = store.getState(); // 非同步資料請求完成後進行服務端render handleRender(); }).catch(error => { console.log(error); res.status(500).send("Internal server error"); }); ... let htmlStr = template .replace(/<title>.*<\/title>/, `${head.title.toString()}`) .replace("<!--react-ssr-head-->", `${head.meta.toString()}\n${head.link.toString()} <script type="text/javascript"> window.__INITIAL_STATE__ = ${JSON.stringify(preloadedState)} </script> `) .replace("<!--react-ssr-outlet-->", `<div id='app'>${html}</div>\n${loadableState.getScriptTag()}`); 複製程式碼
App.jsx
中獲取 window.__INITIAL_STATE__
// 獲取服務端初始化的state,建立store const initialState = window.__INITIAL_STATE__; const store = createStore(initialState); 複製程式碼
此時客戶端和服務端資料可以同步了
客戶端資料獲取
對於客戶端路由跳轉,是在瀏覽器上完成的,這個時候客戶端也需要請求資料
在 TopList
元件的 componentDidMount
生命週期函式中 dispatch
非同步Action建立函式 fatchTopList
的返回值
componentDidMount() { this.props.dispatch(fatchTopList()); } 複製程式碼
這裡元件已經被例項化,所以可以通過 this
訪問Store的 dispatch
,同時這個函式只會在客戶端執行
你可能會想要在 componentWillMount
中 dispatch
非同步Action,官方已經對生命週期函式做了更改(請戳這裡),16.x版本中啟用對 componentWillMount
、 componentWillReceiveProps
和 componentWillUpdate
過期警告,17版本中會移除這三個周期函式,推薦在 componentDidMount
中獲取資料(請戳這裡)
有一種情況如果服務端提前載入了資料,當客戶端掛載DOM後執行了 componentDidMount
又會執行一次資料載入,這一次資料載入是多餘的,看下圖

訪問 http://localhost:3000/top-list
,服務端已經預取到資料並把結果HTML字串渲染好了,紅色方框中是客戶端DOM掛載以後傳送的請求。為了避免這種情況,新增一個state叫 clientShouldLoad
預設值為 true
,表示客戶端是否載入資料,為 clientShouldLoad
編寫好actionType、action建立函式和reducer函式
actionTypes.js
export const SET_CLIENT_LOAD = "SET_CLIENT_LOAD"; 複製程式碼
actions.js
import { SET_CLIENT_LOAD, SET_TOP_LIST, SET_TOP_DETAIL } from "./actionTypes"; export function setClientLoad(clientShouldLoad) { return { type: SET_CLIENT_LOAD, clientShouldLoad }; } 複製程式碼
reducers.js
const initialState = { clientShouldLoad: true, topList: [], topDetail: {} } function clientShouldLoad(clientShouldLoad = initialState.clientShouldLoad, action) { switch (action.type) { case ActionTypes.SET_CLIENT_LOAD: return action.clientShouldLoad; default: return clientShouldLoad; } } ... const reducer = combineReducers({ clientShouldLoad, topList, topDetail }); 複製程式碼
容器元件 TopList
中對 clientShouldLoad
進行對映
src/containers/TopList.jsx
const mapStateToProps = (state) => ({ clientShouldLoad: state.clientShouldLoad, topList: state.topList }); 複製程式碼
當服務端預取資料後修改 clientShouldLoad
為 false
,客戶端掛載後判斷 clientShouldLoad
是否為 true
,如果為 true
就獲取資料,為 false
就將 clientShouldLoad
改為 true
,以便客戶端跳轉到其它路由後獲取的 clientShouldLoad
為 true
,進行資料獲取
在非同步Action建立函式中,當前執行的是服務端資料,請求完成後dispatch
actions.js
export function fatchTopList() { // dispatch由thunkMiddleware傳入 return (dispatch, getState) => { return getTopList().then(response => { const data = response.data; if (data.code === 0) { // 獲取資料後dispatch,存入store dispatch(setTopList(data.data.topList)); } if (process.env.REACT_ENV === "server") { dispatch(setClientLoad(false)); } }); } } 複製程式碼
TopList
元件中增加判斷
TopList.jsx
componentDidMount() { // 判斷是否需要載入資料 if (this.props.clientShouldLoad === true) { this.props.dispatch(fatchTopList()); } else { // 客戶端執行後,將客戶端是否載入資料設定為true this.props.dispatch(setClientLoad(true)); } } 複製程式碼
此時訪問 http://localhost:3000/top-list
,客戶端少了一次資料請求。如下圖

總結
本節利用webpack動態匯入的特性對路由進行懶載入,以減少打包後的檔案大小,做到按需載入,利用webpack自帶的CommonsChunkPlugin外掛分離第三方模組,讓客戶端更好的快取。一般的客戶端都是在DOM掛載以後獲取資料,而服務端渲染就要在服務端提前載入資料,然後把資料返回給客戶端,客戶端獲取服務端返回的資料,保證前後端資料是一致的
搭建服務端渲染是一個非常繁瑣而又困難的過程,一篇文章是介紹不完實際開發所需要的點,本系列文章從起步再到接近實際專案介紹瞭如何搭建服務端渲染,其中涉及的技術點非常多。對於服務端渲染官方也沒有一套完整的案例,因此做法也不是唯一的