JavaScript: Сортировка строк таблицы

Dec 01, 2008 22:36


Месяц у меня вылеживалась эта дура на 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.

И все!

Обо всех обнаруженных опечатках, ляпах и неточностях большая просьба написать мне.

статьи

Previous post Next post
Up