原文地址 https://github.com/joeyguo/blog/issues/9javascript
React 的實踐從去年在 PC QQ家校羣開始,因爲 PC 上的網絡及環境都至關好,因此在使用時可謂一路順風,偶爾遇到點小磕絆,也可以快速地填補磨平。而最近一段時間,咱們將手Q的家校羣重構成 React,除了原有框架上存在明顯問題的緣由外,選擇React也是由於它確實有足夠的吸引力以及優點,加之在PC家校羣上的實踐經驗,斟酌下便開始了,到如今已有頁面在線上正常跑起。css
因爲移動端上的網絡及環境迥異,性能誤差。因此在移動端上用 React 時,遇到了很多的坑點,也花了一些力氣在上面。關於在移動端上的優化,可看咱們團隊的另外一篇文章的 React移動端web極致優化html
一提到優化,不得不提直出
關於這塊能夠查看 Node直出理論與實踐總結,這篇文章較詳細的分析直出的概念及一步步優化,也結合了 手Q家校羣使用快速的數據直出方式來優化性能的總結與性能數據分析前端
一提到 React,不得不提同構
同構基於服務端渲染,卻不止是服務端渲染。java
服務端渲染的方案早在後臺程序先後端包辦的時代上就有了,那時候使用JSP、PHP等動態語言將數據與頁面模版整合後輸出給瀏覽器,一步到位node
這個時候,前端開發跟後端揉爲一體,項目小的時候,先後端的開發和調試還真能夠稱爲一步到位。但當項目龐大起來的時候,不管是修改某個樣式要起一個龐大服務的尷尬,仍是先後端糅合的地帶變得愈來愈難以維護,都很難過。react
先後端分離後,服務端渲染的模式就開始被淡化了。這時候的服務端渲染比較尷尬,因爲先後端的編碼語言不一樣,連頁面模板都不能複用,只能讓在先後端開發完成後,再將前端代碼改成給後端使用的頁面模板,增大了工做量。最終也仍是跟後臺包辦異曲同工。webpack
Node 駕着祥雲騰空而來,谷歌 V8 引擎給力支持,衆前端拿着看家本領(JavaScript)開始涉足服務端,因而服務端渲染上又一步進階git
因爲先後端時候的相同的語言,因此先後端在代碼的共用上達到了新的高度,頁面模版、node modules 均可以作成先後通用。同構的雛形,只是共用的代碼仍是有侷限。es6
有了Node 後,前端便有了更多的想象空間。前端框架開始考慮兼容服務端渲染,提供更方便的 API,先後端共用一套代碼的方案,讓服務端渲染愈來愈便捷。固然,不僅是 React 作了這件事,但 React 將這種思想推向高潮,同構的概念也開始廣爲人傳。
關於 React 網上已有大多教程,能夠查看阮老師的react-demos。關於 React 上的數據流管理方案,如今最爲火熱的 Redux 應該是首選,具體能夠查看另外一篇文章 React 數據流管理架構之Redux,此篇就再也不贅述,下面講講 React 同構的理論與在手Q家校羣上的具體實踐總結。
React 的虛擬 Dom 以對象樹的形式保存在內存中,並存在先後端兩種展露原型的形式
完善的 Compponent 屬性及生命週期與客戶端的 render 時機是 React 同構的關鍵。
DOM 的一致性
在先後端渲染相同的 Compponent,將輸出一致的 Dom 結構。
不一樣的生命週期
在服務端上 Component 生命週期只會到 componentWillMount,客戶端則是完整的。
客戶端 render 時機
同構時,服務端結合數據將 Component 渲染成完整的 HTML 字符串並將數據狀態返回給客戶端,客戶端會判斷是否能夠直接使用或須要從新掛載。
以上即是 React 在同構/服務端渲染的提供的基礎條件。在實際項目應用中,還須要考慮其餘邊角問題,例如服務器端沒有 window 對象,須要作不一樣處理等。下面將經過在手Q家校羣上的具體實踐,分享一些同構的 Tips 及優化成果
手Q家校羣使用 React + Redux + Webpack 的架構
ReactDOMServer 提供 renderToString 和 renderToStaticMarkup 的方法,大多數狀況使用 renderToString,這樣會爲組件增長 checksum
React 在客戶端經過 checksum 判斷是否須要從新render
相同則不從新render,省略建立DOM和掛載DOM的過程,接着觸發 componentDidMount 等事件來處理服務端上的未盡事宜(事件綁定等),從而加快了交互時間;不一樣時,組件將客戶端上被從新掛載 render。
renderToStaticMarkup 則不會生成與 react 相關的data-*,也不存在 checksum,輸出的 html 以下
在客戶端時組件會被從新掛載,客戶端從新掛載不生成 checknum( 也沒這個必要 ),因此該方法只當服務端上所渲染的組件在客戶端不須要時才使用
服務端上的產生的數據須要隨着頁面一同返回,客戶端使用該數據去 render,從而保持狀態一致。服務端上使用 renderToString 而在客戶端上依然從新掛載組件的狀況大可能是由於在返回 HTML 的時候沒有將服務端上的數據一同返回,或者是返回的數據格式不對致使,開發時能夠留意 chrome 上的提示如
平臺上的差別,服務端渲染只會執行到 compnentWillMount 上,因此爲了達到同構的目的,能夠把拉取數據的邏輯寫到 React Class 的靜態方法上,一方面服務端上能夠經過直接操做靜態方法來提早拉取數據再根據數據生成 HTML,另外一方面客戶端能夠在 componentDidMount 時去調用該靜態方法拉取數據
這裏指影響組件 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} />);複製代碼
當先後端共用一套代碼的時候,像前端特有的 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
}複製代碼
這是爲了減小服務端的負擔,也是加快首屏展現時間,如在手Q家校羣列表中存在 「我發佈的」 和 「所有」 兩個 tab,內容都爲做業列表,這次實踐在服務端上只處理首屏可視內容,即只輸出 「我發佈的」 的完整HTML,另一個tab的內容在客戶端上經過 react 的 dom diff 機制來動態掛載,無頁面刷新的感知。
舉個例子,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);
}
}複製代碼
下圖爲其中一種形式,先進行數據請求,再將請求到的數據 dispatch 一個 action,經過在reducer將數據進行 redux 的 state 化。還有其餘方式,如直接 dispatch 一個 action,在action裏面去作數據請求,後續是同樣的,不過這樣就要求請求數據的模塊是 isomorphism 即先後端通用的。
設計好 store state 是使用 redux 的關鍵,而在服務端上,合理的扁平化 state 能在其被序列化時,減小 CPU 消耗
客戶端上,因爲 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);
}複製代碼
手Q家校羣上使用了 immutable 來保證數據的不可變,提升數據對比速度,而在同構時須要注意兩點
1.服務端上,從 store 中拿到的 state 爲immutable對象,需轉成 string 再同HTML返回
2.客戶端上,從服務端注入到HTML上的 state 數據,須要將其轉成 immutable對象,再放到 configureStore 中,如
var __serverData__ = Immutable.fromJS(window.__serverData__);
var store = configureStore(__serverData__);複製代碼
實際上,若是是一個單獨的服務的話,可使用babel提供的方式來讓node環境兼容好 E6
require("babel-register")({
extensions: [".jsx"],
presets: ['react']
});
require("babel-polyfill");複製代碼
但若是是以同一個直出服務器,多個項目的直出代碼都放在這個服務上,那麼,仍是建議使用 webpack 的方式去兼容 ES6,減小 babel 對全局環境的影響。使用 webpack 的話,在項目完成後,可將 es6 代碼編譯成 es5 再放到真正的 server 上,這樣也能夠減小動態編譯耗時。
使用webpack時,默認是將css文件以 css in js 的方式打包起來,這種狀況將增長服務端運行耗時,經過將 css 外鏈,或在webpack打包成獨立的css文件後再inline進去,能夠減小服務端的處理耗時及負荷。
上面說起使用webpack編譯後的代碼放到真正的server上去跑,在前端發佈前通常會進行代碼uglify,然後端實際上沒多大必要,在實際應用中發現,使用 UglifyJsPlugin 後運行服務端會報錯,需慎用。
當服務端代碼須要使用到 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下,使用的是雙斜槓)
而不在webpack上配置時,dirname則爲 / ,filename則爲文件名,這是不正確的
使用 webpack 將一個模塊編譯後將造成一個當即執行函數,函數中返回對象。若是須要將編譯後的代碼也做爲一個模塊供其餘地方使用時,那麼須要從新將該模塊暴露出去( 如當業務上的直出代碼只是做爲直出服務器的其中一個任務時,那麼須要將編譯後的代碼做爲一個模塊 exports 出去,即在編譯後代碼前從新加上 module.exports =,從而直出服務將可以使用到這個編譯後的模塊代碼 )。寫了一個 webpack 插件來自動添加 module.exports,比較簡單,有興趣的歡迎使用 webpack-add-module-expors,效果以下
編譯前
編譯後
使用 webpack-add-module-expors編譯後將帶上module.exports
當服務端上不想處理樣式模塊或一些瀏覽器才須要的模塊(如前端上報)時,須要在服務端上將其忽略。嘗試 webpack 自帶的 webpack.IgnorePlugin 插件後出現一些奇奇怪怪的問題,重溫 如何開發一個 Webpack Loader ( 一 ) 時想起 webpack 在執行時會將原文件經webpack loaders進行轉換,如 jsx 轉成 js等。因此想法是將在服務端上須要忽略的模塊,在loader前執行前就將其忽略。寫了個 ignored-loader,能夠將須要忽略的模塊在 loader 執行前直接返回空,因此後續就再也不作其餘處理,簡單但也知足現有需求。
服務端上的耗時增長了,但總體上的首屏渲染完成時間大大減小
服務端渲染方案將數據的拉取和模板的渲染從客戶端移到了服務端,因爲服務端的環境以及數據拉取存在優點(詳見 Node直出理論與實踐總結),因此在相比下,這塊耗時大大減小,但確實存在,這兩塊耗時是服務端渲染相比於客戶端渲染在服務端上多出來。因此本次也作了耗時的數據統計,以下圖
從統計的數據上看,服務端上數據拉取的時間約 61.75 ms,服務端render耗時爲16.32 ms,這兩塊時間的和爲 78 ms,這耗時仍是比較大。因此這次在同構耗時在計算上包含了服務端數據拉取與模板渲染的時間
服務端渲染時因爲不須要等待 JS 加載和 數據請求(詳見 Node直出理論與實踐總結),在首屏展現時間耗時上將大大減小,這次在手Q家校羣列表頁首屏渲染完成時間上,優化前平均耗時約1643.914 ms,而同構優化後平均耗時爲 696.62 ms,有了 947ms 的優化,提高約 57.5% 的性能,秒開搓搓有餘!
1.優化前
2.優化後(同構直出)
可明顯看出同構直出後,白屏時間大大減小,可交互時間也獲得了提早,產品體驗將變得更好。
服務端渲染的方式可以很好的減小首屏展現時間,React 同構的方式讓先後端模板、類庫、以及數據模型上共用,大大減小的服務端渲染的工做量。
因爲在服務端上渲染模板,render 時過多的調用棧增長了服務端負載,也增長了 CPU 的壓力,因此能夠只直出首屏可視區域,減小Component層級,減小調用棧,最後,作好容災方案,如真的服務端掛了( 雖然狀況比較少 ),能夠直接切換到普通的客戶端渲染方案,保證用戶體驗。
以上,即是近期在 React 同構上的實踐總結,若有不妥,懇請斧正,謝謝。