HTML5精通:树遍历

HTML5 Mastery系列图片

树遍历是DOM中最重要的概念之一。 自将计算机科学确立为自己的研究领域以来,数十年来的研究已用于数据结构和算法。 最常用的结构之一是一棵树。 到处都是树木。 一个非常基本但有用且经常使用的版本是二叉树。 锦标赛可以表示为二叉树。 但是,DOM树不是二进制的。 相反,它是一棵Kary树。 每个节点可能有零到N个子节点,称为childNodes

DOM树承载大量可能的节点类型。 其中可能有TextElementComment和其他特殊的,例如ProcessingInstructionDocumentType 。 根据定义,它们中的大多数将没有任何childNodes 。 它们是端点,仅携带一条信息。 例如, Comment节点仅携带指定的注释字符串。 Text节点仅可用于存储内容字符串。

Element节点托管其他节点。 我们可以递归地从一个元素降到另一个元素,以遍历系统中所有可用的节点。

一个说明性的例子

一个与前一篇关于<template>元素的文章有关的示例正在填写DOM子树结构。 子树是树的一部分,它从指定的元素开始。 我们将指定的元素称为子树根。 如果我们将树的<html>元素作为子树的根,则该子树将与真实树几乎相同,后者从document开始,即documentElement下方的一层。

填写子树结构要求我们迭代子树根的所有子级。 在每个节点上,我们需要检查节点的确切类型,然后以适当的方式继续进行。 例如,每个元素都需要再次被视为子树的根。 另一方面,必须更仔细地评估文本节点。 也许我们还想检查注释节点中的特殊指令。 此外,还应考虑元素的属性。

对于这种情况,我们使用一种称为applyModel的方法,用来自模型的值填充模板化的字符串。 该方法如下所示,当然可以进一步优化。 但是,就我们的目的而言,这当然足够了。

function applyModel(model, data) {
	var rx = new RegExp('\{\s*(.+?)\s*\}', 'g');
	var group = rx.exec(data);
	
	while (group) {
		var name = group[1];
		var value = '';
		eval('with (model) { value = ' + name + '; }');
		data = data.replace(group[0], value);
		group = rx.exec(data);
	}

	return data;
}

让我们看一下所描述场景的实现,该实现在各种场合下都使用applyModel方法。 这需要template元素的实例和名为model的对象,以返回新的DocumentFragment 。 新片段使用来自模型的数据将所有值从{X}更改为使用提供的对象评估表达式X的结果。

function iterateClassic (template, model) {
	var fragment = template.content.clone(true);
	var allNodes = findAllNodes(fragment);

	allNodes.forEach(changeNode);
	return fragment;
}

前面的代码使用findAllNodes函数,该函数接受一个节点并将其所有子级存储在一个数组中。 然后在每个孩子上递归调用该函数。 最后,所有结果都合并到整个子树的单个数组中,即,我们将树结构转换为一维数组。

以下代码片段显示了所描述算法的示例实现。

function findAllNodes (childNodes) {
	var nodes = [];

	if (childNodes && childNodes.length > 0) {
		for (var i = 0, length = childNodes.length; i < length; i++) {
			nodes.push(childNodes[i]);
			nodes = nodes.concat(findAllNodes(childNodes[i].childNodes));
		}
	}

	return nodes;
}

更改数组中每个节点的功能如下所示。 该函数根据节点的类型执行一些操作。 我们只关心属性和文本节点。

function changeNode (node) {
	switch (node.nodeType) {
		case Node.TEXT_NODE:
			node.text.data = applyModel(model, node.text.data);
			break;
		case Node.ELEMENT_NODE:
			Array.prototype.forEach.call(node.attributes, function (attribute) {
				attribute.value = applyModel(model, attribute.value);
			});
			break;
	}
}

即使代码易于理解,也不是很漂亮。 我们有很多性能问题,特别是因为我们有很多不幸的是必需的DOM操作。 使用DOM树帮助程序之一可以更有效地完成此操作。 注意, findAllNodes方法返回一个包含所有节点的数组,而不仅仅是子树中的所有Element实例。 如果我们对后者感兴趣,我们可以简单地使用querySelectorAll('*')调用,该调用为我们执行迭代。

遍历节点

立即出现的解决方案是使用NodeIteratorNodeIterator遍历节点。 它几乎完全符合我们的标准。 我们可以使用document对象的createNodeIterator方法创建一个新的NodeIterator 。 有三个关键参数:

  1. 要迭代的子树的根节点。
  2. 过滤,选择/迭代哪个节点。
  3. 具有用于自定义过滤的acceptNode的对象。

虽然第一个参数只是一个普通的DOM节点,但其他两个则使用特殊的常量。 例如,如果应显示所有节点,则必须传递-1作为过滤器。 或者,我们可以使用NodeFilter.SHOW_ALL 。 我们可以通过几种方式组合多个过滤器。 例如,显示所有注释和所有元素的组合可以用NodeFilter.SHOW_COMMENT | NodeFilter.SHOW_ELEMENT表达NodeFilter.SHOW_COMMENT | NodeFilter.SHOW_ELEMENT NodeFilter.SHOW_COMMENT | NodeFilter.SHOW_ELEMENT

第三个参数是一个对象,其外观可能与以下代码段一样原始。 即使包装该函数的对象似乎是多余的,也已通过这种方式指定了它。 一些浏览器,例如Mozilla Firefox,使我们可以将对象简化为单个功能。

var acceptAllNodes = {
	acceptNode: function (node) { 
		return NodeFilter.FILTER_ACCEPT;
	}
};

在这里,我们接受传入的任何节点。此外,我们还可以使用FILTER_REJECT选项拒绝一个节点(及其子节点)。 如果我们只想跳过该节点,但仍对其子节点感兴趣(如果有),则可以使用FILTER_SKIP常量。

使用NodeIterator实现我们之前的示例非常简单。 我们通过使用document的构造方法创建一个新的迭代器。 然后,我们使用nextNode方法遍历所有节点。

让我们看一下转换后的示例。

function iterateNodeIterator (template, model) {
	var currentNode;
	var fragment = template.content.clone(true);
	var iterator = document.createNodeIterator(
	    fragment,
	    NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT,
	    acceptAllNodes,
	    false
	);

	while ((currentNode = iterator.nextNode()))
		changeNode(currentNode);

	return fragment;
}

DOM查找对我们完全隐藏了。 这是一个很大的好处。 我们只请求所需的节点,其余的则以浏览器引擎中最有效的方式完成。 但是,另一方面,我们仍然必须提供用于迭代属性的代码。

即使属性由SHOW_ATTRIBUTE常量覆盖,但它们与作为子元素的元素节点也不相关。 相反,它们位于NamedNodeMap集合中, NodeIterator不会将其包含在查找中。 如果我们从某个属性开始迭代,则只能对属性进行迭代,从而将自己限制为仅属性。

前面的示例还可以在提供的过滤器中调用更改。 但是,这不是一个好习惯,因为我们可能还要将迭代器用于其他目的。 因此,迭代器实际上应该只提供只读解决方案,该解决方案不会使树发生变异。

NodeIterator也不太支持对树进行NodeIterator 。 可以将迭代器视为文档中的光标,放置在两个(最后一个和下一个)节点之间。 因此, NodeIterator不会指向任何节点。

走在树上

我们要遍历子树中的节点。 我们想到的另一个选择是使用TreeWalker 。 正如名字所暗示的,我们在这里漫步。 我们指定一个根节点和要在路由中考虑的元素,然后进行处理。 有趣的部分是TreeWalkerNodeIterator有很多共同点。 我们不仅已经看到很多共享属性,而且还使用相同的NodeFilter来设置约束。

在大多数情况下, TreeWalker实际上是比NodeIterator更好的选择。 NodeIterator的API为其提供的功能NodeIterator肿。 TreeWalker包含更多的方法和设置,但至少使用它们。

TreeWalkerNodeIterator之间的主要区别在于,前者提供子树中节点的面向树的视图,而不是迭代器的面向列表的视图。 虽然NodeIterator允许我们向前或向后移动,但TreeWalker还为我们提供了移动到节点的父级,其子级之一或同级的选项。

function iterateTreeWalker (template, model) {
	var fragment = template.content.clone(true);
	var walker = document.createTreeWalker(
	    fragment,
	    NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT,
	    acceptAllNodes,
	    false
	);

	while (walker.nextNode())
		changeNode(treeWalker.currentNode);

	return fragment;
}

NodeIterator ,“ TreeWalker”直接指向树中的特定节点。 如果所指向的节点到处移动, TreeWalker将随之而来。 更重要的是,如果从树中删除了指向的节点,那么我们将有效地结束于文档树之外。 如果我们遵循有关NodeIterator的建议,并且在遍历过程中不对树进行变异,则最终将获得相同的路径。

就我们的目的NodeIteratorTreeWalker似乎与NodeIterator几乎相同。 有一些原因使后者无法引起足够的重视。 尽管如此, TreeWalker也不是很出名。 也许它的使用范围太有限,使我们无法再次遍历属性-特别是使用DOM树迭代的第三个选项时。

范围选择

最后,在某些情况下可能会有第三种有趣的构造。 如果我们想在一维数组中选择一个范围,那么我们只需使用两个索引就可以轻松实现: i为初始(左)边界, f为最终(右)边界,我们有[i, f]

如果我们用链表替换数组,那么两个索引也可以用两个具体节点[n_i, n_f] 。 这种选择的优点在于隐式更新机制。 如果在两者之间插入节点,则不必更新范围的边界。 同样,如果从链接列表中删除了左边界,我们将获得一个向左扩展的范围,例如[0, n_f]

现在我们没有一维问题,而是一个树结构。 选择Kary树的范围并不是一件容易的事。 我们可以提出自己的算法,但是DOM树有一些特殊问题。 例如,我们有文本节点,也可能是某个范围的主题。 在我们的DOM树中,范围包括四个属性。 我们有一个开始节点,一个结束节点和两者的偏移量。

还有一些帮助器,例如selectNodeselectNodeContents方法,它们执行setStartsetEnd的正确调用。 例如,调用selectNodeContents(node)等效于代码片段:

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

范围超越了纯粹的程序选择。 它们还用于浏览器中的实际视觉选择。 window上下文的getSelection()方法产生一个Selection对象,可以通过调用getRangeAt(0)轻松将其转换为Range 。 如果未选择任何内容,则前一条语句将失败。

让我们考虑一个简单的选择示例,结果如下图所示。

DOM选择

在这里,我们从第一个列表项的文本节点开始,到一个强元素的文本节点的末尾结束。 下图从源代码的角度说明了覆盖范围。

DOM范围来源

显示提供的Range实例的DOM树也很有趣。 我们看到,这样的范围能够覆盖与其祖先或兄弟姐妹无关的各种节点。

DOM范围树

提取选定的节点将为我们提供一个DocumentFragment ,它从一个新的列表项开始,到Strong元素之后结束。

DOM范围提取

提取实际上是DOM变异操作,即它将修改现有的树。 现在,剩下的两个项目与我们预期的完全一样。

提取的DOM范围

由于文本可能包含元素及其包含的所有内容,因此范围也需要涵盖这些情况。 乍一看, Range很奇怪,因为它始终需要关心至少两种情况:文本和非文本(主要是元素)。 但是,正如我们已经看到的,有很好的理由区分这两种情况,否则我们将无法选择文本片段。

Range对象缺乏我们之前体验的迭代功能。 相反,我们提高了序列化和导出功能。 因此,一开始改变我们以前的例子很麻烦。 不过,通过引入一些新方法,我们可以将Range的灵活性与增强的迭代结合在一起。

Range.prototype.current = function () {
	if (this.started)
		return this.startContainer.childNodes[this.startOffset];
};

Range.prototype.next = function (types) {
	var s = this.startContainer;
	var o = this.startOffset;
	var n = this.current();

	if (n) {
		if (n.childNodes.length) {
			s = n;
			o = 0;
		} else if (o + 1 < s.childNodes.length) {
			o += 1;
		} else {
			do {			
				n = s.parentNode;

				if (s == this.endContainer)
					return false;

				o = Array.prototype.indexOf.call(n.childNodes, s) + 1;
				s = n;
			} while (o === s.childNodes.length);
		}

		this.setStart(s, o);
		n = this.current();
	} else if (!this.started) {
		this.started = true;
		n = this.current();
	}

	if (n && types && Array.isArray(types) && types.indexOf(n.nodeType) < 0)
		return this.next();

	return !!n;
};

这两种方法使我们像以前的迭代器一样使用Range 。 现在,我们只能朝一个方向前进。 但是,我们可以轻松实现跳过子级,直接转到父级或执行任何其他操作的方法。

function iterateRange (template, model) {
	var fragment = template.content.clone(true);
	var range = document.createRange();
    range.selectNodeContents(fragment);

	while (range.nextNode([Node.TEXT_NODE, Node.ELEMENT_NODE]))
		changeNode(range.current());

	range.detach();
	return fragment;
}

即使Range像其他任何JavaScript对象一样被垃圾回收,也仍然可以使用detach函数释放它。 原因之一是所有Range实例实际上都保存在document ,如果DOM发生突变,它们将在其中进行更新。

Range上定义我们自己的迭代器方法是有益的。 偏移量会自动更新,我们有一些辅助功能,例如将当前选择克隆为DocumentFragment ,提取或删除所选节点。 我们也可以根据自己的需要设计API。

结论

遍历DOM树对于任何考虑DOM操作和有效节点检索的人来说都是一个有趣的话题。 在大多数情况下,已经有相应的API。 我们是否需要简单的迭代? 我们要选择一个范围吗? 我们热衷于走树吗? 每种方法都有优点和缺点。 如果我们知道自己想要什么,那么我们可以做出正确的选择。

不幸的是,树结构不像一维数组那么简单。 它们可以映射到一维数组,但是映射遵循与迭代其结构相同的算法。 如果使用提供的结构之一,则可以访问已经遵循这些算法的方法。 因此,我们获得了一种方便的方法来对Kary树中的节点进行一些迭代。 我们还通过执行更少的DOM操作来节省一些性能。

翻译自: https://code.tutsplus.com/tutorials/html5-mastery-tree-traversal--cms-24843

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值