DOM操作算法002-寻找指定DOM节点的上一个或下一个节点—— getDomNode
当我们需要寻找指定DOM节点的上一个节点或下一个节点时,我们可能第一时间会想到下面两个API:
node.previousSibling
node.nextSibling
但是这俩 API 只是针对上一个【兄弟节点】或下一个【兄弟节点】的,如果你确定指定DOM节点前后是有兄弟节点的,那可以使用这俩API,但是如果不确定,比如指定DOM节点可能是某个节点的第一个子节点,那它的上一个节点可能就是它父节点的上一个兄弟节点,或父节点的上一个兄弟节点的最后一个子节点。比如下面的代码:
注意,这里说的是节点,不是元素,意味着我们获取到的也包括文本节点(包括回车、换行、空格等)和注释节点。在正常的前端页面处理中,我们往往是忽略这些节点的,但是在富文本编辑器中,我们却不能听之任之,所以这里我们是需要获取节点而不是元素。
所以,下面的例子中,我们将在html中去除一部分格式化,因为那会引入回车节点,不利于讲解。
<div class="root">
<div class="f1">
<div class="f11">f11</div><div class="f12">f12</div></div><div class="f2"><div class="f21">f21</div>
<div class="f22">f22</div>
</div>
</div>
为了方便理解,下面是实际结构
节点f12
的下一个节点是它父节点的兄弟节点f2
,而f21
的上一个节点则是f1
;
这样的情况,我们就无法使用上面的API了,需要我们自己来实现。
1.0 版 —— 简单遍历
- 首先,确定方法名,这里将其命名为
getDomNode
; - 然后,确定参数:
- 源节点:
node
,告诉我们要找哪一个节点的下一个或上一个节点 - 查找方向:
ltf
,告诉我们向前查找还是向后查找(上一个还是下一个),为了方便操作,这个值可以直接传previousSibling
或nextSibling
- 源节点:
- 确定算法:
- 代码实现
function getDomNode(node, ltr) {
while(node) {
if (node) {
if (node[ltr]) {
return node[ltr];
} else {
if (node.parentNode) {
node = node.parentNode;
} else {
return null;
}
}
} else {
return null;
}
}
}
针对上面的html
示例测试一下:
<script>
const f21node = document.querySelector('.f21');
const preNode = getDomNode(f21node, 'previousSibling');
console.log('f21的上一个节点:', preNode);
const f12node = document.querySelector('.f12');
const nextNode = getDomNode(f12node, 'nextSibling');
console.log('f12的下一个节点:',nextNode);
</script>
结果:
我们上面的实现,有太多的if-else
嵌套,可以适当优化一下:
function getDomNode(node, ltr) {
var tmpNode, parent;
node && (tmpNode = node[ltr]);
while(!tmpNode && (parent = (parent || node).parentNode)) {
tmpNode = parent[ltr];
}
!tmpNode && (tmpNode = null);
return tmpNode;
}
我们引入了两个临时变量:
tmpNode
: 结果节点parent
: 当前父节点
上面的逻辑可表述为:
- 如果
node
存在,则尝试将node
的下一个兄弟节点赋值给结果节点 - 如果第1步后,结果节点有值,则不触发
while
循环,直接返回这个结果节点 - 如果第1步后,结果节点仍为未定义,则尝试将当前节点父节点赋值给当前父节点
(parent = (parent || node).parentNode)
这一句的意思是:- 第一次循环时,
parent
未定义,则取node
的父节点赋值给当前父节点 - 后面的每次循环,
parent
已经有了值,则取parent
的父节点赋值给当前父节点,实现不断向上层DOM树的遍历
- 第一次循环时,
- 如果经过遍历循环后, 结果节点仍旧为未定义,则为其赋值
null
- 返回结果节点
2.0 考虑最外层为 body
的情形
上面的示例中,如果我们查找root
节点的上一个节点呢?
</head><body><div class="root">
<script>
const froot = document.querySelector('.root');
const preNode = getDomNode(froot, 'previousSibling');
console.log('root的上一个节点:', preNode);
</script>
从这个结构,也可以看出,root
的节点没有兄弟节点,那就会向上找到body
节点的上一个兄弟节点,那就是head
节点:
但是实际上,我们在开发中,查找到body
如果还没查找到兄弟节点,就到此结束了,并不需要继续查找head
节点
所以,我们的代码还需要优化一下, 遇到父节点是body
还没找到时,就直接返回null
:
function getDomNode(node, ltr) {
var tmpNode, parent;
node && (tmpNode = node[ltr]);
while(!tmpNode && (parent = (parent || node).parentNode)) {
if (parent.tagName == 'BODY') {
return null;
}
tmpNode = parent[ltr];
}
!tmpNode && (tmpNode = null);
return tmpNode;
}
3.0 节点过滤
前面我们说过,previousSibling
与 nextSibling
查找到的是节点,而非元素,里面包含了回车、空行、注释、文本等节点。
如果我们不需要这些节点怎么办呢?
那么我们可以提供一个函数,用来根据指定条件(比如nodeType === 1
)来对查找过程中遇到到节点进行过滤,过滤掉那些不需要的节点类型,保留我们需要的节点类型。
function getDomNode(node, ltr, fn) {
var tmpNode, parent;
node && (tmpNode = node[ltr]);
while(!tmpNode && (parent = (parent || node).parentNode)) {
if (parent.tagName == 'BODY') {
return null;
}
tmpNode = parent[ltr];
}
if (tmpNode && fn && !fn(tmpNode)) {
return getDomNode(tmpNode, ltr, fn);
}
!tmpNode && (tmpNode = null);
return tmpNode;
}
上面代码中,我们加入的部分:
if (tmpNode && fn && !fn(tmpNode)) {
return getDomNode(tmpNode, ltr, fn);
}
如果查找到了结果节点,但是结果节点并不符合我们过滤函数指定的条件,那么我们继续从这个结果节点开始,向相同方向继续查找。
现在,我们的html
可以恢复格式化了:
<body>
<div class="root">
<div class="f1">
<div class="f11">f11</div>
<div class="f12">f12</div>
</div>
<div class="f2">
<div class="f21">f21</div>
<div class="f22">f22</div>
</div>
</div>
</body>
我们首先来看,不传过滤函数的结果:
const f21 = document.querySelector('.f21');
const preNode = getDomNode(f21, 'previousSibling');
console.log('f21的上一个节点:', preNode);
然后再传入一个过滤函数,再看看:
const f21 = document.querySelector('.f21');
const preNode = getDomNode(f21, 'previousSibling', function (node) {
return node.nodeType !== 3;
});
console.log('f21的上一个节点:', preNode);
这里我们将nodeType ==3
作为结果节点的必备条件,而上面我们看到换行节点的nodeType
就是3,就不满足我们的条件,就会被过滤掉,从而继续向上查找。
到这里,其实这个工具方法就已经可以实现我们的目标了。
我们还可以根据这个方法进一步封装两个方法,一个用来获取上一个节点,一个用来获取下一个节点:
function getPreNode(node, fn) {
return getDomDode(node, 'previousSibling', fn)
}
function getNextNode(node, fn) {
return getDomDode(node, 'nextSibling', fn)
}
4. 扩展版
在ueditor
编辑器的源码中,这个工具方法是这样的:
function getDomNode(node, start, ltr, startFromChild, fn, guard) {
var tmpNode = startFromChild && node[start],
parent;
!tmpNode && (tmpNode = node[ltr]);
while (!tmpNode && (parent = (parent || node).parentNode)) {
if (parent.tagName == 'BODY' || guard && !guard(parent)) {
return null;
}
tmpNode = parent[ltr];
}
if (tmpNode && fn && !fn(tmpNode)) {
return getDomNode(tmpNode, start, ltr, false, fn);
}
return tmpNode;
}
可以看到,除了能实现我们上面的目标外,它还支持以下参数来实现更多的功能:
start
开始查找的节点,有两个取值:firstChild
-从第一个字节点开始查找,lastChild
从最后一个子节点开始查找startFromChild
: 查找过程是否从其子节点开始,布尔值guard
:守护函数,结果节点的父节点必须符合该函数指定的条件