Appearance
DOM 事件与节点操作
理解路径
DOM 是浏览器把 HTML 文档暴露给 JavaScript 的对象模型。事件机制解决"用户或浏览器动作如何传递给节点",节点操作解决"如何查找、读取和修改文档结构"。它们都属于浏览器宿主 API,不属于 ECMAScript 语言核心。
DOM 事件是什么?
DOM 事件是文档或浏览器窗口中发生的特定交互瞬间。它可以来自用户操作,也可以来自浏览器或脚本触发。
常见事件:
- 用户操作:
click、input、keydown、mouseover。 - 页面和资源状态:
load、DOMContentLoaded、error。 - 表单和焦点:
submit、change、focus、blur。
事件是 JavaScript 和 DOM 之间交互的入口。JavaScript 本身提供语法和执行能力,浏览器通过 DOM 事件把页面动作通知给 JavaScript。
DOM 事件流是什么?
浏览器事件传播通常分为捕获、目标和冒泡三个阶段。
- 捕获阶段:事件从
window、document等外层对象向目标元素传播。 - 目标阶段:事件到达真实触发的目标元素。
- 冒泡阶段:事件从目标元素向外层祖先节点回传。
早期浏览器事件模型曾分别强调"捕获型事件流"和"冒泡型事件流"。现代 DOM 事件模型把两者合并到同一套传播流程中:先捕获,再到目标,再冒泡。
addEventListener 第三个参数或 options 中的 capture 可以决定监听捕获阶段还是冒泡阶段:
js
button.addEventListener('click', onClick, { capture: false })目标元素上的监听器会在目标阶段触发。注册为捕获监听器的回调通常先于冒泡监听器执行,但目标阶段不应理解成"目标元素不接收捕获监听器"。
常用控制方法:
event.preventDefault():阻止默认行为,例如阻止链接跳转或表单默认提交。event.stopPropagation():阻止事件继续向后续传播阶段传播。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)事件委托有什么作用?
事件委托把子元素事件统一绑定到稳定的父元素上,利用冒泡阶段识别真实触发目标。
适用场景:
- 列表项会动态增删。
- 子元素数量多,需要减少监听器数量。
- 多个子元素共享同类交互逻辑。
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 查询和操作有哪些注意点?
querySelector/querySelectorAll使用 CSS 选择器,表达能力强。getElementById返回单个元素,适合直接按 id 查询。getElementsByClassName、getElementsByTagName返回动态集合,DOM 变化后集合也会变化。querySelectorAll返回静态集合,查询后不会随 DOM 自动更新。getAttribute/setAttribute读写 HTML 属性,和元素对象属性不完全等价。- 频繁读写布局属性可能触发同步布局,应批量读写。
- 大量插入节点时可以使用
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 - scrollTopscrollTop:已经向下滚动的距离。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>