Skip to content

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

特性NodeListHTMLCollection
获取方式querySelectorAllgetElementsBy*
静态/动态静态(快照)动态(实时更新)
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)
}

⚠️ requestIdleCallback IE 不支持,Safari 也不支持。可用 setTimeout 作为降级方案。

getBoundingClientRect() vs IntersectionObserver

特性getBoundingClientRectIntersectionObserver
用法同步返回位置信息异步回调通知
性能需手动监听 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

参考

个人学习笔记,部分内容借助 AI 辅助整理,仅供查阅参考,请以官方文档为准