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

_蔣鵬飛發表於2021-01-28

本文會分享一個React效能優化的故事,這也是我在工作中真實遇到的故事,最終我們是通過魔改第三方庫原始碼將它效能提高了幾十倍。這個第三方庫也是很有名的,在GitHub上有4.5k star,這就是:react-big-calendar

這個工作不是我一個人做的,而是我們團隊幾個月前共同完成的,我覺得挺有意思,就將它覆盤總結了一下,分享給大家

在本文中你可以看到:

  1. React常用效能分析工具的使用介紹
  2. 效能問題的定位思路
  3. 常見效能優化的方式和效果:PureComponent, shouldComponentUpdate, Context, 按需渲染等等
  4. 對於第三方庫的問題的解決思路

關於我工作中遇到的故事,我前面其實也分享過兩篇文章了:

  1. 速度提高几百倍,記一次資料結構在實際工作中的運用

  2. 使用mono-repo實現跨專案元件共享

特別是速度提高几百倍,記一次資料結構在實際工作中的運用,這篇文章在某平臺單篇閱讀都有三萬多,有些朋友也提出了質疑。覺得我這篇文章裡面提到的問題現實中不太可能遇到,裡面的效能優化更多是偏理論的,有點杞人憂天。這個觀點我基本是認可的,我在那篇文章正文也提到過可能是個偽需求,但是技術問題本來很多就是理論上的,我們在leetcode上刷題還是純理論呢,理論結合實際才能發揮其真正的價值,即使是杞人憂天,但是效能確實快上了那麼一點點,也給大家提供了另一個思路,我覺得也是值得的。

與之相對的,本文提到的問題完全不是杞人憂天了,而是實打實的使用者需求,我們經過使用者調研,發現使用者確實有這麼多資料量,需求上不可能再壓縮了,只能技術上優化,這也是逼得我們去改第三方庫原始碼的原因。

需求背景

老規矩,為了讓大家快速理解我們遇到的問題,我會簡單講一下我們的需求背景。我還是在那家外企,不久前我們接到一個需求:做一個體育場館管理Web App。這裡面有一個核心功能是場館日程的管理,有點類似於大家Outlook裡面的Calendar。大家如果用過Outlook,應該對他的Calendar有印象,基本上我們的會議及其他日程安排都可以很方便的放在裡面。我們要做的這個也是類似的,體育場館的老闆可以用這個日曆來管理他下面場地的預定。

假設你現在是一個羽毛球場的老闆,來了個客戶說,嘿,老闆,這週六場地有空嗎,我訂一個小時呢!場館每天都很多預定,你也不記得週六有沒有空,所以你開啟我們的網站,看了下日曆:

image-20210117111412119

你發現1月15號,也就是星期五有兩個預定,週六還全是空閒的,於是給他說:你運氣真好,週六目前還沒人預定,時段隨便挑!上面這個截圖是react-big-calendar的官方示例,我們也是選定用他來搭建我們自己的應用。

真實場景

上面這個例子只是說明下我們的應用場景,裡面預定只有兩個,場地只有一塊。但是我們真實的客戶可比這個大多了,根據我們的調研,我們較大的客戶有數百塊場地,每個場地每天的預定可能有二三十個。上面那個例子我們換個生意比較好的老闆,假設這個老闆有20塊羽毛球場地,每天客戶都很多,某天還是來了個客戶說,嘿,老闆,這週六場地有空嗎,我訂一個小時呢!但是這個老闆生意很好,他看到的日曆是這樣的:

image-20210117112848684

本週場館1全滿!!如果老闆想要為客戶找到一個有空的場地,他需要連續切換場館1,場館2。。。一直到場館20,手都點酸了。。。為了減少老闆手的負擔,我們的產品經理提出一個需求,同時在頁面上顯示10個場館的日曆,好在react-big-calendar本身就是支援這個的,他把這個叫做resources

效能爆炸

看起來我們要的基本功能react-big-calendar都能提供,前途還是很美好的,直到我們將真實的資料渲染到頁面上。。。我們的預定不僅僅是展示,還需要支援一系列的操作,比如編輯,複製,剪下,貼上,拖拽等等。當然這一切操作的前提都是選中這個預定,下面這個截圖是我選中某個預定的耗時:

image-20210117114847440

僅僅是一個最簡單的點選事件,指令碼執行耗時6827ms,渲染耗時708ms,總計耗時7.5s左右,這TM!這玩意兒還想賣錢?送給我,我都不想用

可能有朋友不知道這個效能怎麼看,這其實是Chrome自帶的效能工具,基本步驟是:

  1. 開啟Chrome除錯工具,點到Performance一欄
  2. 點選左上角的小圓點,開始錄製
  3. 執行你想要的操作,我這裡就是點選一個預定
  4. 等你想要的結果出來,我這裡就是點選的預定顏色加深
  5. 再點選左上角的小圓點,結束錄製就可以看到了

為了讓大家看得更清楚,我這裡錄製了一個操作的動圖,這個圖可以看到,點選操作的響應花了很長時間,Chrome載入這個效能資料也花了很長時間:

Jan-17-2021 12-51-51

測試資料量

上面僅僅一個點選耗時就七八秒,是因為我故意用了很大資料量嗎?不是!我的測試資料量是完全按照使用者真實場景計算的:同時顯示10個場館,每個場館每天20個預定,上面使用的是周檢視,也就是可以同時看到7天的資料,那總共顯示的預定就是:

10 * 20 * 7 = 1400,總共1400個預定顯示在頁面上。

為了跟上面這個龜速點選做個對比,我再放下優化後的動圖,讓大家對後面這個長篇大論實現的效果先有個預期:

Jan-20-2021 16-42-53

定位問題

我們一般印象中,React不至於這麼慢啊,如果慢了,大概率是寫程式碼的人沒寫好!我們都知道React有個虛擬樹,當一個狀態改變了,我們只需要更新與這個狀態相關的節點就行了,出現這種情況,是不是他幹了其他不必要的更新與渲染呢?為了解決這個疑惑,我們安裝了React專用除錯工具:React Developer Tools。這是一個Chrome的外掛,Chrome外掛市場可以下載,安裝成功後,Chrome的除錯工具下面會多兩個Tab頁:

image-20210117130740746

Components這個Tab下有個設定,開啟這個設定可以看到你每次操作觸發哪些元件更新,我們就是從這裡面發現了一點驚喜:

image-20210117130951475

為了看清楚點選事件觸發哪些更新,我們先減少資料量,只保留一兩個預定,然後開啟這個設定看看:

Jan-17-2021 13-21-55

哼,這有點意思。。。我只是點選一個預定,你把整個日曆的所有元件都給我更新了!那整個日曆有多少元件呢?上面這個圖可以看出10:00 AM10: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來改變他的選中狀態,執行效果如下:

Jan-17-2021 15-17-38

這段程式碼所有資料都在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);

這段程式碼的優化主要在這幾個地方:

  1. 將資料從單純的items拆分成了idsitems
  2. 頂層元件App使用ids來渲染列表,ids裡面只有id,所以只要不是增加和刪除,僅僅單條資料的狀態變化,ids並不需要變化,所以App不會更新。
  3. Item元件自己去連線自己需要的資料,當自己關心的資料變化時才更新,其他元件的資料變化並不會觸發更新。

拆解第三方庫原始碼

上面通過使用除錯工具我看到了一個熟悉的現象,並猜到了他慢的原因,但是目前僅僅是猜測,具體是不是這個原因還要看看他的原始碼才能確認。好在我在看他的原始碼前先去看了下他的文件,然後發現了這個:

image-20210117162411789

react-big-calendar接收兩個引數onSelectEventselectedselected表示當前被選中的事件(預定),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其實自帶一個全域性狀態共享的功能,那就是ContextReact Context API官方有詳細介紹我之前的一篇文章也介紹過他的基本使用方法,這裡不再講述他的基本用法,我這裡想提的是他的另一個特性:使用Context Provider包裹時,如果你傳入的value變了,會執行下面所有節點的render函式,這跟前面提到的普通props是一樣的。但是,如果Provider下面的兒子節點是PureComponent,可以不執行兒子節點的render函式,而直接執行使用這個value的孫子節點

什麼意思呢,下面我將我們面臨的問題簡化來說明下。假設我們只有三層,第一層是頂層容器Calendar,第二層是背景的空白格子(兒子),第三層是真正需要使用selected的事件(孫子):

image-20210119144005794

示例程式碼如下:

// 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來說沒有變化時,其實就不需要執行rendershouldComponentUpdate就可以這樣寫:

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://github.com/jquense/react-big-calendar/blob/master/src/Calendar.js#L790

這行程式碼的意思是每次props改變都去重新計算狀態state,而他的計算程式碼是這樣的:

image-20210119151747973

程式碼地址:https://github.com/jquense/react-big-calendar/blob/master/src/Calendar.js#L794

注意他的返回值是一個新的物件,而且這個物件裡面的屬性,比如localizer的計算方法mergeWithDefaults也是這樣,每次都返回新的物件:

image-20210119151956459

程式碼地址: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秒左右。

image-20210119160608456

按需渲染

上面我們用shouldComponentUpdate阻止了7000個背景格子的更新,響應時間下降了兩秒多,但是還是需要5秒多時間,這也很難接受,還需要進一步優化。按照我們之前說的如果還能阻止另外1399個事件的更新那就更好了,但是經過對他資料結構的分析,我們發現他的資料結構跟我們前面舉的列表例子還不一樣。我們列表的例子所有資料都在items裡面,是否選中是item的一個屬性,而react-big-calendar的資料結構裡面eventselectedEvent是兩個不同的屬性,每個事件通過判斷自己的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秒:

image-20210120140421092

仔細看上圖,我們發現渲染事件Rendering時間從1秒左右下降到了43毫秒,快了二十幾倍,這得益於渲染內容的減少,但是Scripting時間,也就是指令碼執行時間仍然高達4.1秒,還需要進一步優化。

砍掉mousedown事件

渲染這塊已經沒有太多辦法可以用了,只能看看Scripting了,我們發現效能圖上滑鼠事件有點刺眼:

image-20210119170345316

一次點選同時觸發了三個點選事件:mousedownmouseupclick。如果我們能幹掉mousedownmouseup是不是時間又可以省一半,先去看看他註冊這兩個事件時幹什麼的吧。可以直接在程式碼裡面全域性搜mousedown,最終發現都是在Selection.js,通過對這個類程式碼的閱讀,發現他是個典型的觀察者模式,然後再搜new Selection找到使用的地方,發現mousedownmouseup主要是用來實現事件的拖拽功能的,mousedown標記拖拽開始,mouseup標記拖拽結束。如果我把它去掉,拖拽功能就沒有了。經過跟產品經理溝通,我們後面是需要拖拽的,所以這個不能刪。

事情進行到這裡,我也沒有更多辦法了,但是響應時間還是有4秒,真是讓人頭大

image-20210120144109109

反正沒啥好辦法了,我就隨便點著玩,突然,我發現mousedown的呼叫棧好像有點問題:

image-20210120144433528

這個呼叫棧我用數字分成了三塊:

  1. 這裡面有很多熟悉的函式名啊,像啥performUnitOfWorkbeginWork,這不都是我在React Fiber這篇文章中提過的嗎?所以這些是React自己內部的函式呼叫
  2. render函式,這是某個元件的渲染函式
  3. 這個render裡面又呼叫了renderEvents函式,看起來是用來渲染事件列表的,主要的時間都耗在這裡了

mousedown監聽本身我是幹不掉了,但是裡面的執行是不是可以優化呢?renderEvents已經是庫自己寫的程式碼了,所以可以直接全域性搜,看看在哪裡執行的。最終發現是在TimeGrid.jsrender函式被執行了,其實這個是不需要執行的,我們直接把前面歪門邪道的shouldComponentUpdate複製過來就可以阻止他的執行。然後再看下效能資料呢:

image-20210120145945555

我們發現Scripting下降到了3.2秒左右,比之前減少約800毫秒,而mousedown的時間也從之前的幾百毫秒下降到了50毫秒,在圖上幾乎都看不到了,mouseup事件也不怎麼看得到了,又算進了一步吧~

忍痛閹割功能

到目前為止,我們的效能優化都沒有閹割功能,響應速度從7.5秒下降到了3秒多一點,優化差不多一倍。但是,目前這速度還是要三秒多,別說作為一個工程師了,作為一個使用者我都忍不了。咋辦呢?我們是真的有點黔驢技窮了。。。

看看上面那個效能圖,主要消耗時間的有兩個,一個是click事件,還有個timertimer到現在我還不知道他哪裡來的,但是click事件我們是知道的,就是使用者點選某個事件後,更改SelectContextselected屬性,然後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 -- EventSelectContext.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也可以,可以直接作為EventContainerstate但是如果EventContainerEvent中間還有層級,需要穿透傳遞,仍然需要Context,中間層級和以前的類似,使用shouldComponentUpdate阻止更新

還有一點,因為selected不在頂層了,所以selected更新也不會觸發中間Background更新了,所以Background上的shouldComponentUpdate也可以刪掉了。

我們這樣優化後,效能又提升了:

image-20210120161336248

現在Scripting時間直接從3.2秒降到了800毫秒,其中click事件只有163毫秒,現在從我使用來看,卡頓已經不明顯了,直接錄個動圖來對比下吧:

Jan-20-2021 16-42-53

上面這個動圖已經基本看不出卡頓了,但是我們效能圖上為啥還有800毫秒呢,而且有一個很長的Timer Fired。經過我們的仔細排查,發現這其實是個烏龍,Timer Fired在我一開始錄製效能就出現了,那時候我還在切換頁面,還沒來得及點選呢,如果我們點進去會發現他其實是按需渲染引入的react-visibility-sensor的一個檢查元素可見性的定時任務,並不是我們點選事件的響應時間。把這塊去掉,我們點選事件的響應時間其實不到200毫秒。

從7秒多優化到不到200毫秒,三十多倍的效能優化,終於可以交差了,哈哈?

總結

本文分享的是我工作中實際遇到的一個案例,實現的效果是將7秒左右的響應時間優化到了不到200毫秒,優化了三十幾倍,優化的代價是犧牲了一個不常用的功能。

本來想著要是優化好了可以給這個庫提個PR,造福大家的。但是優化方案確實有點歪門邪道:

  1. 使用了JSON.stringify來進行shouldComponentUpdate的對比優化,對於函式,Symbol屬性的改變沒法監聽到,不適合開放使用,只能在資料自己可控的情況下小規模使用。
  2. 犧牲了一個暴露給外部的受控屬性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個元件更新仍然要花大量的時間。

第二步優化

為了減少點選後更新的事件數量,我們為事件引入按需渲染,只渲染使用者可見的事件元件。同時我們還對mousedownmouseup進行了優化,也是使用shouldComponentUpdate阻止了不必要的更新。

第二步優化效果

響應時間從5秒多下降到3秒多。

第二步優化的問題

響應時間仍然有三秒多,經過分析發現,背景7000個格子雖然使用shouldComponentUpdate阻止了render函式的執行,但是shouldComponentUpdate本身執行7000次也要費很長時間。

第三步優化

為了讓7000背景格子連shouldComponentUpdate都不執行,我們忍痛閹割了頂層受控的selected屬性,直接將它放到了事件的容器上,它的更新再也不會觸發背景格子的更新了,也就是連shouldComponentUpdate都不執行了。

第三步優化效果

響應時間從3秒多下降到不到200毫秒。

第三步優化的問題

功能被閹割了,其他完美!

參考資料:

react-big-calendar倉庫

high-performance-redux

文章的最後,感謝你花費寶貴的時間閱讀本文,如果本文給了你一點點幫助或者啟發,請不要吝嗇你的贊和GitHub小星星,你的支援是作者持續創作的動力。

歡迎關注我的公眾號進擊的大前端第一時間獲取高質量原創~

“前端進階知識”系列文章:https://juejin.im/post/5e3ffc85518825494e2772fd

“前端進階知識”系列文章原始碼GitHub地址: https://github.com/dennis-jiang/Front-End-Knowledges

QR1270

相關文章