четверг, 10 ноября 2011 г.

Коллекции в JavaScript - допиливаем setAddEventListener

Иногда Java-программистам в JavaScript`е не хватает коллекций. Давайте разберёмся - как можно в JavaScript без написания громоздких тяжеловесных решений обойти ситуацию, для которой нам могла бы понадобиться коллекция?

Итак, в Java есть три типа коллекций - List, Set и Map.

Эмулируем List

Строго говоря, как таковая эмуляция List`а в JavaScript нам в общем-то и не нужна - по сути ей и является массив (Array). Собственно, List`ы и в Java-то нам были нужны лишь исключительно по той причине, что массивы в Java имеют чёткий заранее определённый размер, который не может быть изменён, а по задаче мы не всегда можем сказать, массив какого размера нам понадобится. Т.к. в JavaScript такой проблемы нет - нет и необходимости создавать этот тип коллекций специально.

Эмулируем Set

Множество (Set) уникальных значений в JavaScript можно составить на основании того же массива (Array) с добавлением метода, который проверял бы уникальность добавляемого значения:

/** Определяет индекс элемента в массиве.
 * @param {Object} value
 * @return {number} индекс переданного объекта, если тот содержится в массиве.
 * Если он в нём отсутствует, возарвщается -1. */
Array.prototype.indexOf = function(value) 
{
 var /** @type {number} */ length = this.length;
 for (var /** @type {number} */ i = 0; i < length; i++)
  if (this[i] == value)
   return i;
 return -1;
};
/** Определяет, содержит лимассив переданный объект.
 * @param {Object} value
 * @return {boolean} */
Array.prototype.contains = function(value) 
{
 return this.indexOf(value) === -1;
};
Теперь достаточно просто перед добавлением элемента проверять его методом contains - для простых зазачь этого вполне хватит.

Эмуляция Map

С коллекциями типа map будет немного сложнее. В принципе, для наиболее частого случая, когда в качестве ключей нас устроят строки, решение тривиально - это Object. Т.е. создаём объект - он и есть наша карта. Его свойства - это ключи, а их значения - это значения полей коллекции.

Но вот что делать, если ключами по логике задачи не должны быть строки, а ими должны быть другие объекты? Для того, что бы действовать в этой ситуации, нужен эффективный механизм преобразования объектов в строку. В качестве такого метода в общем случае может выступать какое-либо специальное поле-идентификатор, либо метод, возвращающий уникальное строковое значение для каждого объекта, который будет выполнять роль ключа.

Пример эмуляции Map

Например, в одной из недавних статей, где я рассматривал шаблон "Заплатка", я писал о методе создать на основе механизма навешивания слушателей в IE6-8 (attachEvent) стандартный механизм навешивания слушателей (addEventListener). Решение, напомню, было таким:

function setAddEventListener()
{
    if ('attachEvent' in this && !('addEventListener' in this))
        this.addEventListener = function(eventName, handler, isCapturing) {
            if (isCapturing)
                throw new Error("We are in IE, so we haven`t way to set event listener on capturing phase of any event to any of HTML-elements");
            attachEvent("on" + eventName, handler);
        }
}
setAddEventListener.call(document);//Для document`а, например, вызываем так
Однако, если подходить серьёзно, то с этим решением в реальном проекте будет некоторое количество проблем. Первая из них - неполная совместимость IE`шного объекта Event и стандартного. Путь для решения этой проблемы я уже демонстрировал в статье про pattern "Enrichment". Напомню, что там получилось:
/** Конструктор, служащий для придания не совместимым с W3C DOM level 3 объектам
 * Event стандартного поведения.
 * @constructor
 * @extends Event */
function EventW3C() {
    /** Отменяет поведение браузера для данного события по-умолчанию. */
    this.preventDefault = function() {
        this.returnValue = false;
    };
    // Выставление других стандартных свойств

    return this;
}
function setAddEventListener()
{
    if ('attachEvent' in this && !('addEventListener' in this))
        this.addEventListener = function(eventName, handler, isCapturing) {
            if (isCapturing)
                throw new Error("We are in IE, so we haven`t way to set event listener on capturing phase of any event to any of HTML-elements");
            attachEvent("on" + eventName, function() {
                handler(EventW3C.call(event) //Здесь используется pattern "Enrichment"
            });
        }
}
Вторая проблема - ссылка this. В обработчик IE6-8 по ней не передаётся элемент, с которым связано событие. Решение простое - запомнить ссылку на элемент в отдельной переменной в замыкании и передать её в качестве ссылки this обработчику при помощи метода call объекта Function:
function setAddEventListener()
{
    if ('attachEvent' in this && !('addEventListener' in this))
        this.addEventListener = function(eventName, handler, isCapturing) {
            if (isCapturing)
                throw new Error("We are in IE, so we haven`t way to set event listener on capturing phase of any event to any of HTML-elements");

                //Запоминаем элемент
                var /** @type Element */ that = this;

                this.attachEvent(
                    'on' + eventName, function() {
                        handler.call(that, EventW3C.call(event));//Правильно вызываем обработчик
                    }
                );
        }
}
При этом приёме, правда, как утверждает Д.Флэнаган, есть проблема с утечкой памяти в ранних версиях IE, но это сейчас out of scope. И, наконец, третья проблема - всплывает, когда мы попытаемся рассмотреть применение механизма удаления обработчика IE6-8 detachEvent для эмуляции стандартного removeEventListener. Дело в том, что т.к. мы используем не сам handler, а функцию, которая его вызывает, то и detach`ить нам нужно её, а не изначальный handler. Соответственно, для удаления нам где-то нужно её найти, так? Но в стандартный метод removeEventListener будет передаваться лишь сам handler. Значит, нам нужно по handler`у найти настраивающую его функцию. Тут-то нам и понадобится коллекция типа Map.

Итак, есть соответствие двух функций и по ключу - одной функции мы должны найти значение - другую функцию, что бы её удалить из обработчиков данного события. Т.к. Map мы эмулируем на основе Object`а, у которого в качестве имён полей, т.е. ключей, традиционно выступают строки, то нам, очевидно, нужен метод, который преобразует объект в уникальную для него строку. В общем случае эта задача не является тривиальной, но в нашем конкретном - какая удача! - для функций, т.е. для объектов Function, этот метод уже есть! И называется он - ни за что не догадаетесь! - toString()! :)))) Этот метод по сути возвращает представление данной функции в виде многострочного текста её определения. Очевидно, что текст определения функции не меняется никогда и он соответствует критерию уникальности для каждой функции, а для одной и той же функции всегда будет возвращать одинаковый результат.

Так что вот какое компактное решение получаем в данном случае:

/** Конструктор, служащий для придания не совместимым с W3C DOM level 3 объектам Event стандартного поведения.
 * @constructor
 * @extends Event */
function EventW3C() {
    /** Отменяет поведение браузера для данного события по-умолчанию. */
    if (!('preventDefault' in this))
        this.preventDefault = function() {
            this.returnValue = false;
        };

    if (!('stopPropagation' in this))
        this.stopPropagation = function(){
            this.cancelBubble = true;
        };

    // Выставление других стандартных свойств

    return this;
}

(/** IE patch for addEventListener and removeEventListener methods. */
function setAddEventListener() {
    if (!('addEventListener' in this))
        if ('attachEvent' in this) {
            var /** Объект-карта всех слушателей данного объекта
                 * @type Object<Object<Function>> */
                fnMap = {};
            this.addEventListener = function(eventName, handler) {
                if (!(eventName in fnMap)) //Если для данного события ещё нет коллекции обработчиков...
                    /**@type Object<Function> */ fnMap[eventName] = {}; //...создаём её
                var /** @type Element */ that = this; //Запоминаем элемент
                this.attachEvent('on' + eventName,
                    fnMap[eventName][handler.toString()] = function() {
                        handler.call(that, EventW3C.call(event));//Правильно вызываем обработчик
                    }
                );
            };
            this.removeEventListener = function(eventName, handler) {
                if (eventName in fnMap && handler.toString() in fnMap[eventName]) {
                    this.detachEvent(
                        'on' + eventName,
                        fnMap[eventName][handler.toString()]
                    );
                    delete fnMap[eventName][handler.toString()];
                }
            };
        }
})(); // Вызываем для window сразу
setAddEventListener.call(document);//Для document`а, например, вызываем так

Комментариев нет: