Advertisement
  1. Code
  2. HTML5

Осваиваем HTML5: обход дерева

Scroll to top
Read Time: 14 min
This post is part of a series called HTML5 Mastery Class.
HTML5 Mastery: Fragments
HTML5 Mastery: Constraint Validation

Russian (Pусский) translation by AlexBioJS (you can also view the original English article)

HTML5 Mastery series imageHTML5 Mastery series imageHTML5 Mastery series image

Одна из наиболее важных концепций DOM – обход дерева (* процесс единоразового посещения (проверки и/или обновления) каждого узла дерева (структура данных). Здесь и далее примеч. пер.). Поскольку теория вычислительных машин и систем (* компьютерные науки; общее название для совокупности дисциплин, связанных с конструированием компьютеров и их использованием в обработке информации. Объединяет теоретические и практические аспекты многих наук, таких как электроника, программирование, математика, искусственный интеллект, человеко-машинное взаимодействие, конструирование и др.) сформировалась как самостоятельная область исследований, десятилетия были потрачены на разработку структур данных (* способ хранения и организации данных для более удобного доступа к ним и обработки; может представлять собой описание полей записи, таблицы, списка, массива, файла и т. п.) и алгоритмов (* математическая функция или конечный чёткий набор описаний логической последовательности действий, необходимых для того, чтобы компьютер или интеллектуальное устройство выполнили за конечное время некоторую задачу). Одной из наиболее часто используемых структур является дерево. Деревья представлены везде. Одной из простейших и в то же время полезной и часто используемой версией дерева является двоичное дерево (* структура данных, представляющая собой дерево, каждая вершина (узел) которого имеет не более двух потомков. Каждая вершина может также содержать ключ, идентифицирующий эту вершину и ассоциированный с некоторыми данными. Двоичные деревья используются в некоторых алгоритмах сортировки и поиска данных. У двоичных деревьев различают следующие части: корень (root), левая ветвь (left branch), правая ветвь (right branch) и листья (leaf node)). Спортивное соревнование можно представить в виде двоичного дерева. Однако дерево DOM не является двоичным. Это K-арное дерево (* дерево с корнем, в каждом узле которого имеется не более k дочерних узлов). Каждый узел может иметь от нуля до N подузлов, называемых childNodes.

В дереве DOM содержится большое разнообразие всевозможных типов узлов. Это могут быть Text, Element, Comment и другие дополнительные, например ProcessingInstruction или DocumentType помимо многих других. Большинство из них не будет содержать каких-либо childNodes (* дочерних элементов) по определению (* по своей природе). Они являются конечными точками (* термин "конечная точка" (endpoint) часто используется для описания отдельной функции API) и содержат только один тип информации. Например узел Comment содержит только указанную строку комментария. В узле Text может содержаться только строка с контентом.

В узле Element содержатся другие узлы. Мы можем рекурсивно (* с повторным использованием процедуры, правила для достижения результата) спускаться к элементам для обхода всех доступных узлов дерева.

Наглядный пример

Примером, который также относится к предыдущей статье, в которой рассматривался элемент <template>, является заполнение поддерева. Поддерево – часть дерева, которая начинается с указанного элемента. Этот элемент называется корнем поддерева. Если бы мы взяли элемент <html> дерева в качестве корня поддерева, то поддерево было бы почти идентично дереву документа, которое начинается с document, то есть располагалось бы на один уровень ниже documentElement.

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

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

1
function applyModel(model, data) {
2
  var rx = new RegExp('\\{\\s*(.+?)\\s*\\}', 'g');
3
	var group = rx.exec(data);
4
	
5
	while (group) {
6
		var name = group[1];
7
		var value = '';
8
		eval('with (model) { value = ' + name + '; }');
9
		data = data.replace(group[0], value);
10
		group = rx.exec(data);
11
	}
12
13
	return data;
14
}

Давайте рассмотрим реализацию вышеописанного сценария, в котором используется метод applyModel в различных ситуациях. Этот метод принимает образец элемента template и объект model для возвращения нового DocumentFragment. В новом фрагменте используются данные из модели для изменения всех значений {X} на результат вычисления выражения X, при котором использовался предоставленный объект.

1
function iterateClassic (template, model) {
2
	var fragment = template.content.clone(true);
3
	var allNodes = findAllNodes(fragment);
4
5
	allNodes.forEach(changeNode);
6
	return fragment;
7
}

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

Ниже показан фрагмент кода для одного из вариантов реализации описанного выше алгоритма.

1
function findAllNodes (childNodes) {
2
	var nodes = [];
3
4
	if (childNodes && childNodes.length > 0) {
5
		for (var i = 0, length = childNodes.length; i < length; i++) {
6
			nodes.push(childNodes[i]);
7
			nodes = nodes.concat(findAllNodes(childNodes[i].childNodes));
8
		}
9
	}
10
11
	return nodes;
12
}

Функция для изменения каждого узла массива показана ниже. Функция выполняет некоторые манипуляции в зависимости от типа узла. Нас интересуют только атрибуты элементов и текстовые узлы.

1
function changeNode (node) {
2
	switch (node.nodeType) {
3
		case Node.TEXT_NODE:
4
			node.text.data = applyModel(model, node.text.data);
5
			break;
6
		case Node.ELEMENT_NODE:
7
			Array.prototype.forEach.call(node.attributes, function (attribute) {
8
				attribute.value = applyModel(model, attribute.value);
9
			});
10
			break;
11
	}
12
}

Хотя вышеописанный код легко понять, в нем не все так гладко. У нас имеется довольно много проблем с производительностью, особенно из-за наличия как на зло необходимых операций с DOM. Наш сценарий можно реализовать более эффективно при помощи одного из вспомогательных интерфейсов дерева DOM. Обратите внимание, что метод findAllNodes возвращает массив со всеми узлами, а не только с образцами Element поддерева. Если мы заинтересованы в этом, то можем просто воспользоваться вызовом querySelectorAll('*'), при помощи которого выполняется итерация.

Итерирование (* процесс повторяющегося выполнения последовательности операторов или команд) для узлов

Сразу же находим решение проблемы – использование интерфейса NodeIterator. NodeIterator осуществляет итерацию для узлов. Он почти идеально подходит под наши требования. Мы можем создать новый NodeIterator при помощи метода createNodeIterator объекта document. Метод принимает три ключевых параметра:

  1. Корневой узел поддерева, для которого необходимо выполнить итерацию.
  2. Фильтры для указания узлов, которые необходимо выбрать / для которых необходимо выполнить итерацию.
  3. Объект с методом acceptNode для фильтрации элементов на основании пользовательских настроек.

В то время как первый аргумент - лишь простой узел DOM, в качестве остальных двух используются специальные константы. Например: если необходимо, чтобы были показаны все узлы, то мы должны передать -1 в качестве фильтра. Альтернативный вариант – можем использовать NodeFilter.SHOW_ALL. Мы могли бы скомбинировать несколько фильтров различными способами. Например для показа всех узлов с комментариями и элементами можно использовать NodeFilter.SHOW_COMMENT | NodeFilter.SHOW_ELEMENT.

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

1
var acceptAllNodes = {
2
	acceptNode: function (node) { 
3
		return NodeFilter.FILTER_ACCEPT;
4
	}
5
};

Здесь мы принимаем любой узел, переданный в функцию. Также у нас имеется возможность отклонить узел (и его дочерние элементы) при помощи опции FILTER_REJECT. Если мы просто хотим пропустить узел, однако по-прежнему заинтересованы в его дочерних элементах, то можем воспользоваться константой FILTER_SKIP.

Реализовать наш предыдущий пример при помощи NodeIterator довольно просто. Мы создаем новый итератор (* управляющая структура, определяющая порядок выполнения некоторых повторяющихся действий, устройство или программа организации циклов) при помощи конструктора document. Затем мы используем метод nextNode для выполнения итерации для всех узлов.

Давайте взглянем на преобразованный пример.

1
function iterateNodeIterator (template, model) {
2
	var currentNode;
3
	var fragment = template.content.clone(true);
4
	var iterator = document.createNodeIterator(
5
	    fragment,
6
	    NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT,
7
	    acceptAllNodes,
8
	    false
9
	);
10
11
	while ((currentNode = iterator.nextNode()))
12
		changeNode(currentNode);
13
14
	return fragment;
15
}

Поиск по DOM полностью скрыт от нас. Это очень выгодно. Мы лишь запрашиваем необходимые нам узлы, остальное выполняется наиболее эффективным способом в движке браузера. Однако, с другой стороны нам по-прежнему необходимо написать код для выполнения итерации для узлов с атрибутами.

Хотя для отбора атрибутов используется константа SHOW_ATTRIBUTE, они не связаны с узлами для элементов как дочерние узлы. Вместо этого они располагаются в коллекции NamedNodeMap, поиск по которой при помощи NodeIterator не будет происходить. Мы можем выполнить итерацию для атрибутов элементов только в том случае, если мы начинаем итерацию с атрибута элемента, при этом ограничивая наш выбор только атрибутами элементов.

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

Изменение дерева также не поддерживается должным образом при использовании NodeIterator. Итератор можно представить в виде курсора в документе, расположенного между двумя (предыдущим и последующим) узлами. Поэтому NodeIterator не указывает ни на какой узел.

Обход дерева

Мы хотим выполнить итерацию для узлов в поддереве. Другой вариант для решения нашей проблемы, который можем придумать, – использование TreeWalker. При этом мы обходим дерево, как следует из имени интерфейса. Мы указываем корневой узел и элементы, которые необходимо принимать во внимание при обходе, и затем начинается обработка. Интересно то, что TreeWalker имеет много общего с NodeIterator. Это не только уже нами увиденные общие свойства, но и использование NodeFilter для установления ограничений.

В большинстве случаев TreeWalker в действительности является лучшим выбором, чем NodeIterator. API NodeIterator чрезвычайно громоздкий для тех возможностей, которые он предоставляет. У TreeWalker имеется еще больше методов и настроек, но, во всяком случае, они находят применение.

Главное различие между TreeWalker и NodeIterator заключается в том, что при использовании первого узлы поддерева представляются в виде дерева, а не списка, как в случае с итератором. В то время как NodeIterator позволяет нам перемещаться вперед или назад, TreeWalker предоставляет нам также возможность перемещения к родительскому элементу узла, к одному из дочерних элементов или к узлу-брату (* вершина дерева, имеющая того же "родителя").

1
function iterateTreeWalker (template, model) {
2
	var fragment = template.content.clone(true);
3
	var walker = document.createTreeWalker(
4
	    fragment,
5
	    NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT,
6
	    acceptAllNodes,
7
	    false
8
	);
9
10
	while (walker.nextNode())
11
		changeNode(treeWalker.currentNode);
12
13
	return fragment;
14
}

В отличие от NodeIterator TreeWalker указывает прямиком на указанный узел дерева. При перемещении узла TreeWalker будет следовать соответственно. Что более важно, – то, что при удалении из дерева узла, на который указывает TreeWalker, мы фактически окажемся за пределами дерева документа. Если мы последуем совету, данному при реализации примера за счет NodeIterator, и не будем изменять дерево при обходе, то получим тот же результат.

Также похоже, что TreeWalker почти идентичен NodeIterator при достижении наших целей. Имеются причины, по которым последний не мог бы привлечь к себе значительное внимание. Тем не менее, TreeWalker также не пользуется особой популярностью. Возможно, что область его применения слишком ограничена, поскольку отсутствует возможность обхода узлов с атрибутами, – особенно при наличии третьего варианта для выполнения итерации для дерева DOM.

Выбор диапазона

Наконец, имеется третья конструкция, которая может пригодиться при некоторых обстоятельствах. Если мы хотим выбрать диапазон значений в одномерном массиве, то можем запросто реализовать это при помощи всего лишь двух индексов: i – для начальной (левой) границы и f – для конечной (правой) границы, [i, f].

Если мы заменим массив связным списком (* список (структура данных), элементы которого не обязательно расположены в памяти последовательно. Доступ к следующему элементу (list element , node) осуществляется с помощью указателя, хранящегося в предыдущем элементе списка. У последнего элемента указатель имеет специальное значение (null pointer), по которому определяется конец списка. Список может быть однонаправленным (one-way list, single-linked list) и двунаправленным (double-linked list), когда каждый его элемент содержит ссылки как на следующий, так и на предшествующий элементы. Индексом элемента списка является порядковый номер элемента в списке), то два индекса также можно будет заменить двумя конкретными элементами, [n_i, n_f]. Преимущество при использовании этого варианта заключается в скрытом механизме обновления. Если мы добавляем в диапазон элементы, нам не нужно обновлять его границы. Также при удалении левой границы из связного списка мы получаем диапазон, увеличившийся в левую сторону, типа [0, n_f].

Что ж, у нас рассматриваемая тут проблема не связана с одномерным массивом, а с древовидной структурой. Выбор диапазона K-арного дерева не так просто осуществить. Мы могли бы придумать свой собственный алгоритм, однако в дереве DOM имеются свои «подводные камни». Например у нас имеются текстовые узлы, которые, возможно, также необходимо добавить в диапазон. В дереве DOM нашего примера диапазон состоит из четырех свойств. У нас имеется стартовый узел, конечный узел и смещение для обоих.

Также имеются хелперы, например метод selectNode или selectNodeContents, при помощи которых выполняются необходимые вызовы setStart и setEnd. К примеру вызов selectNodeContents(node) эквивалентен следующему фрагменту кода:

1
range.setStart(node, 0);
2
range.setEnd(node, node.childNodes.length);

Выбор диапазонов выполняется не только нами в коде. Это происходит и при визуальном выборе в браузере. Метод getSelection() объекта window возвращает объект Selection, который может быть запросто преобразован в Range при помощи вызова метода getRangeAt(0). Если ничего не выбрано, то при вычислении предыдущего выражения возвращается ошибка.

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

DOM Selection

Здесь диапазон начинается в текстовом узле первого элемента списка и заканчивается в конце текстового узла элемента strong. На следующем изображении показан рассматриваемый диапазон с точки зрения исходного кода:

DOM Range Source

Также интересно взглянуть на дерево DOM для рассматриваемого образца Range. Мы видим, что при помощи подобного диапазона можно выбрать все разнообразие узлов без их родительских элементов или узла-брата.

DOM Range TreeDOM Range TreeDOM Range Tree

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

DOM Range ExtractDOM Range ExtractDOM Range Extract

Извлечение – собственно внесение изменений в DOM, то есть при нем будет модифицировано существующее дерево. После манипуляций у нас остались следующие два элемента, точно так, как и ожидалось.

DOM Range Extracted

Поскольку в тексте могут содержаться элементы со всем их содержимым, то диапазон должен подходить и для этих случаев. На первый взгляд может показаться, что Range сконструирован странным образом, поскольку он должен уметь справляться как минимум с двумя вариантами узлов: текстовыми и нетекстовыми (большинство элементов). Однако, как мы видели, имеются веские основания для различения этих двух вариантов, поскольку в ином случае мы бы не смогли выбрать фрагменты текста.

Объекту Range недостает возможности итерации, с которой мы ознакомились ранее. Вместо этого у него имеются усовершенствованные возможности сериализации (* преобразование параллельных данных в последовательные, например для передачи по последовательному каналу) и экспорта. Поэтому измененный код нашего ранее реализованного примера поначалу может показаться громоздким. Тем не менее, за счет добавления нескольких новых методов мы можем объединить гибкость Range с улучшенной итерацией.

1
Range.prototype.current = function () {
2
	if (this.started)
3
		return this.startContainer.childNodes[this.startOffset];
4
};
5
6
Range.prototype.next = function (types) {
7
	var s = this.startContainer;
8
	var o = this.startOffset;
9
	var n = this.current();
10
11
	if (n) {
12
		if (n.childNodes.length) {
13
			s = n;
14
			o = 0;
15
		} else if (o + 1 < s.childNodes.length) {
16
			o += 1;
17
		} else {
18
			do {			
19
				n = s.parentNode;
20
21
				if (s == this.endContainer)
22
					return false;
23
24
				o = Array.prototype.indexOf.call(n.childNodes, s) + 1;
25
				s = n;
26
			} while (o === s.childNodes.length);
27
		}
28
29
		this.setStart(s, o);
30
		n = this.current();
31
	} else if (!this.started) {
32
		this.started = true;
33
		n = this.current();
34
	}
35
36
	if (n && types && Array.isArray(types) && types.indexOf(n.nodeType) < 0)
37
		return this.next();
38
39
	return !!n;
40
};

Эти два метода позволяют нам пользоваться Range так же, как пользовались итератором ранее. Прямо сейчас мы можем лишь перемещаться в одном направлении; однако, мы могли бы запросто реализовать методы для пропуска дочерних элементов, перехода прямо к родительскому элементу или выполнения какого-либо еще перехода.

1
function iterateRange (template, model) {
2
	var fragment = template.content.clone(true);
3
	var range = document.createRange();
4
    range.selectNodeContents(fragment);
5
6
	while (range.nextNode([Node.TEXT_NODE, Node.ELEMENT_NODE]))
7
		changeNode(range.current());
8
9
	range.detach();
10
	return fragment;
11
}

Несмотря на то что Range подвергается «сборке мусора» (* выполняемая во время исполнения программы операция удаления ненужных данных и переупорядочения (объединения в более крупные) блоков динамически распределяемой памяти, необходимой для дальнейшей работы. Эта операция может выполняться средствами , интерпретатора, приложения, аппаратуры - возможно, в разных их сочетаниях. Обычно запускается, когда объём свободной памяти становится меньше заранее определённого. Освобождённая память возвращается в пул свободной (доступной для распределения) памяти. Впервые сборка мусора была введена в Lisp в начале 1960-х годов), как и любой другой объект JavaScript, по-прежнему избавление от него при помощи функции detach считается устоявшейся практикой. Одна из причин – то, что все образцы Range в действительности сохраняются в document, где они обновляются при манипуляциях с DOM.

Определение наших собственных методов для Range – удачная идея. Смещения автоматически обновляются, и у нас имеются дополнительные возможности, например клонирования текущего выбранного ряда элементов в качестве DocumentFragment, извлечения или удаления выбранных узлов. Также мы можем создать API для наших собственных нужд.

Заключение

Выполнение итерации для дерева DOM – интересная тема для всех, кто интересуется манипулированием DOM и эффективным получением узлов. Для большинства возможных сценариев уже имеется соответствующий API. Нам нужно просто выполнить итерацию? Мы хотим выбрать диапазон узлов? Нас интересует обход дерева? У каждого метода имеются свои недостатки и преимущества. Если мы знаем, что нам необходимо, то можем сделать правильный выбор.

К сожалению, древовидные структуры не так просты, как одномерные массивы. Их можно превратить (* построить соответствие) в одномерные массивы, однако при этом учитываются те же алгоритмы, что и при выполнении итерации для их структуры. Если мы пользуемся одной из предоставленных нам структур, то получаем доступ к уже готовым методам, которые работают в соответствии с этими алгоритмами. Поэтому мы получаем удобный способ выполнения итерации для узлов в K-арном дереве. Также мы несколько улучшаем производительность за счет выполнения меньшего количества операций с DOM.

Ссылки

Advertisement
Did you find this post useful?
Want a weekly email summary?
Subscribe below and we’ll send you a weekly email summary of all new Code tutorials. Never miss out on learning about the next big thing.
Advertisement
Looking for something to help kick start your next project?
Envato Market has a range of items for sale to help get you started.