【Web APIs-Day3】事件进阶与页面交互

2 阅读5分钟

【Web APIs-Day3】事件进阶与页面交互

📺 对应视频:P105-P116 | 🎯 核心目标:掌握事件流机制、事件委托、滚动事件、offset/client尺寸属性


一、事件流

1.1 什么是事件流?

点击一个元素时,事件不只在目标元素上触发,而是经历一个传播过程(事件流):

事件流三阶段:
1. 捕获阶段(Capture):从 window → document → htmlbody → ... → 目标元素
2. 目标阶段(Target):到达目标元素
3. 冒泡阶段(Bubble):从目标元素 → ... → bodyhtml → document → window
<div class="outer">
  <div class="inner">
    <button>点我</button>
  </div>
</div>
// 点击 button,事件传播顺序:
// 捕获:window → document → htmlbody → outer → inner → button
// 冒泡:button → inner → outer → bodyhtml → document → window

1.2 addEventListener 第三个参数

// 第三个参数:true = 捕获阶段监听,false = 冒泡阶段监听(默认)
element.addEventListener('click', handler, false)  // 冒泡(默认)
element.addEventListener('click', handler, true)   // 捕获

// 或传对象(更灵活)
element.addEventListener('click', handler, {
  capture: false,  // 是否在捕获阶段
  once: true,      // 只触发一次
  passive: true    // 不会调用 preventDefault(用于滚动优化)
})

1.3 阻止冒泡

document.querySelector('.inner').addEventListener('click', e => {
  console.log('inner 被点击')
  e.stopPropagation()  // 阻止继续冒泡到 outer
})

document.querySelector('.outer').addEventListener('click', e => {
  console.log('outer 被点击')  // 不会执行,因为冒泡被阻止了
})

二、事件委托(Event Delegation)

2.1 为什么需要事件委托?

// 问题:给 100 个 li 各绑一个事件,性能差;动态新增的 li 更没有事件
const lis = document.querySelectorAll('li')
lis.forEach(li => {
  li.addEventListener('click', () => { ... })  // 100 个监听器!
})

2.2 事件委托原理

利用冒泡,把事件绑定到父元素,通过 e.target 判断实际点击的子元素。

// 只绑定一个监听器在父元素 ul 上
const ul = document.querySelector('ul')

ul.addEventListener('click', e => {
  // e.target:实际被点击的元素
  if (e.target.tagName === 'LI') {
    console.log('点击了 li:', e.target.textContent)
    e.target.classList.toggle('active')
  }
})

2.3 更精准的目标匹配

ul.addEventListener('click', e => {
  // matches:判断元素是否匹配选择器
  if (e.target.matches('li.item')) {
    console.log('匹配到 .item 类的 li')
  }
  
  // closest:从当前元素向上查找最近的匹配祖先
  const li = e.target.closest('li')
  if (li) {
    console.log('找到最近的 li 祖先:', li)
  }
})

2.4 事件委托实战:动态列表

const ul = document.querySelector('.todo-list')
const input = document.querySelector('input')
const addBtn = document.querySelector('.add-btn')

// 添加新项
addBtn.addEventListener('click', () => {
  if (!input.value.trim()) return
  const li = document.createElement('li')
  li.innerHTML = `
    <span>${input.value}</span>
    <button class="del-btn">删除</button>
  `
  ul.appendChild(li)
  input.value = ''
})

// 用事件委托处理删除(包括动态新增的元素)
ul.addEventListener('click', e => {
  if (e.target.classList.contains('del-btn')) {
    e.target.closest('li').remove()
  }
})

三、滚动事件与页面尺寸

3.1 scroll 事件

// 监听页面滚动
window.addEventListener('scroll', () => {
  const scrollTop = document.documentElement.scrollTop  // 垂直滚动距离
  const scrollLeft = document.documentElement.scrollLeft // 水平滚动距离
  
  // 回到顶部按钮显隐
  const backToTop = document.querySelector('.back-top')
  backToTop.style.display = scrollTop > 300 ? 'block' : 'none'
})

// 监听某个元素内的滚动
const box = document.querySelector('.scroll-box')
box.addEventListener('scroll', () => {
  console.log('盒子内滚动:', box.scrollTop)
})

// 回到顶部
document.querySelector('.back-top').addEventListener('click', () => {
  window.scrollTo({ top: 0, behavior: 'smooth' })  // 平滑滚动
  // 或 document.documentElement.scrollTop = 0(直接跳)
})

3.2 scroll 相关属性

// 页面滚动距离(读写)
document.documentElement.scrollTop   // 垂直滚动量(常用)
document.documentElement.scrollLeft  // 水平滚动量

// 元素内容的完整高度(含不可见部分)
element.scrollHeight  // 内容总高度
element.scrollWidth   // 内容总宽度

// 判断是否滚动到底部
element.scrollTop + element.clientHeight >= element.scrollHeight

四、元素尺寸属性

4.1 offsetWidth / offsetHeight

const box = document.querySelector('.box')

// offset:元素实际占用空间(含 padding + border,不含 margin)
box.offsetWidth   // 宽度(content + padding + border)
box.offsetHeight  // 高度

// offsetLeft / offsetTop:相对于最近的定位祖先元素的距离
box.offsetLeft    // 距定位父元素左侧的距离
box.offsetTop     // 距定位父元素顶部的距离

// offsetParent:最近的定位祖先元素(position不为static)
box.offsetParent  // 如果没有定位祖先,返回 body

4.2 clientWidth / clientHeight

// client:元素可见区域(含 padding,不含 border 和 margin)
box.clientWidth   // content + padding
box.clientHeight

// clientLeft / clientTop:左/上边框的宽度
box.clientLeft    // 左边框宽度(即 border-left-width)
box.clientTop     // 上边框宽度

// 获取视口(浏览器可见区域)大小
document.documentElement.clientWidth   // 视口宽度(不含滚动条)
document.documentElement.clientHeight  // 视口高度
window.innerWidth    // 视口宽度(含滚动条)
window.innerHeight   // 视口高度

4.3 getBoundingClientRect()

// 获取元素相对于视口的位置和尺寸(最常用!)
const rect = box.getBoundingClientRect()

rect.top     // 元素顶部距视口顶部的距离(随滚动变化)
rect.bottom  // 元素底部距视口顶部的距离
rect.left    // 元素左侧距视口左侧的距离
rect.right   // 元素右侧距视口左侧的距离
rect.width   // 元素宽度(与 offsetWidth 类似)
rect.height  // 元素高度

// 实际应用:懒加载图片
window.addEventListener('scroll', () => {
  document.querySelectorAll('img[data-src]').forEach(img => {
    const rect = img.getBoundingClientRect()
    if (rect.top < window.innerHeight) {
      img.src = img.dataset.src
      img.removeAttribute('data-src')
    }
  })
})

4.4 三者速查对比

属性          包含内容                    用途
offsetWidth   content + padding + border  获取元素真实占位大小
clientWidth   content + padding           获取内容区大小
scrollWidth   content(含隐藏部分)       判断是否有溢出内容

五、综合案例

案例:吸顶导航栏

const nav = document.querySelector('.nav')
const navTop = nav.offsetTop  // 导航到顶部的距离

window.addEventListener('scroll', () => {
  const scrollTop = document.documentElement.scrollTop
  if (scrollTop >= navTop) {
    nav.classList.add('fixed')   // 切换为固定定位
  } else {
    nav.classList.remove('fixed')
  }
})

案例:无限滚动加载

window.addEventListener('scroll', () => {
  const { scrollTop, scrollHeight, clientHeight } = document.documentElement
  // 距底部 100px 时开始加载
  if (scrollTop + clientHeight >= scrollHeight - 100) {
    loadMoreData()
  }
})

六、知识图谱

事件进阶与页面交互
├── 事件流
│   ├── 捕获阶段(从上到下)
│   ├── 目标阶段
│   └── 冒泡阶段(从下到上)
├── 事件委托
│   ├── 原理:冒泡 + e.target 判断
│   ├── 优势:少绑定、支持动态元素
│   └── 工具:matches() / closest()
├── 滚动
│   ├── scroll 事件
│   ├── scrollTop / scrollLeft(滚动距离)
│   └── scrollTo({ behavior: 'smooth' })
└── 元素尺寸
    ├── offsetWidth/Height(含border)
    ├── clientWidth/Height(含padding)
    ├── scrollWidth/Height(含溢出)
    └── getBoundingClientRect()(视口位置)

七、高频面试题

Q1:事件冒泡和事件捕获的区别?

捕获从 window 向下传播到目标元素;冒泡从目标元素向上传播到 window。addEventListener 默认在冒泡阶段触发(第三参数 false),传 true 则在捕获阶段触发。

Q2:什么是事件委托?有什么优势?

把子元素的事件监听绑定在父元素上,利用冒泡通过 e.target 判断实际触发者。优势:① 减少监听器数量,节省内存;② 自动处理动态添加的子元素;③ 减少重复代码。

Q3: offsetWidth clientWidth 的区别?

offsetWidth = content + padding + border,是元素在布局中真实占用的宽度;clientWidth = content + padding,不含 border,是元素的可视内容区域宽度。


⬅️ 上一篇Web APIs Day2 - 事件监听 ➡️ 下一篇Web APIs Day4 - 节点操作与移动端事件