JavaScript DOM 操作与事件
大白话解释: DOM 就是"页面的结构树"。每个 HTML 标签都是树上的一个节点,JavaScript 可以通过 DOM API 来操作这些节点(增删改查、添加事件、修改样式)。
为什么要学 DOM 操作?
- 动态更新页面:不用刷新页面就能更新内容
- 响应用户操作:点击按钮、输入内容、滚动页面等
- 操作样式和布局:动态修改元素的样式、位置、大小
DOM 操作的常见场景:
- 获取元素并修改内容(getElementById、querySelector)
- 创建新元素并插入页面(createElement、appendChild)
- 删除元素(remove)
- 添加事件监听(addEventListener)
- 事件委托(在父元素上监听子元素的事件)
前端开发中最核心的 DOM 操作、事件机制和常见模式。
DOM 选择器
js
// ---- 单个元素 ----
document.getElementById('app') // 通过 ID
document.querySelector('.card') // CSS 选择器(第一个)
document.querySelector('#app > .header') // 复杂选择器
// ---- 元素集合 ----
document.querySelectorAll('.item') // NodeList(静态)
document.getElementsByClassName('item') // HTMLCollection(动态)
document.getElementsByTagName('div') // HTMLCollection(动态)
// ---- 关系遍历 ----
el.parentElement // 父元素
el.children // 子元素集合(HTMLCollection)
el.firstElementChild // 第一个子元素
el.lastElementChild // 最后一个子元素
el.nextElementSibling // 下一个兄弟元素
el.previousElementSibling // 上一个兄弟元素
// closest —— 向上查找最近的匹配祖先
el.closest('.card') // 最近的 .card 祖先
el.closest('form') // 最近的 form 祖先NodeList vs HTMLCollection
| 特性 | NodeList | HTMLCollection |
|---|---|---|
| 获取方式 | querySelectorAll | getElementsBy* |
| 静态/动态 | 静态(快照) | 动态(实时更新) |
| forEach | ✅ 支持 | ❌ 不支持(需先转数组) |
| Array.from | ✅ 转数组 | ✅ 转数组 |
js
// 静态 vs 动态
const list = document.querySelectorAll('.item') // 静态
const items = document.getElementsByClassName('item') // 动态
const newElement = document.createElement('div')
newElement.className = 'item'
document.body.appendChild(newElement)
console.log(list.length) // 不变(快照)
console.log(items.length) // +1(实时更新)
// ⚠️ HTMLCollection 不能直接用 forEach!
// items.forEach(...) // ❌ TypeError: items.forEach is not a function
// ✅ 需要先转数组
const arr1 = Array.from(items)
const arr2 = [...items]
arr1.forEach((el) => { /* ... */ })
// 转数组后可用数组方法
const arr = [...document.querySelectorAll('.item')]
arr.filter((el) => el.classList.contains('active'))DOM 操作
创建与插入
js
// 创建元素
const div = document.createElement('div')
div.textContent = 'Hello'
div.className = 'card'
div.id = 'my-card'
// 设置属性
div.setAttribute('data-id', '123')
div.dataset.id = '123' // 等价
// 插入
parent.appendChild(child) // 末尾追加
parent.insertBefore(newNode, refNode) // 在 refNode 之前
// 新增插入方法(更灵活)
parent.append(child1, child2, '文本') // 末尾追加(支持多节点+文本)
parent.prepend(child) // 开头插入
refNode.after(newNode) // 之后插入
refNode.before(newNode) // 之前插入
// 克隆
const clone = node.cloneNode(true) // 深克隆(含子节点)
const shallow = node.cloneNode(false) // 浅克隆删除与替换
js
// 删除
parent.removeChild(child) // 传统方式
child.remove() // 现代方式(推荐)
// 替换
parent.replaceChild(newNode, oldNode) // 传统方式
oldNode.replaceWith(newNode) // 现代方式(推荐)修改内容
js
// 文本内容(安全,不解析 HTML)
el.textContent = 'Hello <b>World</b>' // 纯文本显示,<b> 不会被解析
// HTML 内容(⚠️ 有 XSS 风险!)
// ⚠️ innerHTML 会解析并执行 HTML,如果内容来自用户输入,可能导致 XSS 攻击
// 例:el.innerHTML = userInput → 恶意脚本可能被执行
// ✅ 安全做法:始终对用户输入进行转义,或改用 textContent
el.innerHTML = '<b>Hello</b>' // 解析为 HTML
// ❌ XSS 攻击示例
const userInput = '<img src=x onerror="alert(1)">'
el.innerHTML = userInput // 会执行恶意脚本!
// ✅ 安全做法
el.textContent = userInput // 纯文本显示,不会执行脚本
// ✅ 或者用 DOMPurify 等库过滤
// el.innerHTML = DOMPurify.sanitize(userInput)
// outerHTML(包含自身)
el.outerHTML = '<section>新内容</section>'
// 纯文本(保留格式)
el.innerText = 'Hello\nWorld' // 考虑 CSS 样式样式与类名
js
// ---- 类名操作 ----
el.classList.add('active')
el.classList.remove('active')
el.classList.toggle('active')
el.classList.toggle('active', condition) // 条件切换
el.classList.contains('active') // true/false
el.classList.replace('old', 'new')
// ---- 内联样式 ----
el.style.color = 'red'
el.style.backgroundColor = '#f0f0f0' // 驼峰命名
el.style.cssText = 'color: red; font-size: 14px;' // 批量设置
// ---- 计算样式 ----
const style = window.getComputedStyle(el)
console.log(style.color) // 'rgb(255, 0, 0)'
console.log(style.fontSize) // '14px'
// ---- 尺寸 ----
el.clientWidth // 内容 + padding(不含边框和滚动条)
el.clientHeight
el.offsetWidth // 内容 + padding + 边框
el.offsetHeight
el.scrollWidth // 实际内容宽度(含溢出)
el.scrollHeight
// ---- 位置 ----
el.getBoundingClientRect() // 相对于视口的位置
// { top, right, bottom, left, width, height, x, y }事件机制
事件流:捕获 → 目标 → 冒泡
捕获阶段(Capture)
window → document → html → body → ... → 目标元素
↓
目标阶段(Target)
目标元素
↓
冒泡阶段(Bubble)
window ← document ← html ← body ← ... ← 目标元素addEventListener
js
element.addEventListener(type, listener, options)
// 基本用法
button.addEventListener('click', function (event) {
console.log('clicked')
})
// 第三个参数:useCapture(布尔)
el.addEventListener('click', handler, true) // 捕获阶段
el.addEventListener('click', handler, false) // 冒泡阶段(默认)
// 第三个参数:options 对象
el.addEventListener('click', handler, {
capture: false, // 是否捕获阶段
once: true, // 只触发一次(自动移除)
passive: true, // 不会调用 preventDefault(优化滚动性能)
signal: controller.signal, // AbortController 控制移除
})
// once: true 独立示例 —— 只触发一次的事件
button.addEventListener('click', () => {
console.log('只会在第一次点击时触发')
button.disabled = true
}, { once: true })
// 等价于手动实现
button.addEventListener('click', function handler() {
console.log('只会在第一次点击时触发')
button.removeEventListener('click', handler)
})移除事件
js
// 必须传入同一个函数引用
function handler() { /* ... */ }
el.addEventListener('click', handler)
el.removeEventListener('click', handler) // ✅ 成功移除
// ❌ 匿名函数无法移除
el.addEventListener('click', () => { /* ... */ })
el.removeEventListener('click', () => { /* ... */ }) // 不是同一个引用
// AbortController 批量移除(推荐)
const controller = new AbortController()
el.addEventListener('click', handler, { signal: controller.signal })
el.addEventListener('scroll', handler2, { signal: controller.signal })
controller.abort() // 一次性移除所有事件对象
js
el.addEventListener('click', (event) => {
event.target // 实际点击的元素
event.currentTarget // 绑定事件的元素(即 el)
event.type // 'click'
event.timeStamp // 时间戳
// 阻止默认行为
event.preventDefault() // 如阻止链接跳转、表单提交
// 阻止冒泡
event.stopPropagation() // 阻止冒泡到父元素
event.stopImmediatePropagation() // 阻止同元素上的后续监听器
// 鼠标位置
event.clientX // 相对于视口
event.clientY
event.pageX // 相对于页面(含滚动)
event.pageY
event.offsetX // 相对于目标元素
event.offsetY
})事件委托
利用事件冒泡,把子元素的事件绑定在父元素上。
js
// ❌ 给每个按钮绑定事件(性能差,动态元素无法绑定)
document.querySelectorAll('.btn').forEach((btn) => {
btn.addEventListener('click', handleClick)
})
// ✅ 事件委托(推荐)
document.querySelector('.btn-container').addEventListener('click', (e) => {
const btn = e.target.closest('.btn')
if (!btn) return // 点击的不是按钮
const action = btn.dataset.action
switch (action) {
case 'delete':
deleteItem(btn.dataset.id)
break
case 'edit':
editItem(btn.dataset.id)
break
}
})html
<div class="btn-container">
<button class="btn" data-action="edit" data-id="1">编辑</button>
<button class="btn" data-action="delete" data-id="1">删除</button>
</div>为什么用 closest
html
<div id="container">
<button class="btn">
<span class="icon">+</span>
<span class="text">添加</span>
</button>
</div>js
// ❌ 直接 e.target 可能点到 icon 或 text
container.addEventListener('click', (e) => {
if (e.target.classList.contains('btn')) { /* ... */ }
// 点击 icon 时 e.target 是 span.icon,不是 button
})
// ✅ 用 closest 向上查找
container.addEventListener('click', (e) => {
const btn = e.target.closest('.btn')
if (!btn) return
// 无论点击 icon、text 还是 button,都能找到 .btn
})常见事件类型
鼠标事件
js
el.addEventListener('click', handler) // 单击
el.addEventListener('dblclick', handler) // 双击
el.addEventListener('mousedown', handler) // 按下
el.addEventListener('mouseup', handler) // 释放
el.addEventListener('mousemove', handler) // 移动
el.addEventListener('mouseenter', handler) // 进入(不冒泡)
el.addEventListener('mouseleave', handler) // 离开(不冒泡)
el.addEventListener('mouseover', handler) // 进入(冒泡)
el.addEventListener('mouseout', handler) // 离开(冒泡)
el.addEventListener('contextmenu', handler) // 右键菜单键盘事件
js
document.addEventListener('keydown', (e) => {
console.log(e.key) // 'Enter', 'Escape', 'a', 'ArrowUp'
console.log(e.code) // 'KeyA', 'Enter', 'ArrowUp'
console.log(e.ctrlKey) // true/false
console.log(e.shiftKey)
console.log(e.altKey)
console.log(e.metaKey) // Mac 的 Cmd
// 快捷键
if (e.ctrlKey && e.key === 's') {
e.preventDefault()
// save() —— 保存当前状态(需自行实现)
document.querySelector('#status').textContent = '已保存'
}
if (e.key === 'Escape') {
// closeModal() —— 关闭弹窗(需自行实现)
document.querySelector('.modal')?.classList.remove('visible')
}
})表单事件
js
input.addEventListener('input', (e) => {
console.log(e.target.value) // 实时获取输入值
})
input.addEventListener('change', (e) => {
// 失去焦点时触发(与 input 不同)
})
form.addEventListener('submit', (e) => {
e.preventDefault() // 阻止默认提交
const formData = new FormData(form)
// ...
})
input.addEventListener('focus', handler)
input.addEventListener('blur', handler)滚动与视口
js
// 页面滚动
window.addEventListener('scroll', () => {
console.log(window.scrollY) // 垂直滚动距离
}, { passive: true }) // ⚠️ 添加 passive: true 优化滚动性能
// 元素滚动
container.addEventListener('scroll', () => {
console.log(container.scrollTop)
}, { passive: true }) // passive 告诉浏览器不会调用 preventDefault,可优化性能
// 视口大小变化
window.addEventListener('resize', () => {
console.log(window.innerWidth)
})
// 元素进入视口(推荐替代 scroll 监听)
// IntersectionObserver 是 Web API(非 ES 标准),Chrome 58+(2017)支持
// ⚠️ IE 不支持,如需兼容 IE 请使用 polyfill 或 scroll + getBoundingClientRect 方案
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
console.log('元素进入了视口')
// 懒加载图片、无限滚动等
}
})
}, { threshold: 0.1 })
observer.observe(document.querySelector('.target'))自定义事件
js
// 创建自定义事件
const event = new CustomEvent('user:login', {
detail: { id: 1, name: '张三' },
bubbles: true, // 允许冒泡
cancelable: true, // 允许 preventDefault
})
// 触发
el.dispatchEvent(event)
// 监听
el.addEventListener('user:login', (e) => {
console.log(e.detail) // { id: 1, name: '张三' }
})
// cancelable: true 的用途 —— 允许监听器阻止某些行为
const formEvent = new CustomEvent('form:submit', {
detail: { data: { name: '张三' } },
cancelable: true,
})
formEl.addEventListener('form:submit', (e) => {
if (!validate(e.detail.data)) {
e.preventDefault() // 阻止后续逻辑
console.log('表单验证失败')
return
}
console.log('表单验证通过,继续提交')
})
// dispatchEvent 返回 false 表示被 preventDefault 了
const result = formEl.dispatchEvent(formEvent)
if (!result) {
console.log('事件被取消')
}性能优化
防抖(Debounce)
js
function debounce(fn, delay) {
let timer
return function (...args) {
clearTimeout(timer)
timer = setTimeout(() => fn.apply(this, args), delay)
}
}
// 搜索框输入
input.addEventListener('input', debounce((e) => {
search(e.target.value)
}, 300))节流(Throttle)
js
function throttle(fn, delay) {
let lastTime = 0
return function (...args) {
const now = Date.now()
if (now - lastTime >= delay) {
lastTime = now
fn.apply(this, args)
}
}
}
// 滚动事件
window.addEventListener('scroll', throttle(() => {
updateScrollIndicator()
}, 100))requestAnimationFrame
js
// 动画用 rAF,不用 setTimeout
let position = 0
function animate() {
element.style.left = `${position++}px`
if (position < 500) {
requestAnimationFrame(animate)
}
}
requestAnimationFrame(animate)进阶 API
MutationObserver —— 监听 DOM 变化
大白话解释: MutationObserver 就像"DOM 监控摄像头",当 DOM 结构发生变化(添加/删除节点、修改属性、改变文本内容)时,会自动通知你。
js
// 创建观察器
const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
console.log('变化类型:', mutation.type) // 'childList' | 'attributes' | 'characterData'
if (mutation.type === 'childList') {
console.log('新增节点:', mutation.addedNodes)
console.log('删除节点:', mutation.removedNodes)
}
if (mutation.type === 'attributes') {
console.log('变化的属性:', mutation.attributeName)
console.log('旧值:', mutation.oldValue)
}
})
})
// 开始观察
observer.observe(targetNode, {
childList: true, // 监听子节点增删
attributes: true, // 监听属性变化
characterData: true, // 监听文本内容变化
subtree: true, // 监听所有后代节点
attributeOldValue: true, // 记录属性旧值
attributeFilter: ['class', 'style'], // 只监听指定属性
})
// 停止观察
observer.disconnect()
// 获取并清空未处理的变化记录
const records = observer.takeRecords()实际应用场景:
js
// 1. 监听第三方库动态插入的元素
const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
mutation.addedNodes.forEach((node) => {
if (node.classList?.contains('third-party-widget')) {
initWidget(node) // 初始化第三方组件
}
})
})
})
observer.observe(document.body, { childList: true, subtree: true })
// 2. 监听 Vue 组件渲染完成
// 在 Vue 中通常用 nextTick 或 watch,但某些场景需要监听 DOM 变化
const observer = new MutationObserver(() => {
console.log('列表已更新')
})
observer.observe(listRef.value, { childList: true })document.createDocumentFragment() —— 批量插入优化
大白话解释: DocumentFragment 就像"临时工坊"。你先把所有要插入的元素都放进去,最后一次性插入页面,避免频繁触发回流(reflow)。
js
// ❌ 频繁插入会触发多次回流
for (let i = 0; i < 1000; i++) {
const li = document.createElement('li')
li.textContent = `Item ${i}`
list.appendChild(li) // 每次都触发回流
}
// ✅ 使用 DocumentFragment 批量插入(只触发一次回流)
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) // 一次性插入,只触发一次回流
// 现代替代方案:直接用 innerHTML 或 insertAdjacentHTML
list.innerHTML = Array.from({ length: 1000 }, (_, i) => `<li>Item ${i}</li>`).join('')为什么 DocumentFragment 不触发回流?
js
// DocumentFragment 是轻量级文档,不属于 DOM 树
const fragment = document.createDocumentFragment()
console.log(fragment.parentNode) // null(不在 DOM 树中)
// 只有当 fragment 被插入到 DOM 树中时,才会触发回流
document.body.appendChild(fragment) // 触发一次回流requestIdleCallback() —— 低优先级任务调度
大白话解释: requestIdleCallback 就像"见缝插针"。浏览器空闲时才执行你的任务,不会影响页面渲染和用户交互。
js
// 基本用法
requestIdleCallback((deadline) => {
// deadline.timeRemaining() 返回当前帧剩余时间(毫秒)
console.log('剩余时间:', deadline.timeRemaining())
// deadline.didTimeout 表示是否超时
if (deadline.didTimeout) {
console.log('任务超时,必须立即执行')
}
// 在剩余时间内执行任务
while (deadline.timeRemaining() > 0 && tasks.length > 0) {
const task = tasks.shift()
processTask(task)
}
})
// 设置超时时间(确保任务最终会执行)
requestIdleCallback((deadline) => {
// 处理任务
}, { timeout: 1000 }) // 最多等 1 秒,超时后强制执行实际应用场景:
js
// 1. 非关键数据上报
function reportAnalytics(data) {
requestIdleCallback(() => {
navigator.sendBeacon('/analytics', JSON.stringify(data))
}, { timeout: 2000 })
}
// 2. 批量处理大量数据(不阻塞主线程)
function processLargeDataset(items) {
let index = 0
function processBatch(deadline) {
while (deadline.timeRemaining() > 0 && index < items.length) {
processItem(items[index])
index++
}
if (index < items.length) {
requestIdleCallback(processBatch)
}
}
requestIdleCallback(processBatch)
}⚠️
requestIdleCallbackIE 不支持,Safari 也不支持。可用setTimeout作为降级方案。
getBoundingClientRect() vs IntersectionObserver
| 特性 | getBoundingClientRect | IntersectionObserver |
|---|---|---|
| 用法 | 同步返回位置信息 | 异步回调通知 |
| 性能 | 需手动监听 scroll,可能触发回流 | 浏览器优化,不触发回流 |
| 精度 | 精确像素值 | 只有是否相交 + 比例 |
| 适用场景 | 需要精确位置(如拖拽、定位) | 是否进入视口(如懒加载、无限滚动) |
| 兼容性 | IE9+ | Chrome 58+,IE 不支持 |
js
// getBoundingClientRect:获取精确位置
const rect = element.getBoundingClientRect()
console.log(rect.top) // 距离视口顶部的距离
console.log(rect.left) // 距离视口左边的距离
console.log(rect.width) // 元素宽度
console.log(rect.height) // 元素高度
// 判断元素是否在视口内(手动计算)
function isInViewport(el) {
const rect = el.getBoundingClientRect()
return (
rect.top < window.innerHeight &&
rect.bottom > 0 &&
rect.left < window.innerWidth &&
rect.right > 0
)
}
// IntersectionObserver:更优雅的视口检测(推荐)
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
console.log('进入视口')
console.log('相交比例:', entry.intersectionRatio)
}
})
}, { threshold: [0, 0.5, 1] }) // 0%、50%、100% 时触发
observer.observe(element)选择建议:
- ✅ 懒加载、无限滚动、吸顶判断 →
IntersectionObserver - ✅ 拖拽定位、弹窗位置、精确坐标 →
getBoundingClientRect