1. 程式人生 > >歪門邪道效能優化:魔改三方庫原始碼,效能提高几十倍!

歪門邪道效能優化:魔改三方庫原始碼,效能提高几十倍!

本文會分享一個React效能優化的故事,這也是我在工作中真實遇到的故事,最終我們是通過魔改第三方庫原始碼將它效能提高了幾十倍。這個第三方庫也是很有名的,在GitHub上有4.5k star,這就是:[react-big-calendar](https://github.com/jquense/react-big-calendar)。 **這個工作不是我一個人做的,而是我們團隊幾個月前共同完成的,我覺得挺有意思,就將它覆盤總結了一下,分享給大家**。 在本文中你可以看到: 1. React常用效能分析工具的使用介紹 2. 效能問題的定位思路 3. 常見效能優化的方式和效果:`PureComponent`, `shouldComponentUpdate`, `Context`, `按需渲染`等等 4. 對於第三方庫的問題的解決思路 關於我工作中遇到的故事,我前面其實也分享過兩篇文章了: 1. [速度提高几百倍,記一次資料結構在實際工作中的運用](https://www.cnblogs.com/dennisj/p/14030280.html) 2. [使用mono-repo實現跨專案元件共享](https://www.cnblogs.com/dennisj/p/14230178.html) 特別是**速度提高几百倍,記一次資料結構在實際工作中的運用**,這篇文章在某平臺單篇閱讀都有三萬多,有些朋友也提出了質疑。覺得我這篇文章裡面提到的問題現實中不太可能遇到,裡面的效能優化更多是偏理論的,有點杞人憂天。這個觀點我基本是認可的,我在那篇文章正文也提到過可能是個偽需求,但是技術問題本來很多就是理論上的,我們在leetcode上刷題還是純理論呢,理論結合實際才能發揮其真正的價值,即使是杞人憂天,但是效能確實快上了那麼一點點,也給大家提供了另一個思路,我覺得也是值得的。 與之相對的,本文提到的問題完全不是杞人憂天了,而是實打實的使用者需求,我們經過使用者調研,發現使用者確實有這麼多資料量,需求上不可能再壓縮了,只能技術上優化,這也是逼得我們去改第三方庫原始碼的原因。 ## 需求背景 老規矩,為了讓大家快速理解我們遇到的問題,我會簡單講一下我們的需求背景。我還是在那家外企,不久前我們接到一個需求:做一個體育場館管理`Web App`。這裡面有一個核心功能是場館日程的管理,有點類似於大家`Outlook`裡面的`Calendar`。大家如果用過`Outlook`,應該對他的`Calendar`有印象,基本上我們的會議及其他日程安排都可以很方便的放在裡面。我們要做的這個也是類似的,體育場館的老闆可以用這個日曆來管理他下面場地的預定。 假設你現在是一個羽毛球場的老闆,來了個客戶說,嘿,老闆,這週六場地有空嗎,我訂一個小時呢!場館每天都很多預定,你也不記得週六有沒有空,所以你開啟我們的網站,看了下日曆: ![image-20210117111412119](https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/c87a47ef1c814ec2844c6e4e6354639b~tplv-k3u1fbpfcp-zoom-1.image) 你發現1月15號,也就是星期五有兩個預定,週六還全是空閒的,於是給他說:你運氣真好,週六目前還沒人預定,時段隨便挑!上面這個截圖是`react-big-calendar`的官方示例,我們也是選定用他來搭建我們自己的應用。 ### 真實場景 上面這個例子只是說明下我們的應用場景,裡面預定只有兩個,場地只有一塊。但是我們真實的客戶可比這個大多了,根據我們的調研,我們較大的客戶有**數百塊場地**,每個場地每天的預定可能有**二三十個**。上面那個例子我們換個生意比較好的老闆,假設這個老闆有20塊羽毛球場地,每天客戶都很多,某天還是來了個客戶說,嘿,老闆,這週六場地有空嗎,我訂一個小時呢!但是這個老闆生意很好,他看到的日曆是這樣的: ![image-20210117112848684](https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/6509cd4cc6d5472e996f55d5d61357e8~tplv-k3u1fbpfcp-zoom-1.image) 本週**場館1全滿**!!如果老闆想要為客戶找到一個有空的場地,他需要連續切換場館1,場館2。。。一直到場館20,手都點酸了。。。為了減少老闆手的負擔,我們的產品經理提出一個需求,**同時在頁面上顯示10個場館的日曆**,好在`react-big-calendar`本身就是支援這個的,他把這個叫做[resources](http://jquense.github.io/react-big-calendar/examples/index.html#prop-resources)。 ## 效能爆炸 看起來我們要的基本功能`react-big-calendar`都能提供,前途還是很美好的,直到我們將真實的資料渲染到頁面上。。。我們的預定不僅僅是展示,還需要支援一系列的操作,比如編輯,複製,剪下,貼上,拖拽等等。當然這一切操作的前提都是選中這個預定,下面這個截圖是我選中某個預定的耗時: ![image-20210117114847440](https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/2d0e5e59fe864cff9ff8defe275f1063~tplv-k3u1fbpfcp-zoom-1.image) **僅僅是一個最簡單的點選事件,指令碼執行耗時`6827ms`,渲染耗時`708ms`,總計耗時`7.5s`左右,這TM!這玩意兒還想賣錢?送給我,我都不想用**! 可能有朋友不知道這個效能怎麼看,這其實是Chrome自帶的效能工具,基本步驟是: 1. 開啟Chrome除錯工具,點到`Performance`一欄 2. 點選左上角的小圓點,開始錄製 3. 執行你想要的操作,我這裡就是點選一個預定 4. 等你想要的結果出來,我這裡就是點選的預定顏色加深 5. 再點選左上角的小圓點,結束錄製就可以看到了 為了讓大家看得更清楚,我這裡錄製了一個操作的動圖,這個圖可以看到,點選操作的響應花了很長時間,Chrome載入這個效能資料也花了很長時間: ![Jan-17-2021 12-51-51](https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/9b5c7f48244144e1bb683e2e96e04ebb~tplv-k3u1fbpfcp-zoom-1.image) ### 測試資料量 上面僅僅一個點選耗時就七八秒,是因為我故意用了很大資料量嗎?不是!我的測試資料量是完全按照使用者真實場景計算的:同時顯示10個場館,每個場館每天20個預定,上面使用的是周檢視,也就是可以同時看到7天的資料,那總共顯示的預定就是: `10 * 20 * 7 = 1400`,總共**1400**個預定顯示在頁面上。 為了跟上面這個龜速點選做個對比,我再放下優化後的動圖,讓大家對後面這個長篇大論實現的效果先有個預期: ![Jan-20-2021 16-42-53](https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/c1bef9328f984245994956266735056b~tplv-k3u1fbpfcp-zoom-1.image) ## 定位問題 我們一般印象中,React不至於這麼慢啊,如果慢了,大概率是寫程式碼的人沒寫好!我們都知道React有個虛擬樹,當一個狀態改變了,我們只需要更新與這個狀態相關的節點就行了,出現這種情況,是不是他幹了其他不必要的更新與渲染呢?為了解決這個疑惑,我們安裝了React專用除錯工具:[React Developer Tools](https://chrome.google.com/webstore/detail/react-developer-tools/fmkadmapgofadopljbjfkapdkoienihi)。這是一個Chrome的外掛,Chrome外掛市場可以下載,安裝成功後,Chrome的除錯工具下面會多兩個Tab頁: ![image-20210117130740746](https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/e3e6097d31ef4deca0a4fb8b6febae64~tplv-k3u1fbpfcp-zoom-1.image) 在`Components`這個Tab下有個設定,開啟這個設定可以看到你每次操作觸發哪些元件更新,我們就是從這裡面發現了一點驚喜: ![image-20210117130951475](https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/0b761bbeb9e64cfb9d0ffebcffbfa450~tplv-k3u1fbpfcp-zoom-1.image) 為了看清楚點選事件觸發哪些更新,我們先減少資料量,只保留一兩個預定,然後開啟這個設定看看: ![Jan-17-2021 13-21-55](https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/5b018dc0609f42dba024c8c24e8390ed~tplv-k3u1fbpfcp-zoom-1.image) 哼,這有點意思。。。我只是點選一個預定,你把整個日曆的所有元件都給我更新了!那整個日曆有多少元件呢?上面這個圖可以看出`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](http://somebody32.github.io/high-performance-redux/)。下面這個例子來源於這篇文章: ```javascript 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); ``` 上面這段程式碼不復雜,就是一個`App`,接收一個`items`引數,然後將這個引數全部渲染成`Item`元件,然後你可以點選單個`Item`來改變他的選中狀態,執行效果如下: ![Jan-17-2021 15-17-38](https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/4290ddd123824ed0af37801e830c667c~tplv-k3u1fbpfcp-zoom-1.image) 這段程式碼所有資料都在`items`裡面,這個引數從頂層`App`傳進去,當點選`Item`的時候改變`items`資料,從而更新整個列表。這個執行結果跟我們上面的`Calendar`有類似的問題,當單條`Item`狀態改變的時候,其他沒有涉及的`Item`也會更新。原因也是一樣的:頂層的引數`items`改變了。 說實話,類似的寫法我見過很多,即使不是從`App`傳入,也會從其他大的元件節點傳入,從而引起類似的問題。當資料量少的時候,這個問題不明顯,很多時候都被忽略了,像上面這個圖,即使一萬條資料,因為每個`Item`都很簡單,所以執行一萬次`render`你也不會明顯感知出來,在控制檯看也就一百多毫秒。但是我們面臨的`Calendar`就複雜多了,每個子節點的運算邏輯都更復雜,最終將我們的響應速度拖累到了七八秒上。 ### 優化方案 還是先說這個一萬條的列表,原作者除了提出問題外,也提出瞭解決方案:頂層`App`只傳id,`Item`渲染的資料自己連線`redux store`獲取。下面這段程式碼同樣來自這篇文章: ```javascript // 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 ( ); } } ``` ```javascript // app.js class App extends Component { static rerenderViz = true; render() { // App元件只使用ids來渲染列表,不關心具體的資料 const { ids } = this.props; return ( { ids.map(id => { return ); } }; function mapStateToProps(state) { return {ids: state.ids}; } export default connect(mapStateToProps)(App); ``` ```javascript // 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 ( {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); ``` 這段程式碼的優化主要在這幾個地方: 1. 將資料從單純的`items`拆分成了`ids`和`items`。 2. 頂層元件`App`使用`ids`來渲染列表,`ids`裡面只有`id`,所以只要不是增加和刪除,僅僅單條資料的狀態變化,`ids`並不需要變化,所以`App`不會更新。 3. `Item`元件自己去連線自己需要的資料,當自己關心的資料變化時才更新,其他元件的資料變化並不會觸發更新。 ## 拆解第三方庫原始碼 上面通過使用除錯工具我看到了一個熟悉的現象,並猜到了他慢的原因,但是目前僅僅是猜測,具體是不是這個原因還要看看他的原始碼才能確認。好在我在看他的原始碼前先去看了下[他的文件](http://jquense.github.io/react-big-calendar/examples/index.html#api),然後發現了這個: ![image-20210117162411789](https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/5a08b59425fb4d87b0d09433c88dd352~tplv-k3u1fbpfcp-zoom-1.image) `react-big-calendar`接收兩個引數`onSelectEvent`和`selected`,`selected`表示當前被選中的事件(預定),`onSelectEvent`可以用來改變`selected`的值。也就是說當我們選中某個預定的時候,會改變`selected`的值,由於這個引數是從頂層往下傳的,所以他會引起下面所有子節點的更新,在我們這裡就是差不多`7000個背景格子 + 1399個其他事件`,這樣就導致不需要更新的元件更新了。 ### 頂層selected換成Context? `react-big-calendar`在頂層設計`selected`這樣一個引數是可以理解的,因為使用者可以通過修改這個值來控制選中的事件。這樣選中一個事件就有了兩個途徑: 1. 使用者通過點選某個事件來改變`selected`的值 2. 開發者可以在外部直接修改`selected`的值來選中某個事件 有了前面一萬條資料列表優化的經驗,我們知道對於這種問題的處理辦法了:使用`selected`的元件自己去連線Redux獲取值,而不是從頂部傳入。**可惜,`react-big-calendar`並沒有使用Redux,也沒有使用其他任何狀態管理庫。**如果他使用Redux,我們還可以考慮新增一個`action`來給外部修改`selected`,可惜他沒有。沒有Redux就玩不轉了嗎?當然不是!React其實自帶一個全域性狀態共享的功能,那就是`Context`。`React Context API`[官方有詳細介紹](https://reactjs.org/docs/context.html),[我之前的一篇文章也介紹過他的基本使用方法](https://www.cnblogs.com/dennisj/p/13272107.html),這裡不再講述他的基本用法,我這裡想提的是他的另一個特性:使用`Context Provider`包裹時,如果你傳入的`value`變了,會執行下面所有節點的render函式,這跟前面提到的普通`props`是一樣的。**但是,如果Provider下面的兒子節點是PureComponent,可以不執行兒子節點的render函式,而直接執行使用這個value的孫子節點**。 什麼意思呢,下面我將我們面臨的問題簡化來說明下。假設我們只有三層,第一層是頂層容器`Calendar`,第二層是背景的空白格子(兒子),第三層是真正需要使用`selected`的事件(孫子): ![image-20210119144005794](https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/bf03b2c08be44e6d9ed572077ec729fc~tplv-k3u1fbpfcp-zoom-1.image) 示例程式碼如下: ```javascript // SelectContext.js // 一個簡單的Context import React from 'react' const SelectContext = React.createContext() export default SelectContext; ``` ```javascript // 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 ( ) } } ``` ```javascript // Background.js // 繼承自PureComponent,渲染背景格子和事件Event class Background extends PureComponent { render() { const { events } = this.props; return ( 這裡面是7000個背景格子 下面是渲染1400個事件 {events.map(event => ) } } ``` ```javascript // 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? 我們知道如果我們想阻止一個元件的`render`函式執行,我們可以在`shouldComponentUpdate`返回`false`,當新的`props`相對於老的`props`來說沒有變化時,其實就不需要執行`render`,`shouldComponentUpdate`就可以這樣寫: ```javascript 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`裡面有這種操作: ![image-20210119151326161](https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/a031ae751ef8493e8d0eb20f40696c72~tplv-k3u1fbpfcp-zoom-1.image) 程式碼地址:[https://github.com/jquense/react-big-calendar/blob/master/src/Calendar.js#L790](https://github.com/jquense/react-big-calendar/blob/master/src/Calendar.js#L790) 這行程式碼的意思是每次`props`改變都去重新計算狀態`state`,而他的計算程式碼是這樣的: ![image-20210119151747973](https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/5ecac47465724259b3f8fbc546bd1749~tplv-k3u1fbpfcp-zoom-1.image) 程式碼地址:[https://github.com/jquense/react-big-calendar/blob/master/src/Calendar.js#L794](https://github.com/jquense/react-big-calendar/blob/master/src/Calendar.js#L794) 注意他的返回值是一個新的物件,而且這個物件裡面的屬性,比如`localizer`的計算方法`mergeWithDefaults`也是這樣,每次都返回新的物件: ![image-20210119151956459](https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/b16774e5e80d4eb18b189bf456e5dd9b~tplv-k3u1fbpfcp-zoom-1.image) 程式碼地址:[https://github.com/jquense/react-big-calendar/blob/master/src/localizer.js#L39](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`快,而我們面臨的大多數情況都是這種效能最差的情況。所以遞迴對比不太靠譜,其實如果你對這些資料心裡有數,沒有迴圈引用什麼的,你可以考慮直接將兩個物件轉化為字串來進行對比,也就是 ```javascript JSON.stringify(this.props[field]) !== JSON.stringify(nextProps[field]) ``` **注意,這種方式只適用於你對props資料瞭解,沒有迴圈引用,沒有變化的Symbol,函式之類的屬性,因為JSON.stringify執行時會丟掉Symbol和函式,所以我說他是歪門邪道效能優化**。 將這個轉化為字串比較的`shouldComponentUpdate`加到背景格子的元件上,效能得到了明顯增強,點選相應速度從7.5秒下降到了5.3秒左右。 ![image-20210119160608456](https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/333956aadabb4315884e7b83410ffe70~tplv-k3u1fbpfcp-zoom-1.image) ### 按需渲染 上面我們用`shouldComponentUpdate`阻止了7000個背景格子的更新,響應時間下降了兩秒多,但是還是需要5秒多時間,這也很難接受,還需要進一步優化。按照我們之前說的如果還能阻止另外1399個事件的更新那就更好了,但是經過對他資料結構的分析,我們發現他的資料結構跟我們前面舉的列表例子還不一樣。我們列表的例子所有資料都在`items`裡面,是否選中是`item`的一個屬性,而`react-big-calendar`的資料結構裡面`event`和`selectedEvent`是兩個不同的屬性,每個事件通過判斷自己的`event`是否等於`selectedEvent`來判斷自己是否被選中。這造成的結果就是每次我們選中一個事件,`selectedEvent`的值都會變化,每個事件的屬性都會變化,也就是會更新,執行`render`函式。如果不改這種資料結構,是阻止不了另外1399個事件更新的。但是改這個資料結構改動太大,對於一個第三方庫,我們又不想動這麼多,怎麼辦呢? 這條路走不通了,我們完全可以換一個思路,背景7000個格子,再加上1400個事件,使用者螢幕有那麼大嗎,看得完嗎?肯定是看不完的,既然看不完,那我們只渲染他能看到部分不就可以了!按照這個思路,我們找到了一個庫:[react-visibility-sensor](https://www.npmjs.com/package/react-visibility-sensor)。這個庫使用方法也很簡單: ```javascript function MyComponent (props) { return ( ); } ``` 結合我們前面說的,我們可以將`VisibilitySensor`套在`Background`上面: ```javascript class Background extends PureComponent { render() { return ( ) } } ``` 然後`Event`元件如果發現自己處於不可見狀態,就不用渲染了,只有當自己可見時才渲染: ```javascript class Event extends Component { render() { const { selected } = this.context; const { isVisible, event } = this.props; return ( { isVisible ? ( 複雜內容 ) : null} ) } } Event.contextType = SelectContext; ``` 按照這個思路我們又改了一下,發現效能又提升了,整體時間下降到了大概4.1秒: ![image-20210120140421092](https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/4fea9cacab354f4997313baedc05b294~tplv-k3u1fbpfcp-zoom-1.image) 仔細看上圖,我們發現渲染事件`Rendering`時間從1秒左右下降到了43毫秒,快了二十幾倍,這得益於渲染內容的減少,但是`Scripting`時間,也就是指令碼執行時間仍然高達4.1秒,還需要進一步優化。 ### 砍掉mousedown事件 渲染這塊已經沒有太多辦法可以用了,只能看看`Scripting`了,我們發現效能圖上滑鼠事件有點刺眼: ![image-20210119170345316](https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/b7355e9fa7644df8800a808e85438f86~tplv-k3u1fbpfcp-zoom-1.image) 一次點選同時觸發了三個點選事件:`mousedown`,`mouseup`,`click`。如果我們能幹掉`mousedown`,`mouseup`是不是時間又可以省一半,先去看看他註冊這兩個事件時幹什麼的吧。可以直接在程式碼裡面全域性搜`mousedown`,最終發現都是在[Selection.js](https://github.com/jquense/react-big-calendar/blob/master/src/Selection.js),通過對這個類程式碼的閱讀,發現他是個典型的觀察者模式,然後再搜`new Selection`找到使用的地方,發現`mousedown`,`mouseup`主要是用來實現事件的拖拽功能的,`mousedown`標記拖拽開始,`mouseup`標記拖拽結束。如果我把它去掉,拖拽功能就沒有了。經過跟產品經理溝通,我們後面是需要拖拽的,所以這個不能刪。 事情進行到這裡,我也沒有更多辦法了,但是響應時間還是有4秒,真是讓人頭大 ![image-20210120144109109](https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/28e3b9d1551c437a9bc4796705ce283e~tplv-k3u1fbpfcp-zoom-1.image) 反正沒啥好辦法了,我就隨便點著玩,突然,我發現`mousedown`的呼叫棧好像有點問題: ![image-20210120144433528](https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/aae6b9dd1bfb4117b5e5c2a5f2a8cbfc~tplv-k3u1fbpfcp-zoom-1.image) 這個呼叫棧我用數字分成了三塊: 1. 這裡面有很多熟悉的函式名啊,像啥`performUnitOfWork`,`beginWork`,這不都是我在[React Fiber這篇文章中提過的嗎?](https://www.cnblogs.com/dennisj/p/13183332.html)所以這些是React自己內部的函式呼叫 2. `render`函式,這是某個元件的渲染函式 3. 這個`render`裡面又呼叫了`renderEvents`函式,看起來是用來渲染事件列表的,主要的時間都耗在這裡了 `mousedown`監聽本身我是幹不掉了,但是裡面的執行是不是可以優化呢?`renderEvents`已經是庫自己寫的程式碼了,所以可以直接全域性搜,看看在哪裡執行的。最終發現是在[TimeGrid.js](https://github.com/jquense/react-big-calendar/blob/master/src/TimeGrid.js)的`render`函式被執行了,其實這個是不需要執行的,我們直接把前面歪門邪道的`shouldComponentUpdate`複製過來就可以阻止他的執行。然後再看下效能資料呢: ![image-20210120145945555](https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/1e1db99495b34b6b9182de82cf3bc778~tplv-k3u1fbpfcp-zoom-1.image) 我們發現`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`屬性! 前面我們提到過選中一個事件有兩個途徑: 1. 使用者通過點選某個事件來改變`selected`的值 2. 開發者可以在外部直接修改`selected`的值來選中某個事件 之所以`selected`要放在頂層元件上就是為了實現第二個功能,讓外部開發者可以通過這個受控的`selected`屬性來改變選中的事件。但是經過我們評估,`外部修改selected`這個並不是我們的需求,我們的需求都是使用者點選來選中,也就是說`外部修改selected`這個功能我們可以不要。 如果不要這個功能那就有得玩了,`selected`完全不用放在頂層了,只需要放在事件外層的容器上就行,這樣,改變`selected`值只會觸發事件的更新,啥背景格子的更新壓根就不會觸發,那怎麼改呢?在我們前面的`Calendar -- Background -- Event`模型上再加一層`EventContainer`,變成`Calendar -- Background -- EventContainer -- Event`。`SelectContext.Provider`也不用包裹`Calendar`了,直接包裹`EventContainer`就行。程式碼大概是這個樣子: ```javascript // Calendar.js // Calendar簡單了,不用接受selected引數,也不用SelectContext.Provider包裹了 class Calendar extends Component { render() { return