手機QQweb React同構直出優化總結

Joeyguo發表於2017-03-28

原文地址 https://github.com/joeyguo/blog/issues/9

React 的實踐從去年在 PC QQ家校群開始,由於 PC 上的網路及環境都相當好,所以在使用時可謂一帆風順,偶爾遇到點小磕絆,也能夠快速地填補磨平。而最近一段時間,我們將手Q的家校群重構成 React,除了原有框架上存在明顯問題的原因外,選擇React也是因為它確實有足夠的吸引力以及優勢,加之在PC家校群上的實踐經驗,斟酌下便開始了,到現在已有頁面線上上正常跑起。

由於移動端上的網路及環境迥異,效能偏差。所以在移動端上用 React 時,遇到了不少的坑點,也花了一些力氣在上面。關於在移動端上的優化,可看我們團隊的另一篇文章的 React移動端web極致優化

一提到優化,不得不提直出
關於這塊可以檢視 Node直出理論與實踐總結,這篇文章較詳細的分析直出的概念及一步步優化,也結合了 手Q家校群使用快速的資料直出方式來優化效能的總結與效能資料分析

一提到 React,不得不提同構
同構基於服務端渲染,卻不止是服務端渲染。

服務端渲染到同構的這一路

後臺包辦

服務端渲染的方案早在後臺程式前後端包辦的時代上就有了,那時候使用JSP、PHP等動態語言將資料與頁面模版整合後輸出給瀏覽器,一步到位

手機QQweb React同構直出優化總結
22

這個時候,前端開發跟後端揉為一體,專案小的時候,前後端的開發和除錯還真可以稱為一步到位。但當專案龐大起來的時候,無論是修改某個樣式要起一個龐大服務的尷尬,還是前後端糅合的地帶變得越來越難以維護,都很難過。

前後分離

前後端分離後,服務端渲染的模式就開始被淡化了。這時候的服務端渲染比較尷尬,由於前後端的編碼語言不同,連頁面模板都不能複用,只能讓在前後端開發完成後,再將前端程式碼改為給後端使用的頁面模板,增大了工作量。最終也還是跟後臺包辦殊途同歸。

語言變通

Node 駕著祥雲騰空而來,谷歌 V8 引擎給力支援,眾前端拿著看家本領(JavaScript)開始涉足服務端,於是服務端渲染上又一步進階

手機QQweb React同構直出優化總結
33

由於前後端時候的相同的語言,所以前後端在程式碼的共用上達到了新的高度,頁面模版、node modules 都可以做成前後通用。同構的雛形,只是共用的程式碼還是有侷限。

前後同構

有了Node 後,前端便有了更多的想象空間。前端框架開始考慮相容服務端渲染,提供更方便的 API,前後端共用一套程式碼的方案,讓服務端渲染越來越便捷。當然,不只是 React 做了這件事,但 React 將這種思想推向高潮,同構的概念也開始廣為人傳。

手機QQweb React同構直出優化總結
55

關於 React 網上已有大多教程,可以檢視阮老師的react-demos。關於 React 上的資料流管理方案,現在最為火熱的 Redux 應該是首選,具體可以檢視另一篇文章 React 資料流管理架構之Redux,此篇就不再贅述,下面講講 React 同構的理論與在手Q家校群上的具體實踐總結。

React 同構

React 虛擬 Dom

React 的虛擬 Dom 以物件樹的形式儲存在記憶體中,並存在前後端兩種展露原型的形式

手機QQweb React同構直出優化總結
rendertype

  1. 客戶端上,虛擬 Dom 通過 ReactDOM 的 Render 方法渲染到頁面中
  2. 服務端上,React 提供的另外兩個方法:ReactDOMServer.renderToString 和 ReactDOMServer.renderToStaticMarkup 可將其渲染為 HTML 字串。

    React 同構的關鍵要素

完善的 Compponent 屬性及生命週期與客戶端的 render 時機是 React 同構的關鍵。
DOM 的一致性
在前後端渲染相同的 Compponent,將輸出一致的 Dom 結構。
不同的生命週期
在服務端上 Component 生命週期只會到 componentWillMount,客戶端則是完整的。
客戶端 render 時機
同構時,服務端結合資料將 Component 渲染成完整的 HTML 字串並將資料狀態返回給客戶端,客戶端會判斷是否可以直接使用或需要重新掛載。

以上便是 React 在同構/服務端渲染的提供的基礎條件。在實際專案應用中,還需要考慮其他邊角問題,例如伺服器端沒有 window 物件,需要做不同處理等。下面將通過在手Q家校群上的具體實踐,分享一些同構的 Tips 及優化成果

以手Q家校群 React 同構實踐為例

手Q家校群使用 React + Redux + Webpack 的架構

同構實踐 Tips

1. renderToString 和 renderToStaticMarkup

ReactDOMServer 提供 renderToString 和 renderToStaticMarkup 的方法,大多數情況使用 renderToString,這樣會為元件增加 checksum

手機QQweb React同構直出優化總結
checknum

React 在客戶端通過 checksum 判斷是否需要重新render
相同則不重新render,省略建立DOM和掛載DOM的過程,接著觸發 componentDidMount 等事件來處理服務端上的未盡事宜(事件繫結等),從而加快了互動時間;不同時,元件將客戶端上被重新掛載 render。

renderToStaticMarkup 則不會生成與 react 相關的data-*,也不存在 checksum,輸出的 html 如下

手機QQweb React同構直出優化總結
3333

在客戶端時元件會被重新掛載,客戶端重新掛載不生成 checknum( 也沒這個必要 ),所以該方法只當服務端上所渲染的元件在客戶端不需要時才使用

手機QQweb React同構直出優化總結
checknum

2. 服務端上的資料狀態與同步給客戶端

服務端上的產生的資料需要隨著頁面一同返回,客戶端使用該資料去 render,從而保持狀態一致。服務端上使用 renderToString 而在客戶端上依然重新掛載元件的情況大多是因為在返回 HTML 的時候沒有將服務端上的資料一同返回,或者是返回的資料格式不對導致,開發時可以留意 chrome 上的提示如

手機QQweb React同構直出優化總結
noti

3. 服務端需提前拉取資料,客戶端則在 componentDidMount 呼叫

平臺上的差異,服務端渲染只會執行到 compnentWillMount 上,所以為了達到同構的目的,可以把拉取資料的邏輯寫到 React Class 的靜態方法上,一方面服務端上可以通過直接操作靜態方法來提前拉取資料再根據資料生成 HTML,另一方面客戶端可以在 componentDidMount 時去呼叫該靜態方法拉取資料

4. 保持資料的確定性

這裡指影響元件 render 結果的資料,舉個例子,下面的元件由於在服務端與客戶端渲染上會因為元件上產生不同隨機數的原因而導致客戶端將重新渲染。

Class Wrapper extends Component {
  render() {
    return (<h1>{Math.random()}</h1>);
  }
};複製程式碼

可以將 Math.random() 封裝至Component 的 props 中,在服務端上生成隨機數並傳入到這個component中,從而保證隨機數在客戶端和服務端一致。如

Class Wrapper extends Component {
  render() {
    return (<h1>{this.props.randomNum}</h1>);
  }
};複製程式碼

服務端上傳入randomNum

let randomNum = Math.random()
var html = ReacDOMServer.renderToString(<Wrapper randomNum={randomNum} />);複製程式碼

5. 平臺區分

當前後端共用一套程式碼的時候,像前端特有的 Window 物件,Ajax 請求 在後端是無法使用上的,後端需要去掉這些前端特有的物件邏輯或使用對應的後端方案,如後端可以使用 http.request 替代 Ajax 請求,所以需要進行平臺區分,主要有以下幾種方式

1.程式碼使用前後端通用的模組,如 isomorphic-fetch
2.前後端通過webpack 配置 resolve.alias 對應不同的檔案,如
客戶端使用 /browser/request.js 來做 ajax 請求

resolve: {
    alias: {
        'request': path.join(pathConfig.src, '/browser/request'),
    }
}複製程式碼

服務端 webpack 上使用 /server/request.js 以 http.request 替代 ajax 請求

resolve: {
    alias: {
        'request': path.join(pathConfig.src, '/server/request'),
    }
}複製程式碼

3.使用 webpack.DefinePlugin 在構建時新增一個平臺區分的值,這種方式的在 webpack UglifyJsPlugin 編譯後,非當前平臺( 不可達程式碼 )的程式碼將會被去掉,不會增加檔案大小。如
在服務端的 webpack 加上下面配置

new webpack.DefinePlugin({
    "__ISOMORPHIC__": true
}),複製程式碼

在JS邏輯上做判斷

if(__ISOMORPHIC__){
    // do server thing
} else {
    // do browser thing
}複製程式碼

4.window 是瀏覽器上特有的物件,所以也可以用來做平臺區分

var isNode = typeof window === 'undefined';
if (isNode) {
    // do server thing
} else {
    // do browser thing
}複製程式碼

6. 只直出首屏頁面可視內容,其他在客戶端上延遲處理

這是為了減少服務端的負擔,也是加快首屏展示時間,如在手Q家校群列表中存在 “我釋出的” 和 “全部” 兩個 tab,內容都為作業列表,此次實踐在服務端上只處理首屏可視內容,即只輸出 “我釋出的” 的完整HTML,另外一個tab的內容在客戶端上通過 react 的 dom diff 機制來動態掛載,無頁面重新整理的感知。

手機QQweb React同構直出優化總結
default

7. componentWillReceiveProps 中,依賴資料變化的方法,需考慮在 componentDidMount 做相容

舉個例子,identity 預設為 UNKOWN,從後臺拉取到資料後,更新其值,從而觸發 setButton 方法

componentWillReceiveProps(nextProps) {
    if (nextProps.role.get('identity') !== UNKOWN &&
        nextProps.role.get('identity')  !== this.props.role.get('identity'))) {
        this.setButton();
    }
}複製程式碼

同構時,由於服務端上已做了第一次資料拉取,所以上面程式碼在客戶端上將由於 identity 已存在而導致永不執行 setButton 方法,解決方式可在 componentDidMount 做相容處理

componentDidMount() {
    // .. 判斷是否為同構 
    if (identity !== UNKOWN) {
        this.setButton(identity);
    }
}複製程式碼

8. redux在服務端上的使用方式 (redux)

下圖為其中一種形式,先進行資料請求,再將請求到的資料 dispatch 一個 action,通過在reducer將資料進行 redux 的 state 化。還有其他方式,如直接 dispatch 一個 action,在action裡面去做資料請求,後續是一樣的,不過這樣就要求請求資料的模組是 isomorphism 即前後端通用的。

手機QQweb React同構直出優化總結
default

9. 設計好 store state (redux)

設計好 store state 是使用 redux 的關鍵,而在服務端上,合理的扁平化 state 能在其被序列化時,減少 CPU 消耗

10. 兩個 action 在同個component中資料存在依賴關係時,考慮setState的非同步問題 (redux)

客戶端上,由於 react 中 setState 的非同步機制,所以在同個component中觸發多個action,會出現一種情況是:第一個 action 對 state 的改變還沒來得及更新component時,第二個action便開始執行,即第二個 action 將使用到未更新的值。
而在同構中,如果第一個 action (如下的 fetchData)是在服務端執行了,第二個 action 在客戶端執行時將使用到的是第一個 action 對 state 改變後的值,即更新後的值。這時,同構需要做相容處理。

fetchData() {
    this.props.setCourse(lastCourseId, lastCourseName);
}
render() {
    this.props.updateTab(TAB);
}複製程式碼

11. immutable 在同構上的姿勢 (immutable/redux)

手Q家校群上使用了 immutable 來保證資料的不可變,提高資料對比速度,而在同構時需要注意兩點
1.服務端上,從 store 中拿到的 state 為immutable物件,需轉成 string 再同HTML返回
2.客戶端上,從服務端注入到HTML上的 state 資料,需要將其轉成 immutable物件,再放到 configureStore 中,如

var __serverData__ = Immutable.fromJS(window.__serverData__);
var store = configureStore(__serverData__);複製程式碼

12. 使用 webpack 去做 ES6 語法相容 (webpack)

實際上,如果是一個單獨的服務的話,可以使用babel提供的方式來讓node環境相容好 E6

require("babel-register")({
    extensions: [".jsx"],
    presets: ['react']
});
require("babel-polyfill");複製程式碼

但如果是以同一個直出伺服器,多個專案的直出程式碼都放在這個服務上,那麼,還是建議使用 webpack 的方式去相容 ES6,減少 babel 對全域性環境的影響。使用 webpack 的話,在專案完成後,可將 es6 程式碼編譯成 es5 再放到真正的 server 上,這樣也可以減少動態編譯耗時。

13. 不使用 webpack 的 css in js 的方式

使用webpack時,預設是將css檔案以 css in js 的方式打包起來,這種情況將增加服務端執行耗時,通過將 css 外鏈,或在webpack打包成獨立的css檔案後再inline進去,可以減少服務端的處理耗時及負荷。

14. UglifyJsPlugin 在服務端編譯時慎用

上面提及使用webpack編譯後的程式碼放到真正的server上去跑,在前端釋出前一般會進行程式碼uglify,而後端實際上沒多大必要,在實際應用中發現,使用 UglifyJsPlugin 後執行服務端會報錯,需慎用。

15. 糾正 dirname 與 filename 的值 (webpack)

當服務端程式碼需要使用到 dirname 時,需在 webpack.config.js 配置 target 為 node,並在 node 中宣告filename和dirname為true,否則拿不到準確值,如在服務端程式碼上新增 console.log(dirname); 和 console.log(__filenam );
在服務端使用的 webpack 上指定 target 為 node,如下

target: 'node', 
node: {
    __filename: true,
    __dirname: true
}複製程式碼

經 webpack 編譯後輸出如下程式碼,可看出 dirname 和 filename 將正確輸出(注:需考慮生成的路徑是否能在不同系統上跑,如下圖是在window下,使用的是雙斜槓)

手機QQweb React同構直出優化總結
node

而不在webpack上配置時,dirname則為 / ,filename則為檔名,這是不正確的

手機QQweb React同構直出優化總結
target node

16.將 webpack 編譯後的檔案暴露出來 (webpack)

使用 webpack 將一個模組編譯後將形成一個立即執行函式,函式中返回物件。如果需要將編譯後的程式碼也作為一個模組供其他地方使用時,那麼需要重新將該模組暴露出去( 如當業務上的直出程式碼只是作為直出伺服器的其中一個任務時,那麼需要將編譯後的程式碼作為一個模組 exports 出去,即在編譯後程式碼前重新加上 module.exports =,從而直出服務將能夠使用到這個編譯後的模組程式碼 )。寫了一個 webpack 外掛來自動新增 module.exports,比較簡單,有興趣的歡迎使用 webpack-add-module-expors,效果如下

編譯前

手機QQweb React同構直出優化總結
222222222

編譯後

手機QQweb React同構直出優化總結
exports

使用 webpack-add-module-expors編譯後將帶上module.exports

手機QQweb React同構直出優化總結
3331

17. 去掉index.scss和瀏覽器專用模組(webpack)

當服務端上不想處理樣式模組或一些瀏覽器才需要的模組(如前端上報)時,需要在服務端上將其忽略。嘗試 webpack 自帶的 webpack.IgnorePlugin 外掛後出現一些奇奇怪怪的問題,重溫 如何開發一個 Webpack Loader ( 一 ) 時想起 webpack 在執行時會將原檔案經webpack loaders進行轉換,如 jsx 轉成 js等。所以想法是將在服務端上需要忽略的模組,在loader前執行前就將其忽略。寫了個 ignored-loader,可以將需要忽略的模組在 loader 執行前直接返回空,所以後續就不再做其他處理,簡單但也滿足現有需求。

優化成果

服務端上的耗時增加了,但整體上的首屏渲染完成時間大大減少

服務端上增加的耗時

服務端渲染方案將資料的拉取和模板的渲染從客戶端移到了服務端,由於服務端的環境以及資料拉取存在優勢(詳見 Node直出理論與實踐總結),所以在相比下,這塊耗時大大減少,但確實存在,這兩塊耗時是服務端渲染相比於客戶端渲染在服務端上多出來。所以本次也做了耗時的資料統計,如下圖

手機QQweb React同構直出優化總結
default

從統計的資料上看,服務端上資料拉取的時間約 61.75 ms,服務端render耗時為16.32 ms,這兩塊時間的和為 78 ms,這耗時還是比較大。所以此次在同構耗時在計算上包含了服務端資料拉取與模板渲染的時間

首屏渲染完成時間對比

服務端渲染時由於不需要等待 JS 載入和 資料請求(詳見 Node直出理論與實踐總結),在首屏展示時間耗時上將大大減少,此次在手Q家校群列表頁首屏渲染完成時間上,優化前平均耗時約1643.914 ms,而同構優化後平均耗時為 696.62 ms,有了 947ms 的優化,提升約 57.5% 的效能,秒開搓搓有餘!

手機QQweb React同構直出優化總結
default

手機QQweb React同構直出優化總結
default

優化前與優化後的頁面展示情況對比

1.優化前

手機QQweb React同構直出優化總結
predata

2.優化後(同構直出)

手機QQweb React同構直出優化總結
iso

可明顯看出同構直出後,白屏時間大大減少,可互動時間也得到了提前,產品體驗將變得更好。

總結

服務端渲染的方式能夠很好的減少首屏展示時間,React 同構的方式讓前後端模板、類庫、以及資料模型上共用,大大減少的服務端渲染的工作量。
由於在服務端上渲染模板,render 時過多的呼叫棧增加了服務端負載,也增加了 CPU 的壓力,所以可以只直出首屏可視區域,減少Component層級,減少呼叫棧,最後,做好容災方案,如真的服務端掛了( 雖然情況比較少 ),可以直接切換到普通的客戶端渲染方案,保證使用者體驗。

以上,便是近期在 React 同構上的實踐總結,如有不妥,懇請斧正,謝謝。

檢視更多文章 >>
github.com/joeyguo/blo…

相關文章