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); } }
最後,留個小問題。誰知道上述的代碼中,留言者提出的為什麼異常到最後才打印出來不?