В этой статье мы создадим мини-библиотеку, которая будет кросс-браузерно работать с событиями.
Ее задача - навешивать/убирать обработчики, а также делать одинаковой работу с ними для разных браузеров.
Основные требования: простота, грамотность и компактный код.
Те же требования, более подробно:
Изначальные требования:
event и текущий элемент this.attachEvent + addEventListenerНаибольшее распространение в интернете получил следующий "универсальный" код.
function addEvent(elem, type, handler){
if (elem.addEventListener){
elem.addEventListener(type, handler, false)
} else {
elem.attachEvent("on"+type, handler)
}
}
// ..и аналогичный метод removeEvent
Он, в самом деле, очень прост. Однако, есть и недостатки.
this).handler должен самостоятельно производить кросс-браузерную предобработку событияПередать текущий элемент в IE можно через замыкание: elem.attachEvent("on"+type, function() { handler.call(elem) }), что, впрочем, приведет к утечкам памяти в IE6 до апдейта июня 2007 года.
Современные яваскрипт-библиотеки, как правило, не используют этот код.
На текущий момент более-менее устоялся стандартный способ кросс-браузерного добавления событий.
Большой вклад в это внесли Dean Edwards и Tino Zeidel, на библиотеках которых зачастую и основан современный код. Например, это так для dojo toolkit и jQuery.
Кроме того, что в новом коде не будет описанных недостатков, он предоставит ряд приятных дополнительных фич:
Подходящее решение состоит из двух частей, его реализацию мы рассмотрим в процессе создания новой мини-библиотеки событий. Назовем ее Event.
Основная логика работы:
events, который содержит назначенные обработчики событий.
Например, добавим чистому элементу elem события:
/* Event - наша новая библиотека */
Event.add(elem, "click", function(e) { alert("Hi") })
Event.add(elem, "click", function(e) { alert("I am clicked") })
Event.add(elem, "mouseover", function(e) { alert("Mouse over!") })
Это создаст следующий служебный объект:
elem.events = {
'click' : {
1 : function(e) { alert("Hi!") },
2 : function(e) { alert("I am clicked") }
},
'mouseover' : {
3 : function(e) { alert("Mouse over!") }
}
}
То есть, обработчики добавляются в соответствующий подсписок events один за другим. При этом каждый новый обработчик при добавлении получает уникальный номер.
По этому номеру обработчик можно будет удалить.
Event.add при помощи addEventListener/attachEvent вешает на это событие специальный служебный обработчик handle. Обработчик создается так, чтобы помнил элемент, на котором висит.
Теперь при наступлении события браузер запустит функцию-обработчик handle.
А уже она, зная свой элемент, получает объект events и запускает назначенные обработчики из списка.
Обработчики при этом запускаются ровно в той последовательности, в которой они были добавлены.
Что немаловажно, handle не просто запускает обработчики, а еще и осуществляет кроссбраузерную обработку объекта события event, так что не надо беспокоиться о различиях между браузерами.
Теперь, когда у нас есть общий формат, и в общих чертах понятно, как оно будет работать - займемся деталями реализации.
Мини-библиотечка Event будет представлять собой синглтон с несколькими приватными и двумя публичными функциями:
handle для события type на элементе elem
Event = (function() {
// текущий номер обработчика
var guid = 0
function fixEvent(event) {
// кросс-браузерная предобработка объекта-события
// нормализация свойств и т.п.
}
function commonHandler(event) {
// вспомогательный универсальный обработчик
}
return {
add: function(elem, type, handler) {
// добавить обработчик события
},
remove: function(elem, type, handler) {
// удалить обработчик события
}
}
}())
Добавление обработчика осуществляется функцией add:
add: function(elem, type, handler) {
// исправляем небольшой глюк IE с передачей объекта window
if (elem.setInterval && ( elem != window && !elem.frameElement ) ) {
elem = window;
}
// (1)
if (!handler.guid) {
handler.guid = ++guid
}
// (2)
if (!elem.events) {
elem.events = {}
elem.handle = function(event) {
if (typeof Event !== "undefined") {
return commonHandle.call(elem, event)
}
}
}
// 3
if (!elem.events[type]) {
elem.events[type] = {}
if (elem.addEventListener)
elem.addEventListener(type, elem.handle, false)
else if (elem.attachEvent)
elem.attachEvent("on" + type, elem.handle)
}
// (4)
elem.events[type][handler.guid] = handler
}
Вот расшифровка того, что она делает:
events[type].
events и обработчик handle.
Обработчик handle фильтрует редко возникающую ошибку, когда событие отрабатывает после unload'а страницы.
Основная же его задача - передать вызов универсальному обработчику commonHandle с правильным указанием текущего элемента this.
Как и events, handle достаточно инициализовать один раз для любых событий.
events[type] и вешаем elem.handle как обработчик на elem для запуска браузером по событию type.elem.events[type] под заданным номером.
Так как номер устанавливается один раз, и далее не меняется - это приводит к ряду интересных фич. Например, запуск add с одинаковыми аргументами добавит событие только один раз.
function handleIt(e) { ... }
Event.add(elem, type, handleIt)
Event.add(elem, type, handleIt)
// добавился 1 обработчик handleIt
Кроме того, можно добавить и удалить одну и ту же функцию как обработчик для разных событий.
Итак, все просто, не так ли? Но этот код появился спустя столько лет, в результате многочисленных и острых дискуссий.
Вспомогательная служебная функция-обработчик:
function commonHandle(event) {
// (1)
event = fixEvent(event)
// (2)
var handlers = this.events[event.type]
for ( var g in handlers ) {
// (3)
var ret = handlers[g].call(this, event)
// (4)
if ( ret === false ) {
event.preventDefault()
event.stopPropagation()
}
}
}
return false из обработчикаТеперь уже достаточно просто написать удаление обработчика:
remove: function(elem, type, handler) {
// (1)
var handlers = elem.events && elem.events[type]
if (!handlers) return
// (2)
delete handlers[handler.guid]
// (3)
for(var any in handlers) return
// (3.1)
if (elem.removeEventListener)
elem.removeEventListener(type, elem.handle, false)
else if (elem.detachEvent)
elem.detachEvent("on" + type, elem.handle)
delete elem.events[type]
// (3.2)
for (var any in elem.events) return
try {
delete elem.handle
delete elem.events
} catch(e) { // IE
elem.removeAttribute("handle")
elem.removeAttribute("events")
}
}
events[type]events и handle за ненадобностью. IE может выдать ошибку при delete свойства элемента, поэтому для него предусмотрен блок catch.Ряд свойств объекта события следует привести к кросс-браузерному, удобному для использования виду.
Как это делать для каждого свойства - подробно описано в статье о свойствах объекта события.
По ее материалам работает функция fixEvent, которая добавляет в объект событие отсутствующие там свойства и возможности, делая его одинаковым для всех браузеров.
Почти все свойства объекта события read only, то есть доступны только для чтения.
Поэтому при добавлении возможно отсутствующих свойств в объект события нельзя написать такой код:
event.target = event.target || event.srcElement
По замыслу он должен ставить свойство target, когда его нет (IE), а по факту - будет вываливаться с ошибкой в Firefox, т.к target там уже присутствует. В этом случае выполнится присваивание event.target = event.target, что недопустимо, т.к target - readOnly.
Правильный вариант:
if (!event.target) {
event.target = event.srcElement
}
function fixEvent(event) {
// получить объект события
event = event || window.event
// один объект события может передаваться по цепочке разным обработчикам
// при этом кроссбраузерная обработка будет вызвана только 1 раз
if ( event.isFixed ) {
return event
}
event.isFixed = true // пометить событие как обработанное
// добавить preventDefault/stopPropagation для IE
event.preventDefault = event.preventDefault || function(){this.returnValue = false}
event.stopPropagation = event.stopPropagaton || function(){this.cancelBubble = true}
// добавить target для IE
if (!event.target) {
event.target = event.srcElement
}
// добавить relatedTarget в IE, если это нужно
if (!event.relatedTarget && event.fromElement) {
event.relatedTarget = event.fromElement == event.target ? event.toElement : event.fromElement;
}
// вычислить pageX/pageY для IE
if ( event.pageX == null && event.clientX != null ) {
var html = document.documentElement, body = document.body;
event.pageX = event.clientX + (html && html.scrollLeft || body && body.scrollLeft || 0) - (html.clientLeft || 0);
event.pageY = event.clientY + (html && html.scrollTop || body && body.scrollTop || 0) - (html.clientTop || 0);
}
// записать нажатую кнопку мыши в which для IE
// 1 == левая; 2 == средняя; 3 == правая
if ( !event.which && event.button ) {
event.which = (event.button & 1 ? 1 : ( event.button & 2 ? 3 : ( event.button & 4 ? 2 : 0 ) ));
}
return event
}
Единственное, что fixEvent не исправляет - это несовместимости событий клавиатуры.
Как правило, это удобнее делать самому, т.к коды клавиш в разных браузерах отличаются, и лучше посмотреть их в таблице, чем засорять код.
Event = (function() {
var guid = 0
function fixEvent(event) {
event = event || window.event
if ( event.isFixed ) {
return event
}
event.isFixed = true
event.preventDefault = event.preventDefault || function(){this.returnValue = false}
event.stopPropagation = event.stopPropagaton || function(){this.cancelBubble = true}
if (!event.target) {
event.target = event.srcElement
}
if (!event.relatedTarget && event.fromElement) {
event.relatedTarget = event.fromElement == event.target ? event.toElement : event.fromElement;
}
if ( event.pageX == null && event.clientX != null ) {
var html = document.documentElement, body = document.body;
event.pageX = event.clientX + (html && html.scrollLeft || body && body.scrollLeft || 0) - (html.clientLeft || 0);
event.pageY = event.clientY + (html && html.scrollTop || body && body.scrollTop || 0) - (html.clientTop || 0);
}
if ( !event.which && event.button ) {
event.which = (event.button & 1 ? 1 : ( event.button & 2 ? 3 : ( event.button & 4 ? 2 : 0 ) ));
}
return event
}
/* Вызывается в контексте элемента всегда this = element */
function commonHandle(event) {
event = fixEvent(event)
var handlers = this.events[event.type]
for ( var g in handlers ) {
var handler = handlers[g]
var ret = handler.call(this, event)
if ( ret === false ) {
event.preventDefault()
event.stopPropagation()
}
}
}
return {
add: function(elem, type, handler) {
if (elem.setInterval && ( elem != window && !elem.frameElement ) ) {
elem = window;
}
if (!handler.guid) {
handler.guid = ++guid
}
if (!elem.events) {
elem.events = {}
elem.handle = function(event) {
if (typeof Event !== "undefined") {
return commonHandle.call(elem, event)
}
}
}
if (!elem.events[type]) {
elem.events[type] = {}
if (elem.addEventListener)
elem.addEventListener(type, elem.handle, false)
else if (elem.attachEvent)
elem.attachEvent("on" + type, elem.handle)
}
elem.events[type][handler.guid] = handler
},
remove: function(elem, type, handler) {
var handlers = elem.events && elem.events[type]
if (!handlers) return
delete handlers[handler.guid]
for(var any in handlers) return
if (elem.removeEventListener)
elem.removeEventListener(type, elem.handle, false)
else if (elem.detachEvent)
elem.detachEvent("on" + type, elem.handle)
delete elem.events[type]
for (var any in elem.events) return
try {
delete elem.handle
delete elem.events
} catch(e) { // IE
elem.removeAttribute("handle")
elem.removeAttribute("events")
}
}
}
}())
Следующий код добавляет небольшой обработчик:
function handler(event) {
this.innerHTML = "event.pageX="+event.pageX
}
Event.add(elem, 'click', handler)
// и никакого дополнительного кросс-браузерного кода
.. на этот div...
Как видно, корректно передано событие, this и кроссбраузерно установлена координата мыши event.pageX
Удаление:
Event.remove(elem, 'click', handler)
Дополнительные фичи опциональны, поэтому не включены в библиотеку.
Мы разберем их по очереди, чтобы вы сами могли их реализовать, если это нужно.
Если обработчик хочет предотвратить запуск следующих за ним обработчиков события в этом же элементе - он может поставить специальный флаг event.stopNow.
Для того, чтобы это работало, в цикле вызова обработчиков достаточно добавить проверку:
for (var g in handlers ) {
var handler = handlers[g]
var ret = handler.call(elem, event)
if ( ret === false ) {
event.preventDefault()
event.stopPropagation()
}
**if (event.stopNow) break**
}
В jQuery аналогичный флаг ставится методом event.stopImmediatePropagation().
Для передачи значения дальше по цепочке, обработчик может записать его в объект event:
event.currentResult = myValue
Эту логику можно добавить в общий цикл вызова обработчиков. Любое возвращаемое значение, кроме false будет записываться как event.result:
if ( ret === false ) {
event.preventDefault()
event.stopPropagation()
} else if ( ret !== undefined) {
event.result = ret
}
Для удаления всех обработчиков определенного типа, или вообще - всех обработчиков для элемента, функция remove имеет все необходимое.
Самое удобное: добавить проверку на аргумент handler, и если конкретный обработчик не указан - убивать их все:
remove: function(elem, type, handler) {
var handlers = elem.events && elem.events[type]
if (!handlers) return
if (!handler) {
for ( var handle in handlers ) {
delete events[type][handle]
}
return
}
// остальная часть функции - без изменений
delete handlers[handler.guid]
// ...
}
Наличие структуры events позволяет получить назначенные обработчики.
Полностью очистить элемент от обработчиков можно в 3 счета:
elem.eventsremoveEventListener/detachEvent для elem.handleelem.handle. Все, элемент чист.Стандартный способ избавления от утечек - записывать все элементы, у которых есть события, в специальный объект toClobber, а затем, при unload страницы или при удалении элемента из DOM - очищать эти элементы(и их потомки) от обработчиков.
Этот подход (или его вариант) реализован практически во всех современных яваскрипт-библиотеках.
Остальные браузеры, как и сам IE6 с этим исправлением (ставится автоматически microsoft update) в этом не нуждаются.
Это - о реальной утечке, при которой память не освобождается при переходе на следующую страницу.
Если вы пишете сложное AJAX-приложение, в котором посетитель долго остается на одной странице, то могут иметь место псевдоутечки, при которых память не освобождается просто потому, что объект окончательно не убит, а находится "в зоне видимости" интерпретатора.
Например, вы добавили элемент списка, поставили ему обработчик, а потом убрали элемент из DOM. И обработчик и элемент при этом останутся в памяти, если хотя бы на один из них есть ссылка с другого, доступного элемента.
В этом смысле сборщик мусора в браузерах работает так же, как в Java/PHP5+/Python и других современных языках программирования. Разница в том, что в яваскрипт нечаянную ссылку легко создать при помощи замыкания.
Все это, разумеется, не важно, если посетитель находится на странице недолго, ведь при переходе на другую страницу браузер почистит все сам.
Мы получили простую удобную библиотеку для кросс-браузерной работы с событиями.
А, главное, разобрались, как это работает, и почему удобно работать с событиями именно так.