Месяц у меня вылеживалась эта дура на 10 листов. Нашла, подправила. Пусть будет...
Попробую описать реализацию своего класса для динамической сортировки строк таблицы в JavaScript. "Ни пуха" мне...
План таков:
Постановка задачи
Максимально универсальная функция должна:
- Легко подключаться, не зависеть от дизайна, никакого JS-кода не должно быть в объявлении таблицы.
- Корректно работать для строковых и числовых значений.
- Корректно обрабатывать теги внутри ячеек таблицы.
- Автоматически менять вид заголовка столбца в зависимости от направления сортировки.
- Корректно работать во всех распространенных браузерах. В моем понимании это IE, Opera и FF. В других теоретически тоже должно работать.
Выбор алгоритма сортировки
Из множества алгоритмов сортировки здесь выбираем самый простой. Суть такова: находим наименьший (или наибольший - в зависимости от порядка сортировки) элемент массива, меняем его местами с первым элементом. Далее находим второй наименьший (или наибольший), и меняем его со вторым. Ну и так далее.
Для наглядности привожу классическую реализацию алгоритма сортировки массива по возрастанию:
var arr = new Array(4, 5, 1, 3, 2);
for (var i=0; i
Реализация предельно простая, не буду даже комментировать.
Несколько слов о DOM-модели
DOM-модель (Document Object Model) - это способ представления структуры HTML-документа (и не только HTML) в виде дерева. Простой пример - и все станет ясно.
DOM-модель
Тут заголовок
Тут текст абзаца
В виде DOM-модели этот документ будет выглядеть следующим образом:
document - это корень дерева DOM-модели, с него начинается навигация по дереву.
Для навигации и манипулирования узлами DOM-модели существует множество функций. Для решения нашей задачи будем использоваться следующие:
getElementById() - метод объекта document, возвращает ссылку на узел дерева по идентификатору элемента. Идентификатор задается в виде атрибута id соответствующего тега.
childNodes - это свойство хранит список всех дочерних узлов элемента в виде массива.
firstChild - свойство хранит первый дочерний узел элемента.
nodeName - имя узла, содержит имя тега. Для текстового узла возвращает значение "#text".
nodeValue - значение узла. Применимо только для текстовых узлов, для всех прочих возвращает NULL.
insertBefore(N, E) - вставляет узел N перед существующим узлом E.
Вместо стандартной функции getElementById() я всегда использую функцию objectFindDOM(), код которой взяла из книги Джейсона Кренфорда "DHTML и CSS". Текст функции есть в файлах проекта, поэтому приводить его здесь не буду.
Несколько слов о реализации ООП в JavaScript
На лекцию про объектно-ориентированное программирование меня не хватит, поэтому я просто коротенько опишу особенности реализации классов в JS.
Создание объекта начинается с написание конструктора. Конструктор - это просто функция, в которой выполняется инициализация свойств и методов объекта. Конструктор может принимать произвольное количество параметров.
Для обращения к свойствам и методом из функций объекта используется ключевое слово this (это важно!), указывающее на контекст выполнения функции.
Классический пример:
function Circle(x, y, r){
// объявляем свойства объекта
this.x = x;
this.y = y;
this.r = r;
// объявляем методы объекта
this.show = showCircle;
this.hide = hideCircle;
this.mode = moveCircle;
}
Далее должны быть реализованы все методы объекта. Функции, реализующие методы, могут иметь любые имена, могут быть объявлены как до, так и после объявления конструктора. Они могут быть реализованы даже во внешних файлах. Из такой реализации методов объекта следует, что одна и та же функция может использоваться несколькими разными классами, а также вызываться сама по себе, вне контекста объектов.
function showCircle(){
// чертим окружность
}
function hideCircle(){
// стираем окружность
}
function moveCircle(dx, dy){
this.hide();
this.x += dx;
this.y += dy;
this.show();
}
Для создания экземпляров объекта служит оператор new, который имеет следующий синтаксис:
имяЭкземпляра = new имяКонструктора(список параметров);
Вот так:
firstCircle = new Circle(100, 100, 25);
firstCircle.draw();
firstCircle.move(10, 10);
Наследование, полиморфизм, а также сокрытие свойств и методов объекта в JavaScript на данный момент не поддерживаются.
А теперь ближе к делу.
Реализация диспетчера столбцов
Зачем вообще нужен диспетчер и что он делает?
Было бы неплохо, если бы внешний вид заголовков при сортировке как-то менялся. Например, появлялась бы стрелочка, обозначающая направление сортировки.
Однако если до этого таблица сортировалась по другому столбцу, нужно вернуть в исходное состояние его заголовок.
Поскольку объекты столбцов ничего не знают друг о друге, нужна внешняя структура, которая будет хранить список всех столбцов и обеспечивать обновление заголовков при сортировке или иную синхронизацию объектов.
Можно предложить несколько реализаций диспетчера, более или менее простых. Я решила реализовать диспетчер как объект класса ColumnsDispatcher. Класс определяется следующим образом:
Свойство list - список столбцов, который изначально пуст. Список на самом деле будет представлять собой двумерный массив (дерево) вида list[идентификатор_таблицы][идентификатор_столбца], чтобы работать со столбцами нескольких независимых таблиц.
Метод add - функция-регистратор новых столбцов.
Метод manage - собственно, функция-диспетчер, которая будет вызываться при клике по заголовку таблицы. Строго говоря, в данном случае ее не обязательно объявлять как метод класса ColumnsDispatcher. Вызов функции вешается на обработчик клика мыши и при вызове она получит контекст (this) узла DOM-модели, а не своего объекта.
function ColumnsDispatcher(){
this.list = new Array();
this.add = addColumn;
this.manage = manageColumns;
}
function addColumn(obj){
if (this.list[obj.table_id] == undefined) this.list[obj.table_id] = new Array();
this.list[obj.table_id][obj.id] = obj;
}
function manageColumns(){
var colsList = ColumnsList.list;
var table_id = null;
// Находим идентификатор таблицы, которой принадлежит столбец
for (var i in colsList)
if (colsList[i][this.id] != undefined) table_id = i;
// Для всех столбцов этой таблицы обновляем стиль заголовка. Для всех столбцов, кроме выбранного, сбрасываем направление сортировки, чтобы при каждом первом клике по заголовку выполнялась сортировка по возрастанию.
for (var i in colsList[table_id]){
colsList[table_id][i].changeClass('no_sort');
if (i != this.id) colsList[table_id][i].order = 'none';
}
colsList[table_id][this.id].sort();
}
// Сразу создаем объект диспетчера
ColumnsList = new ColumnsDispatcher();
Описание класса Column
Каждому столбцу таблицы, по значениям которого предполагается сортировка, будет ставиться в соответствие объект класса Column. Описываем класс следующим образом:
function Column(table_id, column_id, column_type){
this.dom = objectFindDOM(column_id);
this.id = column_id;
this.table_id = table_id;
this.type = column_type;
this.order = null;
this.sort = sortColumn;
this.dom.onclick = ColumnsList.manage;
ColsList.add(this);
}
Конструктору Column передаются следующие параметры: идентификатор таблицы, идентификатор заголовка столбца, тип столбца. Объект имеет следующие свойства и методы:
this.dom - хранит ссылку на DOM-узел заголовка столбца.
this.id - хранит идентификатор заголовка.
this.table_id - хранит идентификатор таблицы.
this.type - тип столбца, strings или numbers. Нужен для реализации пункта 2 требований. Дело в том, что сортировка строк отличается от сортировки чисел. Очевидно, например, что строка "20" больше строки "100". Для числовых данных это будет недопустимо.
this.order - порядок сортировки, asc или desc. При первом клике по заголовку столбца строки сортируются по возрастанию, при втором - по убыванию. И так далее. В этой переменной запоминается текущий порядок сортировки.
this.sort -собственно, ключевой метод, ради него все и затевалось. Реализуется функцией sortColumn, описание чуть ниже.
this.dom.onclick = ColumnsList.manage - здесь мы вешаем на заголовок таблицы обработчик события onclick. Пункт 1 условий - таким образом мы избавляем верстальщика от необходимости знать, какая именно функция должна обрабатывать клик по заголовку.
ColumnsList.add(this) - регистрируем столбец в диспетчере столбцов. Переменная ColumnsList объявлена как глобальная, поэтому к ней можно обращаться из тела нашего конструктора, равно как и из любой другой функции.
На самом деле это не правильно с точки зрения хорошего стиля программирования, но реализация ООП в js настолько слаба, что закапываться в тонкости и изощряться для наведения красоты у меня пока нет времени.
Реализация алгоритма
Итак, самое интересное.
function sortColumn(sort_order)
{
// Это маленькая внутренняя функция, которая будет извлекать текстовое значение ячейки таблицы.
function getNodeValue(node, type){
// Сейчас внимание - реализуем пункт 3 условий. Если узел ячейки таблицы содержит текстовое значение, сохраняем его. Иначе углубляемся на один уровень по DOM-дереву. Это обеспечивает корректную обработку вложений только одного уровня, но в подавляющем большинстве случаев этого будет достаточно.
var nodeValue = (node.nodeName == '#text')
? node.nodeValue
: node.firstChild.nodeValue;
if (type=='numbers'){
// Если столбец объявлен как числовой, получаем из строки числовую составляющую. Теоретически, JS должен автоматически делать это при переводе строки в число, но у меня упорно получался 0, если строка содержала символы, отличные от цифровых.
var Pattern = /^\d+(\.\d+){0,1}/;
var num = Pattern.exec(nodeValue.replace(/,/, '.'));
nodeValue = (num != null) ? num[0] : 0;
var value = new Number(nodeValue);
} else {
// Для текстового столбца переводим значение в нижний регистр.
var value = nodeValue.toLowerCase();
}
return value;
}
//Функции может передаваться порядок сортировки sort_order, а может и не передаваться. Если ожидаемый входной параметр не передан, его значение будет равно null. JS вообще очень гибок по отношению к параметрам функций...
//Так вот, если функция вызвана без входных параметров, то определяем желаемый порядок сортировки исходя из текущего значения this.order.
if (sort_order != null) this.order = sort_order;
else this.order = (this.order == 'asc') ? 'desc' : 'asc';
var table = objectFindDOM(this.table_id);
// Функция clearEmptyNodes позволяет обеспечить совместимость кода с FF. Описание функции смотрите в следующем разделе.
clearEmptyNodes(table);
var tbody = table.firstChild;
var cols = tbody.childNodes;
var header = cols.item(0).childNodes;
// Здесь вычисляем номер столбца в строке по идентификатору его заголовка.
for (var i=0; i
//Объявляем внешний цикл. Начинаем просматривать строки со второй (var i=1), потому что заголовок не должен участвовать в сортировке.
for (var i=1; i
//Сохраняем первую строку и ее номер.
var cur = cols.item(i);
var num = i;
// Объявляем внутренний цикл.
for (var j=i+1; j
// el - текущий рассматриваемый элемент "массива", содержащий всю строку таблицы.
var el = cols.item(j);
// Здесь по предварительно вычисленному номеру столбца получаем значения соответствующих ячеек таблицы.
var elNode = el.childNodes.item(column).firstChild;
var curNode = cur.childNodes.item(column).firstChild;
var elv = getNodeValue(elNode, this.type);
var curv = getNodeValue(curNode, this.type);
// Итак, имеем значения ячеек таблицы в "чистом виде" (elv и curv) и теперь можем выполнить сравнение. Чтобы не писать сложных условий в зависимости от требуемого порядка сортировки, воспользуемся замечательной функцией eval, которая исполняет переданную ей строку как js-код. Все просто, попробуйте разобраться самостоятельно.
var comp = (this.order=='desc') ? '>' : '<';
if (eval('elv'+comp+'curv')){
cur = el;
num = j;
}
}
// Завершился внутренний цикл, и переменная cur хранит строку таблицы, содержащую минимальный или максимальный элемент не просмотренной последовательности. Теперь просто меняем ее местами с i-ой строкой.
tbody.insertBefore(cols.item(i), cols.item(num));
tbody.insertBefore(cur, cols.item(i));
}
// И меняем класс ячейки-заголовка столбца
this.changeClass(this.order);
}
Обеспечение совместимости с FF
Наш красивый алгоритм не будет работать в браузере Firefox. Загвоздка в том, что все переводы строки и пробельные символы FF трактует как пустые текстовые узлы.
Может быть он и прав, но, например, вместо ожидаемого элемента tbody свойство firstChild узла table будет содержать пустую строку. Что повлечет ошибку, если не учесть эту особенность Огнелиса.
Я надумала целых три решения проблемы (не считая "забить на FF"):
- Каждый раз, когда потенциально может встретиться пустая строка вместо ожидаемого узла, ставить проверку if. Минус - много лишнего кода
- Вручную удалить все возвраты строки и пробелы из html-кода таблицы. Это чертовски действенно, но минусы решения я даже не буду перечислять
- Написать функцию, которая рекурсивно удалит все пустые строки заданного узла. Большой плюс - только один вызов функции, и никаких проблем. Минус - может быть удалено что-нибудь лишнее. Но если в таблице нет пустых ячеек, этого можно не опасаться
Очевидно, выбираем третье решение. Функция коротенькая:
function clearEmptyNodes(node){
for (var i=0; i
// Определяем регулярное выражение, которое можно прочитать примерно так: мажду началом (^) и концом ($) строки должны быть хотя бы один (+) пробельный символ (\s), и больше ничего. К пробельным символом относятся собственно пробелы, символы табуляции, перевода строки, возврат каретки и т.п.
var pattern = /^\s+$/;
if (child.nodeName=='#text' && pattern.test(child.nodeValue)){
// Если это текстовый узел, содержащий только пробельные символы, удаляем его. Не забываем уменьшить переменную цикла на единицу - это важно.
node.removeChild(child);
i--;
} else {
if (child.childNodes.length != 0)
// Если узел имеет потомков, удаляем все пустые строки из него тоже. Можно удалить рекурсивный вызов и вызывать функцию для каждого узла дерева вручную. Это обеспечит больший контроль над результатами работы функции.
clearEmptyNodes(child);
}
}
}
Дальше можно научить функцию игнорировать пустые строки внутри таких тегов как a, p и подобных. Или еще чему-нибудь хорошему научить, но статья не об этом. Я только предложила решение проблемы.
Внедрение
Осталось подключить класс к любой таблице:
// Присваиваем идентификаторы таблице и заголовкам столбцов
id="table1" cellpadding="3" cellspacing="0">
id="title">Название книги
id="sell">Продано
id="rate">Рейтинг
Холодное сердце (новинка!)
25
4,8
Гарри Поттер и Орден Феникса
54
1,8
Приключение Тома Соейра
86
5
Алиса в Стране чудес
104
4,5
// И создаем для столбцов объекты класса Column.
// Файл скриптов, конечно, лучше подключать в блоке head. А чтобы совсем отделить js-код от html, создание объектов можно реализовать в обработчике события onLoad.
И все!
Обо всех обнаруженных опечатках, ляпах и неточностях большая просьба
написать мне.