Заметки с тегом «jquery» :: Хранитель заметок

noteskeeper.ru

Персональный журнал для заметок Владимира Кузнецова

Заметки с тегом «jquery»

Самый маленький плагин для jQuery

Простейший плагин будет состоять только из одного выражения return this.

$.fn.noop = function () {
    return this;
};

Профит от этого плагина не очевиден на первый взгляд. Если вызвать его, как вызываются тысячи других, то в результате получим ту же самую не модифицированную коллекцию элементов.

$("div.title").noop().doSomething();

Практическая польза от него будет, когда плагин должен вызываться неявно.

$("div.title")[isVisible() ? "fadeTo" : "noop"](333, 0).doSomething();

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

На написание заметки вдохновил твит:

Оповещение модулей через jQuery.Callbacks

Обмен событиями между отдельными компонентами программы можно построить различными способами. В зависимости от потребностей это можно сделать:

  1. через интерфейс Observable или произвольные DOM-события;

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

  2. через глобальный объект;

    Экземпляры компонент ничего не знают друг о друге. Все события пересылаются через один и тот же объект.

  3. через объекты событий.

    В этом случае экземпляры компонент так же ничего не знаю друг о друге. А каждое событие имеет отдельный объект в глобальном пространстве имен.

Интерфейс Observable удобен тем, что события одного экземпляра ни как не пересекаются с событиями другого экземпляра одной и той же компоненты.

$("div.primary div.widget").on("update", function () {
    alert("update in primary block");
}

$("div.secondary div.widget").on("update", function () {
    alert("update in secondary block");
}

Два экземпляра одного и того же виджета, но расположенных в разных блоках генерируют разные события «update».

Когда экземпляр компоненты, генерирующей события, в приложении один или речь идет об общих для всего приложения событиях, то можно использовать паттерн Topic.

Создается глобальный объект, имеющий методы регистрации функции обратного вызова для события и отмены регистрации, а так же метод вызова зарегистрированных колбеков.

var Topic = (function () {
    var topics = {};
    return function (name) {
        var callbacks, method, topic;
        if (!name) {
            throw new Error("topic must be specified");
        }
        topic = topics[name];
        if (!topic) {
            callbacks = jQuery.Callbacks();
            topic = {
                publish: callbacks.fire,
                subscribe: callbacks.add,
                unsubscribe: callbacks.remove
            };
            topics[name] = topic;
        }
        return topic;
    };
}());

Замыкание содержит в себе список всех зарегистрированных событий. Для каждого из них создается менеджер очереди колбеков jQuery.Callbacks, который регистрирует и вызывает их.

Наружу возвращается объект с методами экземпляра jQuery.Callbacks, но именованными в соответствии с действиями, за которые они отвечают: subscribe, unsubscribe и publish.

Topic("archiveAdd").subscribe(function (id) {
    console.log("add file with id = " + id + " to archive");
});
Topic("archiveCreate").subscribe(function () {
    console.log("start archiving");
});

И в другой компоненте:

$(root).on("click", "li.file button.add", function () {
    Topic("archiveAdd").publish($(this).data("fileId"));
});
$(root).on("click", "button.create", function () {
    Topic("archiveCreate").publish();
});

В некоторых случаях может быть полезным для каждого события создать свой отдельный объект и использовать ссылку на него в компонентах

var EventObject = function () {
    var callbacks = jQuery.Callbacks();
    return {
        publish: callbacks.fire,
        subscribe: callbacks.add,
        unsubscribe: callbacks.remove
    };
};

Такая реализация может быть близка сторонникам процедурных языков, но мне кажется чужеродным для функционального стиля JavaScript.

Очередь функций обратного вызова jQuery.Callbacks

В jQuery 1.7 появился новый объект для создания и управления очередью колбеков – jQuery.Callbacks. Он был предложен на рассмотрение команде разработчиков еще 6 месяцев назад. Менеджер было успешно опробован и пошел в релиз. На его основе был переписан jQuery.Deferred, который в свою очередь лежит в основе системы колбеков jQuery.ajax.

Конструктор jQuery.Callbacks можно вызывать как с оператором new, так и без него. В результате будет создан объект с несколькими методами: add(), remove(), fire(), disable() и др.

В параметры конструктору можно передать один или несколько флагов. От их наличия и сочетания будут меняться параметры, с которыми вызываются колбеки, и поведение очереди в целом. Рассмотрим их подробнее.

Вызов конструктора с различными флагами

Очередь, созданная без флагов, ведет себя так как если бы это была, например, очередь используемая в событиях.

var c = $.Callbacks();
c.add(function (val) {
    console.log("1: " + val);
});
c.fire("foo");
c.add(function (val) {
    console.log("2: " + val);
});
c.fire("bar");

После выполнения этого кода в консоль будет выведено:

1: foo

1: bar

2: bar

Метод add() добавляет в очередь новую функцию обратного вызова, а метод fire() по очереди вызывает все функции из очереди с указанными параметрами.

Флаг once указывает на то, что метод fire() будет выполнен только один раз для такой очереди.

var c = $.Callbacks("once");
c.add(function (val) {
    console.log("1: " + val);
});
c.fire("foo");
c.add(function (val) {
    console.log("2: " + val);
});
c.fire("bar");

В этом коде в консоль выведутся результаты работы только первой функции.

1: foo

Флаг memory предписывает запоминать последнее значение, с которым был вызван метод fire() и автоматически вызывать вновь добавляемые колбеки с этим значением.

var c = $.Callbacks("memory");
c.add(function (val) {
    console.log("1: " + val);
});
c.fire("foo");
c.add(function (val) {
    console.log("2: " + val);
});
c.fire("bar");

Когда вызывается метод fire("foo"), то второго колбека еще нет в очереди. Но как только он будет добавлен, так сразу вызовется с параметром "foo".

1: foo

2: foo

1: bar

2: bar

Флаги можно комбинировать. Так одновременное использование once и memory даст результат аналогичный по свой сути работе jQuery.Deferred – вызов колбеков будет сделан только один раз, а новые колбеки сразу будут вызываться при их добавлении.

var c = $.Callbacks("once memory");
c.add(function (val) {
    console.log("1: " + val);
});
c.fire("foo");
c.add(function (val) {
    console.log("2: " + val);
});
c.fire("bar");

1: foo

2: foo

Если будет установлен флаг unique, то одна и та же функция обратного вызова в очередь будет добавлена только один раз.

И последний флаг stopOnFalse прерывает выполнение очереди, если какой-либо колбек вернет значение false.

Применение

Благодаря этому менеджеру, например, в плагинах можно легко добавить поддержку не одного колбека, а сразу цепочки нескольких функций. Или же с помощью него можно уменьшить связанность различных компонент.

Deferred Object

Термин «отложенный объект» тесно связан с событийной моделью создания компонент и модулей приложения. По сути, представляет собой интерфейс, с помощью которого можно в одном модуле подписываться на события другого модуля. Основной отличительной чертой deferred object является то, что если ожидаемое событие уже наступило (например, окончилась ajax-загрузка), то подписчики все равно будут уведомлены о результате, как только они добавят свои функции обратного вызова. В обычной событийной модели такого поведения не предполагается.

Для полного понимания приведу пример фабрики отложенных объектов Дугласа Крокфорда из его презентации «Act III: Function the Ultimate»

function make_promise() {
    var status = 'unresolved',
        outcome,
        waiting = [],
        dreading = [];

    function vouch(deed, func) {
        switch (status) {
            case 'unresolved':
                (deed === 'fulfilled' ? waiting : dreading).push(func);
                break;
            case deed:
                func(outcome);
                break;
        }
    }

    function resolve(deed, value) {
        if (status !== 'unresolved') {
            throw new Error('The promise has already been resolved:' + status);
        }
        status = deed;
        outcome = value;
        (deed == 'fulfilled' ? waiting : dreading)
            .forEach(function (func) {
                try {
                    func(outcome);
                } catch (ignore) {}
            });
        waiting = null;
        dreading = null;
    }

    return {
        when: function (func) {
            vouch('fulfilled', func);
        },
        fail: function (func) {
            vouch('smashed', func);
        },
        fulfill: function (value) {
            resolve('fulfilled', value);
        },
        smash: function (string) {
            resolve('smashed', string);
        },
        status: function () {
            return status;
        }
    };
}

Состояние объекта можно изменить только один раз каким-либо методом: fulfill или smash. После этого будет выполнены функции, которые были зарегистрированы методами when и fail, соответственно.

Стоит заметить, что отложенные объекты применимы только к разовым событиям. После того, как объект был установлен в какое-то состояние, изменить его уже нельзя.

Эту концепцию команда разработчиков jQuery реализовала в своем фреймворке и начала использовать при работе с AJAX. Далее я буду использовать именно эту реализацию.

Рассмотрим пример приложения, с использованием отложенных объектов: «По клику на элемент появляется селектор с мероприятиями, содержимое которого загружается из внешнего сервиса. Когда пользователь выбирает мероприятие, оно добавляется в список».

Начнем с функции, отслеживающей клики. Она ничего не знает о селекторе и списке кроме того, что они существуют и поддерживают интерфейс jQuery.Deferred.

$(function () {
    // отслеживаем нажатие элемента
    $("span.show-selector").bind("click", function () {
        var ele = $(this), select;
        // ничего не делаем, если уже есть блок с селектором
        if (ele.hasClass("busy")) {
            return;
        }
        ele.addClass("busy");

        // создаем селектор
        select = buildSelect();
        // передаем отложенный объект селектора в функцию обновления списка
        updateList(select);

        // как только в селекторе было выбрано мероприятие снимаем флаг
        select.always(function () {
            ele.removeClass("busy");
        });
    });
});

Функция, создающая селектор знает, что список мероприятия нужно откуда-то получить, но не вдается в реализацию этого процесса. Она рисует селектор и возвращает отложенный объект, который изменит свой статус после того как пользователь сделает свой выбор. Статус так же изменится, если при получении списка возникнет ошибка.

function buildSelect() {

    var select = $.Deferred(),
        fetcher = fetchEvents();

    // когда данные о мероприятиях будут получены, рисуем селектор
    fetcher.done(function (data) {
        var ele = [], cache = {};

        $.each(data.query.results.event, function (index, upcomingEvent) {
            ele.push('<option value="' + upcomingEvent.id + '">');
            ele.push(upcomingEvent.name);
            ele.push('</option>');
            cache[upcomingEvent.id] = upcomingEvent;
        });

        $("p.title").append('<div id="upcoming-events">' +
            '<select>' + ele.join('') + '</select>' +
            '<button class="btn-done">Done</button>' +
            '<button class="btn-cancel">Cancel</button>' +
            '</div>');

        // отслеживаем нажания на кнопки, чтобы выбрать
        // мероприятие или отменить выбор
        $("#upcoming-events").delegate("button", "click", function () {
            var upcomingEvent;
            if ($(this).hasClass("btn-done")) {
                // мероприятие было выбрно
                upcomingEvent = cache[$("#upcoming-events select").val()];
                // меняем состояние отложенного объекта
                // и передаем подписчикам информацию о мероприятии
                select.resolve(upcomingEvent);
            } else if ($(this).hasClass("btn-cancel")) {
                // выбор был отменен пользователем
                select.reject("user");
            }
        });

    });

    // ошибка при получении списка мероприятий
    fetcher.fail(function () {
        // отменяем выбор из-за сбоя связи с сервисом YQL
        select.reject("network");
    });

    // удаляем селектор, когда было выбрано какое-то мероприятие
    // или выбор был отменен по каким-то причинам
    select.always(function () {
        $("#upcoming-events").remove();
    });

    // возвращаем отложенный объект селектора
    return select;
}

Внутри она сама подписывается на изменение статуса для того, чтобы удалить селектор со страницы. Так как в общем случае deferred object предполагает двустороннюю коммуникацию, то ничего не мешает отменить выбор из вне.

Функция получения списка мероприятий запрашивает их через YQL. Сейчас она реализована в самом простом варианте без кеширования полученных результатов. Но благодаря тому, что используются унифицированный deferred object, а не функции обратного вызова в ajax-запросе, функциональность можно легко дополнить.

function fetchEvents() {
    // получаем список мероприятий через сервис YQL
    var jqXHR = $.ajax({
        url: "http://query.yahooapis.com/v1/public/yql",
        data: {
            q: 'select * from upcoming.events where tags="birthday"',
            format: 'json'
        },
        dataType: 'jsonp'
    });
    // возвращаем отложенный объект ajax-загрузки
    return jqXHR;
}

И наконец последняя функция, рисующая выбранное мероприятие, просто подписывается на событие выбора.

function updateList(select) {
    // в селекторе было выбрано какое-то мероприятие
    select.done(function (upcomingEvent) {
        // получаем элемент списка или создаем его
        var list = $("dl.list");
        if (!list.length) {
            list = $('<dl class="list"></dl>').appendTo("body");
        }

        // добавляем новую запись о мероприятии в список
        list.append('<dt>' + upcomingEvent.name + '</dt>' +
            '<dd>' + upcomingEvent.description + '</dd>');
    });
}

Посмотреть работу примера.

В заключении хочу сказать, что deferred object не представляет собой замену обычным событиям, а скорее является частным случаем, когда допустимы следующие особенности:

  • ожидаемое событие случается только один раз;
  • подписчики могут добавлять функции обратного вызова даже после того как событие случилось.

Так же отложенный объект имеет преимущества перед функциями обратного вызова для связи модулей и компонент, так как позволяет создавать цепочки из таких функций.

Порядок элементов виджета Sortable

У виджета jQuery Sortable помимо метода serialize есть еще другой метод, который можно использовать для сохранения порядка сортировки элементов, — toArray. Его отличие заключается в том, что он возвращает массив id элементов, а не просто строку.

<ul id="search-engines">
  <li id="item-1">Яндекс</li>
  <li id="item-3">Bing </li>
  <li id="item-2">Google</li>
</ul>

Получаем массив

var engines = $("#search-engines").sortable("toArray");

В результате в переменной engines будет массив ["item-1", "item-3", "item-2"].

toArray будет полезен, скорее всего, в ситуациях, когда требуется пост-обработка данных (например, фильтрация или переименование ключей) перед сохранением.

Sizzle задом наперед

В состав jQuery входит очень мощная библиотека Sizzle, которая отвечает за получение списка DOM-элементов, соответствующих заданным условиям. В ней реализованы все селекторы CSS3 и некоторые другие, которые не включены в спецификацию.

Хотя в некоторых современных браузерах у DOM-элементов есть специальные методы querySelector и querySelectorAll, которые реализуют этот функционал, тем не менее актуальность этой библиотеки остаётся неизменной из-за поддержки старых браузеров и браузеров, в которых нет эти методов.

Чтобы полностью раскрыть скоростной потенциал Sizzle нужно понимать, как на самом деле происходит фильтрация элементов документа.

Хорошим тоном при составлении правила будет первым селектором использовать получение элемента по атрибуту id, так как эта функция работает очень быстро и реализована во всех браузерах. Как вариант, можно задавать контекст поиска, если он известен, что сильно сокращает количество элементов, которые придется перебирать.

$("#data div.frame p span.dashed");

$("#data").find("div.frame p span.dashed");

$("div.frame p span.dashed", $("#data"));

В каждом правиле (за исключением поиска по id) стоит указывать тег, которому оно соответствует. Функция поиска по тегу тоже присутствует по всех браузерах. Так запрос

$("div.frame p span.dashed");

выполнится быстрее чем

$(".frame p .dashed");

Поиск по значению класса тоже уже реализован во многих браузерах, но в них, скорее всего, будет использоваться метод querySelectorAll.

А теперь самое интересное. Поиск элементов в документе или указанном контексте начинается с самого последнего правила. Затем уже отбрасываются элементы, предки которых не соответствуют требуемым условиям.

$("div.frame p span.dashed");

В этом примере сначала будут найдены все элементы span в документе. Затем отфильтрованы элементы, которые содержат класс dashed. После этого для каждого найденного элемента будет предпринята попытка найти у него предка с тегом p, затем предка с тегом div и классом frame.

Такой, казалось бы, не очевидный подход позволяет резко сократить количество операций на поиск элементов.

Как и везде, в алгоритме Sizzle есть исключение. Запрос типа $("#data span.dashed") будет обработан особым образом — селекторы в этом случае традиционно применятся слева направо.

Определение IE6 через CSS селектор

Определение версии браузера через переменную User-Agent не всегда работает правильно из-за того, что это значение легко подменить. Разработчики jQuery вообще решили отказаться от его использования, предложив как альтернативу определение особенностей браузера программно. Различные реализации одних и тех же свойств или отсутствие какой-либо реализации позволяет четко выделить Internet Explorer на фоне остальных. Но вот выделить какую-либо версию уже становится большой проблемой.

Следуя предложенной концепции выявления особенностей браузера, я решил использовать CSS селектор tag[class] для обнаружения IE6. В старших версиях IE, как и в других современных браузерах, этот селектор корректно обрабатывается, а в 6-й версии он игнорируется.

(function ($) {
    var cssCode = 'html.jQueryDetectIE6{background-color:#ffffff;}html.jQueryDetectIE6[class]{background-color:#fefefe;}',
        styleElement, html;
    try {
        html = $("html");
        if (html.length) {
            styleElement = document.createElement("style");
            styleElement.type = "text/css";
            if (styleElement.styleSheet) {
                styleElement.styleSheet.cssText = cssCode;
            } else {
                styleElement.appendChild(document.createTextNode(cssCode));
            }
            $("head").prepend(styleElement);
            html.addClass("jQueryDetectIE6");
            $.support.cssClassSelector = /254|fe/ig.test(html.css("backgroundColor"));
        }
    } catch (e) {
        // nothing here
    } finally {
        // cleanup
        if (html && html.length) {
            html.removeClass("jQueryDetectIE6");
        }
        if (styleElement && styleElement.parentNode) {
            styleElement.parentNode.removeChild(styleElement);
        }
    }
}(jQuery));

Во время своего выполнения функция добавляет необходимый стиль в тег head и проверяет их влияние на элемент. Результат проверки заносится в поле $.support.cssClassSelector.

Флаг хотелось установить еще до полной загрузки документа. По этому требовалось свести к минимуму модификацию DOM-документа. Это удалось решить через проверку цвета у корневого элемента html.

Другая проблема возникла из-за различного поведения браузеров при добавлении стилей через JavaScript. IE и Safari начинают применять эти правила только, если они были добавлены в тег head. Тут нет побочных эффектов, так как этот элемент уже существует на момент выполнения кода. Но сам элемент style нужно сформировать особым образом. В IE есть свойство styleSheet у элемента, которое позволяет работать с правилами. В свою очередь у объекта styleSheet есть поле cssText, которое позволяет получать и устанавливать текстовое представление правил. Хочу отметить, что изменить свойство cssText можно только однажды. Если все же потребуется его поменять, то нужно будет удалить старый элемент style и добавить новый с обновленными значениями.

Выбор диапазона дат с помощью jQuery UI Datepicker

Для очередного проекта понадобился виджет для выбора даты. Очевидным, по крайней мере для меня, решением было jQuery UI Datepicker. Но когда позже понадобилось выбирать не одну дату, а диапазон, я попал в тупик. В API не было ни какого упоминания о такой возможности. Поисковики все как один выдавали какие-то «самоделки» далеко не первой свежести.

Копнув глубже исходники Datepicker, я все-таки обнаружил возможность выбора диапазона. Реализуется все логика через событие «onSelect».

У экземпляра Datepicker есть флаг stayOpen. Если его установить внутри события, то календарь не закроется после клика, а занесет выбранную дату в поле rangeStart и позволит выбрать еще дату. Когда будет выбран конец диапазона, нужно сбросить флаг stayOpen и обновить содержимое текстового поля.

(function ($) {

    $.fn.daterange = function () {
        // опции
        var opts = $.extend({
            "dateFormat": "dd.mm.yy",
            "changeMonth": false,
            "changeYear": false,
            "numberOfMonths": 2,
            "rangeSeparator": "-"
        }, arguments[0] || {}, {
            // обработчики событий datepicker
            // закрытие
            "onClose": function (dateText, inst) {
                if ($.isFunction(opts.callback)) {
                    opts.callback.apply(this, arguments);
                }
            },
            // выбор даты
            "onSelect": function (dateText, inst) {
                var textStart;
                if (!inst.rangeStart) {
                    inst.stayOpen = true;
                } else {
                    inst.stayOpen = false;
                    textStart = $.datepicker.formatDate(opts.dateFormat, inst.rangeStart);
                    if (textStart !== dateText) {
                        $(this).val(textStart + " " + opts.rangeSeparator + " " + dateText);
                    }
                }
            }
        });

        return this.each(function () {
            var input = $(this);
            if (input.is("input")) {
                input.datepicker(opts);
            }
        });
    };

}(jQuery));

Еще одно событие, которое может пригодиться, это «onClose». Оно будет сгенерировано в момент закрытия календаря. В плагине я просто делегирую все исполнение внешнему обработчику, но на практике мне удобно было добавить туда больше функционала.

Плагин может быть проинициализирован любыми параметрами, которые используются в Datepicker. Так как обработчик события «onClose» переопределяется, то вместо него используется поле callback.

Событие окончания загрузки картинки

Чтобы отследить момент загрузки картинки одного обработчика события «load» бывает не достаточно. Если он будет прикреплен к картинке после того, как она уже была загружена, то событие больше не повторится и обработчик не будет вызван.

Поможет решить эту проблему вспомогательный плагин к jQuery.

;(function ($) {
    $.fn.bindImageLoad = function (callback) {
        function isImageLoaded(img) {
            // Во время события load IE и другие браузеры правильно
            // определяют состояние картинки через атрибут complete.
            // Исключение составляют Gecko-based браузеры.
            if (!img.complete) {
                return false;
            }
            // Тем не менее, у них есть два очень полезных свойства: naturalWidth и naturalHeight.
            // Они дают истинный размер изображения. Если какртинка еще не загрузилась,
            // то они должны быть равны нулю.
            if (typeof img.naturalWidth !== "undefined" && img.naturalWidth === 0) {
                return false;
            }
            // Картинка загружена.
            return true;
        }

        return this.each(function () {
            var ele = $(this);
            if (ele.is("img") && $.isFunction(callback)) {
                ele.one("load", callback);
                if (isImageLoaded(this)) {
                    ele.trigger("load");
                }
            }
        });
    };
})(jQuery);

Обновление: Работу плагина можно посмотреть в примере. Там используется немного модифицированная версия, которая формирует специальное событие.

Очереди в jQuery

Важным инструментом для контроля последовательности исполнения функций в асинхронной среде являются очереди. Об одной из реализаций я писал в заметке «Очередь: синхронное выполнение функций».

В jQuery тоже есть механизм, реализующий очередь — это функции queue и dequeue.

Каждая именованная очередь прикрепляется к DOM элементу, который и будет контекстом выполнения функций. К одному элементу может быть прикреплено сколько угодно очередей. Важно лишь, чтобы они имели различные имена.

Рассмотрим пример:

$("#result")
    .queue("test", function () {
        $(this)
            .append("<p>1st function</p>")
            .queue("test", function () {
                $(this)
                    .append("<p>4th function</p>")
                    .dequeue("test");
            })
            .dequeue("test");
    })
    .queue("test", function () {
        $(this)
            .append("<p>2nd function</p>")
            .dequeue("test");
    })
    .queue("test", function () {
        $(this)
            .append("<p>3rd function. " +
                    $(this).queue("test").length +
                    " function(s) in queue</p>")
            .queue("test", function () {
                $(this)
                    .append("<p>5th function</p>")
                    .dequeue("test");
            })
            .dequeue("test");
    })
    .dequeue("test");

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

Результатом работы этого кода будет:

1st function
2nd function
3rd function. 1 function(s) in queue
4th function
5th function

В третьей по счету функции выводится количество оставшихся функций в очереди. На момент исполнения там остается только одна функция (4-ая по счету), но сразу после этого добавляется еще и 5-ая.

Каждая функция по своему завершению должна вызвать метод dequeue, чтобы передать управление следующей функции в очереди.