Bastet (blackbastet) wrote,
Bastet
blackbastet

Categories:

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

Месяц у меня вылеживалась эта дура на 10 листов. Нашла, подправила. Пусть будет...

Попробую описать реализацию своего класса для динамической сортировки строк таблицы в JavaScript. "Ни пуха" мне...

План таков:


Постановка задачи

Максимально универсальная функция должна:

  • Легко подключаться, не зависеть от дизайна, никакого JS-кода не должно быть в объявлении таблицы.
  • Корректно работать для строковых и числовых значений.
  • Корректно обрабатывать теги внутри ячеек таблицы.
  • Автоматически менять вид заголовка столбца в зависимости от направления сортировки.
  • Корректно работать во всех распространенных браузерах. В моем понимании это IE, Opera и FF. В других теоретически тоже должно работать.

Выбор алгоритма сортировки

Из множества алгоритмов сортировки здесь выбираем самый простой. Суть такова: находим наименьший (или наибольший - в зависимости от порядка сортировки) элемент массива, меняем его местами с первым элементом. Далее находим второй наименьший (или наибольший), и меняем его со вторым. Ну и так далее.

Для наглядности привожу классическую реализацию алгоритма сортировки массива по возрастанию:

var arr = new Array(4, 5, 1, 3, 2);
for (var i=0; i<arr.length; i++){
  min = arr[i];
  num = i;
  for (var j=i+1; j<arr.length; j++){
    if (arr[j]<min){
      min = arr[j];
      num = j;
    }
  }
  arr[num] = arr[i];
  arr[i] = min;
}

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


Несколько слов о DOM-модели

DOM-модель (Document Object Model) - это способ представления структуры HTML-документа (и не только HTML) в виде дерева. Простой пример - и все станет ясно.

<html>
  <head><title>DOM-модель</title></head>
  <body>
    <h1>Тут заголовок</h1>
    <p>Тут текст абзаца</p>
  </body>
</html>

В виде 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<header.length; i++){
      if (header.item(i).id == this.id) var column = i;
    }

//Объявляем внешний цикл. Начинаем просматривать строки со второй (var i=1), потому что заголовок не должен участвовать в сортировке.

    for (var i=1; i<cols.length; i++){

//Сохраняем первую строку и ее номер.

        var cur = cols.item(i);  
        var num = i;

// Объявляем внутренний цикл.

        for (var j=i+1; j<cols.length; 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<node.childNodes.length; i++){
    var child = node.childNodes.item(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 и подобных. Или еще чему-нибудь хорошему научить, но статья не об этом. Я только предложила решение проблемы.


Внедрение

Осталось подключить класс к любой таблице:

// Присваиваем идентификаторы таблице и заголовкам столбцов

<table id="table1" cellpadding="3" cellspacing="0">
  <tr class="header">
    <td id="title">Название книги</td>
    <td id="sell">Продано</td>
    <td id="rate">Рейтинг</td>
  </tr>
  <tr>
    <td><a href="#">Холодное сердце</a> (новинка!)</td>
    <td>25</td>
    <td>4,8</td>
  </tr>
  <tr>
    <td><a href="#">Гарри Поттер и Орден Феникса</a></td>
    <td>54</td>
    <td>1,8</td>
  </tr>
  <tr>
    <td><a href="#">Приключение Тома Соейра</a></td>
    <td>86</td>
    <td>5</td>
  </tr>
  <tr>
    <td><a href="#">Алиса в Стране чудес</a></td>
    <td>104</td>
    <td>4,5</td>
  </tr>
</table>

// И создаем для столбцов объекты класса Column.
// Файл скриптов, конечно, лучше подключать в блоке head. А чтобы совсем отделить js-код от html, создание объектов можно реализовать в обработчике события onLoad.

<script src="table_sorting.js" type="text/javascript"></script>
<script type="text/javascript" language="JavaScript">
  new Column("table1", "title", "strings");
  new Column("table1", "sell", "numbers");
  new Column("table1", "rate", "numbers");
</script>

И все!

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

Tags: статьи
Subscribe

  • Люди, которые играют в куклы

    Если кто-то еще не знает, я увлеклась коллекционированием fashion-кукол масштаба 1/6 (как Барби) и их фотографированием. Со временем расскажу о…

  • I'll be back

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

  • Куклы JAMIEShow

    Производитель fashion-кукол, который обычно делает 16-ти дюймовых кукол, наконец-то выпускает серию 12-ти дюймовых, которые я, собственно,…

  • Post a new comment

    Error

    Anonymous comments are disabled in this journal

    default userpic

    Your reply will be screened

    Your IP address will be recorded 

  • 14 comments

  • Люди, которые играют в куклы

    Если кто-то еще не знает, я увлеклась коллекционированием fashion-кукол масштаба 1/6 (как Барби) и их фотографированием. Со временем расскажу о…

  • I'll be back

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

  • Куклы JAMIEShow

    Производитель fashion-кукол, который обычно делает 16-ти дюймовых кукол, наконец-то выпускает серию 12-ти дюймовых, которые я, собственно,…