跳到主要内容

判断 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)需要额外空间
compareDocumentPositionO(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: containscompareDocumentPosition 的区别?

答案

特性containscompareDocumentPosition
返回值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
};
}

相关链接