跳到主要内容

JavaScript 将树遍历算法应用于 DOM

· 阅读需 7 分钟

前言

到目前为止,我们已经了解了一些二叉树遍历的方法:

  • 使用递归和迭代算法遍历二叉树
  • 使用父指针遍历二叉树 在本文中,我们将把这些知识用于 n 叉树,即 DOM。我们将看到如何使用各种 CSS 选择器定位 DOM 元素,而无需使用内置 API,如 getElementById、getElementsByClassname 或 querySelector/querySelectorAll。因此,本文将阐明这些 API 可能如何在幕后工作。

DOM 遍历

借用 使用递归和迭代算法遍历二叉树的思路,我们来得出DOM的前序遍历算法:

function walkPreOrder(node){
if(!node) return

// do something here
console.log(node)

for(let child of node.children){
walkPreOrder(child)
}
}

我们可以修改这个算法使之来返回一个迭代器:

function* walkPreOrder(node){
if(!node) return

// do something here
yield node
for(let child of node.children){
yield* walkPreOrder(child)
}
}

// USAGE
for(let node of walkPreOrder(root)){
console.log(node)
}

我们可以使用任何广度优先或深度优先算法(在之前的文章中讨论过)来遍历 DOM。 我们还假设正在处理具有以下 HTML 的文档:

<html>
<head>
<title>DOM selection algorithm</title>
</head>
<body>

<div class="container">
<div class="body">
<div class="row">
<img id="profile" src="xyz.jpg" alt="">
</div>
<div class="row"></div>
<div class="row"></div>
</div>
</div>

</body>
</html>

通过 ID 定位节点

function locateById(nodeId){
// iterate through all nodes in depth first (preOrder) fashion
// return the node as soon as it's found
for(let node of walkPreOrder(document.body)){
if(node.id === nodeId){
return node
}
}
return null
}

我们可以使用 locateById() 函数如下:

const img = locateById('profile')
// returns the image node

通过ClassName 定位节点

浏览器提供 document.getElementsByClassName() API 来实现此结果。我们如何实现类似的东西:

function locateAllByClassName(className){
const result = []
for(let node of walkPreOrder(document.body)){
if(node.classList.contains(className)){
result.push(node)
}
}
return result
}

// USAGE
const elements = locateAllByClassName('row')

浏览器如何优化选择查询

选择 DOM 节点是 Web 应用程序相当常见的操作。为同一个选择器多次遍历树似乎不是最佳选择。浏览器通过使用记忆优化选择。 查看 mozilla 解析器的源代码,即函数 startTag 的摘录:

 // ID uniqueness
@IdType String id = attributes.getId();
if (id != null) {
LocatorImpl oldLoc = idLocations.get(id);
if (oldLoc != null) {
err("Duplicate ID \u201C" + id + "\u201D.");
errorHandler.warning(new SAXParseException(
"The first occurrence of ID \u201C" + id
+ "\u201D was here.", oldLoc));
} else {
idLocations.put(id, new LocatorImpl(tokenizer));
}
}

我们可以看到这些节点 ID 保存在一个简单的哈希映射中。我们可以使用类似的方法来确保对同一 ID 的重复查询不需要完全遍历,相反,我们可以从 hashMap 中查找并返回它。 以下是我们的解决方案:

function getSelectors(){
const idLocations = {}
const classLocations = {}

// updated selector functions
function locateById(nodeId){
if(idLocations.hasOwnProperty(nodeId))
return idLocations[nodeId]

for(let node of walkPreOrder(document.body)){
if(node.id === nodeId){
idLocations[nodeId]= node //memoize
return node
}
}
idLocations[nodeId]= null // memoize
return null
}

function locateAllByClassName(className){
if(classLocations.hasOwnProperty(className))
return classLocations[className]

const result = []
for(let node of walkPreOrder(document.body)){
if(node.classList.contains(className)){
result.push(node)
}
}
classLocations[nodeId]= result
return result
}

return {
locateById,
locateAllByClassName
}

}

// USAGE
const {locateById, locateAllByClassName} = getSelectors();
const result = locateAllByClassName('row') // returns array of elements
const img = locateById('profile') // returns an element, if found

处理更复杂的选择器

让我们尝试实现类似 element.querySelector 的方法。以下是 MDN 的描述:

The querySelector() method of the Element interface returns the first element that is a descendant of the element on which it is invoked that matches the specified group of selectors.

样例

const firstRow = document.querySelector('.container .row:first-child')

在这种情况下,我们可以将任何 CSS 选择器传递给函数,它应该能够遍历 DOM 为我们找到该元素。让我们看看它是如何实现的:

// given a selector and root node, find that selector within the root node
function select(selector, root){
for(let node of walkPreOrder(root)){
if(node.matches(selector)){
return node
}
}
return null;
}

function myQuerySelector(path, node){ // if path is empty, nothing to find if(path.length === 0) return null;

// if node is not provided, let's assume user wants to search within document.body let root = node || document.body;
const selector = path[0];

// if there's only one selector in the path, just traverse using select function above if(path.length === 1) return select(selector, root);

// else, either the current node matches the first selector in path or not // if first selector matches with current node, look through it's children for subsequent selectors only // else, look through it's children for the whole path const newPath = root.matches(selector) ? path.slice(1): path; for(let child of root.children){ const ans = myQuerySelector(newPath, child); if(ans) return ans }

// nothing found return null; }

// USAGE: const firstRow = myQuerySelector([".container", ".row"])

myQuerySelectorAll 的实现(类似于 element.querySelectorAll)也遵循相同的方法,稍作修改:
```javascript
function selectAll(selector, root){
let result = []
for(let node of walkPreOrder(root)){
if(node.matches(selector)){
result.push(node)
}
}
return result;
}

function myQuerySelectorAll(path, node){
let result = [];
if(path.length === 0) return result;

let root = node || document.body;
const selector = path[0];

if(path.length === 1) return selectAll(selector, root);

const newPath = root.matches(selector) ? path.slice(1): path;
for(let child of root.children){
result = [...result, ...myQuerySelectorAll(newPath, child)]

}

return result;
}

进阶

我们可以使用本文开头描述的递归前序遍历方法来克隆任何树。让我们看看我们如何使用它来克隆任何 DOM 树,类似于 element.cloneNode(true) 所做的:

  • 通过创建具有相同 tagName 的新节点然后复制属性来创建源节点的克隆。
  • 对源节点的所有子节点递归调用 cloneTree 方法,并将返回的节点作为子节点附加到克隆节点。
function cloneTree(node){
if(!node) return

const clonedNode = document.createElement(node.tagName.toLowerCase())
const attributes = node.getAttributeNames()

attributes.forEach(attribute => {
clonedNode.setAttribute(attribute, node.getAttribute(attribute))
})

for(const child of node.children){
clonedNode.append(cloneTree(child))
}

return clonedNode
}