從一次重寫原生方法遇到的坑,總結一下Web中的事件系統

寫在前面

前段時間,我寫過一篇文章前端開發中的Error以及異常捕獲。 在文章中,我提到了這個問題:

clipboard.png

經過不斷探索(不想再噴自己了),我找到了原因。下面一一道來。本文主要講解自己找問題原因的思路,如果想看結論和總結,請直接跳到文末。

問題復現

我是在自己以前的項目中測試addEventListener的重寫的。這裏直接上精簡後的問題代碼:

import React from 'react';
import ReactDOM from 'react-dom';

const nativeAddEventListener = EventTarget.prototype.addEventListener;
EventTarget.prototype.addEventListener = function (type, func, options) {
    const wrappedFunc = function (...args) {
        try {
             return func.apply(this, args);
        } catch (e) {
             const errorObj = {
                 error_msg: e.message || '',
                 error_stack: e.stack || (e.error && e.error.
                 error_native: e
             };
        }
    }
    return self.nativeAddEventListener.call(this, type, wrappedFunc, options);
};

const App = function() {
    return <div>11111</div>
};

ReactDOM.render(<App/>, document.body);

運行這段代碼,瀏覽器上一片空白,但是卻沒有任何報錯。我一臉懵逼。

clipboard.png

問題初探索

刪掉那一點重寫addEventListener的代碼後,表現符合預期了。應該是重寫那兒的問題。但是仔細看了過後,那段代碼並沒有什麼問題。並且這段代碼我在其他地方也試過,表現一直是正常的。是不是和React哪裏衝突了?我使用的React版本是
clipboard.png
我搜索了react-dom源碼中的addEventListener關鍵字,總共出現了四次。初步看了一下,並沒有什麼問題,只是註冊了一些事件而已。沒有具體分析這些代碼的含義,我選擇了先更換React的版本試一試,於是,我換成了15.6.2的版本。令人吃驚的是,表現符合預期了。難道真的和React的版本有關係? 在我的認知中,兩個版本中最大的不同就是:React v16採用了全新的Fiber架構,而我對Fiber的理解大概就是:重新設計了react node的數據結構,模擬實現了自己的任務堆棧,結合時間分片來進行任務的調度,從而更新整個系統。另外,React有自己的一套任務系統,addEventListener和任務也是緊密相關的,難道影響到了這個?

繼續探索

我決定從ReactDOM.render()這個方法入手,調試一下ReactDOM的源代碼。之前並沒有研究過React的源碼,壓力有點大。調試了一翻之後,我並沒有發現什麼問題,並且已經有點懵逼了。我準備同時調試react v15react v16的代碼,看看有什麼不同。爲了方便,我將問題代碼全部抽了出來,全部寫到了一個html文件中,並且直接引用React的cdn地址。這個時候,我發現了一個神奇的問題:直接引用cdn地址後,不管React是什麼版本,就算是v16版本,也不會出現之前問題,表現都是符合預期的。我更加懵逼了。

圖片描述

發現問題

靜下心來仔細觀察後,我發現了,我cdn引用的都是reactproduction版本,而我在項目中使用的react代碼,卻是development版本的,難道是developmentproduction的diff代碼,導致了上面的問題。於是我重新仔細看了一下v16development的代碼,找到了代碼中一段長長的註釋:

clipboard.png

大意就是:在開發版本中,react不會採用try{}catch(){}的方式來捕獲錯誤,而是會把所有開發者定義的callback用一個叫做invokeGuardedCallback的函數包裹起來,然後使用一個假的dom,監聽、觸發自定義事件來執行invokeGuardedCallback,並且通過一個全局的錯誤捕捉函數來捕獲錯誤。

在這段註釋的下面,就是註釋中提到的invokeGuardedCallback的代碼。

我仔細研究了這個invokeGuardedCallback的代碼,其核心就是:

function invokeGuardedCallback(name, func, context, a, b, c, d, e, f){
     ...
     var fakeNode = document.createElement('react');
     var evt = document.createEvent('Event');
     var evtType = 'react-' + (name ? name : 'invokeguardedcallback');
     var callCallback = function(){
        ...
        fakeNode.removeEventListener(evtType, callCallback, false); // 這裏很重要!!!
        ...
        func.apply(context, funcArgs); // 這裏是真正執行react中的邏輯代碼
     } 
     fakeNode.addEventListener(evtType, callCallback, false);
     evt.initEvent(evtType, false, false);
     fakeNode.dispatchEvent(evt);
     ...
}
 

react將所有容易出錯的函數,都用這個invokeGuardedCallback包了起來。每一次都重新造一個虛擬的element,然後監聽其自定義事件,並且立即觸發這個自定義事件。調試了這個invokeGuardedCallback後,我發現在react v16中,發現很多函數被多次執行。
爲什麼會多次執行呢? 終於,我找到了問題的原因:

我重寫了addEventListener, 在函數外包了一層try{}catch(){},返回的是一個新的函數,所以,最終註冊在事件監聽器上的,並不是我傳入的那個函數。這個時候,調用removeEventListener時,無法移除我傳入addEventListener的函數。

invokeGuardedCallback中,removeEventListener的邏輯相當於並沒有生效。於是,在Fiber的調度中,某個函數被多次重複執行了,而被重複執行的函數並不是冪等的,問題便產生了。

問題的總結與思考

問題終於定位了,一句總結,就是:

重寫了addEventListener,卻並沒有考慮到與之對應的removeEventListener,導致removeEventListener無法正常工作。

下面是一些思考:

  1. 一開始,如果我仔細看一下react源碼中addEventListener周圍的代碼,或許能更早發現這個問題,就不用繞這麼大一個圈了。
  2. 自己對於第三方庫的development版本和production版本,並沒有一個很強烈的認知、意識,以前上線的不少項目,線上竟然還是用的第三方庫的development版本,這個毛病,一定得改掉。
  3. 分析問題的能力還很欠缺,不夠敏感。考慮問題的全面性需要提高。
  4. 真的不要隨便重寫原生方法。。。

寫在後面

在探索這個問題的過程中,我看到了react巧妙應用自定義事件來捕獲錯誤。於是,我全面總結一下了Web中的事件系統,也算是對基礎的鞏固。由於篇幅已經不夠了,這裏就直接放文章鏈接吧:

談一談web中的事件
談一談web中的事件


歡迎關注我的公衆號: 符合預期的CoyPan
這裏只有乾貨,符合你的預期。
圖片描述

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章