React 事件系統分析與最佳實踐
1 引子
相信大家在使用 React 的過程中都寫過這樣的代碼
listView = list.map((item,index) => {n return (n <p onClick={this.handleClick.bind(this, item.id)} key={item.id}>{item.text}</p>n );n})n
感謝 @顏什麼都不記得適 提醒了我 key 不該用 index,否則數組中間插入一個值以後,後面就無法保持原來的 key 值,導致需要重新渲染,浪費了性能。
這時,不知道你有沒有想到一種情況:假如 list 有 10000 項會怎麼樣啊?
- 天哪,我生產了一萬個幾乎一模一樣的函數?
- 是的。
- 天哪,我操作了一萬次 DOM?
- 哦,那倒是沒有。
眾所周知
- DOM 是一個獨立於語言的文檔介面 API。在瀏覽器中,該 API 是用 JavaScript 實現的。但瀏覽器通常把DOM 和 JavaScript 分開實現。所以每次 JavaScript 訪問 DOM 都會伴隨著巨大的開銷。
- bind() 會創建一個綁定了作用域的函數實例。於是,內存中存儲了幾乎一樣的函數的一萬個拷貝,這是一種巨大的浪費。
該怎麼解決這一類問題呢?如何不用 bind 還向事件綁定函數傳遞參數呢?React 到底是怎麼實現這些的呢?本文就用於解決這些問題。
2 React 事件系統介紹
- React 自己實現了一套高效的事件註冊、存儲、分發和重用的邏輯,在 DOM 事件體系基礎上做了很大改進。不僅減少了內存消耗,最大化解決了 IE 等瀏覽器的不兼容問題,而且簡化了事件邏輯,對開發者來說非常友好。它有如下的特點:
- 使用事件委託技術進行事件代理,React 組件上聲明的事件最終都轉化為 DOM 原生事件,綁定到了 document 這個 DOM 節點上。從而減少了內存開銷。
- 自身實現了一套事件冒泡機制,以隊列形式,從觸發事件的組件向父組件回溯,調用在 JSX 中綁定的 callback。因此我們也沒法用 event.stopPropagation() 來停止事件傳播,應該使用 React 定義的 event.preventDefault()。
- React 有一套自己的合成事件 SyntheticEvent,而不是單純的使用 DOM 原生事件,但二者可以平滑轉化。
- React 使用對象池來管理合成事件對象的創建和銷毀,這樣減少了垃圾的生成和新對象內存的分配,大大提高了性能。
- 這些是如何實現的呢,通過源碼來分析。
3 React 事件系統源碼分析
- 當我們在 JSX 中寫下 onClick={this.handleClick.bind(this, item.id)},我們即聲明了一個 React 事件。那這個事件是如何被註冊到 React 事件系統中的呢?
- 根據 React 的特點,我們容易知道,這個事件被綁定到了 document 的節點上。實際上,在 React 中,一個事件聲明之後,會在 document 節點相應 addEventListener 並在一個二維數組 listenerBank 中保存相應的事件回調。
3.1 事件註冊
- 事件註冊即在 document 節點,將 React 事件轉化為 DOM 原生事件,並註冊回調。
// enqueuePutListener 負責事件註冊。n// inst:註冊事件的 React 組件實例n// registrationName:React 事件,如:onClick、onChangen// listener:和事件綁定的 React 回調方法,如:handleClick、handleChangen// transaction:React 事務流,不懂沒關係,不太影響對事件系統的理解nfunction enqueuePutListener(inst, registrationName, listener, transaction) {n // 前面太長,省略一部分n doc 為找到的 document 節點n var doc = isDocumentFragment ? containerInfo._node : containerInfo._ownerDocument;n // 事件註冊n listenTo(registrationName, doc);n // 事件存儲,之後會講到,即存儲事件回調方法n transaction.getReactMountReady().enqueue(putListener, {n inst: inst,n registrationName: registrationName,n listener: listenern });n}n
- 來看事件註冊的具體代碼,如何在 document 上綁定 DOM 原生事件。
// 事件註冊n// registrationName:React 事件名,如:onClick、onChangen// contentDocumentHandle:要將事件綁定到的 DOM 節點nlistenTo: function (registrationName, contentDocumentHandle) {n // documentn var mountAt = contentDocumentHandle; n // React 事件和綁定在根節點的 topEvent 的轉化關係,如:onClick -> topClickn var dependencies = EventPluginRegistry.registrationNameDependencies[registrationName];n n for (var i = 0; i < dependencies.length; i++){n // 內部有大量判斷瀏覽器兼容等的步驟,提取一下核心代碼n var dependency = dependencies[i];n n // topEvent 和原生 DOM 事件的轉化關係n if (topEventMapping.hasOwnProperty(dependency)) {n // 三個參數為 topEvent、原生 DOM Event、Documentn // 將事件綁定到冒泡階段n trapBubbledEvent(dependency, topEventMapping[dependency], mountAt);n }n }n}n
- 來看將事件綁定到冒泡階段的具體代碼
// 三個參數為 topEvent、原生 DOM Event、Document(掛載節點)ntrapBubbledEvent: function (topLevelType, handlerBaseName, element) {n if (!element) {n return null;n }n return EventListener.listen(element, handlerBaseName, ReactEventListener.dispatchEvent.bind(null, topLevelType));n}nn// 三個參數為 Document(掛載節點)、原生 DOM Event、事件綁定函數nlisten: function listen(target, eventType, callback) {n // 去除瀏覽器兼容部分,留下核心後n target.addEventListener(eventType, callback, false);n // 返回一個解綁的函數n return {n remove: function remove() {n target.removeEventListener(eventType, callback, false);n }n }n}n
- 在 listen 方法中,我們終於發現了熟悉的 addEventListener 這個原生事件註冊方法。只有 document 節點才會調用這個方法,故僅僅只有 document 節點上才有 DOM 事件。這大大簡化了 DOM 事件邏輯,也節約了內存。
3.2 事件存儲
- 事件註冊之後,還需要將事件綁定的回調函數存儲下來。這樣,在觸發事件後才能去尋找相應回調來觸發。在一開始的代碼中,我們已經看到,是使用 putListener 方法來進行事件回調存儲。
// inst:註冊事件的 React 組件實例n// registrationName:React 事件,如:onClick、onChangen// listener:和事件綁定的 React 回調方法,如:handleClick、handleChangenputListener: function (inst, registrationName, listener) {n // 核心代碼如下n // 生成每個組件實例唯一的標識符 keyn var key = getDictionaryKey(inst);n // 獲取某種 React 事件在回調存儲銀行中的對象n var bankForRegistrationName = listenerBank[registrationName] || (listenerBank[registrationName] = {});n bankForRegistrationName[key] = listener;n}n
- 也就是說,listenerBank 是一個 React 事件和 React 組件的二維映射集合,通過訪問 listenerBank[事件名][組件key],就可以得到對應的綁定回調函數。
3.3 事件執行
- 貼代碼太累了,就講講理論好了。
- 每次觸發事件都會執行根節點上 addEventListener 註冊的回調,也就是 ReactEventListener.dispatchEvent 方法,事件分發入口函數。該函數的主要業務邏輯如下:
- 找到事件觸發的 DOM 和 React Component
- 從該 React Component,調用 findParent 方法,遍歷得到所有父組件,存在數組中。
- 從該組件直到最後一個父組件,根據之前事件存儲,用 React 事件名 + 組件 key,找到對應綁定回調方法,執行,詳細過程為:
- 根據 DOM 事件構造 React 合成事件。
- 將合成事件放入隊列。
- 批處理隊列中的事件(包含之前未處理完的,先入先處理)
- 註:在調用回調時,有一個類似 listener(event) 的調用,所以事件綁定函數可以默認傳參 event。
4 最佳實踐
希望看下面代碼的同學能對 ES 2017 有所了解,我們希望我們寫的代碼能隨時跟隨 ECMAScript 的步伐。所以儘可能不再使用 ES 2015 提倡的 constructor 等特性,而是使用最新的 ES 2017 類屬性提案。
回到這一段代碼,相信大家現在明白這一段代碼到底做了什麼。
listView = list.map((item,index) => {n return (n <p onClick={this.handleClick.bind(this, item.id)} key={item.id}>{item.text}</p>n );n})n
雖然我們的寫法很類似 DOM 原生事件綁定,但實際上 React 仍舊幫你進行了事件委託,這大大優化了性能。
每個 bind 都會生成一個實例存儲於 listenerBank 中。而這些函數實例都功能類似,這樣寫是極大浪費內存的。但我們可以利用默認參數 event,來用解決內存浪費的問題。
this.handleClick = (event) => {n let componentID = event.target.id;n // coden}nlistView = list.map((item,index) => {n return (n <p onClick={this.handleClick} id={item.id} key={item.id}>{item.text}</p>n );n})n
有些封裝的很好的組件(eg:螞蟻金服 ant design 的評分 Rate 組件),不默認傳 event。就導致,有多個相同組件時,無法將所有組件的相同事件綁定在一個函數上,否則無法分辨是哪一個函數觸發了事件。於是只能用 bind 或者箭頭函數內部調用函數的方式來批量創建函數實例,極大的浪費了內存。如果大家有好的方法,也歡迎評論討論。
此處感謝@Kpax Qin對上述問題提供的解決方案,在封裝好的組件外層加一層封裝,這裡直接上代碼了。
- Rate 是阿里下螞蟻金服 ant design 的評分組件,它自定義了 onChange 事件,默認傳遞參數 value,value即評分選擇的星數。
- 這裡有多個 Rate 組件,它們的 value 值,我們在 state 中用一個數組進行存儲。
- Rate 組件不傳遞 event 導致當存在多個 Rate 時無法用一個 handleChange 來處理所有的 onChange 事件,只能調用 bind 來生成多個邏輯相似、傳遞不同參數的函數實例,這是一種內存的浪費。
- 當外面封裝了一層 div 後,基於事件冒泡機制,可以通過 event 來捕獲觸發事件的組件,因此就可以用一個 handleChange 函數來處理所有的 onChange 事件了。
- 點擊 Rate 的結果:
- 但這種處理函數方式會使得業務邏輯更加複雜,因為實際上 handleChange 觸發了兩次,第一次觸發時 value 值需要用臨時變數存儲,然後在第二次調用時根據臨時變數和組件的 id 來對 state 中的 value 數組進行 setState 更新。
- 如果希望更簡明的業務邏輯同時對內存性能的要求不高,建議還是使用 bind(null, id) 的方式進行生成函數實例傳參。類似 4 最佳實踐 里的第一段代碼。
- 歡迎大家提出更好的解決方案。
推薦閱讀:
※前端開發每周閱讀清單:PWA 將與安卓原生平起平坐
※基於 Webpack 的應用包體尺寸優化
※React 實現一個漂亮的 Table