React 與 Redux 在生產環境中的實踐總結
前段時間使用 React 與 Redux 重構了我們360netlab 的 開放數據平台。現將其中一些技術實踐經驗總結如下:
Universal 渲染
Universal (“同構”現在是公認的不准確的叫法)渲染是指在服務端與客戶端使用一套代碼進行渲染的技術。它所帶來的優勢如下:
- 與實現服務端渲染的傳統應用相比,Universal 渲染中的客戶端渲染減少了網絡請求(主要是模板和靜態資源的請求),提高了頁面間切換的速度,可以看到頁面之間的切換都是瞬間完成的。
- 與實現客戶端渲染的傳統 SPA(比如 Angular1.x 搭建的單頁面應用)相比,Universal 渲染的服務端渲染提升了首屏加載速度,無須等待龐大的 Javascript 腳本加載完成后進行渲染,因此也無須使用歡迎界面了。
- 與使用不同語言實現服務端渲染+客戶端渲染的應用(指的是后端語言為 Java、Python、PHP、前端語言為 JavaScript 的應用)相比,由於 Universal 渲染使用同一套代碼(前后端均為 JavaScript),因此至少減少了一半的代碼量。
Universal 渲染非常復雜,需要權衡的東西很多。不過這都是值得的,真正讓網站達到了快如鬼魅的速度!順便引用一句話:
According to research at Google, the difference of just 200 milliseconds in page load performance has an impact on user behavior.
根據 Google 的調查,在一個頁面的加載過程中,僅僅200毫秒的差異就可以影響用戶的行為。
延遲渲染
很多人抱怨 React 並沒有大家說的那么快,其實 React 只是便於優化性能,在沒有經驗的新手手中,React確實可能會很慢。但如果你對 React 非常了解,那么快如鬼魅便不是虛言。React 性能優化的方法很多,網上也有無數的文章對其進行介紹(選擇 React 的另一好處:活躍的社區),常見的方法主要是,使用不可變數據,快速進行變更檢查,以避免不必要的重新渲染。但我們還要介紹一種方法——延遲渲染。
延遲渲染類似於分頁或瀑布流,就是在一個有大量數據頁面中,先渲染一部分,等用戶滾動下去后,再進行渲染。
延遲渲染除了可以提升性能之外,還可以過濾掉不需要在服務端渲染的代碼(服務端可沒有re-render),以減少 Universal 的難度。
延遲渲染的方法很多,實現的輪子也很多,不再贅述了。
減小重量
在 React 與 Redux 的項目中,不可避免要引入一些第三方的庫,因此最終打包的腳本重量很容易達到 500-800kb 以上(gzip 壓縮前)。盡管首屏渲染速度不會受此影響(因為我們實現了 Universal 渲染中的服務端渲染,而瀏覽器又是自上而下解析的),但我們依然希望這個腳本的重量能夠更小。現將一些可行的辦法列舉如下:
改變庫的調用方式
寫過NPM的包的同學很清楚,一個包通常會有一個入口文件,我們將所有的模塊都放在這個入口文件中,以便其他開發者調用。但是如果僅僅只用了一個包中很少一個模塊,那么從入口文件調用就會導致增加了很多多余的模塊。為此,我們應該改變一些庫的調用方式,來避免這種情況,比如:
React Bootstrap 應該這么調用:
import IndexLink from 'react-router/lib/IndexLink'; import Navbar from 'react-bootstrap/lib/Navbar'; import Nav from 'react-bootstrap/lib/Nav'; import NavItem from 'react-bootstrap/lib/NavItem';
不應該這么調用:
import { IndexLink, Navbar, Nav, NavItem }from 'react-router';
React Router 應該這么調用:
import Route from 'react-router/lib/Route'; import IndexRoute from 'react-router/lib/IndexRoute';
不應該這么調用:
import { Route, IndexRoute } from 'react-router/lib/Route';
這種改進方式所帶來的效果非常明顯,至少能減少100kb的重量。
除此之外,Bootstrap的樣式文件也應該進行自定義,並去除一些不用的模塊。最終我們項目中所有的樣式文件合並后也只有22kb(gzip 壓縮后)。
代碼分割
使用 webpack 1.x 的 require.ensure
可以輕易實現代碼分割。分割的對象主要有倆個:
- 路由組件
- 只在個別頁面使用的大型第三方庫
路由組件的分割意義不大,因為我們寫的代碼幾乎很少(這也正是使用庫和框架的意義),即便按需加載,也不會帶來太多的提升。而且,原本打包成一個文件,可以進行代碼去重,但分割后就無法實現這個功能了(當然,如果你將公共庫提取出來了,這個問題就不存在了)。不過,如果你的項目非常龐大,也可以試試。
分割只在個別頁面使用的大型第三方庫是有意義的。比如,我們項目中一些頁面使用了很重的 Highcharts,但也有很多頁面不需要它,如果不對其進行代碼分割,就會連累不使用 Highcharts 的頁面。所以應該對只在個別頁面使用的大型第三方庫進行分割。方法如下:
將這些庫使用 require.ensure
封裝成 Promise:
export const loadHighcharts = () => new Promise((resolve)=> { require.ensure([], (require)=> { if (!window.Highcharts) { window.Highcharts = require('highcharts'); } resolve(window.Highcharts); }, 'highcharts'); });
然后,在組件中調用:
import React, { Component, PropTypes } from 'react'; import { loadHighcharts } from '../Map/load'; class Chart extends Component { componentDidMount() { loadHighcharts() .then(Highcharts => { ... this.chart = Highcharts.chart(this.container, config); }); } componentWillUnmount() { this.chart.destroy(); } render() { return ( <div ref={(c) => { this.container = c; }} style={{ height: 400, minWidth: 310, margin: '0 auto', textAlign:'center' }} > <i className="fa fa-spinner fa-spin fa-2x fa-fw"/> </div> ); } }
啟用 gzip 壓縮
啟用 gzip 壓縮的效果更加明顯,往往能減少 70% 的重量,最終我們項目的代碼重量一共只有130kb(包含了React Bootstrap、React Rouer、Highcharts在內的N多重量級第三方庫,另外還有所有的頁面代碼在里面)。這個方法比較常見,不再贅述。
減小重量的方法就先聊到這吧!
使用更少的樣板代碼發起異步action
很多人說 Redux 代碼多,開發效率低。其實 Redux 是可以靈活使用以及拓展的,經過充分定制的 Redux 其實寫不了幾行代碼。今天先介紹一個很好用的 Redux 拓展—— redux-amrc。它可以幫助我們使用更少的樣板代碼發起異步action。
一般情況下,為了清楚地記錄異步的過程,我們需要使用 三個 action 來記錄狀態變化。通常,我們的代碼會是這樣:
export const USER_REQUEST = 'USER_REQUEST' export const USER_SUCCESS = 'USER_SUCCESS' export const USER_FAILURE = 'USER_FAILURE'
使用了 redux-amrc 后,再也不用寫這么多action了,甚至連處理這些action的reducer都不用寫,你只需要把異步以Promise的形式傳給 redux-amrc就行了:
import { ASYNC } from 'redux-amrc'; /** * 這個action創建函數可以幫你自動發起 LOAD 和 LOAD_SUCCESS, * state.async.[key] 將會變為 'success' */ function success() { return { [ASYNC]: { key: 'key', promise: () => Promise.resolve('success') } } } /** * 這個action創建函數可以幫你自動發起 LOAD 和 LOAD_FAIL, * state.async.loadState.[key].error 將會變為 'fail' */ function fail() { return { [ASYNC]: { key: 'key', promise: () => Promise.reject('fail') } } }
更多的使用方法,請參考官網文檔。
與 傳統的DOM操作相結合
React 強調聲明式構建用戶界面,但在一些情況下,往往還是操作 DOM 來得快。事實上,在 React 中操作 DOM 也很方便。現將一些場景列舉如下:
使用Canvas
有時候我們需要使用 Canvas 畫個多邊形什么的,盡管已經有很多封裝 Canvas 的 React 庫了,但命令式的 Canvas 畫法也非常方便,可以直接在React 中使用:
比如,這么一個畫多邊形的方法:
/** * 使用canvas畫多邊形 * @param c:canvas context * @param n:多邊形的邊數 * @param r:多邊形的半徑 * @param color:線條顏色 */ function drawHexagon(c, n, r, color) { const context = c; const x = context.canvas.width / 2; const y = context.canvas.height / 2; const ang = (Math.PI * 2) / n; // 旋轉的角度 context.save();// 保存狀態 context.fillStyle = 'transparent';// 填充顏色 context.strokeStyle = color;// 填充線條顏色 context.lineWidth = 1;// 設置線寬 context.translate(x, y);// 原點移到x,y處,即要畫的多邊形中心 context.moveTo(0, -r);// 據中心r距離處畫點 context.beginPath(); context.rotate(ang / 2);// 旋轉 for (let i = 0; i < n; i += 1) { context.rotate(ang);// 旋轉 context.lineTo(0, -r);// 據中心r距離處連線 } context.closePath(); context.stroke(); context.fill(); context.restore();// 返回原始狀態 }
可以這么在 React 中用:
class Hexagon extends Component { static propTypes = { title: PropTypes.string.isRequired, content: PropTypes.array.isRequired }; componentDidMount() { const context = this.canvas.getContext('2d'); const sin60 = Math.sin(Math.PI / 3); drawHexagon(context, 6, 80 / sin60, '#D9DADB'); } render() { return ( <div> <canvas width="190px" height="170px" ref={(c) => { this.canvas = c; }} /> </div> ); } }
其實任何基於 DOM 的操作方法都可以這么玩!你可以把 componentDidMount()
當成 jQuery 的 $(document).ready()
方法。
實現圖表
無論你之前使用的是 D3 還是 Highcharts,幾乎都是基於 DOM 來完成圖表的繪制的,在 React 中,如果你不想使用一些封裝好的庫,也可以操作DOM,方法和在 React 組件中畫 Canvas 一樣:
- 渲染一個div
- 使用 ref 屬性獲取DOM
- 操作DOM
需要注意的是,如果你的庫不夠智能,那么你需要在 React 組件 卸載時 銷毀操作DOM 產生的對象,以防止內存泄露。
實現返回頂部
返回頂部這個功能也操作了 BOM 和 DOM,它與 React 的結合方法如下:
- 使用 React 渲染一個
a
標簽,並添加返回頂部的點擊事件 - 在組件掛載和卸載時分別添加和移除
window
的scroll
事件來顯示或隱藏返回頂部的a
標簽
import React, { Component } from 'react'; class ScrollLink extends Component { constructor() { super(); this.state = { linkStyle: { display: 'none' } }; } componentWillMount() { window.addEventListener('scroll', this.handleScroll); } componentWillUnmount() { window.removeEventListener('scroll', this.handleScroll); } handleScroll = () => { const top = window.pageYOffset || document.documentElement.scrollTop; this.setState({ linkStyle: { display: top > 100 ? 'block' : 'none' } }); }; scrollToTop = () => { const scrollTo = (element, to, duration) => { if (duration <= 0) return; const _element = element; const difference = to - _element.scrollTop; const perTick = (difference / duration) * 10; setTimeout(() => { _element.scrollTop += perTick; if (_element.scrollTop === to) return; scrollTo(_element, to, duration - 10); }, 10); }; scrollTo(document.body, 0, 100); }; render() { const styles = require('./index.scss'); return ( <a className={styles.scrollLink} onClick={this.scrollToTop} style={this