JavaScript DOM 操作
大约 7 分钟约 2049 字
JavaScript DOM 操作
DOM 是什么?
DOM(Document Object Model,文档对象模型)是浏览器将 HTML 文档解析为可操作的树形结构。JavaScript 通过 DOM API 可以查询、创建、修改和删除页面中的元素,实现动态交互效果。虽然现代框架(React、Vue)通过虚拟 DOM 抽象了大部分 DOM 操作,但理解原生 DOM 操作仍然是前端开发的基础能力。
Document
└── html (HTMLHtmlElement)
├── head (HTMLHeadElement)
│ ├── title (HTMLTitleElement)
│ ├── meta (HTMLMetaElement)
│ └── link (HTMLLinkElement)
└── body (HTMLBodyElement)
├── header (HTMLElement)
│ └── h1 (HTMLHeadingElement)
├── main (HTMLElement)
│ ├── div (HTMLDivElement)
│ │ ├── p (HTMLParagraphElement)
│ │ └── ul (HTMLUListElement)
│ │ └── li (HTMLLIElement)
│ └── form (HTMLFormElement)
└── footer (HTMLElement)DOM 查询
现代查询 API
// querySelector — 查找第一个匹配的元素
const el = document.querySelector('.card');
const btn = document.querySelector('#submit-btn');
const item = document.querySelector('ul > li:first-child');
const nested = document.querySelector('.container .card .title');
// querySelectorAll — 查找所有匹配的元素(返回 NodeList)
const items = document.querySelectorAll('.list-item');
const links = document.querySelectorAll('a[href^="https"]');
// 遍历 NodeList(可迭代)
items.forEach(item => {
console.log(item.textContent);
});
// 转为数组后使用数组方法
const itemArray = Array.from(items);
const filtered = itemArray.filter(item => item.dataset.active === 'true');
// 注意:querySelectorAll 返回的是静态 NodeList
// 如果 DOM 变化,已获取的 NodeList 不会更新
// getElementsByClassName — 返回实时 HTMLCollection(DOM 变化时自动更新)
const cards = document.getElementsByClassName('card');
// HTMLCollection 不是数组,需要转换
const cardsArray = [...cards];关系查询
const el = document.querySelector('.item');
// 父元素
const parent = el.parentElement;
const closest = el.closest('.container'); // 最近祖先
// 子元素
const children = el.children; // HTMLCollection(只包含元素节点)
const firstChild = el.firstElementChild;
const lastChild = el.lastElementChild;
// 兄弟元素
const prev = el.previousElementSibling;
const next = el.nextElementSibling;
const allSiblings = [...el.parentElement.children].filter(c => c !== el);属性操作
const el = document.querySelector('.user-card');
// 标准属性
el.id = 'card-1';
el.className = 'card active';
el.className += ' highlight'; // 追加 class
// classList API(推荐)
el.classList.add('active', 'visible');
el.classList.remove('hidden');
el.classList.toggle('expanded');
el.classList.contains('active'); // true/false
el.classList.replace('old-class', 'new-class');
// data-* 属性
el.dataset.userId = '123';
el.dataset.status = 'online';
const userId = el.dataset.userId; // '123'
// getAttribute / setAttribute(用于自定义属性)
el.setAttribute('data-id', '123');
const id = el.getAttribute('data-id');
// removeAttribute
el.removeAttribute('disabled');
// hasAttribute
el.hasAttribute('data-id'); // true/false
// style 操作
el.style.color = '#333';
el.style.backgroundColor = 'red'; // 驼峰命名
el.style.display = 'none';
el.style.cssText = 'color: #333; font-size: 14px;'; // 批量设置
// getComputedStyle — 获取计算后的样式
const styles = window.getComputedStyle(el);
const width = styles.width;
const color = styles.color;DOM 创建与修改
创建元素
// createElement
const div = document.createElement('div');
div.className = 'card';
div.id = 'card-1';
div.textContent = 'Hello World'; // 安全,自动转义
div.innerHTML = '<span>Content</span>'; // 注意 XSS 风险
// 创建带属性的元素
const link = document.createElement('a');
link.href = 'https://example.com';
link.target = '_blank';
link.rel = 'noopener noreferrer';
link.textContent = '点击跳转';
link.setAttribute('aria-label', '外部链接');
// 克隆元素
const clone = div.cloneNode(true); // 深克隆(包含子元素)
const shallowClone = div.cloneNode(false); // 浅克隆插入和删除
const parent = document.querySelector('.container');
// 追加子元素
parent.appendChild(div);
// 在指定位置插入
parent.insertBefore(newNode, referenceNode);
// 现代插入 API
parent.append(div, span); // 追加多个子节点(支持文本)
parent.prepend(div); // 在开头插入
div.after(span); // 在 div 后面插入
div.before(span); // 在 div 前面插入
// 删除
parent.removeChild(div); // 传统方式
div.remove(); // 现代方式(更简洁)
// 替换
parent.replaceChild(newNode, oldNode);DocumentFragment — 批量操作优化
// Bad — 每次插入都触发重排
const list = document.querySelector('.list');
for (let i = 0; i < 1000; i++) {
const li = document.createElement('li');
li.textContent = `Item ${i}`;
list.appendChild(li); // 每次循环都触发布局计算
}
// Good — 使用 DocumentFragment 批量操作
const list = document.querySelector('.list');
const fragment = document.createDocumentFragment();
for (let i = 0; i < 1000; i++) {
const li = document.createElement('li');
li.textContent = `Item ${i}`;
fragment.appendChild(li); // 在 fragment 中操作不触发重排
}
list.appendChild(fragment); // 一次性插入,只触发一次重排
// 实际性能对比
// 1000 次 appendChild → 约 50ms
// DocumentFragment → 约 2msinnerHTML vs textContent vs createElement
const userInput = '<script>alert("XSS")</script>';
// textContent — 安全,自动转义
el.textContent = userInput; // 显示为纯文本
// innerHTML — 危险,会解析 HTML(XSS 风险)
el.innerHTML = userInput; // 执行脚本!
// 如果必须使用 innerHTML,先转义
function escapeHTML(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
el.innerHTML = escapeHTML(userInput); // 安全
// DOMPurify 库(推荐)
import DOMPurify from 'dompurify';
el.innerHTML = DOMPurify.sanitize(userInput);事件处理
事件监听
const btn = document.querySelector('#btn');
// 添加事件监听
btn.addEventListener('click', (event) => {
console.log('按钮被点击', event.target);
});
// 移除事件监听
function handleClick(e) {
console.log('Clicked');
btn.removeEventListener('click', handleClick);
}
btn.addEventListener('click', handleClick);
// 事件选项
btn.addEventListener('click', handler, {
capture: true, // 捕获阶段处理(默认冒泡阶段)
once: true, // 只触发一次后自动移除
passive: true, // 告知浏览器不会调用 preventDefault(优化滚动性能)
});
// 移除所有事件监听
function removeAllEvents(el) {
const clone = el.cloneNode(true);
el.parentNode.replaceChild(clone, el);
}事件委托
事件委托利用事件冒泡机制,在父元素上统一处理子元素事件,减少监听器数量。
// Bad — 为每个子元素绑定事件
document.querySelectorAll('.list-item').forEach(item => {
item.addEventListener('click', () => {
console.log('Clicked:', item.dataset.id);
});
});
// 100 个列表项 = 100 个事件监听器
// Good — 事件委托
document.querySelector('.list').addEventListener('click', (e) => {
const item = e.target.closest('.list-item'); // 查找最近的匹配元素
if (!item) return; // 点击的不是列表项
console.log('Clicked:', item.dataset.id);
// 处理动态添加的元素
});
// 实际案例:表格行点击
document.querySelector('.data-table').addEventListener('click', (e) => {
const editBtn = e.target.closest('.edit-btn');
const deleteBtn = e.target.closest('.delete-btn');
if (editBtn) {
editRow(editBtn.dataset.rowId);
} else if (deleteBtn) {
deleteRow(deleteBtn.dataset.rowId);
}
});自定义事件
// 创建自定义事件
const event = new CustomEvent('item-selected', {
detail: { id: 123, name: 'Device A' },
bubbles: true, // 允许冒泡
cancelable: true, // 允许 preventDefault
composed: true, // 允许穿越 Shadow DOM
});
// 派发事件
element.dispatchEvent(event);
// 监听自定义事件
element.addEventListener('item-selected', (e) => {
console.log('Selected:', e.detail.id, e.detail.name);
});
// 实际案例:组件间通信
class EventBus {
constructor() {
this.events = new Map();
}
on(event, handler) {
if (!this.events.has(event)) {
this.events.set(event, []);
}
this.events.get(event).push(handler);
return () => this.off(event, handler); // 返回取消监听函数
}
off(event, handler) {
const handlers = this.events.get(event);
if (handlers) {
const index = handlers.indexOf(handler);
if (index > -1) handlers.splice(index, 1);
}
}
emit(event, ...args) {
const handlers = this.events.get(event);
if (handlers) {
handlers.forEach(handler => handler(...args));
}
}
}
const bus = new EventBus();
const unsubscribe = bus.on('user-updated', (user) => {
console.log('User updated:', user.name);
});
bus.emit('user-updated', { id: 1, name: 'New Name' });
unsubscribe(); // 取消监听Observer API
IntersectionObserver — 可见性检测
// 图片懒加载
const imgObserver = new IntersectionObserver(
(entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const img = entry.target;
img.src = img.dataset.src;
img.classList.add('loaded');
imgObserver.unobserve(img); // 加载后停止观察
}
});
},
{
root: null, // 视口作为根元素
rootMargin: '100px', // 提前 100px 开始加载
threshold: [0, 0.25, 0.5, 0.75, 1], // 交叉比例阈值
}
);
document.querySelectorAll('img[data-src]').forEach(img => {
imgObserver.observe(img);
});// 无限滚动
let page = 1;
const sentinel = document.querySelector('#scroll-sentinel');
const scrollObserver = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting) {
loadMoreData(page);
page++;
}
},
{ rootMargin: '200px' }
);
scrollObserver.observe(sentinel);
async function loadMoreData(pageNum) {
const res = await fetch(`/api/items?page=${pageNum}`);
const items = await res.json();
const list = document.querySelector('.item-list');
items.forEach(item => {
const el = createItemElement(item);
list.appendChild(el);
});
}// 元素曝光统计
function trackExposure(element, callback) {
let hasExposed = false;
const observer = new IntersectionObserver(
(entries) => {
entries.forEach(entry => {
if (entry.isIntersecting && !hasExposed) {
hasExposed = true;
callback(element); // 只曝光一次
observer.unobserve(element);
}
});
},
{ threshold: 0.5 } // 至少 50% 可见
);
observer.observe(element);
}MutationObserver — DOM 变化监听
// 监听子元素变化
const observer = new MutationObserver((mutations) => {
mutations.forEach(mutation => {
if (mutation.type === 'childList') {
mutation.addedNodes.forEach(node => {
if (node.nodeType === Node.ELEMENT_NODE) {
console.log('新增元素:', node);
}
});
mutation.removedNodes.forEach(node => {
console.log('移除元素:', node);
});
}
if (mutation.type === 'attributes') {
console.log('属性变化:', {
attribute: mutation.attributeName,
oldValue: mutation.oldValue,
newValue: (mutation.target as HTMLElement).getAttribute(mutation.attributeName),
});
}
});
});
observer.observe(document.querySelector('.container'), {
childList: true, // 监听子元素增删
subtree: true, // 递归监听所有后代
attributes: true, // 监听属性变化
attributeFilter: ['class', 'data-status'], // 只监听特定属性
});
// 停止观察
observer.disconnect();ResizeObserver — 元素尺寸监听
const resizeObserver = new ResizeObserver((entries) => {
entries.forEach(entry => {
const { width, height } = entry.contentRect;
console.log(`元素尺寸变化: ${width}x${height}`);
});
});
resizeObserver.observe(document.querySelector('.sidebar'));
// 停止观察
resizeObserver.disconnect();常见陷阱
陷阱 1:循环中逐个修改 DOM
// Bad — 每次修改都触发重排
const items = document.querySelectorAll('.item');
items.forEach(item => {
const height = item.offsetHeight; // 读 → 强制布局
item.style.height = height * 2 + 'px'; // 写 → 使布局失效
});
// Good — 批量读再批量写
const heights = [...items].map(item => item.offsetHeight);
items.forEach((item, i) => {
item.style.height = heights[i] * 2 + 'px';
});陷阱 2:innerHTML 导致 XSS
// Bad — 用户输入直接插入 HTML
const userInput = '<img src=x onerror="alert(1)">';
element.innerHTML = userInput; // 触发 XSS
// Good — 使用 textContent
element.textContent = userInput; // 安全显示
// 如果需要 HTML,使用 DOMPurify
element.innerHTML = DOMPurify.sanitize(userInput);陷阱 3:忘记移除事件监听和 Observer
// Bad — 组件卸载时监听器和 Observer 仍在运行(内存泄漏)
class Widget {
constructor() {
window.addEventListener('resize', this.onResize);
this.observer = new IntersectionObserver(this.onIntersect);
this.observer.observe(this.element);
}
// 没有 destroy 方法
}
// Good — 提供销毁方法
class Widget {
constructor(element) {
this.element = element;
this.onResize = this.handleResize.bind(this);
this.observer = new IntersectionObserver(this.onIntersect);
this.observer.observe(element);
window.addEventListener('resize', this.onResize);
}
destroy() {
window.removeEventListener('resize', this.onResize);
this.observer.disconnect();
this.observer = null;
}
}最佳实践总结
- 使用 querySelector/querySelectorAll — 现代 API,支持 CSS 选择器
- 批量 DOM 操作用 DocumentFragment — 减少重排次数
- 事件委托减少监听器 — 父元素统一处理子元素事件
- textContent 替代 innerHTML — 防止 XSS 攻击
- 使用 Observer API — IntersectionObserver、MutationObserver、ResizeObserver
- 组件销毁时清理 — 移除事件监听、断开 Observer
