Фильтр товаров на JavaScript
Задача: сделать фильтр товарных позиций для действующего интернет-магазина. По каким-то причинам предустановленный в CMS фильтр владельца не устраивал.
Особенности реализации:
– обновление списка происходит через перезагрузку страницы;
– типы фильтров: простой список с галочками, “календарик”, слайдер;
– в URL прописываются GET-параметры с выбранными позициями;
– скрипт должен уметь читать эти параметры и отмечать позиции в фильтры как выбранные.
Хотя в итоге по последнему пункту сделали так, чтобы сервер придавал позициям параметр checked=”checked” (особенности реализации именно для данного сайта), здесь приведу пример именно так, как указал вначале: полагаю, это более общий вариант.
Стили фильтра были предоставлены в PSD. На сайте уже использовался jQuery и JQuery UI, поэтому над разработкой механизма слайдера (для цены) думать не пришлось.
Шаги решения с описанием логики
На сайте используется jquery-1.6.1.js, jquery-ui-1.8.13.custom.min.js – их не трогаем. Стили прописываем тоже в отдельный CSS – это понятно.
1. Вёрстка
Решили совместно с заказчиком, что лучше будет сделать каждый блок фильтра классической формой с инпутами. Соображения: SEO, более просто сохранять состояния между перезагрузками страницы.
Поэтому вёрстка имеет такой канонический вид (приведено с тремя разными типами фильтров):
<div class="geke-filter">
<form action="">
<div class="geke-filter-item" data='{"name":"Цвет", "type":"list", "min":"", "max":"", "value":""}'>
<h4>Цвет</h4>
<ul>
<li data-id="1">Черный</li>
<li data-id="2">Белый</li>
.....
<li data-id="12">Голубой</li>
</ul>
<input type="hidden" value="" name="tczvet" class="geke-values" />
</div>
<div class="geke-filter-item" data='{"name":"Размер", "type":"calendar", "min":"32", "max":"60", "value":""}'>
<h4>Размер</h4>
<ul>
<li data-id="1">34</li>
<li data-id="2">35</li>
.....
<li data-id="27">60</li>
</ul>
<input type="hidden" value="" name="razmer" class="geke-values" />
</div>
<div class="geke-filter-item" data='{"name":"Цена", "type":"slider", "min":"1000", "max":"50000", "value":"руб."}'>
<h4>Цена</h4>
<span>От 1000 рублей до 50000 рублей</span>
<input type="hidden" value="" name="tczena" class="geke-values" />
</div>
</form>
</div>
Итак, теперь можно пояснить схему:
– сервер рисует статическую вёрстку фильтра, куда же в JSON-формате складывает данные об экземпляре фильтра;
– на клиенте подключаются стили;
– на клиенте подключается скрипт, который работает с этой вёрсткой и превращает её в полноценный фильтр.
Теперь перейдём непосредственно к нему.
2. Скрипт
Для лёгкости дальнейшей поддержки и оптимизации кода, а также простоты вызова делаем инициализирующую функцию init(), которая просто один раз запускается по загрузке содержимого страницы: window.onload = function() {init()}.
а) Сначала разбираем GET-параметры URL, т.к. по заданию через них передаются значения фильтров, которые должны быть уже отмечены на странице:
function init() {
// парсим url на предмет переданных параметров фильтров
var url = window.location.pathname;
// функция разбора get-параметров, http://stackoverflow.com/questions/979975/how-to-get-the-value-from-the-url-parameter
function getQueryParams(qs) {
qs = qs.split("+").join(" ");
var params = {}, tokens,
re = /[?&]?([^=]+)=([^&]*)/g;
while (tokens = re.exec(qs)) {
params[decodeURIComponent(tokens[1]).slice(7)] = decodeURIComponent(tokens[2]);
}
return params;
}
// объект с get-параметрами в виде свойств
var getParams = getQueryParams(document.location.search);
// в каждый инпут с name, равным имени свойства, кладём значения, которые дальше
обрабатываются скриптом
for (var i in getParams) {
var input = $('input[name="' + i + '"]'),
params = getParams[i].replace(/\|/g,',');
$(input).val(params);
}
// init() ещё не закончилась
Как видно, после получения значения были сразу записаны в соответствующие невидимые поля ввода каждого из фильтров.
б) Вообще, блоков с фильтрами может быть более одного на странице, поэтому отдельно инициализируем каждый блок, а в нём каждый экземпляр фильтра.
// переменная с блоками фильтров
var filter = $('.geke-filter');
// в конец каждого блока добавляем "кнопку" сброса настроек данного блока фильтров
$(filter).append('<div class="geke-filter-reset"><a href="/" title="Сбросить" class="geke-filter-reset">Сбросить все настройки</a></div>').find('.geke-filter-item').each(function(){
initFilterItem.call(this);
});
$(filter).each(function(){
initFilter.call(this);
});
// вызываем каждый блок фильтров
function initFilter() {
var self = this;
filterBlock(self);
}
// вызываем каждый из экземпляров фильтров
function initFilterItem() {
var self = this, // тут запоминаем jQuery-DOM-объект
data = $(this).attr('data'); // и его параметры
data = $.parseJSON(data);
view(self, data);
handler(self, data);
}
}
// теперь init() закончилась
Итак, в результате инициализации для каждого блока фильтров мы вызываем функцию filterBlock(), а для каждого экземпляра фильтра в блоке – функции view() и handler(). Как уже понятно из названий, первая занимается отрисовкой фильтра, вторая отвечает за поведение. Получается некоторая Model – View – Controller – архитектура, хотя полностью соблюсти её не удалось из-за наличия jQuery UI-слайдера.
Перейдём к функциям.
в) filterBlock() для блока фильтров
На уровне блока фильтров нам требуется две вещи:
– при нажатии на кнопку “Сбросить все настройки” фильтр должен очищаться;
– при нажатии на кнопку “Применить” любого из фильтров в блоке GET-ом должны отправляться все параметры, записанные во всех фильтрах данного блока, страница перезагружается.
Поскольку оба типа кнопок по исполнению являются объектами типа “ссылка”, сразу останавливаем их дефолтное поведение методом event.preventDefault():
function filterBlock(block) {
// сбрасываем все настройки
$(block).find('.geke-filter-reset').click(function(event){
event.preventDefault();
$(block).find('.cancel-button').click();
})
// делаем get-запрос с параметрами
$(block).find('.geke-filter-item_submit_button').click(function(event){
event.preventDefault();
var url = '?';
$(block).find('.geke-values').each(function(){
if ($(this).val() !== '') {
values = $(this).val().split(',');
url = url + 'filter_' + $(this).attr('name') + '=';
for (var i=0; i < values.length; i++) {
var theVal = values[i].replace(/\s/g,'_');
i === 0 ? (url = url + theVal) : url = url + '|' + theVal;
}
url = url + '&'
}
})
if (url.slice(-1) === '&') url = url.slice(0,-1);
window.location.href = window.location.pathname + url;
})
}
г) view() для экземпляра фильтра
Функция должна выполнять конечную отрисовку экземпляров фильтра. Из особенностей: если в экземпляре фильтра отмечена хотя бы одна позиция (или ползунки сдвинуты относительно дефолтных значений для слайдера), появляется крестик, нажав на который, можно сбросить настройки данного конкретного экземпляра фильтра:
function view(element, data) {
// смотрим, что пришло с get-параметрами
var restoreData = $(element).find('.geke-values').val().split(',');
if (data.type !== 'slider') restoreList();
function restoreList() {
// словарь id элементов списка
var listGlossary = [];
$(element).find('li').each(function(){
listGlossary.push($(this).attr('data-id'));
})
// находим совпадение, отмечаем элемент, если оно есть
for (var i=0; i< restoreData.length; i++) {
var n = listGlossary.indexOf(restoreData[i]) + 1;
if (n > -1) $(element).find('li:nth-child(' + n + ')').addClass('active');
}
}
// присваиваем элементу фильтра класс, соответствующий типу
$(element).addClass('' + data.type + '');
// для элементов типа list делаем имитацию чекбоксов
if (data.type === 'list') {
$(element).find('li').each(function(){
$(this).html('<div class="geke-filter-checkbox">
<span></span></div>
<div class="geke-fliter-list-text">' + $(this).text() + '</div>')
})
}
// слайдер
if (data.type === 'slider') {
// случайный id для слайдера, чтобы не мешать с другими экземплярами слайдеров,
требуется для корректной работы jQuery UI Slider
var slider_id = Math.round(Math.random()*(9999999)) + 1;
$(element).find('span').remove();
$(element).append('<div id="slider_' + slider_id + '" class="slider_id"></div><div class="slider_values">'<span id="slider_values_left_' + slider_id + '" class="geke-filter-slider-value-left">'</span><span id="slider_values_right_' + slider_id + '" class="geke-filter-slider-value-right"></span></div>');
}
// рисуем кнопку "Применить"
$(element).append('<div class="geke-filter-item_submit">
<a href="/" title="Отфильтровать" class="geke-filter-item_submit_button">Применить</a></div>');
// рисуем крестик с отменой
$(element).prepend('<a href="/" title="Сбросить фильтр" class="cancel-button"></a>');
// если есть предыдущие значения, показываем
if ($(element).find('.geke-values').val().length > 0) $(element).find('.cancel-button').css('display', 'block');
}
Как видно, для типов фильтра кроме слайдера мы отмечаем значения, которые должны быть отмечены. Для этого составляем словарь имеющихся в вёрстке фильтра значений, дальше сравниваем их с теми, которые записали при инициализации из GET-параметров. При совпадении отмечаем. Для слайдера мы это пока сделать не можем, поскольку он не инициализирован (с применением jQuery UI). Инициализировать нам его удобнее будет там же, где устанавливается обработчик его событий, чтобы два раза не писать почти один и тот же код.
д) handler() для экземпляра фильтра
Тут у нас происходит взаимодействие клиента с фильтрами. Для фильтра типа “список” и “календарик” логика абсолютно одинаковая, для слайдера своя.
function handler(element, data) {
var input = $(element).find('.geke-values'),
inputVal = []; // сюда записываем выбранные значения, включая из прошлых сессий
if (data.type == 'slider') {
handlerSlider();
} else {
handlerList();
}
// для списка и календаря
function handlerList() {
if ($(input).val().length > 0) {
inputVal = $(input).val().split(',')
}
$(element).find('li').click(function(){
var x = $(this).attr('data-id');
if (!$(this).hasClass('active')) {
inputVal.push(x);
} else {
inputVal.splice(inputVal.indexOf(x), 1)
}
$(this).toggleClass('active');
$(input).val(inputVal.join(','));
if (inputVal.length > 0) {
$(element).find('.cancel-button').show();
} else {
$(element).find('.cancel-button').hide();
}
});
// нажимаем на крестик
$(element).find('.cancel-button').click(function(event){
event.preventDefault();
$(element).find('li').each(function(){
$(this).removeClass('active');
})
$(input).val('');
inputVal = [];
$(this).hide();
})
}
// для слайдера
function handlerSlider() {
var slider_id = $(element).find('.slider_id').attr('id').slice(7),
thisSlider = $('#slider_' + slider_id);
if ($(input).val().length > 0) {
inputVal = $(input).val().split(',')
}
var val1 = $('#slider_values_left_' + slider_id),
val2 = $('#slider_values_right_' + slider_id),
slMin = parseInt(data.min),
slMax = parseInt(data.max);
$(thisSlider).slider({
range : true,
values : [slMin, slMax],
min : slMin,
max : slMax,
animate : true,
range : true,
slide : function(event, ui) {
$(val1).text(ui.values[0] + ' руб.');
$(val2).text(ui.values[1] + ' руб.');
// если установили изначальные значения, тогда оставляем инпут пустым
if (ui.values[0] === slMin && ui.values[1] === slMax) {
$(input).val('');
$(element).find('.cancel-button').hide();
} else {
$(input).val(ui.values[0] + ',' + ui.values[1]);
$(element).find('.cancel-button').show();
}
}
});
// восстанавливаем данные слайдера (если есть) после инициализации
(function restoreSlider() {
if (inputVal.length > 1) {
$(thisSlider).slider({
values : [inputVal[0], inputVal[1]]
});
$(val1).text(inputVal[0] + ' руб.');
$(val2).text(inputVal[1] + ' руб.');
} else {
$(val1).text(slMin + ' руб.');
$(val2).text(slMax + ' руб.');
}
})();
// нажимаем на крестик
$(element).find('.cancel-button').click(function(event){
event.preventDefault();
$(thisSlider).slider({
values : [slMin, slMax]
});
$(val1).text(slMin + ' руб.');
$(val2).text(slMax + ' руб.');
$(input).val('');
inputVal = [];
$(this).hide();
})
}
}
Думаю, здесь ничего сложного для читателя нет. Как работать с jQuery UI-слайдером, можно почитать здесь: http://api.jqueryui.com/slider.
На этом наш фильтр готов. Подведём итоги, что мы получили:
1) В вёрстку сайта вставляется один или несколько блоков с фильтрами
2) Фильтры в блоке могут быть трёх типов: список, календарь, слайдер, количество не ограничено
3) Блоки фильтров абсолютно независимы друг от друга. Единственное ограничение: названия параметров для каждого из экземпляров фильтра, они же атрибуты name в скрытых инпутах, не должны повторяться
4) Дав прямую ссылку с перечисленными должным образом параметрами, мы можем открыть страницу, на которой в фильтре уже отмечены нужные позиции
Конечно, можно заняться улучшением фильтра:
– сделать подгрузку фильтрованного контента AJAX-ом;
– сделать, чтобы при отправке из одного блока фильтров консолидировалась информация по всем блокам фильтров.
Можно придумать и ухудшение: например, грузить товары “на лету”, когда человек выбирает пункты фильтра.
В любом случае, данная архитектура позволяет достаточно легко расширять функционал фильтра, а также не накладывает никаких ограничений на количество фильтров на странице. Единственное требование – при вёрстке должны быть соблюдены названия классов и теги для списков. Опять же, элементы можно делать вложенными, обёрнутыми и т.д.: поиск методами .siblings(), .parents(), .children() в скрипте не используется.
Тестовый вариант фильтра расположен здесь: http://www.maksimslepov.ru/works/js-filter. Он выполнен в виде обычного html-файла, стили и скрипты там же, в теле файла.