Dean Edwards 最近有篇文章很精彩,忍不住在這裡翻譯下。
-- Split --
很多 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 在內,使用事件執行回調的實現。如果你正編寫基於事件代理的回調系統,我想你會對這一技術感興趣的。
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);
}
}
最後,留個小問題。誰知道上述的代碼中,留言者提出的為什麼異常到最後才打印出來不?