本文會分享一個React效能優化的故事,這也是我在工作中真實遇到的故事,最終我們是通過魔改第三方庫原始碼將它效能提高了幾十倍。這個第三方庫也是很有名的,在GitHub上有4.5k star,這就是:react-big-calendar。
這個工作不是我一個人做的,而是我們團隊幾個月前共同完成的,我覺得挺有意思,就將它覆盤總結了一下,分享給大家。
在本文中你可以看到:
- React常用效能分析工具的使用介紹
- 效能問題的定位思路
- 常見效能優化的方式和效果:
PureComponent
,shouldComponentUpdate
,Context
,按需渲染
等等 - 對於第三方庫的問題的解決思路
關於我工作中遇到的故事,我前面其實也分享過兩篇文章了:
特別是速度提高几百倍,記一次資料結構在實際工作中的運用,這篇文章在某平臺單篇閱讀都有三萬多,有些朋友也提出了質疑。覺得我這篇文章裡面提到的問題現實中不太可能遇到,裡面的效能優化更多是偏理論的,有點杞人憂天。這個觀點我基本是認可的,我在那篇文章正文也提到過可能是個偽需求,但是技術問題本來很多就是理論上的,我們在leetcode上刷題還是純理論呢,理論結合實際才能發揮其真正的價值,即使是杞人憂天,但是效能確實快上了那麼一點點,也給大家提供了另一個思路,我覺得也是值得的。
與之相對的,本文提到的問題完全不是杞人憂天了,而是實打實的使用者需求,我們經過使用者調研,發現使用者確實有這麼多資料量,需求上不可能再壓縮了,只能技術上優化,這也是逼得我們去改第三方庫原始碼的原因。
需求背景
老規矩,為了讓大家快速理解我們遇到的問題,我會簡單講一下我們的需求背景。我還是在那家外企,不久前我們接到一個需求:做一個體育場館管理Web App
。這裡面有一個核心功能是場館日程的管理,有點類似於大家Outlook
裡面的Calendar
。大家如果用過Outlook
,應該對他的Calendar
有印象,基本上我們的會議及其他日程安排都可以很方便的放在裡面。我們要做的這個也是類似的,體育場館的老闆可以用這個日曆來管理他下面場地的預定。
假設你現在是一個羽毛球場的老闆,來了個客戶說,嘿,老闆,這週六場地有空嗎,我訂一個小時呢!場館每天都很多預定,你也不記得週六有沒有空,所以你開啟我們的網站,看了下日曆:
你發現1月15號,也就是星期五有兩個預定,週六還全是空閒的,於是給他說:你運氣真好,週六目前還沒人預定,時段隨便挑!上面這個截圖是react-big-calendar
的官方示例,我們也是選定用他來搭建我們自己的應用。
真實場景
上面這個例子只是說明下我們的應用場景,裡面預定只有兩個,場地只有一塊。但是我們真實的客戶可比這個大多了,根據我們的調研,我們較大的客戶有數百塊場地,每個場地每天的預定可能有二三十個。上面那個例子我們換個生意比較好的老闆,假設這個老闆有20塊羽毛球場地,每天客戶都很多,某天還是來了個客戶說,嘿,老闆,這週六場地有空嗎,我訂一個小時呢!但是這個老闆生意很好,他看到的日曆是這樣的:
本週場館1全滿!!如果老闆想要為客戶找到一個有空的場地,他需要連續切換場館1,場館2。。。一直到場館20,手都點酸了。。。為了減少老闆手的負擔,我們的產品經理提出一個需求,同時在頁面上顯示10個場館的日曆,好在react-big-calendar
本身就是支援這個的,他把這個叫做resources。
效能爆炸
看起來我們要的基本功能react-big-calendar
都能提供,前途還是很美好的,直到我們將真實的資料渲染到頁面上。。。我們的預定不僅僅是展示,還需要支援一系列的操作,比如編輯,複製,剪下,貼上,拖拽等等。當然這一切操作的前提都是選中這個預定,下面這個截圖是我選中某個預定的耗時:
僅僅是一個最簡單的點選事件,指令碼執行耗時6827ms
,渲染耗時708ms
,總計耗時7.5s
左右,這TM!這玩意兒還想賣錢?送給我,我都不想用!
可能有朋友不知道這個效能怎麼看,這其實是Chrome自帶的效能工具,基本步驟是:
- 開啟Chrome除錯工具,點到
Performance
一欄 - 點選左上角的小圓點,開始錄製
- 執行你想要的操作,我這裡就是點選一個預定
- 等你想要的結果出來,我這裡就是點選的預定顏色加深
- 再點選左上角的小圓點,結束錄製就可以看到了
為了讓大家看得更清楚,我這裡錄製了一個操作的動圖,這個圖可以看到,點選操作的響應花了很長時間,Chrome載入這個效能資料也花了很長時間:
測試資料量
上面僅僅一個點選耗時就七八秒,是因為我故意用了很大資料量嗎?不是!我的測試資料量是完全按照使用者真實場景計算的:同時顯示10個場館,每個場館每天20個預定,上面使用的是周檢視,也就是可以同時看到7天的資料,那總共顯示的預定就是:
10 * 20 * 7 = 1400
,總共1400個預定顯示在頁面上。
為了跟上面這個龜速點選做個對比,我再放下優化後的動圖,讓大家對後面這個長篇大論實現的效果先有個預期:
定位問題
我們一般印象中,React不至於這麼慢啊,如果慢了,大概率是寫程式碼的人沒寫好!我們都知道React有個虛擬樹,當一個狀態改變了,我們只需要更新與這個狀態相關的節點就行了,出現這種情況,是不是他幹了其他不必要的更新與渲染呢?為了解決這個疑惑,我們安裝了React專用除錯工具:React Developer Tools。這是一個Chrome的外掛,Chrome外掛市場可以下載,安裝成功後,Chrome的除錯工具下面會多兩個Tab頁:
在Components
這個Tab下有個設定,開啟這個設定可以看到你每次操作觸發哪些元件更新,我們就是從這裡面發現了一點驚喜:
為了看清楚點選事件觸發哪些更新,我們先減少資料量,只保留一兩個預定,然後開啟這個設定看看:
哼,這有點意思。。。我只是點選一個預定,你把整個日曆的所有元件都給我更新了!那整個日曆有多少元件呢?上面這個圖可以看出10:00 AM
到10:30 AM
之間是一個大格子,其實這個大格子中間還有條分割線,只是顏色較淡,看的不明顯,也就是說每15分鐘就是一個格子。這個15分鐘是可以配置的,你也可以設定為1分鐘,但是那樣格子更多,效能更差!我們是根據需求給使用者提供了15分鐘,30分鐘,1小時等三個選項。當使用者選擇15分鐘的時候,渲染的格子最多,效能最差。
那如果一個格子是15分鐘,總共有多少格子呢?一天是24 * 60 = 1440
分鐘,15分鐘一個格子,總共96
個格子。我們周檢視最多展示7天,那就是7 * 96 = 672
格子,最多可以展示10個場館,就是672 * 10 = 6720
個格子,這還沒算日期和時間本身佔據的元件,四捨五入一下姑且就算7000
個格子吧。
我僅僅是點選一下預定,你就把作為背景的7000個格子全部給我更新一遍,怪不得效能差!
再仔細看下上面這個動圖,我點選的是小的那個事件,當我點選他時,注意大的那個事件也更新了,外面也有個藍框,不是很明顯,但是確實是更新了,在我後面除錯打Log的時候也證實了這一點。所以在真實1400條資料下,被更新的還有另外1399個事件,這其實也是不必要的。
我這裡提到的事件
和前文提到的預定
是一個東西,react-big-calendar
裡面將這個稱為event
,也就是事件
,對應我們業務的意義就是預定
。
為什麼會這樣?
這個現象我好像似曾相識,也是我們經常會犯的一個效能上的問題:將一個狀態放到最頂層,然後一層一層往下傳,當下面某個元素更新了這個狀態,會導致根節點更新,從而觸發下面所有子節點的更新。這裡說的更新並不一定要重新渲染DOM節點,但是會執行每個子節點的render
函式,然後根據render
函式執行結果來做diff
,看看要不要更新這個DOM節點。React在這一步會幫我們省略不必要的DOM操作,但是render
函式的執行卻是必須的,而成千上萬次render
函式的執行也會消耗大量效能。
說到這個我想起以前看到過的一個資料,也是講這個問題的,他用了一個一萬行的列表來做例子,原文在這裡:high-performance-redux。下面這個例子來源於這篇文章:
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 (
<div>
{items.map(item =>
<Item key={item.id} id={item.id} marked={item.marked} onClick={markItem} />
)}
</div>
);
}
};
function mapStateToProps(state) {
return state;
}
const markItem = (id) => ({type: 'MARK', id});
export default connect(
mapStateToProps,
{markItem}
)(App);
上面這段程式碼不復雜,就是一個App
,接收一個items
引數,然後將這個引數全部渲染成Item
元件,然後你可以點選單個Item
來改變他的選中狀態,執行效果如下:
這段程式碼所有資料都在items
裡面,這個引數從頂層App
傳進去,當點選Item
的時候改變items
資料,從而更新整個列表。這個執行結果跟我們上面的Calendar
有類似的問題,當單條Item
狀態改變的時候,其他沒有涉及的Item
也會更新。原因也是一樣的:頂層的引數items
改變了。
說實話,類似的寫法我見過很多,即使不是從App
傳入,也會從其他大的元件節點傳入,從而引起類似的問題。當資料量少的時候,這個問題不明顯,很多時候都被忽略了,像上面這個圖,即使一萬條資料,因為每個Item
都很簡單,所以執行一萬次render
你也不會明顯感知出來,在控制檯看也就一百多毫秒。但是我們面臨的Calendar
就複雜多了,每個子節點的運算邏輯都更復雜,最終將我們的響應速度拖累到了七八秒上。
優化方案
還是先說這個一萬條的列表,原作者除了提出問題外,也提出瞭解決方案:頂層App
只傳id,Item
渲染的資料自己連線redux store
獲取。下面這段程式碼同樣來自這篇文章:
// index.js
function 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 {
// 注意這裡,資料多了一個ids
ids: ids(state.ids, action),
items: items(state.items, action),
}
}
const store = createStore(itemsReducer);
export default class NaiveList extends Component {
render() {
return (
<Provider store={store}>
<App />
</Provider>
);
}
}
// app.js
class App extends Component {
static rerenderViz = true;
render() {
// App元件只使用ids來渲染列表,不關心具體的資料
const { ids } = this.props;
return (
<div>
{
ids.map(id => {
return <Item key={id} id={id} />;
})
}
</div>
);
}
};
function mapStateToProps(state) {
return {ids: state.ids};
}
export default connect(mapStateToProps)(App);
// Item.js
// Item元件自己去連線Redux獲取資料
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 (
<div
onClick={this.onClick}
>
{id}
</div>
);
}
}
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);
這段程式碼的優化主要在這幾個地方:
- 將資料從單純的
items
拆分成了ids
和items
。 - 頂層元件
App
使用ids
來渲染列表,ids
裡面只有id
,所以只要不是增加和刪除,僅僅單條資料的狀態變化,ids
並不需要變化,所以App
不會更新。 Item
元件自己去連線自己需要的資料,當自己關心的資料變化時才更新,其他元件的資料變化並不會觸發更新。
拆解第三方庫原始碼
上面通過使用除錯工具我看到了一個熟悉的現象,並猜到了他慢的原因,但是目前僅僅是猜測,具體是不是這個原因還要看看他的原始碼才能確認。好在我在看他的原始碼前先去看了下他的文件,然後發現了這個:
react-big-calendar
接收兩個引數onSelectEvent
和selected
,selected
表示當前被選中的事件(預定),onSelectEvent
可以用來改變selected
的值。也就是說當我們選中某個預定的時候,會改變selected
的值,由於這個引數是從頂層往下傳的,所以他會引起下面所有子節點的更新,在我們這裡就是差不多7000個背景格子 + 1399個其他事件
,這樣就導致不需要更新的元件更新了。
頂層selected換成Context?
react-big-calendar
在頂層設計selected
這樣一個引數是可以理解的,因為使用者可以通過修改這個值來控制選中的事件。這樣選中一個事件就有了兩個途徑:
- 使用者通過點選某個事件來改變
selected
的值 - 開發者可以在外部直接修改
selected
的值來選中某個事件
有了前面一萬條資料列表優化的經驗,我們知道對於這種問題的處理辦法了:使用selected
的元件自己去連線Redux獲取值,而不是從頂部傳入。可惜,react-big-calendar
並沒有使用Redux,也沒有使用其他任何狀態管理庫。如果他使用Redux,我們還可以考慮新增一個action
來給外部修改selected
,可惜他沒有。沒有Redux就玩不轉了嗎?當然不是!React其實自帶一個全域性狀態共享的功能,那就是Context
。React Context API
官方有詳細介紹,我之前的一篇文章也介紹過他的基本使用方法,這裡不再講述他的基本用法,我這裡想提的是他的另一個特性:使用Context Provider
包裹時,如果你傳入的value
變了,會執行下面所有節點的render函式,這跟前面提到的普通props
是一樣的。但是,如果Provider下面的兒子節點是PureComponent,可以不執行兒子節點的render函式,而直接執行使用這個value的孫子節點。
什麼意思呢,下面我將我們面臨的問題簡化來說明下。假設我們只有三層,第一層是頂層容器Calendar
,第二層是背景的空白格子(兒子),第三層是真正需要使用selected
的事件(孫子):
示例程式碼如下:
// SelectContext.js
// 一個簡單的Context
import React from 'react'
const SelectContext = React.createContext()
export default SelectContext;
// Calendar.js
// 使用Context Provider包裹,接收引數selected,渲染背景Background
import 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 (
<SelectContext.Provider value={value}>
<Background />
</SelectContext.Provider>
)
}
}
// Background.js
// 繼承自PureComponent,渲染背景格子和事件Event
class Background extends PureComponent {
render() {
const { events } = this.props;
return (
<div>
<div>這裡面是7000個背景格子</div>
下面是渲染1400個事件
{events.map(event => <Event event={event}/>)}
</div>
)
}
}
// Event.js
// 從Context中取selected來決定自己的渲染樣式
import SelectContext from './SelectContext';
class Event extends Component {
render() {
const { selected, setSelected } = this.context;
const { event } = this.props;
return (
<div className={ selected === event ? 'class1' : 'class2'} onClick={() => setSelected(event)}>
</div>
)
}
}
Event.contextType = SelectContext; // 連線Context
什麼是PureComponent?
我們知道如果我們想阻止一個元件的render
函式執行,我們可以在shouldComponentUpdate
返回false
,當新的props
相對於老的props
來說沒有變化時,其實就不需要執行render
,shouldComponentUpdate
就可以這樣寫:
shouldComponentUpdate(nextProps) {
const fields = Object.keys(this.props)
const fieldsLength = fields.length
let flag = false
for (let i = 0; i < fieldsLength; i = i + 1) {
const field = fields[i]
if (
this.props[field] !== nextProps[field]
) {
flag = true
break
}
}
return flag
}
這段程式碼就是將新的nextProps
與老的props
一一進行對比,如果一樣就返回false
,不需要執行render
。而PureComponent
其實就是React官方幫我們實現了這樣一個shouldComponentUpdate
。所以我們上面的Background
元件繼承自PureComponent
,就自帶了這麼一個優化。如果Background
本身的引數沒有變化,他就不會更新,而Event
因為自己連線了SelectContext
,所以當SelectContext
的值變化的時候,Event
會更新。這就實現了我前面說的如果Provider下面的兒子節點是PureComponent,可以不執行兒子節點的render函式,而直接執行使用這個value的孫子節點。
PureComponent不起作用
理想是美好的,現實是骨感的。。。理論上來說,如果我將中間兒子這層改成了PureComponent
,背景上7000個格子就不應該更新了,效能應該大幅提高才對。但是我測試後發現並沒有什麼用,這7000個格子還是更新了,什麼鬼?其實這是PureComponent
本身的一個問題:只進行淺比較。注意this.props[field] !== nextProps[field]
,如果this.props[field]
是個引用物件呢,比如物件,陣列之類的?因為他是淺比較,所以即使前後屬性內容沒變,但是引用地址變了,這兩個就不一樣了,就會導致元件的更新!
而在react-big-calendar
裡面大量存在這種計算後返回新的物件的操作,比如他在頂層Calendar
裡面有這種操作:
程式碼地址:https://github.com/jquense/react-big-calendar/blob/master/src/Calendar.js#L790
這行程式碼的意思是每次props
改變都去重新計算狀態state
,而他的計算程式碼是這樣的:
程式碼地址:https://github.com/jquense/react-big-calendar/blob/master/src/Calendar.js#L794
注意他的返回值是一個新的物件,而且這個物件裡面的屬性,比如localizer
的計算方法mergeWithDefaults
也是這樣,每次都返回新的物件:
程式碼地址:https://github.com/jquense/react-big-calendar/blob/master/src/localizer.js#L39
這樣會導致中間兒子節點每次接受到的props
雖然內容是一樣的,但是因為是一個新物件,即使使用了PureComponent
,其執行結果也是需要更新。這種操作在他的原始碼中大量存在,其實從功能角度來說,這樣寫是可以理解的,因為我有時候也會這麼幹。。。有時候某個屬性更新了,不太確定要不要更新下面的元件,乾脆直接返回一個新物件觸發更新,省事是省事了,但是面對我們這種近萬個元件的時候效能就崩了。。。
歪門邪道shouldComponentUpdate
如果只有一兩個屬性是這樣返回新物件,我還可以考慮給他重構下,但是除錯了一下發現有大量的屬性都是這樣,我們也不是他作者,也不知道會不會改壞功能,沒敢亂動。但是不動效能也繃不住啊,想來想去,還是在兒子的shouldComponentUpdate
上動點手腳吧。簡單的this.props[field] !== nextProps[field]
判斷肯定是不行的,因為引用地址變啦,但是他內容其實是沒變,那我們就判斷他的內容吧。兩個物件的深度比較需要使用遞迴,也可以參考React diff
演算法來進行效能優化,但是無論你怎麼優化這個演算法,效能最差的時候都是兩個物件一樣的時候,因為他們是一樣的,你需要遍歷到最深處才能肯定他們是一樣的,如果物件很深,這種遞迴演算法不見得會比執行一遍render
快,而我們面臨的大多數情況都是這種效能最差的情況。所以遞迴對比不太靠譜,其實如果你對這些資料心裡有數,沒有迴圈引用什麼的,你可以考慮直接將兩個物件轉化為字串來進行對比,也就是
JSON.stringify(this.props[field]) !== JSON.stringify(nextProps[field])
注意,這種方式只適用於你對props資料瞭解,沒有迴圈引用,沒有變化的Symbol,函式之類的屬性,因為JSON.stringify執行時會丟掉Symbol和函式,所以我說他是歪門邪道效能優化。
將這個轉化為字串比較的shouldComponentUpdate
加到背景格子的元件上,效能得到了明顯增強,點選相應速度從7.5秒下降到了5.3秒左右。
按需渲染
上面我們用shouldComponentUpdate
阻止了7000個背景格子的更新,響應時間下降了兩秒多,但是還是需要5秒多時間,這也很難接受,還需要進一步優化。按照我們之前說的如果還能阻止另外1399個事件的更新那就更好了,但是經過對他資料結構的分析,我們發現他的資料結構跟我們前面舉的列表例子還不一樣。我們列表的例子所有資料都在items
裡面,是否選中是item
的一個屬性,而react-big-calendar
的資料結構裡面event
和selectedEvent
是兩個不同的屬性,每個事件通過判斷自己的event
是否等於selectedEvent
來判斷自己是否被選中。這造成的結果就是每次我們選中一個事件,selectedEvent
的值都會變化,每個事件的屬性都會變化,也就是會更新,執行render
函式。如果不改這種資料結構,是阻止不了另外1399個事件更新的。但是改這個資料結構改動太大,對於一個第三方庫,我們又不想動這麼多,怎麼辦呢?
這條路走不通了,我們完全可以換一個思路,背景7000個格子,再加上1400個事件,使用者螢幕有那麼大嗎,看得完嗎?肯定是看不完的,既然看不完,那我們只渲染他能看到部分不就可以了!按照這個思路,我們找到了一個庫:react-visibility-sensor。這個庫使用方法也很簡單:
function MyComponent (props) {
return (
<VisibilitySensor>
{({isVisible}) =>
<div>I am {isVisible ? 'visible' : 'invisible'}</div>
}
</VisibilitySensor>
);
}
結合我們前面說的,我們可以將VisibilitySensor
套在Background
上面:
class Background extends PureComponent {
render() {
return (
<VisibilitySensor>
{({isVisible}) =>
<Event isVisible={isVisible}/>
}
</VisibilitySensor>
)
}
}
然後Event
元件如果發現自己處於不可見狀態,就不用渲染了,只有當自己可見時才渲染:
class Event extends Component {
render() {
const { selected } = this.context;
const { isVisible, event } = this.props;
return (
{ isVisible ? (
<div className={ selected === event ? 'class1' : 'class2'}>
複雜內容
</div>
) : null}
)
}
}
Event.contextType = SelectContext;
按照這個思路我們又改了一下,發現效能又提升了,整體時間下降到了大概4.1秒:
仔細看上圖,我們發現渲染事件Rendering
時間從1秒左右下降到了43毫秒,快了二十幾倍,這得益於渲染內容的減少,但是Scripting
時間,也就是指令碼執行時間仍然高達4.1秒,還需要進一步優化。
砍掉mousedown事件
渲染這塊已經沒有太多辦法可以用了,只能看看Scripting
了,我們發現效能圖上滑鼠事件有點刺眼:
一次點選同時觸發了三個點選事件:mousedown
,mouseup
,click
。如果我們能幹掉mousedown
,mouseup
是不是時間又可以省一半,先去看看他註冊這兩個事件時幹什麼的吧。可以直接在程式碼裡面全域性搜mousedown
,最終發現都是在Selection.js,通過對這個類程式碼的閱讀,發現他是個典型的觀察者模式,然後再搜new Selection
找到使用的地方,發現mousedown
,mouseup
主要是用來實現事件的拖拽功能的,mousedown
標記拖拽開始,mouseup
標記拖拽結束。如果我把它去掉,拖拽功能就沒有了。經過跟產品經理溝通,我們後面是需要拖拽的,所以這個不能刪。
事情進行到這裡,我也沒有更多辦法了,但是響應時間還是有4秒,真是讓人頭大
反正沒啥好辦法了,我就隨便點著玩,突然,我發現mousedown
的呼叫棧好像有點問題:
這個呼叫棧我用數字分成了三塊:
- 這裡面有很多熟悉的函式名啊,像啥
performUnitOfWork
,beginWork
,這不都是我在React Fiber這篇文章中提過的嗎?所以這些是React自己內部的函式呼叫 render
函式,這是某個元件的渲染函式- 這個
render
裡面又呼叫了renderEvents
函式,看起來是用來渲染事件列表的,主要的時間都耗在這裡了
mousedown
監聽本身我是幹不掉了,但是裡面的執行是不是可以優化呢?renderEvents
已經是庫自己寫的程式碼了,所以可以直接全域性搜,看看在哪裡執行的。最終發現是在TimeGrid.js的render
函式被執行了,其實這個是不需要執行的,我們直接把前面歪門邪道的shouldComponentUpdate
複製過來就可以阻止他的執行。然後再看下效能資料呢:
我們發現Scripting
下降到了3.2秒左右,比之前減少約800毫秒,而mousedown
的時間也從之前的幾百毫秒下降到了50毫秒,在圖上幾乎都看不到了,mouseup
事件也不怎麼看得到了,又算進了一步吧~
忍痛閹割功能
到目前為止,我們的效能優化都沒有閹割功能,響應速度從7.5秒下降到了3秒多一點,優化差不多一倍。但是,目前這速度還是要三秒多,別說作為一個工程師了,作為一個使用者我都忍不了。咋辦呢?我們是真的有點黔驢技窮了。。。
看看上面那個效能圖,主要消耗時間的有兩個,一個是click
事件,還有個timer
。timer
到現在我還不知道他哪裡來的,但是click
事件我們是知道的,就是使用者點選某個事件後,更改SelectContext
的selected
屬性,然後selected
屬性從頂層節點傳入觸發下面元件的更新,中間兒子節點通過shouldComponentUpdate
跳過更新,孫子節點直接連線SelectContext
獲取selected
屬性更新自己的狀態。這個流程是我們前面優化過的,但是,等等,這個貌似還有點問題。
在我們的場景中,中間兒子節點其實包含了高達7000個背景格子,雖然我們通過shouldComponentUpdate
跳過了render
的執行,但是7000個shouldComponentUpdate
本省執行也是需要時間的啊!有沒有辦法連shouldComponentUpdate
的執行也跳過呢?這貌似是個新的思路,但是經過我們的討論,發現沒辦法在保持功能的情況下做到,但是可以適度閹割一個功能就可以做到,那閹割的功能是哪個呢?那就是暴露給外部的受控selected
屬性!
前面我們提到過選中一個事件有兩個途徑:
- 使用者通過點選某個事件來改變
selected
的值 - 開發者可以在外部直接修改
selected
的值來選中某個事件
之所以selected
要放在頂層元件上就是為了實現第二個功能,讓外部開發者可以通過這個受控的selected
屬性來改變選中的事件。但是經過我們評估,外部修改selected
這個並不是我們的需求,我們的需求都是使用者點選來選中,也就是說外部修改selected
這個功能我們可以不要。
如果不要這個功能那就有得玩了,selected
完全不用放在頂層了,只需要放在事件外層的容器上就行,這樣,改變selected
值只會觸發事件的更新,啥背景格子的更新壓根就不會觸發,那怎麼改呢?在我們前面的Calendar -- Background -- Event
模型上再加一層EventContainer
,變成Calendar -- Background -- EventContainer -- Event
。SelectContext.Provider
也不用包裹Calendar
了,直接包裹EventContainer
就行。程式碼大概是這個樣子:
// Calendar.js
// Calendar簡單了,不用接受selected引數,也不用SelectContext.Provider包裹了
class Calendar extends Component {
render() {
return (
<Background />
)
}
}
// Background.js
// Background要不要使用shouldComponentUpdate阻止更新可以看看還有沒有其他引數變化,因為selected已經從頂層拿掉了
// 改變selected本來就不會觸發Background更新
// Background不再渲染單個事件,而是渲染EventContainer
class Background extends PureComponent {
render() {
const { events } = this.props;
return (
<div>
<div>這裡面是7000個背景格子</div>
下面是渲染1400個事件
<EventContainer events={events}/>
</div>
)
}
}
// EventContainer.js
// EventContainer需要SelectContext.Provider包裹
// 程式碼類似之前的Calendar
import 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 (
<SelectContext.Provider value={value}>
{events.map(event => <Event event={event}/>)}
</SelectContext.Provider>
)
}
}
// Event.js
// Event跟之前是一樣的,從Context中取selected來決定自己的渲染樣式
import SelectContext from './SelectContext';
class Event extends Component {
render() {
const { selected, setSelected } = this.context;
const { event } = this.props;
return (
<div className={ selected === event ? 'class1' : 'class2'} onClick={() => setSelected(event)}>
</div>
)
}
}
Event.contextType = SelectContext; // 連線Context
這種結構最大的變化就是當selected
變化的時候,更新的節點是EventContainer
,而不是頂層Calendar
,這樣就不會觸發Calendar
下其他節點的更新。缺點就是Calendar
無法從外部接收selected
了。
需要注意一點是,如果像我們這樣EventContainer
下面直接渲染Event
列表,selected
不用Context
也可以,可以直接作為EventContainer
的state
。但是如果EventContainer
和Event
中間還有層級,需要穿透傳遞,仍然需要Context
,中間層級和以前的類似,使用shouldComponentUpdate
阻止更新。
還有一點,因為selected
不在頂層了,所以selected
更新也不會觸發中間Background
更新了,所以Background
上的shouldComponentUpdate
也可以刪掉了。
我們這樣優化後,效能又提升了:
現在Scripting
時間直接從3.2秒降到了800毫秒,其中click
事件只有163毫秒,現在從我使用來看,卡頓已經不明顯了,直接錄個動圖來對比下吧:
上面這個動圖已經基本看不出卡頓了,但是我們效能圖上為啥還有800毫秒呢,而且有一個很長的Timer Fired
。經過我們的仔細排查,發現這其實是個烏龍,Timer Fired
在我一開始錄製效能就出現了,那時候我還在切換頁面,還沒來得及點選呢,如果我們點進去會發現他其實是按需渲染引入的react-visibility-sensor
的一個檢查元素可見性的定時任務,並不是我們點選事件的響應時間。把這塊去掉,我們點選事件的響應時間其實不到200毫秒。
從7秒多優化到不到200毫秒,三十多倍的效能優化,終於可以交差了,哈哈?
總結
本文分享的是我工作中實際遇到的一個案例,實現的效果是將7秒左右的響應時間優化到了不到200毫秒,優化了三十幾倍,優化的代價是犧牲了一個不常用的功能。
本來想著要是優化好了可以給這個庫提個PR,造福大家的。但是優化方案確實有點歪門邪道:
- 使用了
JSON.stringify
來進行shouldComponentUpdate
的對比優化,對於函式,Symbol
屬性的改變沒法監聽到,不適合開放使用,只能在資料自己可控的情況下小規模使用。 - 犧牲了一個暴露給外部的受控屬性
selected
,破壞了功能。
基於這兩點,PR我們就沒提了,而是將修改後的程式碼放到了自己的私有NPM倉庫。
下面再來總結下本文面臨的問題和優化思路:
遇到的問題
我們需求是要做一個體育場館的管理日曆,所以我們使用了react-big-calendar
這個庫。我們需求的資料量是渲染7000個背景格子,然後在這個背景格子上渲染1400個事件。這近萬個元件渲染後,我們發現僅僅一次點選就需要7秒多,完全不能用。經過細緻排查,我們發現慢的原因是點選事件的時候會改變一個屬性selected
。這個屬性是從頂層傳下來的,改變後會導致所有元件更新,也就是所有元件都會執行render
函式。
第一步優化
為了阻止不必要的render
執行,我們引入了Context
,將selected
放到Context
上進行透傳。中間層級因為不需要使用selected
屬性,所以可以使用shouldComponentUpdate
來阻止render
的執行,底層需要使用selected
的元件自行連線Context
獲取。
第一步優化的效果
響應時間從7秒多下降到5秒多。
第一步優化的問題
底層事件仍然有1400個,獲取selected
屬性後,1400個元件更新仍然要花大量的時間。
第二步優化
為了減少點選後更新的事件數量,我們為事件引入按需渲染,只渲染使用者可見的事件元件。同時我們還對mousedown
和mouseup
進行了優化,也是使用shouldComponentUpdate
阻止了不必要的更新。
第二步優化效果
響應時間從5秒多下降到3秒多。
第二步優化的問題
響應時間仍然有三秒多,經過分析發現,背景7000個格子雖然使用shouldComponentUpdate
阻止了render
函式的執行,但是shouldComponentUpdate
本身執行7000次也要費很長時間。
第三步優化
為了讓7000背景格子連shouldComponentUpdate
都不執行,我們忍痛閹割了頂層受控的selected
屬性,直接將它放到了事件的容器上,它的更新再也不會觸發背景格子的更新了,也就是連shouldComponentUpdate
都不執行了。
第三步優化效果
響應時間從3秒多下降到不到200毫秒。
第三步優化的問題
功能被閹割了,其他完美!
參考資料:
文章的最後,感謝你花費寶貴的時間閱讀本文,如果本文給了你一點點幫助或者啟發,請不要吝嗇你的贊和GitHub小星星,你的支援是作者持續創作的動力。
歡迎關注我的公眾號進擊的大前端第一時間獲取高質量原創~
“前端進階知識”系列文章:https://juejin.im/post/5e3ffc85518825494e2772fd
“前端進階知識”系列文章原始碼GitHub地址: https://github.com/dennis-jiang/Front-End-Knowledges