溫故而知新—再談一次React Universal Rendering

lala
14 min readDec 29, 2017

--

這個故事是這樣開始的,有個素未謀面的學弟找我幫忙一些事情,反正聊著聊著相談甚歡,聽起來其實還滿有趣的,所以就先答應幫忙他一下,我們就先姑且給這個東西一個代號,先叫它Dream Breaker好了。

我一開始學React時,正值React起飛的時候,很多東西都是正處於發展中,民智未開的階段。所以當初其實算是很早期就接觸了React Universal Rendering,也因為是早期實作的方法,所以有相當多可以改進的部分,當然其實一直有想要好好整理這些程式碼的想法,但因為公司業務實在太繁忙,這些東西一直沒辦法排進去。咦?那你哪來的時間搞Side Project啊!?不要問我,我也不知道。

說到這裡,我心中真正的盤算是想利用Dream Breaker把一些我認為是好的東西抽出來並且整理那些我覺得不好的東西,當然這個範疇不止是Universal Rendering,還有相當多的東西值得分享紀錄,如果穩定成熟的話還可以整理回去,有白老鼠讓我試刀,這機會真是非常難得啊!

鋪陳結束了,那這篇文章對誰會有幫助呢?

得先說明一下,這不是一篇入門文章,不過其實是我懶得慢慢從源頭寫,也因為網路上的資源已經非常多了,應該可以找到很多非常好的文章,況且我文筆應該是沒他們厲害才對。

這裡所提出的一些想法、細節以及心得,應該會對正在實作或是已經實作React Universal Rendering的人有幫助,不過這邊還是可以簡單提一下其優點以及缺點。

優點:

  1. 解決了SPA的SEO問題。
  2. Render第一頁頁面時,可以在Server side就準備好資料,不用在Client side再發AJAX去拉資料回來以增進使用者體驗。
  3. 共用Server side以及Client side的程式碼,共同邏輯的地方可用一份程式碼就好。

缺點:

  1. 架構會變相當複雜,譬如Server side必須要知道怎麼把頁面Render出來,以及知道要怎麼去把資料抓回來…等。
  2. Server side為了要可以Render React Component,基本上是用NodeJS或是其他可以執行Javascript的語言來寫是比較方便的選擇,當然我們還是可以把Rendering Engine獨立出來就是了。
  3. 也因為共用程式碼的關係,所以會碰到程式碼無法在Server side或是Client side執行的情況,譬如Server side無法拿到window或是Client side無法打包原生NodeJS module如fs導致整個噴掉,甚至把不該讓Client side看到的東西,較敏感的資訊暴露出去。

如果你想更加了解Universal Rendering的話,可以參考Airbnb在2013年寫的經典文章如果已經瞭解了基本概念想了解早期實作細節的話,特別推薦Codementor Community所發表的這篇文章

Technical Stacks

以下是我們核心部分所使用的Technical Stacks,如果你也剛好跟我們一樣的話,那可以說是英雄所見略同。但如果不一樣也沒關係,我們可以互相學習、比試鄙視。

express: 4.16.2
redux: 3.7.2
redux-saga: 0.16.0
react: 16.2.0
react-dom: 16.2.0
react-router-dom: 4.2.2
react-router-redux: next

其中react-router-redux的版本還在alpha階段,請謹慎參考。

為什麼不用next, electrode或是其他的Universal React Framework,而是選擇自己做呢?我的答案是這樣的,用這些現成的Framework並沒有不好,但有時候為了更彈性或是要達到一些特殊需求的話,這些都不是太好的選擇,更何況自己動手做一次,練練技術也是相當不錯的。

嗯,屁話結束了,接下來是稍微有營養的部分了。

要怎麼讓Server side和Client side產生出來的HTML是一樣的呢?

既然是Universal Rendering,第一個問題就是在Server side產生出來的HTML傳到了Client side,經過一連串的變化後,最終會變成SPA的形狀。要如何確保這個過程不要走鐘太大,至少要確保它們的形狀要有九成像,這要怎麼達到呢?

通常這個問題我們會用Store management如Redux來解決,在Server side除了要把HTML Render出來外,更重要的就是要去設定Initial state,當HTML以及Initial state傳到Client side的時候,也因為Client side有掛上Redux Store,所以他會根據Initial state的值來Render我們的React Component。

慣例的做法是會把Initial store放在window上,所以當HTML傳過去並且執行完javascript的時候,便可以利用這個東西來初始化Redux Store。

大致上HTML Component的程式碼如下:

我們可以看到initial state被掛在window.__INITIAL_STATE__上,不過這邊有個小細節,這樣的做法可能有被XSS攻擊的風險,所以在塞值進去window前必須先做serialize。

當然這個HTML Component的邏輯其實有點複雜,包含設定head以即要讓Client side引入哪些javascript, stylesheet的檔案。更詳細的實作可以參考這裡

我們知道Redux扮演了很重要的地位,那要如何正確設定呢?

我們要知道Server side以及Client side在絕大數情況下的設定方式是不同的,另外一個是要怎麼設定Redux其實跟你選用的Technical Stacks有相當大的關係,譬如說我們選用了Redux Saga, React Router Redux, Redux Dev Tool,所以我們必須額外設定相關的middleware以及reducer。所以在這裡並沒有一個標準的答案,但我還是可以提一下我的心得。

首先第一個降低複雜度的方式當然是用相同的方式去設定Server Side Redux以及Clien Side Redux,只是把可能變動的地方抽出來當作參數傳下去就好。

譬如說我們知道initialState可以被抽出來,在Client Side的時候就是設定之前看過的window.__INITIAL_STATE__。history API也會因為是在Client side會使用Browser History API或是Serve Side使用Memory History API而有所不同。總和言之,這時候就可以把設定的部分交給被抽出來的Redux Manager了,程式碼如下:

另外還有一件事情也是滿值得提的,就是我們之前談過因為共用程式碼的關係,所以會碰到程式碼無法在Server side或是Client side執行的情況。當然一種解決方式是可以在程式碼判斷我現在是在Server side或是Client side然後跑不同的邏輯,但其實這有點醜。

講到這裡實在有點玄,我們可以直接舉例一下,譬如React Component必須把網頁的Domain顯示出來,但因為Server side並沒有辦法拿到window.location.host,所以執行就會有問題。

所以一種可行的解決方案是把值有差異的地方放在Redux Store,然後用Redux Selector的方式去把值拿出來。Server side就是從Express req Object去把Host Header拿出來並且放進Redux Store,而Client side的部分依舊是從window.location.host來拿,譬如以下:

這邊為了保留彈性,我選擇用Reducer去設定Initial State而不是直接塞進Redux Store。

Redux manager 還包含很多其他功能以及實作細節,如果有興趣的話可以參考這裡

都已經用了React Router V4,為什麼還需要React Router Redux?

嗯,這個問題其實相當有趣,原本看到了用StaticRouter來實作Universal Rendering的時候,驚為天人,豪簡單啊!與之前版本相比簡單了許多不少。

但後來再仔細想想,這東西並不太適合我們,第一個問題是他其實相當仰賴Link以及history prop來去更改context,因為我們大部分需要操作Client Routing的部分都寫在saga flow,這邊就不太適合使用這種方式。第二個問題是context並不是會被所有的hisotry API更改到,雖然我們目前實做上沒有碰到,但防止未來踩到雷所以先捨棄了這種做法。

那要如何設定React Router Redux呢?
我們在實作上是用express middleware chaining的方式去一層一層設定React Universal Rendering。這一版的React Router Redux比較特別一點,需要額外設定initialEntries,除此之外應該是沒什麼其他細節了,我們可以從Express req Object中拿到他的Query Path來當作他的initialEntries,如下:

設定完後,便用ConnectedRouter封裝要Render的React Component就好了,其中的routes就是React Router V4的Routing Component,譬如以下:

第二個問題是要怎麼實作HTTP 3XX系列Redirect呢?
這個問題其實很單純,在React Component Render後去檢查Redux Store的router state有沒有被更改就知道了。如果發現與原本(從Express req Object的request url來知道)不一樣就代表是redirect的情況,譬如以下:

如何實作Pre-Fetching呢?

一般在做Universal Rendering的時候通常都會搭配Pre-Fetching來準備Render用的資料,這樣Client side就不需要再發AJAX去抓資料。

有些人在實作Pre-Fetching的時候是搭配React Router match method來知道說哪些React Component會被Render,然後再去呼叫那些React Component的Static Fetch Method去抓資料,這個做法的缺點就是耦合度太高,Universal Rendering必須知道有這個Static Fetch Method的存在,另外一點就是要做到Hierarchical Pre-Fetching會相當複雜。

所以這邊主要用了幾個概念來解決這個問題:

  1. 在Server side使用兩次Render的小技巧,第一次Render是用來改變Redux Store將Fetch結果塞進去,第二次Render才是產生要吐回Client side的HTML。
  2. 善用React Life Cycle,因為Server side在Render React Component時會觸發componentWillMount,這時候我們可以在這裡dispatch Redux Action去觸發特定Saga Flow來進行Fetch Data並且更改Redux Store,此時我們就不需要再寫Static Fetch Method了。
  3. 利用Redux Saga提供的特殊END Action來中斷Server Side的Saga Watcher,可以優雅地停掉Saga Watcher。

整個程式碼的運作大致上如下:

看起來好像簡潔多了,可是這樣就結束了嗎?沒有其他問題了嗎?
這個問題在於我們在Server Side 觸發了兩次componentWillMount,然後跑到Client Side的時候還會在觸發一次,也就是說假如我們什麼事情都不處理的話,他總共會觸發三次Fetch Action。

這個問題其實是需要在Saga Flow解決的,這裡之後會再分享一篇心得深入討論要如何優雅地解決,大概的解決方式就是用一個Dirty Flag來決定是否需要重新Fetch。如果是新鮮剛抓下來的資料就設為non-Dirty,如果是資料發生了改變或是Component被Unmount的話就設為Dirty。

要如何Render?

在React v15的時候,Server side使用ReactDomServer產生Render結果時,會額外產生React內部使用的attributes如data-react-checksum, data-reactid。當Server side產生出來的HTML傳到Client side的時,此時ReactDOM.render就會利用這些資訊來決定是否需要重新Render以及掛上Event Listener。

在React v16時,ReactDomServer就不再額外產生這些attributes了,其目的是希望盡量使用Server Side所產生出來的HTML結構。ReactDOM.render在這個限制下,就不能夠很快速的檢查是否需要重新Render以及掛上Event Listener,當然得到的好處就是因為產生出來的結果已經接近原生HTML,可讀性也因此提升,然後少了內部使用的attributes也使得傳輸量降低了。

為了解決這個問題,多了一個叫做ReactDOM.hydrate的Function。原則上它的差別在於ReactDOM.hydrate會保有完整的HTML結構,不會嘗試去重新Render它,ReactDOM.hydrate只會單純把Event Listener掛上去而已。以前我們寫的ReactDOM.render必須改成ReactDOM.hydrate。

如果不幸發生了Render Error要怎麼解決呢?
從上面的例子可以看到我們都是用Promise chain來串接整個流程,所以遇到Render Error其實只要簡單地在Promise.catch處理就可以,原則上遇到Render Error可以全部轉到客製化Error頁面即可,但是有時候連Error頁面都碰到了Render Error就很麻煩,所以這邊就簡單加個判斷避免無窮的轉頁面就好,譬如以下:

想了解完整的Rendering middleware可以參考這裡。但我們實際上為了能夠更有效率的處理Concurrent Request,我們是使用新推出的renderToNodeStream而不是使用renderToStaticMarkup來進行React Component Render。如果你以前是用react-dom-stream的話,也建議直接使用官方的renderToNodeStream,react-dom-stream的原作者似乎消失了,留下很多問題尚未解決。

另外一點就是我們還使用了Component Based Code Splitting,這個之後也會分享一下,所以整個Rendering middleware會與上面提到的有一些不一樣,但其精神還是相同的。

總結

這裡大概紀錄了一下Dream Breaker實作Universal Rendering的相關細節。也建議大家如果要玩Universal Rendering的話,請使用React v16,因為在React v16的時候才有針對Universal Rendering做了很多最佳化。

如果大家有任何意見想法或是內容有錯需要修正的話,歡迎一起討論或指教!

--

--