歪門邪道性能優(yōu)化:魔改三方庫源碼,性能提高幾十倍!
本文會(huì)分享一個(gè)React性能優(yōu)化的故事,這也是我在工作中真實(shí)遇到的故事,最終我們是通過魔改第三方庫源碼將它性能提高了幾十倍。這個(gè)第三方庫也是很有名的,在GitHub上有4.5k star,這就是:react-big-calendar[1]。
這個(gè)工作不是我一個(gè)人做的,而是我們團(tuán)隊(duì)幾個(gè)月前共同完成的,我覺得挺有意思,就將它復(fù)盤總結(jié)了一下,分享給大家。
在本文中你可以看到:
1.React常用性能分析工具的使用介紹2.性能問題的定位思路3.常見性能優(yōu)化的方式和效果:PureComponent,?shouldComponentUpdate,?Context,?按需渲染等等4.對(duì)于第三方庫的問題的解決思路
關(guān)于我工作中遇到的故事,我前面其實(shí)也分享過兩篇文章了:
1.
速度提高幾百倍,記一次數(shù)據(jù)結(jié)構(gòu)在實(shí)際工作中的運(yùn)用[2]
2.
使用mono-repo實(shí)現(xiàn)跨項(xiàng)目組件共享[3]
特別是速度提高幾百倍,記一次數(shù)據(jù)結(jié)構(gòu)在實(shí)際工作中的運(yùn)用,這篇文章在某平臺(tái)單篇閱讀都有三萬多,有些朋友也提出了質(zhì)疑。覺得我這篇文章里面提到的問題現(xiàn)實(shí)中不太可能遇到,里面的性能優(yōu)化更多是偏理論的,有點(diǎn)杞人憂天。這個(gè)觀點(diǎn)我基本是認(rèn)可的,我在那篇文章正文也提到過可能是個(gè)偽需求,但是技術(shù)問題本來很多就是理論上的,我們?cè)趌eetcode上刷題還是純理論呢,理論結(jié)合實(shí)際才能發(fā)揮其真正的價(jià)值,即使是杞人憂天,但是性能確實(shí)快上了那么一點(diǎn)點(diǎn),也給大家提供了另一個(gè)思路,我覺得也是值得的。
與之相對(duì)的,本文提到的問題完全不是杞人憂天了,而是實(shí)打?qū)嵉挠脩粜枨螅覀兘?jīng)過用戶調(diào)研,發(fā)現(xiàn)用戶確實(shí)有這么多數(shù)據(jù)量,需求上不可能再壓縮了,只能技術(shù)上優(yōu)化,這也是逼得我們?nèi)ジ牡谌綆煸创a的原因。
需求背景
老規(guī)矩,為了讓大家快速理解我們遇到的問題,我會(huì)簡(jiǎn)單講一下我們的需求背景。我還是在那家外企,不久前我們接到一個(gè)需求:做一個(gè)體育場(chǎng)館管理Web App。這里面有一個(gè)核心功能是場(chǎng)館日程的管理,有點(diǎn)類似于大家Outlook里面的Calendar。大家如果用過Outlook,應(yīng)該對(duì)他的Calendar有印象,基本上我們的會(huì)議及其他日程安排都可以很方便的放在里面。我們要做的這個(gè)也是類似的,體育場(chǎng)館的老板可以用這個(gè)日歷來管理他下面場(chǎng)地的預(yù)定。
假設(shè)你現(xiàn)在是一個(gè)羽毛球場(chǎng)的老板,來了個(gè)客戶說,嘿,老板,這周六場(chǎng)地有空嗎,我訂一個(gè)小時(shí)呢!場(chǎng)館每天都很多預(yù)定,你也不記得周六有沒有空,所以你打開我們的網(wǎng)站,看了下日歷:

你發(fā)現(xiàn)1月15號(hào),也就是星期五有兩個(gè)預(yù)定,周六還全是空閑的,于是給他說:你運(yùn)氣真好,周六目前還沒人預(yù)定,時(shí)段隨便挑!上面這個(gè)截圖是react-big-calendar的官方示例,我們也是選定用他來搭建我們自己的應(yīng)用。
真實(shí)場(chǎng)景
上面這個(gè)例子只是說明下我們的應(yīng)用場(chǎng)景,里面預(yù)定只有兩個(gè),場(chǎng)地只有一塊。但是我們真實(shí)的客戶可比這個(gè)大多了,根據(jù)我們的調(diào)研,我們較大的客戶有數(shù)百塊場(chǎng)地,每個(gè)場(chǎng)地每天的預(yù)定可能有二三十個(gè)。上面那個(gè)例子我們換個(gè)生意比較好的老板,假設(shè)這個(gè)老板有20塊羽毛球場(chǎng)地,每天客戶都很多,某天還是來了個(gè)客戶說,嘿,老板,這周六場(chǎng)地有空嗎,我訂一個(gè)小時(shí)呢!但是這個(gè)老板生意很好,他看到的日歷是這樣的:

本周場(chǎng)館1全滿!!如果老板想要為客戶找到一個(gè)有空的場(chǎng)地,他需要連續(xù)切換場(chǎng)館1,場(chǎng)館2。。。一直到場(chǎng)館20,手都點(diǎn)酸了。。。為了減少老板手的負(fù)擔(dān),我們的產(chǎn)品經(jīng)理提出一個(gè)需求,同時(shí)在頁面上顯示10個(gè)場(chǎng)館的日歷,好在react-big-calendar本身就是支持這個(gè)的,他把這個(gè)叫做resources[4]。
性能爆炸
看起來我們要的基本功能react-big-calendar都能提供,前途還是很美好的,直到我們將真實(shí)的數(shù)據(jù)渲染到頁面上。。。我們的預(yù)定不僅僅是展示,還需要支持一系列的操作,比如編輯,復(fù)制,剪切,粘貼,拖拽等等。當(dāng)然這一切操作的前提都是選中這個(gè)預(yù)定,下面這個(gè)截圖是我選中某個(gè)預(yù)定的耗時(shí):

僅僅是一個(gè)最簡(jiǎn)單的點(diǎn)擊事件,腳本執(zhí)行耗時(shí)6827ms,渲染耗時(shí)708ms,總計(jì)耗時(shí)7.5s左右,這TM!這玩意兒還想賣錢?送給我,我都不想用!
可能有朋友不知道這個(gè)性能怎么看,這其實(shí)是Chrome自帶的性能工具,基本步驟是:
1.打開Chrome調(diào)試工具,點(diǎn)到Performance一欄2.點(diǎn)擊左上角的小圓點(diǎn),開始錄制3.執(zhí)行你想要的操作,我這里就是點(diǎn)擊一個(gè)預(yù)定4.等你想要的結(jié)果出來,我這里就是點(diǎn)擊的預(yù)定顏色加深5.再點(diǎn)擊左上角的小圓點(diǎn),結(jié)束錄制就可以看到了
為了讓大家看得更清楚,我這里錄制了一個(gè)操作的動(dòng)圖,這個(gè)圖可以看到,點(diǎn)擊操作的響應(yīng)花了很長(zhǎng)時(shí)間,Chrome加載這個(gè)性能數(shù)據(jù)也花了很長(zhǎng)時(shí)間:
【動(dòng)圖太大,微信公眾號(hào)不讓發(fā),可以點(diǎn)擊“閱讀原文”去掘金看。】
測(cè)試數(shù)據(jù)量
上面僅僅一個(gè)點(diǎn)擊耗時(shí)就七八秒,是因?yàn)槲夜室庥昧撕艽髷?shù)據(jù)量嗎?不是!我的測(cè)試數(shù)據(jù)量是完全按照用戶真實(shí)場(chǎng)景計(jì)算的:同時(shí)顯示10個(gè)場(chǎng)館,每個(gè)場(chǎng)館每天20個(gè)預(yù)定,上面使用的是周視圖,也就是可以同時(shí)看到7天的數(shù)據(jù),那總共顯示的預(yù)定就是:
10 * 20 * 7 = 1400,總共1400個(gè)預(yù)定顯示在頁面上。
為了跟上面這個(gè)龜速點(diǎn)擊做個(gè)對(duì)比,我再放下優(yōu)化后的動(dòng)圖,讓大家對(duì)后面這個(gè)長(zhǎng)篇大論實(shí)現(xiàn)的效果先有個(gè)預(yù)期:
【動(dòng)圖太大,微信公眾號(hào)不讓發(fā),可以點(diǎn)擊“閱讀原文”去掘金看。】
定位問題
我們一般印象中,React不至于這么慢啊,如果慢了,大概率是寫代碼的人沒寫好!我們都知道React有個(gè)虛擬樹,當(dāng)一個(gè)狀態(tài)改變了,我們只需要更新與這個(gè)狀態(tài)相關(guān)的節(jié)點(diǎn)就行了,出現(xiàn)這種情況,是不是他干了其他不必要的更新與渲染呢?為了解決這個(gè)疑惑,我們安裝了React專用調(diào)試工具:React Developer Tools[5]。這是一個(gè)Chrome的插件,Chrome插件市場(chǎng)可以下載,安裝成功后,Chrome的調(diào)試工具下面會(huì)多兩個(gè)Tab頁:

在Components這個(gè)Tab下有個(gè)設(shè)置,打開這個(gè)設(shè)置可以看到你每次操作觸發(fā)哪些組件更新,我們就是從這里面發(fā)現(xiàn)了一點(diǎn)驚喜:

為了看清楚點(diǎn)擊事件觸發(fā)哪些更新,我們先減少數(shù)據(jù)量,只保留一兩個(gè)預(yù)定,然后打開這個(gè)設(shè)置看看:

哼,這有點(diǎn)意思。。。我只是點(diǎn)擊一個(gè)預(yù)定,你把整個(gè)日歷的所有組件都給我更新了!那整個(gè)日歷有多少組件呢?上面這個(gè)圖可以看出10:00 AM到10:30 AM之間是一個(gè)大格子,其實(shí)這個(gè)大格子中間還有條分割線,只是顏色較淡,看的不明顯,也就是說每15分鐘就是一個(gè)格子。這個(gè)15分鐘是可以配置的,你也可以設(shè)置為1分鐘,但是那樣格子更多,性能更差!我們是根據(jù)需求給用戶提供了15分鐘,30分鐘,1小時(shí)等三個(gè)選項(xiàng)。當(dāng)用戶選擇15分鐘的時(shí)候,渲染的格子最多,性能最差。
那如果一個(gè)格子是15分鐘,總共有多少格子呢?一天是24 * 60 = 1440分鐘,15分鐘一個(gè)格子,總共96個(gè)格子。我們周視圖最多展示7天,那就是7 * 96 = 672格子,最多可以展示10個(gè)場(chǎng)館,就是672 * 10 = 6720個(gè)格子,這還沒算日期和時(shí)間本身占據(jù)的組件,四舍五入一下姑且就算7000個(gè)格子吧。
我僅僅是點(diǎn)擊一下預(yù)定,你就把作為背景的7000個(gè)格子全部給我更新一遍,怪不得性能差!
再仔細(xì)看下上面這個(gè)動(dòng)圖,我點(diǎn)擊的是小的那個(gè)事件,當(dāng)我點(diǎn)擊他時(shí),注意大的那個(gè)事件也更新了,外面也有個(gè)藍(lán)框,不是很明顯,但是確實(shí)是更新了,在我后面調(diào)試打Log的時(shí)候也證實(shí)了這一點(diǎn)。所以在真實(shí)1400條數(shù)據(jù)下,被更新的還有另外1399個(gè)事件,這其實(shí)也是不必要的。
我這里提到的事件和前文提到的預(yù)定是一個(gè)東西,react-big-calendar里面將這個(gè)稱為event,也就是事件,對(duì)應(yīng)我們業(yè)務(wù)的意義就是預(yù)定。
插播一個(gè)廣告,我也參加了掘金年度打榜活動(dòng)[6],各位看官給我投個(gè)票唄~ 謝謝各位了!
為什么會(huì)這樣?
這個(gè)現(xiàn)象我好像似曾相識(shí),也是我們經(jīng)常會(huì)犯的一個(gè)性能上的問題:將一個(gè)狀態(tài)放到最頂層,然后一層一層往下傳,當(dāng)下面某個(gè)元素更新了這個(gè)狀態(tài),會(huì)導(dǎo)致根節(jié)點(diǎn)更新,從而觸發(fā)下面所有子節(jié)點(diǎn)的更新。這里說的更新并不一定要重新渲染DOM節(jié)點(diǎn),但是會(huì)運(yùn)行每個(gè)子節(jié)點(diǎn)的render函數(shù),然后根據(jù)render函數(shù)運(yùn)行結(jié)果來做diff,看看要不要更新這個(gè)DOM節(jié)點(diǎn)。React在這一步會(huì)幫我們省略不必要的DOM操作,但是render函數(shù)的運(yùn)行卻是必須的,而成千上萬次render函數(shù)的運(yùn)行也會(huì)消耗大量性能。
說到這個(gè)我想起以前看到過的一個(gè)資料,也是講這個(gè)問題的,他用了一個(gè)一萬行的列表來做例子,原文在這里:high-performance-redux[7]。下面這個(gè)例子來源于這篇文章:
function itemsReducer(state = initial_state, action) {switch (action.type) {case 'MARK':return state.map((item) =>action.id === item.id ?{...item, marked: !item.marked } :item);default:return state;}}class App extends Component {render() {const { items, markItem } = this.props;return ({items.map(item =>)});}};function mapStateToProps(state) {return state;}const markItem = (id) => ({type: 'MARK', id});export default connect(mapStateToProps,{markItem})(App);
上面這段代碼不復(fù)雜,就是一個(gè)App,接收一個(gè)items參數(shù),然后將這個(gè)參數(shù)全部渲染成Item組件,然后你可以點(diǎn)擊單個(gè)Item來改變他的選中狀態(tài),運(yùn)行效果如下:

這段代碼所有數(shù)據(jù)都在items里面,這個(gè)參數(shù)從頂層App傳進(jìn)去,當(dāng)點(diǎn)擊Item的時(shí)候改變items數(shù)據(jù),從而更新整個(gè)列表。這個(gè)運(yùn)行結(jié)果跟我們上面的Calendar有類似的問題,當(dāng)單條Item狀態(tài)改變的時(shí)候,其他沒有涉及的Item也會(huì)更新。原因也是一樣的:頂層的參數(shù)items改變了。
說實(shí)話,類似的寫法我見過很多,即使不是從App傳入,也會(huì)從其他大的組件節(jié)點(diǎn)傳入,從而引起類似的問題。當(dāng)數(shù)據(jù)量少的時(shí)候,這個(gè)問題不明顯,很多時(shí)候都被忽略了,像上面這個(gè)圖,即使一萬條數(shù)據(jù),因?yàn)槊總€(gè)Item都很簡(jiǎn)單,所以運(yùn)行一萬次render你也不會(huì)明顯感知出來,在控制臺(tái)看也就一百多毫秒。但是我們面臨的Calendar就復(fù)雜多了,每個(gè)子節(jié)點(diǎn)的運(yùn)算邏輯都更復(fù)雜,最終將我們的響應(yīng)速度拖累到了七八秒上。
優(yōu)化方案
還是先說這個(gè)一萬條的列表,原作者除了提出問題外,也提出了解決方案:頂層App只傳id,Item渲染的數(shù)據(jù)自己連接redux store獲取。下面這段代碼同樣來自這篇文章:
// index.jsfunction items(state = initial_state, action) {switch (action.type) {case 'MARK':const item = state[action.id];return {...state,[action.id]: {...item, marked: !item.marked}};default:return state;}}function ids(state = initial_ids, action) {return state;}function itemsReducer(state = {}, action) {return {// 注意這里,數(shù)據(jù)多了一個(gè)idsids: ids(state.ids, action),items: items(state.items, action),}}const store = createStore(itemsReducer);export default class NaiveList extends Component {render() {return ();}}
// app.jsclass App extends Component {static rerenderViz = true;render() {// App組件只使用ids來渲染列表,不關(guān)心具體的數(shù)據(jù)const { ids } = this.props;return ({ids.map(id => {return- ;
})});}};function mapStateToProps(state) {return {ids: state.ids};}export default connect(mapStateToProps)(App);
// Item.js// Item組件自己去連接Redux獲取數(shù)據(jù)class Item extends Component {constructor() {super();this.onClick = this.onClick.bind(this);}onClick() {this.props.markItem(this.props.id);}render() {const {id, marked} = this.props.item;const bgColor = marked ? '#ECF0F1' : '#fff';return (onClick={this.onClick}>{id});}}function mapStateToProps(_, initialProps) {const { id } = initialProps;return (state) => {const { items } = state;return {item: items[id],};}}const markItem = (id) => ({type: 'MARK', id});export default connect(mapStateToProps, {markItem})(Item);
這段代碼的優(yōu)化主要在這幾個(gè)地方:
1.將數(shù)據(jù)從單純的items拆分成了ids和items。2.頂層組件App使用ids來渲染列表,ids里面只有id,所以只要不是增加和刪除,僅僅單條數(shù)據(jù)的狀態(tài)變化,ids并不需要變化,所以App不會(huì)更新。3.Item組件自己去連接自己需要的數(shù)據(jù),當(dāng)自己關(guān)心的數(shù)據(jù)變化時(shí)才更新,其他組件的數(shù)據(jù)變化并不會(huì)觸發(fā)更新。
拆解第三方庫源碼
上面通過使用調(diào)試工具我看到了一個(gè)熟悉的現(xiàn)象,并猜到了他慢的原因,但是目前僅僅是猜測(cè),具體是不是這個(gè)原因還要看看他的源碼才能確認(rèn)。好在我在看他的源碼前先去看了下他的文檔[8],然后發(fā)現(xiàn)了這個(gè):

react-big-calendar接收兩個(gè)參數(shù)onSelectEvent和selected,selected表示當(dāng)前被選中的事件(預(yù)定),onSelectEvent可以用來改變selected的值。也就是說當(dāng)我們選中某個(gè)預(yù)定的時(shí)候,會(huì)改變selected的值,由于這個(gè)參數(shù)是從頂層往下傳的,所以他會(huì)引起下面所有子節(jié)點(diǎn)的更新,在我們這里就是差不多7000個(gè)背景格子 + 1399個(gè)其他事件,這樣就導(dǎo)致不需要更新的組件更新了。
頂層selected換成Context?
react-big-calendar在頂層設(shè)計(jì)selected這樣一個(gè)參數(shù)是可以理解的,因?yàn)槭褂谜呖梢酝ㄟ^修改這個(gè)值來控制選中的事件。這樣選中一個(gè)事件就有了兩個(gè)途徑:
1.用戶通過點(diǎn)擊某個(gè)事件來改變selected的值2.開發(fā)者可以在外部直接修改selected的值來選中某個(gè)事件
有了前面一萬條數(shù)據(jù)列表優(yōu)化的經(jīng)驗(yàn),我們知道對(duì)于這種問題的處理辦法了:使用selected的組件自己去連接Redux獲取值,而不是從頂部傳入。可惜,react-big-calendar并沒有使用Redux,也沒有使用其他任何狀態(tài)管理庫。如果他使用Redux,我們還可以考慮添加一個(gè)action來給外部修改selected,可惜他沒有。沒有Redux就玩不轉(zhuǎn)了嗎?當(dāng)然不是!React其實(shí)自帶一個(gè)全局狀態(tài)共享的功能,那就是Context。React Context API官方有詳細(xì)介紹[9],我之前的一篇文章也介紹過他的基本使用方法[10],這里不再講述他的基本用法,我這里想提的是他的另一個(gè)特性:使用Context Provider包裹時(shí),如果你傳入的value變了,會(huì)運(yùn)行下面所有節(jié)點(diǎn)的render函數(shù),這跟前面提到的普通props是一樣的。但是,如果Provider下面的兒子節(jié)點(diǎn)是PureComponent,可以不運(yùn)行兒子節(jié)點(diǎn)的render函數(shù),而直接運(yùn)行使用這個(gè)value的孫子節(jié)點(diǎn)。
什么意思呢,下面我將我們面臨的問題簡(jiǎn)化來說明下。假設(shè)我們只有三層,第一層是頂層容器Calendar,第二層是背景的空白格子(兒子),第三層是真正需要使用selected的事件(孫子):

示例代碼如下:
// SelectContext.js// 一個(gè)簡(jiǎn)單的Contextimport React from 'react'const SelectContext = React.createContext()export default SelectContext;
// Calendar.js// 使用Context Provider包裹,接收參數(shù)selected,渲染背景Backgroundimport SelectContext from './SelectContext';class Calendar extends Component {constructor(...args) {super(...args)this.state = {selected: null};this.setSelected = this.setSelected.bind(this);}setSelected(selected) {this.setState({ selected })}componentDidMount() {const { selected } = this.props;this.setSelected(selected);}render() {const { selected } = this.state;const value = {selected,setSelected: this.setSelected}return ()}}
// Background.js// 繼承自PureComponent,渲染背景格子和事件Eventclass Background extends PureComponent {render() {const { events } = this.props;return (這里面是7000個(gè)背景格子下面是渲染1400個(gè)事件{events.map(event =>)} )}}
// Event.js// 從Context中取selected來決定自己的渲染樣式import SelectContext from './SelectContext';class Event extends Component {render() {const { selected, setSelected } = this.context;const { event } = this.props;return (setSelected(event)}>)}}Event.contextType = SelectContext; // 連接Context
什么是PureComponent?
我們知道如果我們想阻止一個(gè)組件的render函數(shù)運(yùn)行,我們可以在shouldComponentUpdate返回false,當(dāng)新的props相對(duì)于老的props來說沒有變化時(shí),其實(shí)就不需要運(yùn)行render,shouldComponentUpdate就可以這樣寫:
shouldComponentUpdate(nextProps) {const fields = Object.keys(this.props)const fieldsLength = fields.lengthlet flag = falsefor (let i = 0; i < fieldsLength; i = i + 1) {const field = fields[i]if (this.props[field] !== nextProps[field]) {flag = truebreak}}return flag}
這段代碼就是將新的nextProps與老的props一一進(jìn)行對(duì)比,如果一樣就返回false,不需要運(yùn)行render。而PureComponent其實(shí)就是React官方幫我們實(shí)現(xiàn)了這樣一個(gè)shouldComponentUpdate。所以我們上面的Background組件繼承自PureComponent,就自帶了這么一個(gè)優(yōu)化。如果Background本身的參數(shù)沒有變化,他就不會(huì)更新,而Event因?yàn)樽约哼B接了SelectContext,所以當(dāng)SelectContext的值變化的時(shí)候,Event會(huì)更新。這就實(shí)現(xiàn)了我前面說的如果Provider下面的兒子節(jié)點(diǎn)是PureComponent,可以不運(yùn)行兒子節(jié)點(diǎn)的render函數(shù),而直接運(yùn)行使用這個(gè)value的孫子節(jié)點(diǎn)。
PureComponent不起作用
理想是美好的,現(xiàn)實(shí)是骨感的。。。理論上來說,如果我將中間兒子這層改成了PureComponent,背景上7000個(gè)格子就不應(yīng)該更新了,性能應(yīng)該大幅提高才對(duì)。但是我測(cè)試后發(fā)現(xiàn)并沒有什么用,這7000個(gè)格子還是更新了,什么鬼?其實(shí)這是PureComponent本身的一個(gè)問題:只進(jìn)行淺比較。注意this.props[field] !== nextProps[field],如果this.props[field]是個(gè)引用對(duì)象呢,比如對(duì)象,數(shù)組之類的?因?yàn)樗菧\比較,所以即使前后屬性內(nèi)容沒變,但是引用地址變了,這兩個(gè)就不一樣了,就會(huì)導(dǎo)致組件的更新!
而在react-big-calendar里面大量存在這種計(jì)算后返回新的對(duì)象的操作,比如他在頂層Calendar里面有這種操作:

代碼地址:https://github.com/jquense/react-big-calendar/blob/master/src/Calendar.js#L790
這行代碼的意思是每次props改變都去重新計(jì)算狀態(tài)state,而他的計(jì)算代碼是這樣的:

代碼地址:https://github.com/jquense/react-big-calendar/blob/master/src/Calendar.js#L794
注意他的返回值是一個(gè)新的對(duì)象,而且這個(gè)對(duì)象里面的屬性,比如localizer的計(jì)算方法mergeWithDefaults也是這樣,每次都返回新的對(duì)象:

代碼地址:https://github.com/jquense/react-big-calendar/blob/master/src/localizer.js#L39
這樣會(huì)導(dǎo)致中間兒子節(jié)點(diǎn)每次接受到的props雖然內(nèi)容是一樣的,但是因?yàn)槭且粋€(gè)新對(duì)象,即使使用了PureComponent,其運(yùn)行結(jié)果也是需要更新。這種操作在他的源碼中大量存在,其實(shí)從功能角度來說,這樣寫是可以理解的,因?yàn)槲矣袝r(shí)候也會(huì)這么干。。。有時(shí)候某個(gè)屬性更新了,不太確定要不要更新下面的組件,干脆直接返回一個(gè)新對(duì)象觸發(fā)更新,省事是省事了,但是面對(duì)我們這種近萬個(gè)組件的時(shí)候性能就崩了。。。
歪門邪道shouldComponentUpdate
如果只有一兩個(gè)屬性是這樣返回新對(duì)象,我還可以考慮給他重構(gòu)下,但是調(diào)試了一下發(fā)現(xiàn)有大量的屬性都是這樣,咱也不是他作者,也不知道會(huì)不會(huì)改壞功能,沒敢亂動(dòng)。但是不動(dòng)性能也繃不住啊,想來想去,還是在兒子的shouldComponentUpdate上動(dòng)點(diǎn)手腳吧。簡(jiǎn)單的this.props[field] !== nextProps[field]判斷肯定是不行的,因?yàn)橐玫刂纷兝玻撬麅?nèi)容其實(shí)是沒變,那我們就判斷他的內(nèi)容吧。兩個(gè)對(duì)象的深度比較需要使用遞歸,也可以參考React diff算法來進(jìn)行性能優(yōu)化,但是無論你怎么優(yōu)化這個(gè)算法,性能最差的時(shí)候都是兩個(gè)對(duì)象一樣的時(shí)候,因?yàn)樗麄兪且粯拥模阈枰闅v到最深處才能肯定他們是一樣的,如果對(duì)象很深,這種遞歸算法不見得會(huì)比運(yùn)行一遍render快,而我們面臨的大多數(shù)情況都是這種性能最差的情況。所以遞歸對(duì)比不太靠譜,其實(shí)如果你對(duì)這些數(shù)據(jù)心里有數(shù),沒有循環(huán)引用什么的,你可以考慮直接將兩個(gè)對(duì)象轉(zhuǎn)化為字符串來進(jìn)行對(duì)比,也就是
JSON.stringify(this.props[field]) !== JSON.stringify(nextProps[field])注意,這種方式只適用于你對(duì)props數(shù)據(jù)了解,沒有循環(huán)引用,沒有變化的Symbol,函數(shù)之類的屬性,因?yàn)镴SON.stringify執(zhí)行時(shí)會(huì)丟掉Symbol和函數(shù),所以我說他是歪門邪道性能優(yōu)化。
將這個(gè)轉(zhuǎn)化為字符串比較的shouldComponentUpdate加到背景格子的組件上,性能得到了明顯增強(qiáng),點(diǎn)擊相應(yīng)速度從7.5秒下降到了5.3秒左右。

按需渲染
上面我們用shouldComponentUpdate阻止了7000個(gè)背景格子的更新,響應(yīng)時(shí)間下降了兩秒多,但是還是需要5秒多時(shí)間,這也很難接受,還需要進(jìn)一步優(yōu)化。按照我們之前說的如果還能阻止另外1399個(gè)事件的更新那就更好了,但是經(jīng)過對(duì)他數(shù)據(jù)結(jié)構(gòu)的分析,我們發(fā)現(xiàn)他的數(shù)據(jù)結(jié)構(gòu)跟我們前面舉的列表例子還不一樣。我們列表的例子所有數(shù)據(jù)都在items里面,是否選中是item的一個(gè)屬性,而react-big-calendar的數(shù)據(jù)結(jié)構(gòu)里面event和selectedEvent是兩個(gè)不同的屬性,每個(gè)事件通過判斷自己的event是否等于selectedEvent來判斷自己是否被選中。這造成的結(jié)果就是每次我們選中一個(gè)事件,selectedEvent的值都會(huì)變化,每個(gè)事件的屬性都會(huì)變化,也就是會(huì)更新,運(yùn)行render函數(shù)。如果不改這種數(shù)據(jù)結(jié)構(gòu),是阻止不了另外1399個(gè)事件更新的。但是改這個(gè)數(shù)據(jù)結(jié)構(gòu)改動(dòng)太大,對(duì)于一個(gè)第三方庫,我們又不想動(dòng)這么多,怎么辦呢?
這條路走不通了,我們完全可以換一個(gè)思路,背景7000個(gè)格子,再加上1400個(gè)事件,用戶屏幕有那么大嗎,看得完嗎?肯定是看不完的,既然看不完,那我們只渲染他能看到部分不就可以了!按照這個(gè)思路,我們找到了一個(gè)庫:react-visibility-sensor[11]。這個(gè)庫使用方法也很簡(jiǎn)單:
function MyComponent (props) {return ({({isVisible}) =>I am {isVisible ? 'visible' : 'invisible'}});}
結(jié)合我們前面說的,我們可以將VisibilitySensor套在Background上面:
class Background extends PureComponent {render() {return ({({isVisible}) =>})}}
然后Event組件如果發(fā)現(xiàn)自己處于不可見狀態(tài),就不用渲染了,只有當(dāng)自己可見時(shí)才渲染:
class Event extends Component {render() {const { selected } = this.context;const { isVisible, event } = this.props;return ({ isVisible ? (復(fù)雜內(nèi)容) : null})}}Event.contextType = SelectContext;
按照這個(gè)思路我們又改了一下,發(fā)現(xiàn)性能又提升了,整體時(shí)間下降到了大概4.1秒:

仔細(xì)看上圖,我們發(fā)現(xiàn)渲染事件Rendering時(shí)間從1秒左右下降到了43毫秒,快了二十幾倍,這得益于渲染內(nèi)容的減少,但是Scripting時(shí)間,也就是腳本執(zhí)行時(shí)間仍然高達(dá)4.1秒,還需要進(jìn)一步優(yōu)化。
砍掉mousedown事件
渲染這塊已經(jīng)沒有太多辦法可以用了,只能看看Scripting了,我們發(fā)現(xiàn)性能圖上鼠標(biāo)事件有點(diǎn)刺眼:

一次點(diǎn)擊同時(shí)觸發(fā)了三個(gè)點(diǎn)擊事件:mousedown,mouseup,click。如果我們能干掉mousedown,mouseup是不是時(shí)間又可以省一半,先去看看他注冊(cè)這兩個(gè)事件時(shí)干什么的吧。可以直接在代碼里面全局搜mousedown,最終發(fā)現(xiàn)都是在Selection.js[12],通過對(duì)這個(gè)類代碼的閱讀,發(fā)現(xiàn)他是個(gè)典型的觀察者模式,然后再搜new Selection找到使用的地方,發(fā)現(xiàn)mousedown,mouseup主要是用來實(shí)現(xiàn)事件的拖拽功能的,mousedown標(biāo)記拖拽開始,mouseup標(biāo)記拖拽結(jié)束。如果我把它去掉,拖拽功能就沒有了。經(jīng)過跟產(chǎn)品經(jīng)理溝通,我們后面是需要拖拽的,所以這個(gè)不能刪。
事情進(jìn)行到這里,我也沒有更多辦法了,但是響應(yīng)時(shí)間還是有4秒,真是讓人頭大

反正沒啥好辦法了,我就隨便點(diǎn)著玩,突然,我發(fā)現(xiàn)mousedown的調(diào)用棧好像有點(diǎn)問題:

這個(gè)調(diào)用棧我用數(shù)字分成了三塊:
1.這里面有很多熟悉的函數(shù)名啊,像啥performUnitOfWork,beginWork,這不都是我在React Fiber這篇文章中提過的嗎?[13]所以這些是React自己內(nèi)部的函數(shù)調(diào)用2.render函數(shù),這是某個(gè)組件的渲染函數(shù)3.這個(gè)render里面又調(diào)用了renderEvents函數(shù),看起來是用來渲染事件列表的,主要的時(shí)間都耗在這里了
mousedown監(jiān)聽本身我是干不掉了,但是里面的執(zhí)行是不是可以優(yōu)化呢?renderEvents已經(jīng)是庫自己寫的代碼了,所以可以直接全局搜,看看在哪里執(zhí)行的。最終發(fā)現(xiàn)是在TimeGrid.js[14]的render函數(shù)被執(zhí)行了,其實(shí)這個(gè)是不需要執(zhí)行的,我們直接把前面歪門邪道的shouldComponentUpdate復(fù)制過來就可以阻止他的執(zhí)行。然后再看下性能數(shù)據(jù)呢:

我們發(fā)現(xiàn)Scripting下降到了3.2秒左右,比之前減少約800毫秒,而mousedown的時(shí)間也從之前的幾百毫秒下降到了50毫秒,在圖上幾乎都看不到了,mouseup事件也不怎么看得到了,又算進(jìn)了一步吧~
忍痛閹割功能
到目前為止,我們的性能優(yōu)化都沒有閹割功能,響應(yīng)速度從7.5秒下降到了3秒多一點(diǎn),優(yōu)化差不多一倍。但是,目前這速度還是要三秒多,別說作為一個(gè)工程師了,作為一個(gè)用戶我都忍不了。咋辦呢?我們是真的有點(diǎn)黔驢技窮了。。。
看看上面那個(gè)性能圖,主要消耗時(shí)間的有兩個(gè),一個(gè)是click事件,還有個(gè)timer。timer到現(xiàn)在我還不知道他哪里來的,但是click事件我們是知道的,就是用戶點(diǎn)擊某個(gè)事件后,更改SelectContext的selected屬性,然后selected屬性從頂層節(jié)點(diǎn)傳入觸發(fā)下面組件的更新,中間兒子節(jié)點(diǎn)通過shouldComponentUpdate跳過更新,孫子節(jié)點(diǎn)直接連接SelectContext獲取selected屬性更新自己的狀態(tài)。這個(gè)流程是我們前面優(yōu)化過的,但是,等等,這個(gè)貌似還有點(diǎn)問題。
在我們的場(chǎng)景中,中間兒子節(jié)點(diǎn)其實(shí)包含了高達(dá)7000個(gè)背景格子,雖然我們通過shouldComponentUpdate跳過了render的執(zhí)行,但是7000個(gè)shouldComponentUpdate本省執(zhí)行也是需要時(shí)間的啊!有沒有辦法連shouldComponentUpdate的執(zhí)行也跳過呢?這貌似是個(gè)新的思路,但是經(jīng)過我們的討論,發(fā)現(xiàn)沒辦法在保持功能的情況下做到,但是可以適度閹割一個(gè)功能就可以做到,那閹割的功能是哪個(gè)呢?那就是暴露給外部的受控selected屬性!
前面我們提到過選中一個(gè)事件有兩個(gè)途徑:
1.用戶通過點(diǎn)擊某個(gè)事件來改變selected的值2.開發(fā)者可以在外部直接修改selected的值來選中某個(gè)事件
之所以selected要放在頂層組件上就是為了實(shí)現(xiàn)第二個(gè)功能,讓外部開發(fā)者可以通過這個(gè)受控的selected屬性來改變選中的事件。但是經(jīng)過我們?cè)u(píng)估,外部修改selected這個(gè)并不是我們的需求,我們的需求都是用戶點(diǎn)擊來選中,也就是說外部修改selected這個(gè)功能我們可以不要。
如果不要這個(gè)功能那就有得玩了,selected完全不用放在頂層了,只需要放在事件外層的容器上就行,這樣,改變selected值只會(huì)觸發(fā)事件的更新,啥背景格子的更新壓根就不會(huì)觸發(fā),那怎么改呢?在我們前面的Calendar -- Background -- Event模型上再加一層EventContainer,變成Calendar -- Background -- EventContainer -- Event。SelectContext.Provider也不用包裹Calendar了,直接包裹EventContainer就行。代碼大概是這個(gè)樣子:
// Calendar.js// Calendar簡(jiǎn)單了,不用接受selected參數(shù),也不用SelectContext.Provider包裹了class Calendar extends Component {render() {return ()}}
// Background.js// Background要不要使用shouldComponentUpdate阻止更新可以看看還有沒有其他參數(shù)變化,因?yàn)閟elected已經(jīng)從頂層拿掉了// 改變selected本來就不會(huì)觸發(fā)Background更新// Background不再渲染單個(gè)事件,而是渲染EventContainerclass Background extends PureComponent {render() {const { events } = this.props;return (這里面是7000個(gè)背景格子下面是渲染1400個(gè)事件)}}
// EventContainer.js// EventContainer需要SelectContext.Provider包裹// 代碼類似之前的Calendarimport SelectContext from './SelectContext';class EventContainer extends Component {constructor(...args) {super(...args)this.state = {selected: null};this.setSelected = this.setSelected.bind(this);}setSelected(selected) {this.setState({ selected })}render() {const { selected } = this.state;const { events } = this.props;const value = {selected,setSelected: this.setSelected}return ({events.map(event =>)} )}}
// Event.js// Event跟之前是一樣的,從Context中取selected來決定自己的渲染樣式import SelectContext from './SelectContext';class Event extends Component {render() {const { selected, setSelected } = this.context;const { event } = this.props;return (setSelected(event)}>)}}Event.contextType = SelectContext; // 連接Context
這種結(jié)構(gòu)最大的變化就是當(dāng)selected變化的時(shí)候,更新的節(jié)點(diǎn)是EventContainer,而不是頂層Calendar,這樣就不會(huì)觸發(fā)Calendar下其他節(jié)點(diǎn)的更新。缺點(diǎn)就是Calendar無法從外部接收selected了。
需要注意一點(diǎn)是,如果像我們這樣EventContainer下面直接渲染Event列表,selected不用Context也可以,可以直接作為EventContainer的state。但是如果EventContainer和Event中間還有層級(jí),需要穿透?jìng)鬟f,仍然需要Context,中間層級(jí)和以前的類似,使用shouldComponentUpdate阻止更新。
還有一點(diǎn),因?yàn)?code style="box-sizing: border-box;padding: 3px 5px;color: rgb(255, 53, 2);line-height: 1.5;font-family: "Operator Mono", Consolas, Monaco, Menlo, monospace;font-size: 14.4px;background: rgb(248, 245, 236);border-radius: 2px;">selected不在頂層了,所以selected更新也不會(huì)觸發(fā)中間Background更新了,所以Background上的shouldComponentUpdate也可以刪掉了。
我們這樣優(yōu)化后,性能又提升了:

現(xiàn)在Scripting時(shí)間直接從3.2秒降到了800毫秒,其中click事件只有163毫秒,現(xiàn)在從我使用來看,卡頓已經(jīng)不明顯了,直接錄個(gè)動(dòng)圖來對(duì)比下吧:
【動(dòng)圖太大,微信公眾號(hào)不讓發(fā),可以點(diǎn)擊“閱讀原文”去掘金看。】
上面這個(gè)動(dòng)圖已經(jīng)基本看不出卡頓了,但是我們性能圖上為啥還有800毫秒呢,而且有一個(gè)很長(zhǎng)的Timer Fired。經(jīng)過我們的仔細(xì)排查,發(fā)現(xiàn)這其實(shí)是個(gè)烏龍,Timer Fired在我一開始錄制性能就出現(xiàn)了,那時(shí)候我還在切換頁面,還沒來得及點(diǎn)擊呢,如果我們點(diǎn)進(jìn)去會(huì)發(fā)現(xiàn)他其實(shí)是按需渲染引入的react-visibility-sensor的一個(gè)檢查元素可見性的定時(shí)任務(wù),并不是我們點(diǎn)擊事件的響應(yīng)時(shí)間。把這塊去掉,我們點(diǎn)擊事件的響應(yīng)時(shí)間其實(shí)不到200毫秒。
從7秒多優(yōu)化到不到200毫秒,三十多倍的性能優(yōu)化,終于可以交差了,哈哈??
總結(jié)
本文分享的是我工作中實(shí)際遇到的一個(gè)案例,實(shí)現(xiàn)的效果是將7秒左右的響應(yīng)時(shí)間優(yōu)化到了不到200毫秒,優(yōu)化了三十幾倍,優(yōu)化的代價(jià)是犧牲了一個(gè)不常用的功能。
本來想著要是優(yōu)化好了可以給這個(gè)庫提個(gè)PR,造福大家的。但是優(yōu)化方案確實(shí)有點(diǎn)歪門邪道:
1.使用了JSON.stringify來進(jìn)行shouldComponentUpdate的對(duì)比優(yōu)化,對(duì)于函數(shù),Symbol屬性的改變沒法監(jiān)聽到,不適合開放使用,只能在數(shù)據(jù)自己可控的情況下小規(guī)模使用。2.犧牲了一個(gè)暴露給外部的受控屬性selected,破壞了功能。
基于這兩點(diǎn),PR我們就沒提了,而是將修改后的代碼放到了自己的私有NPM倉庫。
下面再來總結(jié)下本文面臨的問題和優(yōu)化思路:
遇到的問題
我們需求是要做一個(gè)體育場(chǎng)館的管理日歷,所以我們使用了react-big-calendar這個(gè)庫。我們需求的數(shù)據(jù)量是渲染7000個(gè)背景格子,然后在這個(gè)背景格子上渲染1400個(gè)事件。這近萬個(gè)組件渲染后,我們發(fā)現(xiàn)僅僅一次點(diǎn)擊就需要7秒多,完全不能用。經(jīng)過細(xì)致排查,我們發(fā)現(xiàn)慢的原因是點(diǎn)擊事件的時(shí)候會(huì)改變一個(gè)屬性selected。這個(gè)屬性是從頂層傳下來的,改變后會(huì)導(dǎo)致所有組件更新,也就是所有組件都會(huì)運(yùn)行render函數(shù)。
第一步優(yōu)化
為了阻止不必要的render運(yùn)行,我們引入了Context,將selected放到Context上進(jìn)行透?jìng)鳌V虚g層級(jí)因?yàn)椴恍枰褂?code style="box-sizing: border-box;padding: 3px 5px;color: rgb(255, 53, 2);line-height: 1.5;font-family: "Operator Mono", Consolas, Monaco, Menlo, monospace;font-size: 14.4px;background: rgb(248, 245, 236);border-radius: 2px;">selected屬性,所以可以使用shouldComponentUpdate來阻止render的運(yùn)行,底層需要使用selected的組件自行連接Context獲取。
第一步優(yōu)化的效果
響應(yīng)時(shí)間從7秒多下降到5秒多。
第一步優(yōu)化的問題
底層事件仍然有1400個(gè),獲取selected屬性后,1400個(gè)組件更新仍然要花大量的時(shí)間。
第二步優(yōu)化
為了減少點(diǎn)擊后更新的事件數(shù)量,我們?yōu)槭录氚葱桎秩荆讳秩居脩艨梢姷氖录M件。同時(shí)我們還對(duì)mousedown和mouseup進(jìn)行了優(yōu)化,也是使用shouldComponentUpdate阻止了不必要的更新。
第二步優(yōu)化效果
響應(yīng)時(shí)間從5秒多下降到3秒多。
第二步優(yōu)化的問題
響應(yīng)時(shí)間仍然有三秒多,經(jīng)過分析發(fā)現(xiàn),背景7000個(gè)格子雖然使用shouldComponentUpdate阻止了render函數(shù)的運(yùn)行,但是shouldComponentUpdate本身運(yùn)行7000次也要費(fèi)很長(zhǎng)時(shí)間。
第三步優(yōu)化
為了讓7000背景格子連shouldComponentUpdate都不運(yùn)行,我們?nèi)掏撮幐盍隧攲邮芸氐?code style="box-sizing: border-box;padding: 3px 5px;color: rgb(255, 53, 2);line-height: 1.5;font-family: "Operator Mono", Consolas, Monaco, Menlo, monospace;font-size: 14.4px;background: rgb(248, 245, 236);border-radius: 2px;">selected屬性,直接將它放到了事件的容器上,它的更新再也不會(huì)觸發(fā)背景格子的更新了,也就是連shouldComponentUpdate都不運(yùn)行了。
第三步優(yōu)化效果
響應(yīng)時(shí)間從3秒多下降到不到200毫秒。
第三步優(yōu)化的問題
功能被閹割了,其他完美!
參考資料:
react-big-calendar倉庫[15]
high-performance-redux[16]
覺得博主寫得還可以的話,不要忘了分享、點(diǎn)贊、在看三連哦~
長(zhǎng)按下方圖片,關(guān)注進(jìn)擊的大前端,獲取更多的優(yōu)質(zhì)原創(chuàng)文章~?
References
[1]?react-big-calendar:?https://github.com/jquense/react-big-calendar[2]?速度提高幾百倍,記一次數(shù)據(jù)結(jié)構(gòu)在實(shí)際工作中的運(yùn)用:?https://juejin.cn/post/6898569107877134350[3]?使用mono-repo實(shí)現(xiàn)跨項(xiàng)目組件共享:?https://juejin.cn/post/6913788953971654663[4]?resources:?http://jquense.github.io/react-big-calendar/examples/index.html#prop-resources[5]?React Developer Tools:?https://chrome.google.com/webstore/detail/react-developer-tools/fmkadmapgofadopljbjfkapdkoienihi[6]?掘金年度打榜活動(dòng):?https://rank.juejin.cn/?u=%E8%92%8B%E9%B9%8F%E9%A3%9E&t=user[7]?high-performance-redux:?http://somebody32.github.io/high-performance-redux/[8]?他的文檔:?http://jquense.github.io/react-big-calendar/examples/index.html#api[9]?官方有詳細(xì)介紹:?https://reactjs.org/docs/context.html[10]?我之前的一篇文章也介紹過他的基本使用方法:?https://juejin.cn/post/6847902222756347911#heading-1[11]?react-visibility-sensor:?https://www.npmjs.com/package/react-visibility-sensor[12]?Selection.js:?https://github.com/jquense/react-big-calendar/blob/master/src/Selection.js[13]?React Fiber這篇文章中提過的嗎?:?https://juejin.cn/post/6844904197008130062[14]?TimeGrid.js:?https://github.com/jquense/react-big-calendar/blob/master/src/TimeGrid.js[15]?react-big-calendar倉庫:?https://github.com/jquense/react-big-calendar[16]?high-performance-redux:?http://somebody32.github.io/high-performance-redux/[17]?進(jìn)擊的大前端:?https://test-dennis.oss-cn-hangzhou.aliyuncs.com/QRCode/QR430.jpg
