Javascript框架的自定義事件(轉)

bigwhiteshark(雲飛揚)發表於2014-05-17

很多 javascript 框架都提供了自定義事件(custom events),例如 jquery、yui 以及 dojo 都支援“document ready”事件。而部分自定義事件是源自回撥(callback)。

回撥將多個事件控制程式碼儲存在陣列中,當滿足觸發條件時,回撥系統則會從陣列中獲取對應的控制程式碼並執行。那麼,這會有什麼陷阱呢?在回答這個問題之前,我們先看下程式碼。

下面是兩段程式碼依次繫結到 domcontentloaded 事件中

document.addeventlistener("domcontentloaded", function() {
  console.log("init: 1");
  does_not_exist++; // 這裡會丟擲異常
}, false);

document.addeventlistener("domcontentloaded", function() {
  console.log("init: 2");
}, false);

那麼執行這段程式碼會返回什麼資訊?顯然,會看見這些(或者類似的):

init: 1
error: does_not_exist is not defined
init: 2

可以看出,兩段函式都被執行。即使第一個函式丟擲了個異常,但並不影響第二段程式碼執行。

麻煩

ok,我們回來看下常見框架中的回撥系統。首先,我們看下 jquery 的(因為它很流行):

$(document).ready(function() {
  console.log("init: 1");
  does_not_exist++; // 這裡會丟擲異常
});

$(document).ready(function() {
  console.log("init: 2");
});

然後控制檯中輸出了什麼?

init: 1
error: does_not_exist is not defined

這樣問題就很明瞭了。回撥系統其實很脆弱 -- 如果中間有段程式碼丟擲了異常,那麼其餘將不會被執行。想象下在實際情況中,這後果可能會更嚴重,譬如有些糟糕的外掛可能會“一粒老屎壞了一鍋粥”。

其他的框架,dojo 的情況和 jquery 類似,不過 yui 的情況有些許不同。在它的回撥系統中,使用了 try/catch 語句避免因異常發生的中斷。但有個小小的負面影響,就是看不到相應的異常了。

yahoo.util.event.ondomready(function() {
  console.log("init: 1");
  does_not_exist++; // 這裡會丟擲異常
});

yahoo.util.event.ondomready(function() {
  console.log("init: 2");
});

輸出:

init: 1
init: 2

那麼,有無完美的解決方案呢?

解決方案

我想到了個解決方案,就是將回撥和事件結合起來。可以先建立個事件,當回撥觸發時才執行它。由於每個事件都有其獨立的執行環境(execution context),那麼即使其中某個事件丟擲了異常將不會影響其他的回撥。

這聽起來有點複雜,還是程式碼說話吧。

var currenthandler;

// 標準事件支援
if (document.addeventlistener) {
    document.addeventlistener("fakeevents", function() {
        // 執行回撥
        currenthandler();
    }, false);

    // 新建事件
    var dispatchfakeevent = function() {
        var fakeevent = document.createevent("uievents");
        fakeevent.initevent("fakeevents", false, false);
        document.dispatchevent(fakeevent);
    };
} else {
    // 針對 ie 的程式碼在後面詳細闡述
}

var onloadhandlers = [];

// 將回撥加入陣列中
function addonload(handler) {
    onloadhandlers.push(handler);
};

// 逐條取出回撥,並利用上述新建的事件執行
onload = function() {
    for (var i = 0; i < onloadhandlers.length; i++) {
        currenthandler = onloadhandlers[i];
        dispatchfakeevent();
    }
};

萬事俱備,讓我們將上面坨程式碼扔到我們新的回撥系統中

addonload(function() {
  console.log("init: 1");
  does_not_exist++; // 這裡會丟擲異常
});

addonload(function() {
  console.log("init: 2");
});

上帝保佑,看執行結果我們看到了如下的資訊:

init: 1
error: does_not_exist is not defined
init: 2

贊!這就是我們期望的。這兩個回撥都執行而且互不影響,並且還能獲得異常的資訊,太好了!

好了,我們回過頭來扶起 internet explorer 這個“阿斗”(我已經聽見場下觀眾的建議了)。internet explorer 不支援 w3c 的標準事件規範,謝天謝地好在它有自身的實現 -- 有個 fireevents 的方法,但只能在使用者事件的時候觸發(例如使用者點選 click)。

不過終於找到了門道,我們來看下具體程式碼:

var currenthandler;

if (document.addeventlistener) {
    // 省略上述的程式碼
} else if (document.attachevent) { // msie
    // 利用擴充套件屬性,當此物件被改變時觸發
    document.documentelement.fakeevents = 0;
    document.documentelement.attachevent("onpropertychange", function(event) {
        if (event.propertyname == "fakeevents") {
            // 執行回撥
            currenthandler();
        }
    });

    dispatchfakeevent = function(handler) {
        // 觸發 propertychange 事件
        document.documentelement.fakeevents++;
    };
}

簡而言之,殊途同歸,只是針對 internet explorer 使用了 propertychange 事件作為觸發器。

更新

有些使用者留言建議使用 settimeout:

try { callback(); } catch(e){ settimeout(function(){ throw e; }, 0); }

而下面是我的考慮

如沒特別的要求,其實定時器的確也能搞定這問題。
上面僅僅是舉例說明了這一技術的可行性。

意義在於,目前很多框架在回撥系統的實現都非常的
脆弱,這或許能給這些框架能它們提供更優化的思路。
而定時器的實現並非實際的觸發了事件,在實際事件
中,事件會被順序的執行、可相互影響(譬如冒泡)、
還可以停止 -- 而這些是定時器無法做到的。

總之,最重要的是已經實現了包括 internet explorer 在內,使用事件執行回撥的實現。如果你正編寫基於事件代理的回撥系統,我想你會對這一技術感興趣的。

更新2

prototype 在針對 internet explorer 的自定義事件處理上,也是同上述的方法觸發回撥:

http://andrewdupont.net/2009/03/24/link-dean-edwards/

譯註,prototype 1.6 對應的程式碼,摘記如下:

function createwrapper(element, eventname, handler) {
    var id = geteventid(element); // 獲取繫結事件的 id
    var c = getwrappersforeventname(id, eventname); // 獲取對應的事件的所有回撥
    if (c.pluck("handler").include(handler)) return false; // 避免重複繫結

    // 新建回撥
    var wrapper = function(event) {
        if (!event || !event.extend ||
                (event.eventname && event.eventname != eventname))
            return false;

        event.extend(event);
        handler.call(element, event);
    };

    // 加入到回撥陣列
    wrapper.handler = handler;
    c.push(wrapper);
    return wrapper;
}

function observe(element, eventname, handler) {
    element = $(element);                  // 對應事件的元素
    var name = getdomeventname(eventname); // 事件執行方式

    var wrapper = createwrapper(element, eventname, handler); // 封裝回撥

    if (!wrapper) return element;

    // 繫結事件
    if (element.addeventlistener) {
        element.addeventlistener(name, wrapper, false);
    } else {
        element.attachevent("on" + name, wrapper);
    }

    return element;
}

// 呼叫方式
document.observe("dom:loaded", function() {
    console.log("init: 1");
    does_not_exist++;
});

document.observe("dom:loaded", function() {
    console.log("init: 2");
});

看把 prototype 的作者給樂的 :-/

-- split --

在本人看來,原文的作者表述的技術點,除了如何建立健壯的回撥系統外,其實還有兩條。

其一,就是如何保證在出現異常的時,繼續執行期望的程式碼;其二,就是如何建立互不干擾的“執行環境”。

原文提到的 createevent 和 settimeout 都是好辦法,只是處理原作者所言在回撥系統中,的確使用 createevent 會比較合適。settimeout 相對應的詳細資訊,可移步到 realazy 兄的相關文章。

而即使出錯也能繼續執行期望的程式碼,其實可以考慮使用 finally 語句,下面是個例子:

var callbacks = [
  function() { console.log(0); },
  function() { console.log(1); throw new error; },
  function() { console.log(2); },
  function() { console.log(3); }
];

for(var i = 0, len = callbacks.length; i < len; i++) {
    try {
        callbacks[i]();
    } catch(e) {
        console.info(e); // 獲得異常資訊
    } finally {
        continue;
    }
}

這一靈感同樣來自 dean edwards 文章後的回覆,在這裡也貼下吧:

function iterate(callbacks, length, i) {
    if (i >= length) return;

    try {
        callbacks[i]();
    } catch(e) {
        throw e;
    } finally {
        iterate(callbacks, length, i+1);
    }
}

最後,留個小問題。誰知道上述的程式碼中,留言者提出的為什麼異常到最後才列印出來不?

相關文章