判断 DOM 祖先节点
问题
给定两个 DOM 节点,判断其中一个是否为另一个的祖先节点。
答案
判断 DOM 节点的祖先关系是前端开发中的常见需求,可用于事件委托、组件边界检测等场景。
使用原生 API
浏览器提供了 contains 方法:
function isAncestor(ancestor: Node, descendant: Node): boolean {
return ancestor.contains(descendant) && ancestor !== descendant;
}
// 测试
const parent = document.getElementById('parent')!;
const child = document.getElementById('child')!;
console.log(isAncestor(parent, child)); // true
console.log(isAncestor(child, parent)); // false
注意
contains 方法在节点等于自身时返回 true,如果要排除自身需要额外判断。
手写实现(向上遍历)
function isAncestorOf(ancestor: Node | null, descendant: Node | null): boolean {
if (!ancestor || !descendant) return false;
// 排除自身
if (ancestor === descendant) return false;
// 从 descendant 向上遍历
let current: Node | null = descendant.parentNode;
while (current) {
if (current === ancestor) {
return true;
}
current = current.parentNode;
}
return false;
}
// 也可以判断任意一个是否为另一个祖先
function isAncestorOrDescendant(
node1: Node,
node2: Node
): 'ancestor' | 'descendant' | 'none' {
if (isAncestorOf(node1, node2)) return 'ancestor';
if (isAncestorOf(node2, node1)) return 'descendant';
return 'none';
}
递归实现
function isAncestorRecursive(ancestor: Node, descendant: Node): boolean {
if (ancestor === descendant) return false;
function checkParent(node: Node | null): boolean {
if (!node) return false;
if (node === ancestor) return true;
return checkParent(node.parentNode);
}
return checkParent(descendant.parentNode);
}
获取两个节点的关系
type NodeRelation =
| 'same' // 同一节点
| 'ancestor' // node1 是 node2 的祖先
| 'descendant' // node1 是 node2 的后代
| 'sibling' // 兄弟节点
| 'none'; // 无直接关系
function getNodeRelation(node1: Node, node2: Node): NodeRelation {
if (node1 === node2) return 'same';
// 检查 node1 是否是 node2 的祖先
let current: Node | null = node2.parentNode;
while (current) {
if (current === node1) return 'ancestor';
current = current.parentNode;
}
// 检查 node2 是否是 node1 的祖先
current = node1.parentNode;
while (current) {
if (current === node2) return 'descendant';
current = current.parentNode;
}
// 检查是否是兄弟节点
if (node1.parentNode === node2.parentNode && node1.parentNode !== null) {
return 'sibling';
}
return 'none';
}
查找最近公共祖先(LCA)
function findCommonAncestor(node1: Node, node2: Node): Node | null {
// 收集 node1 的所有祖先
const ancestors = new Set<Node>();
let current: Node | null = node1;
while (current) {
ancestors.add(current);
current = current.parentNode;
}
// 从 node2 向上找第一个在集合中的节点
current = node2;
while (current) {
if (ancestors.has(current)) {
return current;
}
current = current.parentNode;
}
return null;
}
// 测试
const common = findCommonAncestor(
document.getElementById('a')!,
document.getElementById('b')!
);
console.log(common?.nodeName); // 可能是 'DIV', 'BODY' 等
优化版本(同时向上遍历)
function findCommonAncestorOptimized(node1: Node, node2: Node): Node | null {
const visited = new Set<Node>();
let p1: Node | null = node1;
let p2: Node | null = node2;
while (p1 || p2) {
if (p1) {
if (visited.has(p1)) return p1;
visited.add(p1);
p1 = p1.parentNode;
}
if (p2) {
if (visited.has(p2)) return p2;
visited.add(p2);
p2 = p2.parentNode;
}
}
return null;
}
获取节点路径
function getNodePath(node: Node): Node[] {
const path: Node[] = [];
let current: Node | null = node;
while (current) {
path.unshift(current);
current = current.parentNode;
}
return path;
}
// 使用路径判断祖先关系
function isAncestorByPath(ancestor: Node, descendant: Node): boolean {
const path = getNodePath(descendant);
return path.includes(ancestor) && ancestor !== descendant;
}
// 获取两个节点的相对路径
function getRelativePath(from: Node, to: Node): string {
const fromPath = getNodePath(from);
const toPath = getNodePath(to);
// 找到最近公共祖先
let commonIndex = 0;
while (
commonIndex < fromPath.length &&
commonIndex < toPath.length &&
fromPath[commonIndex] === toPath[commonIndex]
) {
commonIndex++;
}
const upCount = fromPath.length - commonIndex;
const downPath = toPath.slice(commonIndex).map((node) => {
if (node instanceof Element) {
return node.tagName.toLowerCase();
}
return '#text';
});
return '../'.repeat(upCount) + downPath.join('/');
}
检查节点是否在指定容器内
function isInsideContainer(node: Node, container: Node): boolean {
return container.contains(node);
}
// 检查是否在多个容器之一内
function isInsideAny(node: Node, containers: Node[]): boolean {
return containers.some((container) => container.contains(node));
}
// 检查点击是否在元素外部(常用于关闭弹窗)
function isClickOutside(event: MouseEvent, element: Element): boolean {
return !element.contains(event.target as Node);
}
// 使用示例:点击外部关闭弹窗
document.addEventListener('click', (e) => {
const modal = document.querySelector('.modal');
if (modal && isClickOutside(e, modal)) {
modal.classList.add('hidden');
}
});
使用 compareDocumentPosition
compareDocumentPosition 返回位掩码,可以判断各种关系:
function compareNodes(node1: Node, node2: Node): {
isSame: boolean;
isAncestor: boolean;
isDescendant: boolean;
isBefore: boolean;
isAfter: boolean;
} {
if (node1 === node2) {
return {
isSame: true,
isAncestor: false,
isDescendant: false,
isBefore: false,
isAfter: false
};
}
const position = node1.compareDocumentPosition(node2);
return {
isSame: false,
// DOCUMENT_POSITION_CONTAINS: node2 包含 node1
isAncestor: (position & Node.DOCUMENT_POSITION_CONTAINED_BY) !== 0,
// DOCUMENT_POSITION_CONTAINED_BY: node1 包含 node2
isDescendant: (position & Node.DOCUMENT_POSITION_CONTAINS) !== 0,
// DOCUMENT_POSITION_PRECEDING: node2 在 node1 前面
isBefore: (position & Node.DOCUMENT_POSITION_FOLLOWING) !== 0,
// DOCUMENT_POSITION_FOLLOWING: node2 在 node1 后面
isAfter: (position & Node.DOCUMENT_POSITION_PRECEDING) !== 0
};
}
// 位掩码常量
const DOCUMENT_POSITION = {
DISCONNECTED: 1,
PRECEDING: 2,
FOLLOWING: 4,
CONTAINS: 8,
CONTAINED_BY: 16,
IMPLEMENTATION_SPECIFIC: 32
};
性能对比
| 方法 | 时间复杂度 | 说明 |
|---|---|---|
contains() | O(1) ~ O(d) | 原生 API,通常有优化 |
| 向上遍历 | O(d) | d 为深度 |
| 路径比较 | O(d1 + d2) | 需要额外空间 |
compareDocumentPosition | O(d) | 功能更全 |
实际应用场景
1. 事件委托中判断目标
function delegate(
container: Element,
selector: string,
eventType: string,
handler: (e: Event, target: Element) => void
): () => void {
const listener = (e: Event) => {
let target = e.target as Element | null;
while (target && target !== container) {
if (target.matches(selector)) {
handler(e, target);
return;
}
target = target.parentElement;
}
};
container.addEventListener(eventType, listener);
return () => container.removeEventListener(eventType, listener);
}
2. 焦点陷阱(Focus Trap)
function createFocusTrap(container: Element): () => void {
const focusableSelector =
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key !== 'Tab') return;
const focusable = container.querySelectorAll(focusableSelector);
if (focusable.length === 0) return;
const first = focusable[0] as HTMLElement;
const last = focusable[focusable.length - 1] as HTMLElement;
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}
3. 下拉菜单边界检测
function setupDropdown(trigger: Element, dropdown: Element): void {
const handleClickOutside = (e: MouseEvent) => {
const target = e.target as Node;
// 点击不在 trigger 和 dropdown 内部
if (!trigger.contains(target) && !dropdown.contains(target)) {
dropdown.classList.add('hidden');
}
};
trigger.addEventListener('click', () => {
dropdown.classList.toggle('hidden');
});
document.addEventListener('click', handleClickOutside);
}
常见面试问题
Q1: contains 和 compareDocumentPosition 的区别?
答案:
| 特性 | contains | compareDocumentPosition |
|---|---|---|
| 返回值 | boolean | 位掩码 number |
| 功能 | 仅判断包含关系 | 判断多种位置关系 |
| 自身 | 自身返回 true | 返回 0 |
| 跨文档 | 返回 false | 返回 DISCONNECTED |
const parent = document.body;
const child = document.createElement('div');
document.body.appendChild(child);
// contains
console.log(parent.contains(child)); // true
console.log(parent.contains(parent)); // true(包含自身)
// compareDocumentPosition
const pos = parent.compareDocumentPosition(child);
console.log(pos & Node.DOCUMENT_POSITION_CONTAINED_BY); // 16(child 被 parent 包含)
Q2: 如何判断元素是否在视口内?
答案:
function isInViewport(element: Element): boolean {
const rect = element.getBoundingClientRect();
return (
rect.top >= 0 &&
rect.left >= 0 &&
rect.bottom <= (window.innerHeight || document.documentElement.clientHeight) &&
rect.right <= (window.innerWidth || document.documentElement.clientWidth)
);
}
// 判断是否部分可见
function isPartiallyVisible(element: Element): boolean {
const rect = element.getBoundingClientRect();
const windowHeight = window.innerHeight || document.documentElement.clientHeight;
const windowWidth = window.innerWidth || document.documentElement.clientWidth;
return (
rect.top < windowHeight &&
rect.bottom > 0 &&
rect.left < windowWidth &&
rect.right > 0
);
}
// 使用 IntersectionObserver(推荐)
function observeVisibility(
element: Element,
callback: (isVisible: boolean) => void
): () => void {
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
callback(entry.isIntersecting);
});
});
observer.observe(element);
return () => observer.disconnect();
}
Q3: 如何获取元素相对于指定祖先的偏移量?
答案:
function getOffsetRelativeTo(element: Element, ancestor: Element): {
top: number;
left: number;
} {
let top = 0;
let left = 0;
let current: Element | null = element;
while (current && current !== ancestor) {
if (current instanceof HTMLElement) {
top += current.offsetTop;
left += current.offsetLeft;
current = current.offsetParent as Element | null;
} else {
break;
}
}
if (current !== ancestor) {
console.warn('ancestor is not an offset parent of element');
}
return { top, left };
}
// 或使用 getBoundingClientRect
function getOffsetBetween(element: Element, ancestor: Element): {
top: number;
left: number;
} {
const elementRect = element.getBoundingClientRect();
const ancestorRect = ancestor.getBoundingClientRect();
return {
top: elementRect.top - ancestorRect.top,
left: elementRect.left - ancestorRect.left
};
}