【React/Redux】深入理解React服務端渲染
在我的博客上觀看:
【React/Redux】深入理解React服務端渲染 - dtysky|一個行者的軌跡
在上一篇文章【React/Redux/Router/Immutable】React最佳實踐的正確食用姿勢中,已經論述了React最佳實踐的前端部分,但在最後也已說明,那種基本實現對SEO並不友好,並且由於首屏渲染依賴於ajax所以在JS禁用狀態下基本也就廢了,所以我們需要利用服務端渲染(Server side rendering)來對首屏進行優化。雖然React官方提供服務端渲染的API,React-router也支持,但在渲染依賴於ajax請求的狀況下仍然聊勝於無,但這並不是無解的,和Redux的合作便可以相對完美地解決這個問題。
接下來將以我的博客為例,論述一下如何正確進行React的服務端渲染,這一部分的代碼基本都在server/server.bin.js中。
問題
如果沒有服務端渲染,那麼一個React架構的SPA的渲染是這樣的:
客戶端請求資源 -> 返回index.html模板 -> 請求js文件並載入 -> React執行,掛載組件 -> 進入路由 -> 一些根據組件生命周期而做的初始化操作 -> 渲染組件
在這種模式下,一開始客戶端只會收到一個只有架子、沒有內容的Html文件,等待js文件載入完畢後才會執行後續操作並渲染出期望中的頁面。這也就是說,無論是哪個路由下的頁面,一開始都會先進入index.html這個入口,然後再客戶端進行接下來的渲染,這對於大部分用戶確實是沒有什麼問題,但對於部分場景和搜索引擎就不是這樣了。
某些場景
主要是JS被禁用的場景,比如是微信,在某些情況下,你在微信分享的頁面可能會被判定為廣告頁面,在不做認證(備案)的情況下,JS腳本會被禁用,這會使得後續的渲染都無法執行。
SEO
SEO相較前面是更為嚴重和急迫的問題,搜索引擎這個絕大多數網站的主要流量入口,如果不加以重視,除非網站是故意不想被曝光,否則是一筆很大的損失。而根據前面的介紹不難得知,如果每次都將index.html作為入口,利用JS和ajax來渲染,搜索引擎是基本無法獲得你的真正頁面內容的,除了下面這個粗暴的解決方案。
一個粗暴的解決方案
這也是我上次重構最終選擇的方案,對於一些搜索引擎(谷歌明確支持,必應似乎近期也支持了,百度萬年技術最差不用等了),有著明確的對ajax頁面的一個折中解決方案,我們可以在index.html的head里加上這麼一句:
<meta name="fragment" content="!">
搜索引擎解析出這個meta信息後,便會在原始url後面加上一個?_escaped_fragment_來再次發起請求,這時候我們只需要在服務端備好另一套SEO專用的頁面返回給其即可。這種方法雖然有效,但本質上還是要求我們另外準備一套界面,並且這一套和原來的代碼還不能復用。比如,我在那時候就是專門寫了一套jade模板來做SEO:
// Express.js的中間件function SEO(req, res, next) { const escapedFragment = req.query._escaped_fragment_; if (escapedFragment) { const parsedUrl = url.parse(req.url); const rdPath = `/${jade}/parsedUrl.pathname`; logInfo("Redirect: from", req.path, "to ", rdPath); return res.redirect(rdPath); } return next();}
這相當於將工作量翻了兩倍。不僅如此,這還會使得server更加複雜,對於我這個Blog項目,這種妥協似乎還勉強可以接受,但對於一個複雜一些的網站就不是這樣了,我們必須用一種新的方法來用最少的代碼解決這個問題。
React服務端渲染
React+React-router+Redux便是解決這個問題的一套相對的完美的方案,雖然我們還是要對一些渲染邏輯做修改,但相較別的方案,仍然好得多。
ReactDom
React提供了一個API用於將虛擬DOM樹在服務端環境下進行渲染,這個API是ReactDom/server中的renderToString。這個方法接受一個虛擬DOM樹,並返回一個渲染後的HTML字元串,例如我有一個根級組件Root,我便可以使用下列語句得到結果:
import {renderToString} from "ReactDom/server";const markup = renderToString( <Root />);
markup即為渲染後的結果。renderToString這個方法是服務端渲染的基礎,但如果只是單純這樣使用,那麼基本等於將React作為一個複雜很多的模板語言來寫而已,因為這個渲染並不會理會任何的ajax請求,也不會根據url來做任何路由,它只會在第一次render方法調用後結束。這也就是說render方法之後的所有生命周期函數都不會被觸發,再一次服務端渲染中,只有constructor、componentWillMount和render會被各觸發一次,並且在期間使用setState也是沒有意義的。
這顯然不是我們期望的,為了愉快得滿足我們的須有,這裡有兩個問題需要解決:
- 路由。
- ajax請求。
React-router
正如上一篇文章所言,路由問題可以交由React-router解決。它提供了一系列方法來在服務端捕獲請求參數,並和renderToString結合來渲染出路由最終對應的組件,其基本原理是通過一個match方法來根據客戶端請求的url解析出已經定義好的routes的props,解析結果是error(錯誤)、redirectLocation(如果有重定向)和renderPorps(正常狀況下解析到的props),我們只需要根據這些參數來執行對應的操作即可:
import {match, RouterContext} from "react-router";import routes from "../src/routes";match({routes, location: req.url}, (error, redirectLocation, renderProps) => { if (error) { logError("Match routes failed: ", error, error.stack); return res.status(500).sendFile(config.error50xFile); } if (redirectLocation) { logError("Match routes redirectLocation: ", redirectLocation); return res.redirect(302, `${redirectLocation.pathname}${redirectLocation.search}`); } if (renderProps) { return render(req, res, renderProps); } logError("Miss match routes: ", error, error.stack); return res.status(404).sendFile(config.error404File);});
除了前兩種特殊情況和最後沒有匹配到執行的404響應,一般情況下我們都會進入到render這個方法中根據renderProps來進行正常狀況下的響應。renderProps的結構如下(以blog項目,並以當前這個頁面的路由為例):
{ routes: [ {path: "/", component: APP, ......} {path: "article/:name", components: {content: Article}} ], params: [name: Skill-2016_10_11_a"], location: { pathname: "/article/Skill-2016_10_09_a", ...... }, components: [ { [Function: Connect] displayName: "Connect(APP)", ...... }, {content: Article} ], history: {......}, router: {......}, matchContext: {......}}
這裡面對我們有用的信息很多,但如果只是單純用React-router配合來做服務端渲染,我們並不用了解這些,而是直接把renderProps傳給待渲染的組件即可:
import {renderToString} from "react-dom/server";import {match, RouterContext} from "react-router";const markup = renderToString( <RouterContext {...renderProps} />);
通過使用RouterContext,我們便得到了和客戶端渲染時相似的Router組件,將其作為根組件並傳入renderProps,再調用renderToString,一次具有路由的服務端渲染便完成。在此例中,它最終會返回主要區域是Article組件的真實DOM。
通過和React-router的結合,我們解決了第一個、也是相對容易的問題,但這仍然無法解決ajax調用的問題,這時候,Redux就該出場了。
Redux
Redux配合React進行服務端渲染的教程已經有許多,但這些教程大都不過是官網那篇例子的搬運,例子中所述方法的基本思想如下:
基本思想
首先要明確我們真正的問題是什麼,由於服務端渲染只會走一遍生命周期,並且在第一次render後便會停止,所以想要真正渲染出最終的頁面,我們必須在第一次渲染前就將狀態準備好我們期望中的狀態,這也就是說,我們必須要有一次超前的ajax請求實現獲取狀態,然後來根據這個狀態渲染:
function serverSideRender(req, res) { request.get(url) .then(response => normalRender(res, response)) .catch(err => render500(res, err))}
在自己定義的normalRender這個方法里,我們可以通過Redux提供的createStore方法的第二個參數來進行創建帶有初始狀態的store,然後將這個狀態送入根組件,並執行後續的渲染:
function normalRender(res, response) { // 假設response的響應體中就包含了所有狀態信息 const initState = response.body; const store = createStore(reducers, initState); const markup = renderToString( <Provider store={store}> <APP /> </Provider> ); // 將markup和最終的state塞到模板內渲染,這個模板取決於使用的模板引擎,也可以直接字元串替換 return res.render(template, { markup, finalState: store.getState().toJSON() });}
這個方法以響應結果為初始化狀態渲染DOM,並將渲染後的結果塞入模板,值得注意的是,渲染參數裡面有個finalState,這是初次渲染後、store的最終狀態,我們需要將其序列化後強制寫到返回的HTML的script標籤中,將其賦予一個、例如叫initState的變數中,這樣最終返回的HTML結構如下:
<html> <head> ...... <script>window.initState = {{finalState}}</script> </head> <body> <div id="react-container"><div>{{markup}}</div></div> </body></html>
window.initState便擁有了我們服務端渲染後的狀態,如此,客戶端便有了一個途徑來根據這個狀態來初始化客戶端的store,並接續接下來的操作,這實質上是完成了服務端和客戶端之間狀態的對接:
const store = createStore(reducers, window.initState);
特別注意,在將markup寫入模板時我在React頂層DOM中又插入了一層div,這是為了解決一個特殊的警告,詳細請看這裡:Warning: React attempted to reuse markup in a container but the checksum was invalid。
要注意,我們可以延續狀態,並非說Redux可以直接幫我們解決重複渲染的問題,無論如何,在每次重新執行初始渲染的時候,組件的生命周期還是會走一遍,如果阻止重複ajax請求、重複渲染,就是我們要在邏輯里自己把握的事情了,比如,我們的state里如果有一個欄位是list,而這個list在這個應用的一次訪問中只會初始化一次,那麼我們便可以在初始化它的action中這麼寫:
function getList(currentList) { if (!currentList.isEmpty()) { return; }}
那麼list便不會被二次初始化,也不會進行進一步的無效渲染。
這樣,官方建議的服務端渲染便完成。但這其實有另外兩個問題,一個是路由的問題,還有一個,按照這種說法,我們難道要寫另一套專門的邏輯來在渲染前初始化狀態?一個兩個狀態還好,但一般SPA都不會只有這麼點,而且這種ajax請求我們一般都會寫在專門的action中,並在action成功或失敗後交給ruducer,這些代碼難道不能復用嗎?客戶端和服務端的同構化開發就不能實現嗎?
當然可以。
路由
和React-router的結合實際上不是Redux的問題,而是React-router自身的問題,其核心在於如何根據匹配後的renderProps來獲取用於初始化狀態的信息,在上面一章我們已經拿到了renderProps的詳細信息,接下來便可以妥善地利用這些信息。
雖然如何利用它們取決於項目的設計,但一個顯而易見的策略是利用匹配後的params來作為根據進行ajax請求來獲取初始數據:
const {params} = renderProps;const {type, name} = params;request.get(`${backendHost}/${type}/${name}`)......
這只是一個例子,表明如何根據信息來獲取響應數據來初始化狀態。
在這個Blog項目中,我採用了一些特別的方法來進行這一步的操作。在我定義一個和路由相關的組件,比如Article時,我會為其賦予靜態變數type:
export default class Article extends Base { static type = "article"; ......}
再加上上面分析的內容,我們可以從renderProps.components[1].content(content是該路由對應的組件,在此處是Article,請見上一篇文章)中拿到這個type:
const {type} = renderProps.components[1].content;
然後根據這個type進行接下來的操作即可。
進階
有了路由和基礎的服務端渲染,要解決的問題便只剩同構化開發、避免多餘代碼的同構開發問題。這個問題解決的核心在於——store在渲染過程中是可變的,並且在服務端渲染進行過程中,有一個componentWillMount的階段可供我們和客戶端平等使用。這二者是接下來內容的基礎。
由於componentWillMount會在服務端渲染過程中執行,並且執行時this.props中的內容已經根據路由信息被修改,我們可以將ajax請求放入其中:
componentWillMount() { const {dispatch, params} = this.props; const {type, name} = params; dispatch(getList(type, name));}
我們可以在其中執行任意個ajax請求,由於store是可改變的,所以在請求結束後、reducer生效後將會我們便可以取到完整的、初始化過的store。也就是說,我們只要如此寫好組件的生命周期,而後進行正常的服務端渲染,然後只需等待便可以得到一個初始化後的store,隨後只需要將這個store作為初始化數據送入模板即可。
這聽起來是不是很簡單?確實很簡單,但仍然有一點需要注意——ajax請求時非同步的,一次服務端渲染結束後,並不代表ajax請求就結束了,我們仍然需要一些方法來確定所喲請求確實結束,這裡就需要一點設計了——我們可以在store中創建一個state專門用於表示初始化是否結束,並配以相應的action和reducer來修改它:
componentWillMount() { const {dispatch, params} = this.props; const {type, name} = params; dispatch(getList(type, name)) .then(() => dispatch({type: actionTypes.init.all.successful})) .catch(() => dispatch({type: actionTypes.init.all.failed}));}
如此,我們便可以不斷檢查store.getState().state.initDone來確定所有請求是否結束:
function responseWithCheck(res, store, renderProps) { setImmediate(() => { if (!store.getState().state.get("initDone")) { return setImmediate(responseWithCheck, res, store, renderProps); } ...... return res.render(......); });}
setImmediate方法用於設置一個定時任務,此定時任務將在下一次mainLoop中被觸發。
這樣,我們便可以在基本不添加多餘邏輯的情況下復用客戶端代碼。但這樣還是有問題沒有被解決,我們送到客戶端的仍然只是一堆狀態加上等待初始化的頁面,所以還需要更近一步。
二次渲染
想要讓服務端直接渲染出初始化store後的完整頁面,方法很簡單,只需要把第一次渲染後的finalState作為初始狀態進行二次渲染即可:
const store = createStore(reducers, finalState);const markup = renderToString( <Provider store={store}> <APP /> </Provider>);
如果設計足夠規範,做了上述getList那樣的檢查,第二次渲染時是不會進行再次請求的,而是直接根據finalState渲染出真正的首屏頁面。
store的dispatch方法在服務端同樣可以使用,我們可以直接用store.dispatch來觸發某些action來滿足一些特別的需求。
至此,一個完整的、SEO友好的服務端渲染便已完成,但這樣還是會有問題——客戶端的方便是以服務端的消耗為代價的,客戶端的每次請求都會導致服務端重新進行兩次渲染和伴隨的若干次ajax請求,這並不是我們期望的,所以這裡就要用到Memory cache來緩存store和渲染後的頁面,至於我的blog中是如何實現的,將在下一篇文章詳細說明。
推薦閱讀: