Skip to content

DOM 事件与节点操作 ​

理解路径 ​

DOM 是浏览器把 HTML 文档暴露给 JavaScript 的对象模型。事件机制解决"用户或浏览器动作如何传递给节点",节点操作解决"如何查找、读取和修改文档结构"。它们都属于浏览器宿主 API,不属于 ECMAScript 语言核心。

DOM 事件是什么? ​

DOM 事件是文档或浏览器窗口中发生的特定交互瞬间。它可以来自用户操作,也可以来自浏览器或脚本触发。

常见事件:

  1. 用户操作:click、input、keydown、mouseover。
  2. 页面和资源状态:load、DOMContentLoaded、error。
  3. 表单和焦点:submit、change、focus、blur。

事件是 JavaScript 和 DOM 之间交互的入口。JavaScript 本身提供语法和执行能力,浏览器通过 DOM 事件把页面动作通知给 JavaScript。

DOM 事件流是什么? ​

浏览器事件传播通常分为捕获、目标和冒泡三个阶段。

  1. 捕获阶段:事件从 window、document 等外层对象向目标元素传播。
  2. 目标阶段:事件到达真实触发的目标元素。
  3. 冒泡阶段:事件从目标元素向外层祖先节点回传。

早期浏览器事件模型曾分别强调"捕获型事件流"和"冒泡型事件流"。现代 DOM 事件模型把两者合并到同一套传播流程中:先捕获,再到目标,再冒泡。

addEventListener 第三个参数或 options 中的 capture 可以决定监听捕获阶段还是冒泡阶段:

js
button.addEventListener('click', onClick, { capture: false })

目标元素上的监听器会在目标阶段触发。注册为捕获监听器的回调通常先于冒泡监听器执行,但目标阶段不应理解成"目标元素不接收捕获监听器"。

常用控制方法:

  1. event.preventDefault():阻止默认行为,例如阻止链接跳转或表单默认提交。
  2. event.stopPropagation():阻止事件继续向后续传播阶段传播。
  3. event.stopImmediatePropagation():阻止当前节点上后续监听器执行,并停止传播。

DOM 事件有哪些绑定方式? ​

常见事件绑定方式有三类:

方式示例特点
HTML 内联事件<button onclick="handleClick()">按钮</button>写法直观,但结构、行为耦合,不利于维护
DOM0 属性绑定button.onclick = handleClick简单,但同一事件属性后赋值会覆盖前一个处理函数
DOM2 事件监听button.addEventListener('click', handleClick)可绑定多个监听器,可配置捕获、一次性监听和被动监听

实际项目优先使用 addEventListener:

js
function handleClick(event) {
  console.log(event.currentTarget)
}

button.addEventListener('click', handleClick)

需要解绑时,必须传入同一个函数引用:

js
button.removeEventListener('click', handleClick)

事件委托有什么作用? ​

事件委托把子元素事件统一绑定到稳定的父元素上,利用冒泡阶段识别真实触发目标。

适用场景:

  1. 列表项会动态增删。
  2. 子元素数量多,需要减少监听器数量。
  3. 多个子元素共享同类交互逻辑。
js
list.addEventListener('click', (event) => {
  const item = event.target.closest('[data-id]')
  if (!item || !list.contains(item)) return
  console.log(item.dataset.id)
})

不是所有事件都适合委托。例如 focus / blur 默认不冒泡,可改用 focusin / focusout。

如何封装普通绑定和委托绑定? ​

事件绑定工具函数可以同时支持普通绑定和委托绑定。关键点是:如果传入选择器,就从事件目标向上查找匹配元素;如果没有传入选择器,就直接以绑定元素作为 this。

js
function bindEvent(element, type, selector, handler) {
  if (typeof selector === 'function') {
    handler = selector
    selector = null
  }

  element.addEventListener(type, (event) => {
    if (!selector) {
      handler.call(element, event)
      return
    }

    const target = event.target.closest(selector)
    if (!target || !element.contains(target)) return

    handler.call(target, event)
  })
}

bindEvent(button, 'click', function (event) {
  event.preventDefault()
  console.log(this.textContent)
})

bindEvent(list, 'click', 'li', function () {
  console.log(this.textContent)
})

这个封装适合解释委托思路。真实项目中如果需要解绑,还应让函数返回清理函数,或显式保存内部监听器引用。

target 和 currentTarget 有什么区别? ​

event.target 是最初触发事件的节点,event.currentTarget 是当前正在执行监听器的节点。

在事件委托中,通常用 target 找到被点击的子元素,用 currentTarget 表示绑定监听器的父元素。

js
list.addEventListener('click', (event) => {
  console.log(event.target)
  console.log(event.currentTarget)
})

DOM 查询和操作有哪些注意点? ​

  1. querySelector / querySelectorAll 使用 CSS 选择器,表达能力强。
  2. getElementById 返回单个元素,适合直接按 id 查询。
  3. getElementsByClassName、getElementsByTagName 返回动态集合,DOM 变化后集合也会变化。
  4. querySelectorAll 返回静态集合,查询后不会随 DOM 自动更新。
  5. getAttribute / setAttribute 读写 HTML 属性,和元素对象属性不完全等价。
  6. 频繁读写布局属性可能触发同步布局,应批量读写。
  7. 大量插入节点时可以使用 DocumentFragment 或一次性 HTML 片段。

常见创建和修改节点 API:

能力API
创建元素节点document.createElement('h3')
创建文本节点document.createTextNode('text')
追加子节点parent.appendChild(node)
插入到指定节点前parent.insertBefore(newNode, existingNode)
替换子节点parent.replaceChild(newNode, oldNode)
删除子节点parent.removeChild(node)

示例:

js
const title = document.createElement('h3')
title.textContent = 'DOM API'
title.setAttribute('data-type', 'demo')

const fragment = document.createDocumentFragment()
fragment.appendChild(title)

container.appendChild(fragment)

如何判断页面滚动到底部? ​

判断页面是否滚动到底部,应该基于页面实际的滚动元素计算底部剩余距离:

text
底部剩余距离 = scrollHeight - clientHeight - scrollTop
  • scrollTop:已经向下滚动的距离。
  • clientHeight:滚动区域当前可见的高度。
  • scrollHeight:包含不可见部分在内的完整内容高度。
  • 当底部剩余距离小于或等于预设阈值时,就可以认为已接近底部。

不要使用严格相等判断。scrollTop 可能包含小数,而 scrollHeight、clientHeight 通常是整数,因此需要保留约 1px 的误差。

下面是一个变量、回调和清理逻辑都完整的页面触底方案:

js
// 提前 100px 触发,适合在用户真正到达底部前准备下一页内容。
const PRELOAD_DISTANCE_PX = 100

// scroll 事件触发频率很高,用定时器限制检查频率。
const SCROLL_THROTTLE_MS = 50

// scrollTop 可能是小数,而另外两个高度通常是整数。
const ROUNDING_TOLERANCE_PX = 1

// 获取当前文档真正负责滚动的元素;标准模式下通常是 <html>。
const pageScroller =
  document.scrollingElement ?? document.documentElement

let scrollTimerId = null
let wasNearBottom = false

/**
 * 判断指定滚动元素是否已进入底部阈值范围。
 * @param {Element} element 要检查的滚动元素
 * @param {number} threshold 提前多少像素算作“接近底部”
 */
function isNearScrollBottom(element, threshold = 0) {
  const safeThreshold = Math.max(0, threshold)
  const distanceToBottom =
    element.scrollHeight - element.clientHeight - element.scrollTop

  return distanceToBottom <= safeThreshold + ROUNDING_TOLERANCE_PX
}

// 声明触底后的业务回调;真实项目可在这里请求并渲染下一页数据。
function handleReachBottom() {
  console.log('已接近页面底部,可以在这里请求下一页数据')
}

function checkPageBottom() {
  scrollTimerId = null

  const isNearBottom = isNearScrollBottom(
    pageScroller,
    PRELOAD_DISTANCE_PX,
  )

  // 只在“从阈值外进入阈值内”时触发一次,避免停在底部反复执行。
  if (isNearBottom && !wasNearBottom) {
    wasNearBottom = true
    handleReachBottom()
    return
  }

  // 加载更多内容或向上滚动后会离开阈值,下次进入时可再次触发。
  wasNearBottom = isNearBottom
}

function handlePageScroll() {
  // 已安排检查时,不再创建新的定时器。
  if (scrollTimerId !== null) return

  scrollTimerId = window.setTimeout(
    checkPageBottom,
    SCROLL_THROTTLE_MS,
  )
}

document.addEventListener('scroll', handlePageScroll, { passive: true })

// 页面内容不足一屏时不会产生 scroll 事件,因此初始化时也检查一次。
checkPageBottom()

// 单页应用的组件卸载或不再需要监听时调用,避免遗留监听器和定时器。
function stopWatchingPageBottom() {
  document.removeEventListener('scroll', handlePageScroll)

  if (scrollTimerId !== null) {
    window.clearTimeout(scrollTimerId)
    scrollTimerId = null
  }
}

// 示例:不再需要监听时执行。
// stopWatchingPageBottom()

如果要判断某个独立滚动容器,只需把该元素传给相同的判断函数:

js
function isContainerNearBottom(element, threshold = 0) {
  return isNearScrollBottom(element, threshold)
}

这里使用 setTimeout 节流,是因为 scroll 事件可能高速触发;仅把检查放入 requestAnimationFrame 并不能降低它与滚动事件相同的执行频率。无限滚动等场景还可以优先使用 IntersectionObserver 观察列表底部的哨兵元素,由浏览器异步报告它是否进入视口,通常不必在每次滚动时读取这些尺寸。

事件委托 ​

html
<ul id="todo-list">
  <li data-id="1">Learn JS</li>
  <li data-id="2">Review DOM</li>
</ul>

<script>
  const list = document.querySelector('#todo-list')

  list.addEventListener('click', (event) => {
    const item = event.target.closest('li[data-id]')
    if (!item) return
    console.log(item.dataset.id)
  })
</script>

参考来源 ​