JavaScript DOM 完全指南(二):实战与性能篇
本篇由《JavaScript DOM 完全指南》拆分而来,覆盖七至十二章。通过常见交互案例理解 DOM 事件、渲染流程、性能优化与现代 Observer API。
目录
- 七、实战案例
- [7.1 购物车全选功能](#7.1 购物车全选功能)
- [7.2 电子时钟](#7.2 电子时钟)
- [7.3 选项卡切换](#7.3 选项卡切换)
- [7.4 全屏分页滚动](#7.4 全屏分页滚动)
- [7.5 列表复选框:全选、全不选与反选](#7.5 列表复选框:全选、全不选与反选)
- [7.6 主复选框联动子项](#7.6 主复选框联动子项)
- 八、最佳实践与性能优化
- [8.1 DOM 操作性能优化](#8.1 DOM 操作性能优化)
- [8.2 内存管理](#8.2 内存管理)
- [8.3 现代 DOM API 推荐用法](#8.3 现代 DOM API 推荐用法)
- [九、DOM 事件机制详解](#九、DOM 事件机制详解)
- [9.1 事件流](#9.1 事件流)
- [9.2 事件捕获与冒泡](#9.2 事件捕获与冒泡)
- [9.3 事件委托](#9.3 事件委托)
- [9.4 事件对象详解](#9.4 事件对象详解)
- [9.5 preventDefault 与 stopPropagation](#9.5 preventDefault 与 stopPropagation)
- [9.6 事件监听器选项](#9.6 事件监听器选项)
- [9.7 常用 DOM 事件](#9.7 常用 DOM 事件)
- [9.8 本章归纳](#9.8 本章归纳)
- [十、DOM 渲染与性能深度解析](#十、DOM 渲染与性能深度解析)
- [10.1 浏览器渲染流程](#10.1 浏览器渲染流程)
- [10.2 回流(Reflow)与重绘(Repaint)](#10.2 回流(Reflow)与重绘(Repaint))
- [10.3 强制同步布局(Layout Thrashing)](#10.3 强制同步布局(Layout Thrashing))
- [10.4 减少回流与重绘的策略](#10.4 减少回流与重绘的策略)
- [10.5 渲染性能优化实战](#10.5 渲染性能优化实战)
- [10.6 本章归纳](#10.6 本章归纳)
- [十一、现代 DOM API](#十一、现代 DOM API)
- [11.1 IntersectionObserver - 交叉观察器](#11.1 IntersectionObserver - 交叉观察器)
- [11.2 MutationObserver - 变化观察器](#11.2 MutationObserver - 变化观察器)
- [11.3 ResizeObserver - 尺寸观察器](#11.3 ResizeObserver - 尺寸观察器)
- [11.4 现代选择器与遍历 API](#11.4 现代选择器与遍历 API)
- [11.5 本章归纳](#11.5 本章归纳)
- 十二、高级实战案例
- [12.1 虚拟滚动(Virtual Scroll)](#12.1 虚拟滚动(Virtual Scroll))
- [12.2 拖放 API(Drag and Drop)](#12.2 拖放 API(Drag and Drop))
- [12.3 无限滚动加载](#12.3 无限滚动加载)
- [12.4 本章归纳](#12.4 本章归纳)
七、实战案例
本章案例覆盖:购物车联动、时钟、选项卡、全屏分页滚动、列表全选/反选、主复选框联动 ,均基于 DOM 获取、属性与 classList 等 API 组合实现。
7.1 购物车全选功能
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>购物车全选功能</title>
<style>
* {
box-sizing: border-box;
}
body {
font-family: Arial, sans-serif;
background: #f5f5f5;
padding: 20px;
}
.cart-container {
max-width: 800px;
margin: 0 auto;
background: white;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
overflow: hidden;
}
.cart-header {
background: #2196F3;
color: white;
padding: 15px 20px;
display: flex;
justify-content: space-between;
align-items: center;
}
.cart-header h2 {
margin: 0;
}
.cart-items {
padding: 0;
list-style: none;
}
.cart-item {
display: flex;
align-items: center;
padding: 15px 20px;
border-bottom: 1px solid #eee;
transition: background 0.2s;
}
.cart-item:hover {
background: #f9f9f9;
}
.cart-item input[type="checkbox"] {
width: 20px;
height: 20px;
margin-right: 15px;
cursor: pointer;
}
.item-image {
width: 60px;
height: 60px;
margin-right: 15px;
border-radius: 4px;
background: #ddd;
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
}
.item-info {
flex: 1;
}
.item-name {
font-weight: bold;
margin-bottom: 5px;
}
.item-price {
color: #666;
}
.item-quantity {
display: flex;
align-items: center;
gap: 10px;
}
.item-quantity button {
width: 30px;
height: 30px;
border: 1px solid #ddd;
background: white;
cursor: pointer;
border-radius: 4px;
}
.item-quantity button:hover {
background: #f0f0f0;
}
.item-subtotal {
width: 100px;
text-align: right;
font-weight: bold;
color: #f44336;
}
.cart-footer {
padding: 20px;
background: #f9f9f9;
display: flex;
justify-content: space-between;
align-items: center;
}
.select-all {
display: flex;
align-items: center;
gap: 10px;
}
.select-all input {
width: 18px;
height: 18px;
cursor: pointer;
}
.total-section {
text-align: right;
}
.total-price {
font-size: 24px;
font-weight: bold;
color: #f44336;
}
.checkout-btn {
margin-left: 20px;
padding: 12px 30px;
background: #4CAF50;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 16px;
transition: background 0.2s;
}
.checkout-btn:hover {
background: #45a049;
}
.checkout-btn:disabled {
background: #ccc;
cursor: not-allowed;
}
.actions {
display: flex;
gap: 10px;
}
.action-btn {
padding: 8px 15px;
background: #2196F3;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
.action-btn:hover {
background: #0b7dda;
}
</style>
</head>
<body>
<div class="cart-container">
<div class="cart-header">
<h2>购物车</h2>
<div class="actions">
<button class="action-btn" onclick="selectAll()">全选</button>
<button class="action-btn" onclick="deselectAll()">取消</button>
<button class="action-btn" onclick="invertSelection()">反选</button>
<button class="action-btn" onclick="deleteSelected()">删除选中</button>
</div>
</div>
<ul class="cart-items" id="cartItems">
<li class="cart-item" data-price="99">
<input type="checkbox" class="item-checkbox">
<div class="item-image">📱</div>
<div class="item-info">
<div class="item-name">iPhone 手机壳</div>
<div class="item-price">¥99.00</div>
</div>
<div class="item-quantity">
<button onclick="changeQuantity(this, -1)">-</button>
<span class="quantity">1</span>
<button onclick="changeQuantity(this, 1)">+</button>
</div>
<div class="item-subtotal">¥99.00</div>
</li>
<li class="cart-item" data-price="199">
<input type="checkbox" class="item-checkbox">
<div class="item-image">🎧</div>
<div class="item-info">
<div class="item-name">蓝牙耳机</div>
<div class="item-price">¥199.00</div>
</div>
<div class="item-quantity">
<button onclick="changeQuantity(this, -1)">-</button>
<span class="quantity">1</span>
<button onclick="changeQuantity(this, 1)">+</button>
</div>
<div class="item-subtotal">¥199.00</div>
</li>
<li class="cart-item" data-price="49">
<input type="checkbox" class="item-checkbox">
<div class="item-image">🔌</div>
<div class="item-info">
<div class="item-name">USB 数据线</div>
<div class="item-price">¥49.00</div>
</div>
<div class="item-quantity">
<button onclick="changeQuantity(this, -1)">-</button>
<span class="quantity">2</span>
<button onclick="changeQuantity(this, 1)">+</button>
</div>
<div class="item-subtotal">¥98.00</div>
</li>
<li class="cart-item" data-price="299">
<input type="checkbox" class="item-checkbox">
<div class="item-image">⌨️</div>
<div class="item-info">
<div class="item-name">机械键盘</div>
<div class="item-price">¥299.00</div>
</div>
<div class="item-quantity">
<button onclick="changeQuantity(this, -1)">-</button>
<span class="quantity">1</span>
<button onclick="changeQuantity(this, 1)">+</button>
</div>
<div class="item-subtotal">¥299.00</div>
</li>
<li class="cart-item" data-price="149">
<input type="checkbox" class="item-checkbox">
<div class="item-image">🖱️</div>
<div class="item-info">
<div class="item-name">无线鼠标</div>
<div class="item-price">¥149.00</div>
</div>
<div class="item-quantity">
<button onclick="changeQuantity(this, -1)">-</button>
<span class="quantity">1</span>
<button onclick="changeQuantity(this, 1)">+</button>
</div>
<div class="item-subtotal">¥149.00</div>
</li>
</ul>
<div class="cart-footer">
<div class="select-all">
<input type="checkbox" id="selectAllCheckbox" onchange="toggleSelectAll()">
<label for="selectAllCheckbox">全选</label>
</div>
<div class="total-section">
<span>合计:</span>
<span class="total-price" id="totalPrice">¥0.00</span>
<button class="checkout-btn" id="checkoutBtn" onclick="checkout()" disabled>结算(0)</button>
</div>
</div>
</div>
<script>
const cartItems = document.querySelectorAll('.cart-item');
const selectAllCheckbox = document.getElementById('selectAllCheckbox');
const totalPrice = document.getElementById('totalPrice');
const checkoutBtn = document.getElementById('checkoutBtn');
// 为每个复选框添加事件
document.querySelectorAll('.item-checkbox').forEach(checkbox => {
checkbox.addEventListener('change', function() {
updateTotal();
updateSelectAllCheckbox();
});
});
function updateTotal() {
let total = 0;
let count = 0;
cartItems.forEach(item => {
const checkbox = item.querySelector('.item-checkbox');
if (checkbox.checked) {
const price = parseFloat(item.dataset.price);
const quantity = parseInt(item.querySelector('.quantity').textContent);
total += price * quantity;
count++;
}
});
totalPrice.textContent = `¥${total.toFixed(2)}`;
checkoutBtn.textContent = `结算(${count})`;
checkoutBtn.disabled = count === 0;
}
function updateSelectAllCheckbox() {
const allChecked = Array.from(document.querySelectorAll('.item-checkbox'))
.every(checkbox => checkbox.checked);
selectAllCheckbox.checked = allChecked && document.querySelectorAll('.item-checkbox').length > 0;
}
function toggleSelectAll() {
const checkboxes = document.querySelectorAll('.item-checkbox');
checkboxes.forEach(checkbox => {
checkbox.checked = selectAllCheckbox.checked;
});
updateTotal();
}
function selectAll() {
document.querySelectorAll('.item-checkbox').forEach(checkbox => {
checkbox.checked = true;
});
selectAllCheckbox.checked = true;
updateTotal();
}
function deselectAll() {
document.querySelectorAll('.item-checkbox').forEach(checkbox => {
checkbox.checked = false;
});
selectAllCheckbox.checked = false;
updateTotal();
}
function invertSelection() {
document.querySelectorAll('.item-checkbox').forEach(checkbox => {
checkbox.checked = !checkbox.checked;
});
updateSelectAllCheckbox();
updateTotal();
}
function changeQuantity(button, delta) {
const item = button.closest('.cart-item');
const quantitySpan = item.querySelector('.quantity');
const subtotalSpan = item.querySelector('.item-subtotal');
const price = parseFloat(item.dataset.price);
let quantity = parseInt(quantitySpan.textContent) + delta;
if (quantity < 1) quantity = 1;
if (quantity > 99) quantity = 99;
quantitySpan.textContent = quantity;
subtotalSpan.textContent = `¥${(price * quantity).toFixed(2)}`;
updateTotal();
}
function deleteSelected() {
const selectedItems = document.querySelectorAll('.item-checkbox:checked');
if (selectedItems.length === 0) {
alert('请先选择要删除的商品');
return;
}
if (confirm(`确定要删除选中的 ${selectedItems.length} 件商品吗?`)) {
selectedItems.forEach(checkbox => {
checkbox.closest('.cart-item').remove();
});
updateTotal();
updateSelectAllCheckbox();
}
}
function checkout() {
const selectedItems = document.querySelectorAll('.item-checkbox:checked');
alert(`结算成功!共 ${selectedItems.length} 件商品,总价 ${totalPrice.textContent}`);
}
</script>
</body>
</html>
代码解释: 数据流 :每行 .cart-item 用 data-price (对应脚本里 dataset.price )存单价,数量在 DOM 文本节点里,勾选状态在 input.checked 。updateTotal 遍历所有行,仅对勾选行 price * quantity 累加,并驱动结算按钮 disabled 与文案。updateSelectAllCheckbox 用 every 判断是否全选,避免手写 for。toggleSelectAll 把主框状态广播到所有子框。invertSelection 逐框取反 checked。changeQuantity 用 closest('.cart-item') 从按钮冒泡上下文定位所在行,再 querySelector 子节点,体现「局部根 + 相对查询」。deleteSelected 用 :checked 伪类一次性取选中 input,再 remove() 整行------删除后别忘了调用总价与全选同步。工程扩展:大量商品时应把价格数量放内存模型,DOM 只作视图,避免双向数据源不一致。
7.2 电子时钟
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>电子时钟</title>
<style>
body {
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
margin: 0;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
font-family: 'Courier New', monospace;
}
.clock-container {
text-align: center;
background: rgba(255, 255, 255, 0.1);
backdrop-filter: blur(10px);
padding: 40px 60px;
border-radius: 20px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
border: 1px solid rgba(255, 255, 255, 0.2);
}
.clock {
font-size: 72px;
font-weight: bold;
color: white;
text-shadow: 0 0 20px rgba(255, 255, 255, 0.5);
letter-spacing: 5px;
margin: 20px 0;
}
.date {
font-size: 24px;
color: rgba(255, 255, 255, 0.8);
margin-bottom: 10px;
}
.day {
font-size: 18px;
color: rgba(255, 255, 255, 0.6);
}
.format-toggle {
margin-top: 30px;
}
.format-toggle button {
padding: 10px 20px;
margin: 0 5px;
background: rgba(255, 255, 255, 0.2);
color: white;
border: 1px solid rgba(255, 255, 255, 0.3);
border-radius: 20px;
cursor: pointer;
transition: all 0.3s ease;
}
.format-toggle button:hover {
background: rgba(255, 255, 255, 0.3);
}
.format-toggle button.active {
background: rgba(255, 255, 255, 0.4);
border-color: rgba(255, 255, 255, 0.6);
}
</style>
</head>
<body>
<div class="clock-container">
<div class="date" id="date"></div>
<div class="clock" id="clock">00:00:00</div>
<div class="day" id="day"></div>
<div class="format-toggle">
<button id="btn24" onclick="setFormat(24)" class="active">24小时制</button>
<button id="btn12" onclick="setFormat(12)">12小时制</button>
</div>
</div>
<script>
const clockEl = document.getElementById('clock');
const dateEl = document.getElementById('date');
const dayEl = document.getElementById('day');
const btn24 = document.getElementById('btn24');
const btn12 = document.getElementById('btn12');
let is24Hour = true;
function updateClock() {
const now = new Date();
// 获取日期信息
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, '0');
const day = String(now.getDate()).padStart(2, '0');
// 获取时间信息
let hours = now.getHours();
const minutes = String(now.getMinutes()).padStart(2, '0');
const seconds = String(now.getSeconds()).padStart(2, '0');
// 获取星期
const days = ['星期日', '星期一', '星期二', '星期三', '星期四', '星期五', '星期六'];
const dayName = days[now.getDay()];
// 12小时制处理
let period = '';
if (!is24Hour) {
period = hours >= 12 ? ' PM' : ' AM';
hours = hours % 12 || 12;
}
hours = String(hours).padStart(2, '0');
// 更新显示
dateEl.textContent = `${year}-${month}-${day}`;
clockEl.textContent = `${hours}:${minutes}:${seconds}${period}`;
dayEl.textContent = dayName;
}
function setFormat(format) {
is24Hour = format === 24;
btn24.classList.toggle('active', is24Hour);
btn12.classList.toggle('active', !is24Hour);
updateClock();
}
// 初始化
updateClock();
setInterval(updateClock, 1000);
</script>
</body>
</html>
代码解释: 时间源 为 new Date(),每秒由 setInterval(updateClock, 1000) 驱动刷新,属于「拉模型」时钟;若需与服务器对齐可改为定期 NTP/API 校准。padStart(2, '0') 保证两位数显示。12 小时制 通过 hours % 12 || 12 把 0 点转为 12,并拼接 AM/PM。classList.toggle('active', is24Hour) 使用双参数形式,避免先 remove 再 add 的竞态。性能 :getElementById 在定时器外缓存节点引用,避免每秒重复查询 DOM。清理 :在 SPA 中组件销毁时应 clearInterval ,防止内存泄漏与后台仍更新已卸载节点。无障碍 :可额外更新 aria-live="polite" 区域,供读屏软件朗读时间变化。
7.3 选项卡切换
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>选项卡切换</title>
<style>
* {
box-sizing: border-box;
}
body {
font-family: Arial, sans-serif;
background: #f5f5f5;
padding: 20px;
}
.tabs-container {
max-width: 800px;
margin: 0 auto;
background: white;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
overflow: hidden;
}
.tabs-header {
display: flex;
background: #f0f0f0;
border-bottom: 1px solid #ddd;
}
.tab-btn {
flex: 1;
padding: 15px 20px;
background: none;
border: none;
cursor: pointer;
font-size: 16px;
color: #666;
transition: all 0.3s ease;
border-bottom: 3px solid transparent;
}
.tab-btn:hover {
background: rgba(33, 150, 243, 0.1);
color: #2196F3;
}
.tab-btn.active {
color: #2196F3;
border-bottom-color: #2196F3;
background: white;
}
.tabs-content {
padding: 20px;
min-height: 300px;
}
.tab-pane {
display: none;
animation: fadeIn 0.3s ease;
}
.tab-pane.active {
display: block;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
.tab-pane h3 {
margin-top: 0;
color: #333;
}
.tab-pane p {
color: #666;
line-height: 1.8;
}
.feature-list {
list-style: none;
padding: 0;
}
.feature-list li {
padding: 10px 0;
border-bottom: 1px solid #eee;
}
.feature-list li:before {
content: "✓ ";
color: #4CAF50;
font-weight: bold;
margin-right: 10px;
}
</style>
</head>
<body>
<div class="tabs-container">
<div class="tabs-header">
<button class="tab-btn active" data-tab="home">首页</button>
<button class="tab-btn" data-tab="features">功能</button>
<button class="tab-btn" data-tab="about">关于</button>
<button class="tab-btn" data-tab="contact">联系</button>
</div>
<div class="tabs-content">
<div class="tab-pane active" id="home">
<h3>欢迎来到首页</h3>
<p>这是一个使用纯 JavaScript 和 DOM 操作实现的选项卡组件示例。通过点击上方的选项卡按钮,可以切换不同的内容面板。</p>
<p>本示例展示了如何使用 classList API 来管理元素的类名,实现选项卡的切换效果。</p>
</div>
<div class="tab-pane" id="features">
<h3>功能特点</h3>
<ul class="feature-list">
<li>纯 JavaScript 实现,无需任何框架</li>
<li>使用 classList API 进行类名管理</li>
<li>流畅的动画过渡效果</li>
<li>响应式设计,适配各种屏幕</li>
<li>简洁易懂的代码结构</li>
<li>可扩展性强,易于定制</li>
</ul>
</div>
<div class="tab-pane" id="about">
<h3>关于本项目</h3>
<p>本项目是一个 DOM 操作教程的示例代码,展示了如何使用原生 JavaScript 实现常见的 UI 交互功能。</p>
<p>通过学习这些示例,您将掌握:</p>
<ul class="feature-list">
<li>元素的选择和获取</li>
<li>类名的动态管理</li>
<li>事件处理机制</li>
<li>DOM 结构的动态操作</li>
</ul>
</div>
<div class="tab-pane" id="contact">
<h3>联系方式</h3>
<p>如果您有任何问题或建议,欢迎通过以下方式联系我们:</p>
<ul class="feature-list">
<li>邮箱:example@demo.com</li>
<li>电话:400-123-4567</li>
<li>地址:某某市某某区某某街道</li>
</ul>
</div>
</div>
</div>
<script>
const tabBtns = document.querySelectorAll('.tab-btn');
const tabPanes = document.querySelectorAll('.tab-pane');
tabBtns.forEach(btn => {
btn.addEventListener('click', function() {
// 移除所有激活状态
tabBtns.forEach(b => b.classList.remove('active'));
tabPanes.forEach(p => p.classList.remove('active'));
// 激活当前选中的选项卡
this.classList.add('active');
const tabId = this.dataset.tab;
document.getElementById(tabId).classList.add('active');
});
});
</script>
</body>
</html>
代码解释: 状态机 :每个按钮带 data-tab 与对应面板 id 一致,点击时先 querySelectorAll 清掉 所有 .tab-btn、.tab-pane 的 active ,再给当前按钮与 getElementById(tabId) 面板加上 active ,保证任意时刻仅一组可见。CSS 用 .tab-pane { display: none } 与 .tab-pane.active { display: block } 控制显隐,比反复改 style.display 更易维护。键盘无障碍 :生产环境应加 role="tablist" / role="tab" / role="tabpanel" 、aria-selected、aria-controls 与 左右键 切换焦点。扩展 :若面板内容重,可懒加载首次激活时再 fetch 填内容,减少首屏 DOM。
7.4 全屏分页滚动
结合 DOM 设置每屏高度 与 BOM scrollBy / scrollTo,实现整屏翻页与回到顶部(落地页、产品介绍、H5 长页常见)。
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>全屏分页滚动</title>
<style>
body { margin: 0; }
.box {
display: flex;
align-items: center;
justify-content: center;
text-align: center;
overflow: hidden;
}
.box1 { background: #e3f2fd; }
.box2 { background: #fff3e0; }
.box3 { background: #e8f5e9; }
.box4 { background: #fce4ec; }
.box5 { background: #ede7f6; }
.btns {
position: fixed;
right: 16px;
bottom: 24px;
display: flex;
flex-direction: column;
gap: 8px;
}
.btn {
width: 100px;
height: 36px;
line-height: 36px;
color: #fff;
font-size: 13px;
text-align: center;
background: #333;
border-radius: 4px;
cursor: pointer;
user-select: none;
}
.btn:hover { background: #555; }
</style>
</head>
<body>
<div class="box box1"><h2>第一屏</h2></div>
<div class="box box2"><h2>第二屏</h2></div>
<div class="box box3"><h2>第三屏</h2></div>
<div class="box box4"><h2>第四屏</h2></div>
<div class="box box5"><h2>第五屏</h2></div>
<div class="btns">
<div class="btn" id="prevBtn">上一屏</div>
<div class="btn" id="nextBtn">下一屏</div>
<div class="btn" id="topBtn">返回顶部</div>
</div>
<script>
const boxes = document.querySelectorAll('.box');
const vh = window.innerHeight + 'px';
boxes.forEach(function (el) {
el.style.height = vh;
});
document.getElementById('nextBtn').onclick = function () {
scrollBy({ top: innerHeight, behavior: 'smooth' });
};
document.getElementById('prevBtn').onclick = function () {
scrollBy({ top: -innerHeight, behavior: 'smooth' });
};
document.getElementById('topBtn').onclick = function () {
scrollTo({ top: 0, behavior: 'smooth' });
};
</script>
</body>
</html>
代码解释: window.innerHeight 为视口 CSS 像素高度,赋给每屏 el.style.height 使每块占满一屏,形成「整屏滚动」体验。scrollBy({ top: ±innerHeight, behavior: 'smooth' }) 相对当前滚动位置移动一整屏;scrollTo({ top: 0, behavior: 'smooth' }) 回顶。behavior: 'smooth' 依赖浏览器原生平滑滚动,部分旧环境会降级为瞬时滚动。resize :旋转手机或调整窗口后 innerHeight 变化,应 addEventListener('resize', ...) (注意防抖)重新赋值高度,否则会出现断层。可访问性 :全屏滚动站建议提供 跳过链接 与 键盘可操作 的导航,避免仅靠右下角按钮。
7.5 列表复选框:全选、全不选与反选
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>全选 / 全不选 / 反选</title>
<style>
ul { list-style: none; padding: 0; }
li { margin: 8px 0; }
.toolbar { margin-top: 16px; }
button { margin-right: 8px; padding: 8px 14px; cursor: pointer; }
</style>
</head>
<body>
<ul id="checkboxList">
<li><label><input type="checkbox"> 任务项 A</label></li>
<li><label><input type="checkbox"> 任务项 B</label></li>
<li><label><input type="checkbox"> 任务项 C</label></li>
<li><label><input type="checkbox"> 任务项 D</label></li>
<li><label><input type="checkbox"> 任务项 E</label></li>
<li><label><input type="checkbox"> 任务项 F</label></li>
</ul>
<div class="toolbar">
<button id="selectAllBtn">全选</button>
<button id="selectNotAllBtn">取消全选</button>
<button id="invertBtn">反选</button>
</div>
<script>
const checkboxItems = document.querySelectorAll('#checkboxList input[type="checkbox"]');
document.getElementById('selectAllBtn').onclick = function () {
checkboxItems.forEach(function (item) { item.checked = true; });
};
document.getElementById('selectNotAllBtn').onclick = function () {
checkboxItems.forEach(function (item) { item.checked = false; });
};
document.getElementById('invertBtn').onclick = function () {
checkboxItems.forEach(function (item) { item.checked = !item.checked; });
};
</script>
</body>
</html>
代码解释: querySelectorAll('#checkboxList input[type="checkbox"]') 在容器内限定范围,避免误选页面其它复选框;返回 静态 NodeList ,本例只做布尔赋值无增删节点问题。全选 / 全不选 即对同一引用集合 forEach 设 checked = true/false。反选 对每项 checked = !checked ,时间复杂度 O(n)。与 7.6 区别 :本例无主框联动;若需「头行全选」与「部分选中」态,要同步 indeterminate (见 7.6)。提交表单 :若在 <form> 内,未勾选 name 的 checkbox 不会出现在 FormData 中;若需显式传 false,可用隐藏域或服务端默认。
7.6 主复选框联动子项
顶部「全选」控制列表每一项,并支持全不选(电商购物车、邮件客户端、后台表格)。
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>主复选框联动</title>
<style>
ul { list-style: none; padding: 0; margin: 12px 0; }
li { margin: 6px 0; }
.master { font-weight: bold; margin-bottom: 8px; }
</style>
</head>
<body>
<label class="master">
<input type="checkbox" id="masterCheck"> 全选
</label>
<ul id="checkboxList">
<li><label><input type="checkbox" class="item-check"> 选项 1</label></li>
<li><label><input type="checkbox" class="item-check"> 选项 2</label></li>
<li><label><input type="checkbox" class="item-check"> 选项 3</label></li>
<li><label><input type="checkbox" class="item-check"> 选项 4</label></li>
<li><label><input type="checkbox" class="item-check"> 选项 5</label></li>
<li><label><input type="checkbox" class="item-check"> 选项 6</label></li>
</ul>
<script>
const master = document.getElementById('masterCheck');
const items = document.querySelectorAll('.item-check');
master.addEventListener('change', function () {
items.forEach(function (cb) {
cb.checked = master.checked;
});
});
items.forEach(function (cb) {
cb.addEventListener('change', function () {
const allChecked = Array.from(items).every(function (c) { return c.checked; });
const noneChecked = Array.from(items).every(function (c) { return !c.checked; });
master.checked = allChecked;
master.indeterminate = !allChecked && !noneChecked;
});
});
</script>
</body>
</html>
代码解释: 主 → 子 :master 的 change 事件里把 master.checked 广播到所有 item-check ,一键全选/全不选。子 → 主 :每个子框变化时用 Array.from(items).every 判断是否全选 或全未选 ;若介于两者之间,设置 master.indeterminate = true 并通常 master.checked = false ,以符合常见 UI(横杠表示部分选中)。indeterminate 仅为 IDL 属性,不能 通过 HTML 属性静态声明,只能脚本设置。无障碍 :主框应 aria-controls 指向列表 id,子框带 aria-labelledby 或统一 name,便于读屏理解分组关系。
八、最佳实践与性能优化
8.1 DOM 操作性能优化
8.1.1 减少 DOM 访问次数
javascript
// ❌ 不好的做法 - 多次访问 DOM
for (let i = 0; i < 100; i++) {
document.getElementById('list').innerHTML += '<li>Item ' + i + '</li>';
}
// ✅ 好的做法 - 缓存 DOM 引用
const list = document.getElementById('list');
let html = '';
for (let i = 0; i < 100; i++) {
html += '<li>Item ' + i + '</li>';
}
list.innerHTML = html;
代码解释: 反例中 innerHTML += 在循环里每次都会 序列化整棵子树 + 重新解析 ,触发大量 布局与解析 ,且丢失已绑定在该列表上的 事件监听器 。正例先 getElementById 缓存 list,再在内存里拼好字符串 一次写入 innerHTML,将「读---改---写」从 O(n²) 降到 O(n)。更优还可 Document.createDocumentFragment 或 insertAdjacentHTML 。若列表项需独立事件,应用 createElement + appendChild 或事件委托,避免整表 innerHTML 重绘。
8.1.2 使用文档片段批量添加元素
javascript
// ✅ 使用 DocumentFragment 批量添加
const fragment = document.createDocumentFragment();
for (let i = 0; i < 100; i++) {
const li = document.createElement('li');
li.textContent = 'Item ' + i;
fragment.appendChild(li);
}
document.getElementById('list').appendChild(fragment);
代码解释: DocumentFragment 是存在于内存中的「轻量父节点」,把多个子节点先挂到 fragment 上时不会 触发页面多次 reflow ;最终 appendChild(fragment) 时,子节点会一次性接到真实 DOM 上,只触发一次 合并后的布局更新(具体次数仍受浏览器优化影响,但远优于循环 appendChild 到 body)。适合批量插入列表行、表格单元、虚拟滚动补窗等。注意 :fragment 被插入后其下子节点会被移出 fragment,fragment 本身不会出现在文档树中。
8.1.3 合理使用选择器
javascript
// ✅ 快速选择器
document.getElementById('id'); // 最快
document.getElementsByClassName('class'); // 较快
// ⚠️ 较慢的选择器(复杂 CSS)
document.querySelectorAll('.container div.item:first-child');
代码解释: getElementById 由 id 哈希直接定位,通常最快。getElementsByClassName 走类索引,也较省。querySelectorAll 需 CSS 引擎解析选择器,:first-child、组合后代、通配 等会提高成本;热点路径(滚动、输入防抖回调)应避免在每一帧里跑重选择器,可 缓存 NodeList 或给目标打 稳定 class / data-hook 。若只需第一个匹配,用 querySelector 可在引擎找到首个后即停(仍取决于选择器复杂度)。
8.2 内存管理
8.2.1 及时清理引用
javascript
// 创建元素
let element = document.createElement('div');
document.body.appendChild(element);
// 使用完毕后移除
document.body.removeChild(element);
// 清空引用(需使用 let/var 才可重新赋值)
element = null;
代码解释: 从文档 removeChild (或 remove() )后,节点不再显示,但若仍有 闭包、全局数组、Map 引用该节点,则无法被 GC ,并可能继续持有大型子树。element = null 仅在你用 let / var 声明且可重新赋值时才有意义;若示例写成 const element 则不能再赋 null,应改用 element.remove() 后让块级作用域结束或显式从集合里删除引用。现代写法 推荐 element.remove() ,等价于 parent.removeChild(element) 且更短。Detach 再复用 :有时先把子树 appendChild 到 fragment 暂存,比反复 innerHTML 更可控。
8.2.2 移除事件监听器
javascript
function handleClick() {
console.log('Clicked');
}
button.addEventListener('click', handleClick);
// 移除事件监听器
button.removeEventListener('click', handleClick);
代码解释: removeEventListener 必须与 addEventListener 的 函数引用、类型、capture 选项 完全一致,匿名函数无法移除。用途 :单页应用切路由、销毁组件时注销监听,避免 幽灵回调 与内存泄漏。{ once: true } 适合一次性任务。被动事件 :touchstart/wheel 若需 preventDefault,需 { passive: false } 且谨慎使用以免影响滚动性能。
8.3 现代 DOM API 推荐用法
javascript
// ✅ 使用 querySelector/querySelectorAll
const element = document.querySelector('.class');
const elements = document.querySelectorAll('.class');
// ✅ 使用 classList
element.classList.add('active');
element.classList.remove('active');
element.classList.toggle('active');
// ✅ 使用 dataset
element.dataset.userId = '123';
const userId = element.dataset.userId;
// ✅ 使用 closest 查找最近的祖先元素
const parent = element.closest('.parent-class');
代码解释: 这一组是日常开发「查询---打标---读配置---找上下文 」的推荐组合:querySelector(All) 写清选择器;classList 管状态类;dataset 存与渲染相关的轻量元数据(复杂数据仍建议 JSON 或状态库);closest 从事件目标向上找最近的匹配选择器祖先,常用于 table 行内按钮找 tr 、卡片内按钮找 .card ,比手写 while (p && !p.matches(...)) 更短更稳。类型注意 :dataset 的值始终是 字符串 ,比较数字前要 Number() 或 parseInt。
九、DOM 事件机制详解
事件是 DOM 交互的核心,理解事件机制对于开发复杂的交互应用至关重要。
9.1 事件流
事件在 DOM 中的传播分为三个阶段:
1.捕获阶段
捕获
捕获
捕获
捕获
2.目标阶段
3.冒泡阶段
冒泡
冒泡
冒泡
冒泡
Window
Document
html
body
div
button 目标
名词解析:
| 阶段 | 说明 | 触发顺序 |
|---|---|---|
| 捕获阶段 | 事件从 Window 向下传播到目标元素 | 1️⃣ |
| 目标阶段 | 事件到达目标元素 | 2️⃣ |
| 冒泡阶段 | 事件从目标元素向上传播回 Window | 3️⃣ |
9.2 事件捕获与冒泡
javascript
// 捕获阶段监听(第三个参数为 true)
element.addEventListener('click', handler, true);
// 冒泡阶段监听(第三个参数为 false 或省略)
element.addEventListener('click', handler, false);
element.addEventListener('click', handler);
完整示例:
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>事件流演示</title>
<style>
.box {
padding: 40px;
margin: 20px auto;
text-align: center;
border: 2px solid #333;
background: #f0f0f0;
}
.outer {
width: 400px;
background: #ffebee;
}
.middle {
width: 300px;
background: #fff3e0;
}
.inner {
width: 200px;
background: #e8f5e9;
}
#log {
max-width: 600px;
margin: 20px auto;
padding: 15px;
background: #263238;
color: #aed581;
border-radius: 4px;
font-family: monospace;
max-height: 300px;
overflow-y: auto;
}
button {
padding: 10px 20px;
margin: 5px;
cursor: pointer;
background: #2196F3;
color: white;
border: none;
border-radius: 4px;
}
</style>
</head>
<body>
<div style="text-align: center; margin: 20px;">
<button onclick="clearLog()">清空日志</button>
<button onclick="resetDemo()">重置演示</button>
</div>
<div class="box outer" id="outer">
外层盒子
<div class="box middle" id="middle">
中间盒子
<div class="box inner" id="inner">
内层盒子
<button id="triggerBtn">点击触发事件</button>
</div>
</div>
</div>
<div id="log"></div>
<script>
const log = document.getElementById('log');
let eventCount = 0;
function addLog(message, phase) {
eventCount++;
const indent = ' '.repeat(eventCount);
const phaseIcon = {
'capture': '⬇️',
'target': '🎯',
'bubble': '⬆️'
}[phase] || '•';
log.innerHTML = `${indent}[${phaseIcon}] ${message}\n` + log.innerHTML;
}
function clearLog() {
log.innerHTML = '';
eventCount = 0;
}
function resetDemo() {
clearLog();
addLog('=== 事件流演示已重置 ===', 'target');
}
// 捕获阶段监听
document.getElementById('outer').addEventListener('click', (e) => {
addLog('外层盒子 - 捕获阶段', 'capture');
}, true);
document.getElementById('middle').addEventListener('click', (e) => {
addLog('中间盒子 - 捕获阶段', 'capture');
}, true);
document.getElementById('inner').addEventListener('click', (e) => {
addLog('内层盒子 - 捕获阶段', 'capture');
}, true);
// 冒泡阶段监听
document.getElementById('outer').addEventListener('click', (e) => {
addLog('外层盒子 - 冒泡阶段', 'bubble');
}, false);
document.getElementById('middle').addEventListener('click', (e) => {
addLog('中间盒子 - 冒泡阶段', 'bubble');
}, false);
document.getElementById('inner').addEventListener('click', (e) => {
addLog('内层盒子 - 冒泡阶段', 'bubble');
}, false);
// 目标元素
document.getElementById('triggerBtn').addEventListener('click', (e) => {
addLog('🎯 目标元素(按钮)被点击', 'target');
eventCount = 0;
});
// 初始化
addLog('=== 点击按钮查看事件流 ===', 'target');
</script>
</body>
</html>
代码解释: 捕获阶段从 Window → Document → html → body → outer → middle → inner → button ;目标阶段在 button 上触发;冒泡阶段按相反顺序回传。注意:在目标元素上注册的监听器,无论 useCapture 是 true 还是 false,都会在目标阶段 按注册顺序触发,不会分两次。经典应用 :event.stopPropagation() 可中断后续传播;event.stopImmediatePropagation() 进一步阻止同一元素上其他监听器。
9.3 事件委托
事件委托是利用事件冒泡机制,在父元素上统一管理子元素事件的高效模式。
名词解析:
- 事件委托(Event Delegation):将事件监听器设置在父元素上,利用冒泡处理子元素事件
- 性能优势:减少监听器数量,动态添加的子元素自动继承事件处理
javascript
// ❌ 不好的做法 - 为每个子元素单独绑定
const items = document.querySelectorAll('.item');
items.forEach(item => {
item.addEventListener('click', handleClick);
});
// ✅ 好的做法 - 使用事件委托
document.querySelector('.container').addEventListener('click', (e) => {
if (e.target.matches('.item')) {
handleClick(e);
}
});
完整示例:
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>事件委托演示</title>
<style>
* {
box-sizing: border-box;
}
body {
font-family: Arial, sans-serif;
background: #f5f5f5;
padding: 20px;
}
.container {
max-width: 800px;
margin: 0 auto;
background: white;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
overflow: hidden;
}
.toolbar {
padding: 15px 20px;
background: #2196F3;
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.toolbar button {
padding: 8px 15px;
background: white;
color: #2196F3;
border: none;
border-radius: 4px;
cursor: pointer;
font-weight: bold;
}
.toolbar button:hover {
background: #f0f0f0;
}
.list {
padding: 20px;
}
.list-item {
display: flex;
align-items: center;
padding: 15px;
margin-bottom: 10px;
background: #f9f9f9;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s;
}
.list-item:hover {
background: #e3f2fd;
transform: translateX(5px);
}
.list-item.selected {
background: #e8f5e9;
border-left: 4px solid #4CAF50;
}
.list-item.completed {
opacity: 0.6;
text-decoration: line-through;
}
.item-checkbox {
width: 20px;
height: 20px;
margin-right: 15px;
cursor: pointer;
}
.item-text {
flex: 1;
}
.item-delete {
padding: 5px 10px;
background: #f44336;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
opacity: 0;
transition: opacity 0.2s;
}
.list-item:hover .item-delete {
opacity: 1;
}
.stats {
padding: 15px 20px;
background: #f9f9f9;
border-top: 1px solid #eee;
display: flex;
justify-content: space-between;
color: #666;
}
#log {
max-width: 800px;
margin: 20px auto;
padding: 15px;
background: #263238;
color: #aed581;
border-radius: 4px;
font-family: monospace;
font-size: 12px;
max-height: 200px;
overflow-y: auto;
}
.new-item-input {
flex: 1;
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 4px;
margin-right: 10px;
}
</style>
</head>
<body>
<div class="container">
<div class="toolbar">
<input type="text" id="newItemInput" class="new-item-input" placeholder="输入新任务...">
<button onclick="addNewItem()">添加任务</button>
<button onclick="selectFirst()">选择第一个</button>
<button onclick="selectAll()">全选</button>
<button onclick="deselectAll()">取消全选</button>
<button onclick="deleteSelected()">删除选中</button>
<button onclick="clearCompleted()">清除已完成</button>
</div>
<div class="list" id="taskList">
<div class="list-item" data-id="1">
<input type="checkbox" class="item-checkbox">
<span class="item-text">学习 JavaScript DOM 基础</span>
<button class="item-delete">删除</button>
</div>
<div class="list-item" data-id="2">
<input type="checkbox" class="item-checkbox">
<span class="item-text">掌握事件委托机制</span>
<button class="item-delete">删除</button>
</div>
<div class="list-item" data-id="3">
<input type="checkbox" class="item-checkbox">
<span class="item-text">理解浏览器渲染原理</span>
<button class="item-delete">删除</button>
</div>
<div class="list-item" data-id="4">
<input type="checkbox" class="item-checkbox">
<span class="item-text">实践性能优化技巧</span>
<button class="item-delete">删除</button>
</div>
<div class="list-item" data-id="5">
<input type="checkbox" class="item-checkbox">
<span class="item-text">构建完整的知识体系</span>
<button class="item-delete">删除</button>
</div>
</div>
<div class="stats">
<span id="totalCount">总计: 5</span>
<span id="selectedCount">已选: 0</span>
<span id="completedCount">已完成: 0</span>
</div>
</div>
<div id="log"></div>
<script>
const taskList = document.getElementById('taskList');
const log = document.getElementById('log');
let nextId = 6;
function addLog(message) {
const time = new Date().toLocaleTimeString();
log.innerHTML = `[${time}] ${message}\n` + log.innerHTML;
}
// ========== 事件委托核心代码 ==========
// 整个列表只使用一个事件监听器
taskList.addEventListener('click', (e) => {
const item = e.target.closest('.list-item');
if (!item) return;
// 处理复选框点击
if (e.target.matches('.item-checkbox')) {
item.classList.toggle('completed', e.target.checked);
addLog(`复选框变化: ${item.querySelector('.item-text').textContent} - ${e.target.checked ? '已完成' : '未完成'}`);
updateStats();
return;
}
// 处理删除按钮点击
if (e.target.matches('.item-delete')) {
const text = item.querySelector('.item-text').textContent;
item.remove();
addLog(`删除任务: ${text}`);
updateStats();
return;
}
// 处理列表项点击(选中/取消选中)
if (e.target.closest('.list-item')) {
// 如果点击的不是复选框和删除按钮,则切换选中状态
if (!e.target.matches('.item-checkbox') && !e.target.matches('.item-delete')) {
item.classList.toggle('selected');
addLog(`${item.classList.contains('selected') ? '选中' : '取消选中'}: ${item.querySelector('.item-text').textContent}`);
updateStats();
}
}
});
// 双击事件委托 - 编辑任务
taskList.addEventListener('dblclick', (e) => {
const item = e.target.closest('.list-item');
if (!item) return;
const textSpan = item.querySelector('.item-text');
if (e.target === textSpan) {
const newText = prompt('编辑任务:', textSpan.textContent);
if (newText && newText.trim()) {
addLog(`编辑任务: "${textSpan.textContent}" → "${newText.trim()}"`);
textSpan.textContent = newText.trim();
}
}
});
// ======================================
function addNewItem() {
const input = document.getElementById('newItemInput');
const text = input.value.trim();
if (!text) {
alert('请输入任务内容');
return;
}
const newItem = document.createElement('div');
newItem.className = 'list-item';
newItem.dataset.id = nextId++;
newItem.innerHTML = `
<input type="checkbox" class="item-checkbox">
<span class="item-text">${text}</span>
<button class="item-delete">删除</button>
`;
taskList.appendChild(newItem);
input.value = '';
addLog(`添加新任务: ${text}`);
updateStats();
}
function selectFirst() {
const first = taskList.querySelector('.list-item');
if (first) {
first.classList.add('selected');
addLog('选中第一个任务');
updateStats();
}
}
function selectAll() {
taskList.querySelectorAll('.list-item').forEach(item => {
item.classList.add('selected');
});
addLog('全选所有任务');
updateStats();
}
function deselectAll() {
taskList.querySelectorAll('.list-item').forEach(item => {
item.classList.remove('selected');
});
addLog('取消全选');
updateStats();
}
function deleteSelected() {
const selected = taskList.querySelectorAll('.list-item.selected');
if (selected.length === 0) {
alert('请先选择要删除的任务');
return;
}
if (confirm(`确定要删除选中的 ${selected.length} 个任务吗?`)) {
selected.forEach(item => item.remove());
addLog(`删除了 ${selected.length} 个选中的任务`);
updateStats();
}
}
function clearCompleted() {
const completed = taskList.querySelectorAll('.list-item.completed');
if (completed.length === 0) {
alert('没有已完成的任务');
return;
}
if (confirm(`确定要清除 ${completed.length} 个已完成的任务吗?`)) {
completed.forEach(item => item.remove());
addLog(`清除了 ${completed.length} 个已完成的任务`);
updateStats();
}
}
function updateStats() {
const total = taskList.querySelectorAll('.list-item').length;
const selected = taskList.querySelectorAll('.list-item.selected').length;
const completed = taskList.querySelectorAll('.list-item.completed').length;
document.getElementById('totalCount').textContent = `总计: ${total}`;
document.getElementById('selectedCount').textContent = `已选: ${selected}`;
document.getElementById('completedCount').textContent = `已完成: ${completed}`;
}
// 回车添加任务
document.getElementById('newItemInput').addEventListener('keypress', (e) => {
if (e.key === 'Enter') {
addNewItem();
}
});
// 初始化
updateStats();
addLog('事件委托演示已就绪 - 所有子元素事件通过父元素统一处理');
</script>
</body>
</html>
代码解释: 核心思想 :无论列表有多少项(包括动态添加的),只在父容器 #taskList 上绑定一个 click 监听器 。事件冒泡到父元素后,用 e.target.closest('.list-item') 找到被点击的列表项,再用 e.target.matches() 判断具体点击的是复选框、删除按钮还是其他区域。优势 :① 动态添加的项自动享有事件处理,无需重新绑定;② 内存占用小,10000 项只需 1 个监听器而非 10000 个;③ 代码集中,便于维护。注意 :closest 从当前元素开始向上找,若点击本身就是 .list-item 则直接返回;matches 精确匹配选择器。双击编辑 :通过同一父元素监听 dblclick,实现复用事件委托。
9.4 事件对象详解
javascript
element.addEventListener('click', (event) => {
// 事件对象的核心属性
console.log('事件类型:', event.type); // 'click'
console.log('目标元素:', event.target); // 实际被点击的元素
console.log('当前元素:', event.currentTarget); // 绑定监听器的元素
console.log('时间戳:', event.timeStamp); // 事件创建时间
// 坐标信息
console.log('页面坐标:', event.pageX, event.pageY);
console.log('客户区坐标:', event.clientX, event.clientY);
console.log('屏幕坐标:', event.screenX, event.screenY);
// 按键信息(鼠标事件)
console.log('按键:', event.button); // 0:左键 1:中键 2:右键
console.log('Ctrl键:', event.ctrlKey);
console.log('Shift键:', event.shiftKey);
console.log('Alt键:', event.altKey);
// 按键信息(键盘事件)
console.log('按键码:', event.keyCode); // 已废弃,用 key
console.log('按键:', event.key); // 'a', 'Enter', 'Escape' 等
console.log('代码:', event.code); // 'KeyA', 'Enter' 等
});
代码解释: event.target 是事件源头 (可能随冒泡变化),event.currentTarget 是监听器绑定的元素 (this 等价)。坐标系 :pageX/Y 相对于文档左上角(含滚动),clientX/Y 相对于视口左上角,screenX/Y 相对于屏幕左上角。event.key 可读字符串,优于 keyCode 的数字码(已从标准移除)。修饰键 :ctrlKey/shiftKey/altKey/metaKey 常用于快捷键实现。
9.5 preventDefault 与 stopPropagation
javascript
// preventDefault - 阻止默认行为
document.querySelector('a[href]').addEventListener('click', (e) => {
e.preventDefault(); // 阻止链接跳转
console.log('链接被点击,但不会跳转');
});
// 表单提交验证
document.querySelector('form').addEventListener('submit', (e) => {
if (!isValid()) {
e.preventDefault(); // 验证失败阻止提交
}
});
// stopPropagation - 阻止事件传播
document.querySelector('.child').addEventListener('click', (e) => {
e.stopPropagation(); // 阻止继续冒泡
console.log('子元素点击,不会触发父元素');
});
// stopImmediatePropagation - 阻止同元素其他监听器
element.addEventListener('click', (e) => {
e.stopImmediatePropagation();
console.log('第一个监听器');
});
element.addEventListener('click', (e) => {
console.log('这个监听器不会执行');
});
完整示例:
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>事件控制演示</title>
<style>
.demo-container {
max-width: 600px;
margin: 20px auto;
padding: 20px;
}
.box {
padding: 40px;
margin: 20px 0;
text-align: center;
border: 2px solid #333;
background: #f0f0f0;
}
.parent {
background: #ffebee;
}
.child {
background: #fff3e0;
margin: 20px;
}
.link-demo {
padding: 20px;
background: #e8f5e9;
}
.form-demo {
padding: 20px;
background: #e3f2fd;
}
input {
padding: 8px;
margin: 5px;
border: 1px solid #ddd;
border-radius: 4px;
}
button {
padding: 10px 20px;
margin: 5px;
cursor: pointer;
background: #2196F3;
color: white;
border: none;
border-radius: 4px;
}
a {
color: #2196F3;
text-decoration: none;
}
#log {
max-width: 600px;
margin: 20px auto;
padding: 15px;
background: #263238;
color: #aed581;
border-radius: 4px;
font-family: monospace;
min-height: 100px;
}
</style>
</head>
<body>
<div class="demo-container">
<h2>preventDefault 演示</h2>
<div class="link-demo">
<h3>链接默认行为</h3>
<a href="https://example.com" class="normal-link">普通链接(会跳转)</a>
<a href="https://example.com" class="prevent-link">阻止默认的链接(不会跳转)</a>
</div>
<div class="form-demo">
<h3>表单验证</h3>
<form id="demoForm">
<input type="text" id="username" placeholder="用户名(至少3个字符)" required>
<input type="email" id="email" placeholder="邮箱地址" required>
<button type="submit">提交</button>
</form>
</div>
<h2>stopPropagation 演示</h2>
<div class="box parent" id="parentBox">
父盒子
<div class="box child" id="childBox">
子盒子(点击时阻止冒泡)
</div>
</div>
</div>
<div id="log"></div>
<script>
const log = document.getElementById('log');
function addLog(message) {
const time = new Date().toLocaleTimeString();
log.innerHTML = `[${time}] ${message}\n` + log.innerHTML;
}
// preventDefault 演示
document.querySelector('.prevent-link').addEventListener('click', (e) => {
e.preventDefault();
addLog('🔗 链接默认跳转被阻止');
});
document.querySelector('.normal-link').addEventListener('click', () => {
addLog('🔗 普通链接被点击(即将跳转)');
});
// 表单验证
document.getElementById('demoForm').addEventListener('submit', (e) => {
const username = document.getElementById('username').value;
const email = document.getElementById('email').value;
addLog(`📝 表单提交 - 用户名: ${username}, 邮箱: ${email}`);
if (username.length < 3) {
e.preventDefault();
addLog('❌ 验证失败:用户名至少需要3个字符');
alert('用户名至少需要3个字符');
return;
}
if (!email.includes('@')) {
e.preventDefault();
addLog('❌ 验证失败:邮箱格式不正确');
alert('请输入有效的邮箱地址');
return;
}
addLog('✅ 验证通过,表单将被提交');
});
// stopPropagation 演示
document.getElementById('parentBox').addEventListener('click', () => {
addLog('🔵 父盒子被点击');
});
document.getElementById('childBox').addEventListener('click', (e) => {
e.stopPropagation();
addLog('🟠 子盒子被点击(冒泡已阻止)');
});
</script>
</body>
</html>
代码解释: preventDefault 只阻止浏览器默认行为(如链接跳转、表单提交、右键菜单、文本选择),不影响事件传播。stopPropagation 只阻止事件传播,不影响默认行为。组合使用 :若要同时阻止默认行为与传播,需同时调用 两者;常见场景:自定义下拉框点击外部关闭时,内部点击需 e.stopPropagation() 防止误触发关闭。注意 :被动事件监听器({ passive: true })中无法调用 preventDefault,否则浏览器会警告。
9.6 事件监听器选项
javascript
// once - 只触发一次
button.addEventListener('click', handler, { once: true });
// passive - 承诺不调用 preventDefault(提升滚动性能)
document.addEventListener('touchstart', handler, { passive: true });
// capture - 捕获阶段监听
element.addEventListener('click', handler, { capture: true });
// signal - 使用 AbortController 取消监听
const controller = new AbortController();
element.addEventListener('click', handler, { signal: controller.signal });
controller.abort(); // 取消所有关联的监听器
代码解释: { once: true } 等价于监听器首次触发后自动 removeEventListener,适合初始化、一次性统计。{ passive: true } 告诉浏览器该监听器不会 调用 preventDefault,浏览器可并行执行滚动而不等待 JS 回调完成,显著提升 触摸滚动帧率。{ signal } 是现代取消机制,比 removeEventListener 更适合批量管理。兼容性 :once/passive/signal 在现代浏览器均支持,IE 需 polyfill。
9.7 常用 DOM 事件
| 事件类别 | 事件名 | 触发时机 | 典型应用 |
|---|---|---|---|
| 鼠标事件 | click |
点击 | 按钮交互 |
dblclick |
双击 | 编辑文本 | |
mousedown/mouseup |
按下/抬起 | 拖拽开始/结束 | |
mouseover/mouseout |
悬入/悬出 | 提示框 | |
mouseenter/mouseleave |
进入/离开(不冒泡) | 菜单高亮 | |
mousemove |
移动 | 绘图、跟随 | |
| 键盘事件 | keydown |
按下 | 快捷键、游戏 |
keyup |
抬起 | 组合键检测 | |
keypress |
按下(已废弃) | ------ | |
| 表单事件 | submit |
提交表单 | 验证 |
input |
输入变化 | 实时搜索 | |
change |
值改变且失焦 | 下拉框、单选框 | |
focus/blur |
获得失去焦点 | 表单验证 | |
| 文档事件 | DOMContentLoaded |
DOM 解析完成 | 初始化脚本 |
load |
资源加载完成 | 图片加载后操作 | |
beforeunload |
页面卸载前 | 防止误操作 | |
scroll |
滚动 | 懒加载、回到顶部 | |
resize |
窗口大小变化 | 响应式调整 |
9.8 本章归纳
捕获
冒泡
阻止默认
阻止传播
只执行一次
事件触发
捕获/冒泡?
Window → 目标
目标 → Window
事件委托
需要控制?
preventDefault
stopPropagation
once: true
| 技巧 | 应用场景 |
|---|---|
| 事件委托 | 列表、表格、动态内容 |
preventDefault |
表单验证、自定义链接、拖放 |
stopPropagation |
下拉框、模态框点击外部关闭 |
closest + matches |
精准识别事件源 |
{ passive: true } |
滚动性能优化 |
十、DOM 渲染与性能深度解析
10.1 浏览器渲染流程
理解浏览器如何将 DOM 渲染到屏幕上,对于性能优化至关重要。
HTML
DOM Tree
CSS
CSSOM Tree
Render Tree
Layout 回流
Paint 重绘
Composite 合成
显示到屏幕
名词解析:
| 阶段 | 说明 | 触发条件 |
|---|---|---|
| DOM Tree | HTML 解析为节点树 | HTML 解析 |
| CSSOM Tree | CSS 解析为样式树 | CSS 解析 |
| Render Tree | DOM + CSSOM 合并,只含可见节点 | DOM/CSSOM 就绪 |
| Layout / Reflow | 计算每个节点的几何信息(位置、大小) | 几何属性变化 |
| Paint / Repaint | 填充像素(颜色、阴影等) | 样式属性变化 |
| Composite | 创建图层并合成 | transform/opacity/滤镜 |
10.2 回流(Reflow)与重绘(Repaint)
回流(重排):元素的大小、位置发生变化,浏览器需要重新计算布局。
重绘:元素的外观样式发生变化,但布局不变。
javascript
// 触发回流
element.style.width = '100px'; // 宽度变化
element.style.height = '100px'; // 高度变化
element.style.position = 'absolute'; // 定位变化
element.style.display = 'none'; // 显示/隐藏
element.appendChild(newChild); // 添加/删除节点
// 触发重绘(不触发回流)
element.style.color = 'red'; // 颜色变化
element.style.background = '#fff'; // 背景变化
element.style.visibility = 'hidden'; // 可见性变化
// 只触发合成(最优)
element.style.transform = 'translateX(100px)'; // 变换
element.style.opacity = '0.5'; // 透明度
代码解释: 回流一定 触发重绘,重绘不一定 触发回流。回流成本远高于重绘,因为需要重新计算整棵树。transform / opacity 由合成器线程处理,不触发主线程回流,是动画性能优化的关键。
完整示例:
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>回流与重绘演示</title>
<style>
.demo-container {
max-width: 800px;
margin: 20px auto;
padding: 20px;
}
.box {
width: 100px;
height: 100px;
margin: 20px;
background: #2196F3;
display: inline-block;
text-align: center;
line-height: 100px;
color: white;
font-weight: bold;
transition: all 0.3s ease;
}
.controls {
margin: 20px 0;
}
button {
padding: 10px 20px;
margin: 5px;
cursor: pointer;
background: #4CAF50;
color: white;
border: none;
border-radius: 4px;
}
.performance-stats {
margin: 20px 0;
padding: 15px;
background: #f9f9f9;
border-radius: 4px;
}
#renderLog {
max-width: 800px;
margin: 20px auto;
padding: 15px;
background: #263238;
color: #aed581;
border-radius: 4px;
font-family: monospace;
font-size: 12px;
max-height: 300px;
overflow-y: auto;
}
.gpu-accelerated {
will-change: transform, opacity;
transform: translateZ(0);
}
</style>
</head>
<body>
<div class="demo-container">
<h2>回流与重绘性能对比</h2>
<div class="controls">
<button onclick="testReflow()">测试回流(改宽度)</button>
<button onclick="testRepaint()">测试重绘(改颜色)</button>
<button onclick="testComposite()">测试合成(transform)</button>
<button onclick="batchChanges()">批量优化</button>
<button onclick="toggleGPU()">切换 GPU 加速</button>
</div>
<div class="performance-stats">
<div id="reflowBox1" class="box">回流</div>
<div id="repaintBox" class="box">重绘</div>
<div id="compositeBox" class="box">合成</div>
</div>
<div id="renderLog"></div>
</div>
<script>
const renderLog = document.getElementById('renderLog');
let reflowCount = 0;
let repaintCount = 0;
let compositeCount = 0;
function addLog(message, type) {
const time = new Date().toLocaleTimeString();
const icon = {
'reflow': '🔄',
'repaint': '🎨',
'composite': '⚡',
'info': 'ℹ️'
}[type] || '•';
renderLog.innerHTML = `[${time}] ${icon} ${message}\n` + renderLog.innerHTML;
}
// 测试回流
function testReflow() {
const box = document.getElementById('reflowBox1');
const start = performance.now();
for (let i = 0; i < 100; i++) {
box.style.width = (100 + i) + 'px';
}
const end = performance.now();
reflowCount++;
addLog(`回流测试完成: 100次宽度变化,耗时 ${(end - start).toFixed(2)}ms`, 'reflow');
}
// 测试重绘
function testRepaint() {
const box = document.getElementById('repaintBox');
const start = performance.now();
for (let i = 0; i < 100; i++) {
box.style.background = `rgb(${i * 2}, ${100 + i}, ${200 - i})`;
}
const end = performance.now();
repaintCount++;
addLog(`重绘测试完成: 100次颜色变化,耗时 ${(end - start).toFixed(2)}ms`, 'repaint');
}
// 测试合成(GPU加速)
function testComposite() {
const box = document.getElementById('compositeBox');
const start = performance.now();
for (let i = 0; i < 100; i++) {
box.style.transform = `translateX(${i}px)`;
}
const end = performance.now();
compositeCount++;
addLog(`合成测试完成: 100次transform变化,耗时 ${(end - start).toFixed(2)}ms`, 'composite');
}
// 批量优化示例
function batchChanges() {
const box = document.getElementById('reflowBox1');
const start = performance.now();
// ❌ 不好的做法 - 每次修改都触发回流
// box.style.width = '150px';
// box.style.height = '150px';
// box.style.margin = '30px';
// ✅ 好的做法 - 批量修改,只触发一次回流
box.style.cssText = 'width: 150px; height: 150px; margin: 30px;';
const end = performance.now();
addLog(`批量优化: 使用 cssText 一次性修改多个属性,耗时 ${(end - start).toFixed(2)}ms`, 'info');
}
// GPU 加速
function toggleGPU() {
const boxes = document.querySelectorAll('.box');
boxes.forEach(box => {
box.classList.toggle('gpu-accelerated');
});
const isGPU = document.querySelector('.gpu-accelerated') !== null;
addLog(`GPU 加速${isGPU ? '已启用' : '已禁用'} (will-change + translateZ(0))`, 'info');
}
</script>
</body>
</html>
代码解释: 回流测试 循环改 width,每次都触发重排 ,耗时最长。重绘测试 改颜色,只触发绘制 ,耗时居中。合成测试 改 transform,由 GPU 处理,耗时最短。cssText 一次性写入多项样式,减少中间状态的重排次数。will-change: transform, opacity 提前告知浏览器优化,translateZ(0) 强制创建独立图层;两者组合常见于高性能动画。注意 :will-change 滥用会消耗内存,应仅对关键动画元素使用。
10.3 强制同步布局(Layout Thrashing)
Layout Thrashing 指在 JavaScript 中交替读写布局属性,导致浏览器被迫反复同步计算布局。
javascript
// ❌ 强制同步布局 - 性能杀手
function bad() {
for (let i = 0; i < 100; i++) {
const height = elements[i].offsetHeight; // 读布局,触发回流
elements[i].style.height = height + 10 + 'px'; // 写布局,再次触发回流
}
}
// ✅ 正确做法 - 批量读 + 批量写
function good() {
const heights = [];
// 先批量读取
for (let i = 0; i < 100; i++) {
heights.push(elements[i].offsetHeight);
}
// 再批量写入
for (let i = 0; i < 100; i++) {
elements[i].style.height = heights[i] + 10 + 'px';
}
}
代码解释: 浏览器会延迟 布局计算,直到脚本"需要"结果(读布局属性)或帧结束。在循环里读 → 写 → 读 → 写 ,每次读都会触发强制同步布局。批量读 + 批量写 把计算压缩到一次。现代 API :requestAnimationFrame 回调内读写,或用 ResizeObserver 监听尺寸变化。
完整示例:
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>布局抖动演示</title>
<style>
.container {
max-width: 800px;
margin: 20px auto;
padding: 20px;
}
.items {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.item {
width: 100px;
height: 100px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
display: flex;
align-items: center;
justify-content: center;
color: white;
font-weight: bold;
border-radius: 8px;
transition: height 0.3s ease;
}
.controls {
margin: 20px 0;
display: flex;
gap: 10px;
flex-wrap: wrap;
}
button {
padding: 10px 20px;
cursor: pointer;
background: #4CAF50;
color: white;
border: none;
border-radius: 4px;
}
#results {
margin: 20px 0;
padding: 15px;
background: #f9f9f9;
border-radius: 4px;
font-family: monospace;
}
.result-item {
padding: 10px;
margin: 5px 0;
background: white;
border-radius: 4px;
display: flex;
justify-content: space-between;
}
.fast {
border-left: 4px solid #4CAF50;
}
.slow {
border-left: 4px solid #f44336;
}
</style>
</head>
<body>
<div class="container">
<h2>布局抖动性能对比</h2>
<div class="controls">
<button onclick="testBadApproach()">❌ 糟糕做法(交错读写)</button>
<button onclick="testGoodApproach()">✅ 正确做法(批量读写)</button>
<button onclick="testRAFApproach()">🚀 RAF 优化</button>
<button onclick="resetItems()">重置</button>
</div>
<div class="items" id="itemsContainer"></div>
<div id="results"></div>
</div>
<script>
const container = document.getElementById('itemsContainer');
const results = document.getElementById('results');
const itemCount = 100;
// 初始化项目
function initItems() {
container.innerHTML = '';
for (let i = 0; i < itemCount; i++) {
const item = document.createElement('div');
item.className = 'item';
item.textContent = i + 1;
container.appendChild(item);
}
}
function addResult(label, time, type) {
const div = document.createElement('div');
div.className = `result-item ${type}`;
div.innerHTML = `
<span>${label}</span>
<span>${time.toFixed(2)}ms</span>
`;
results.appendChild(div);
}
function resetItems() {
initItems();
results.innerHTML = '';
}
// ❌ 糟糕做法 - 强制同步布局
function testBadApproach() {
const items = document.querySelectorAll('.item');
const start = performance.now();
for (let i = 0; i < items.length; i++) {
// 读取布局属性,强制浏览器同步计算
const height = items[i].offsetHeight;
// 写入布局属性,使刚才的计算失效
items[i].style.height = (height + 10) + 'px';
}
const end = performance.now();
addResult('❌ 糟糕做法(交错读写)', end - start, 'slow');
}
// ✅ 正确做法 - 批量读写
function testGoodApproach() {
const items = document.querySelectorAll('.item');
const start = performance.now();
// 第一阶段:批量读取所有布局信息
const heights = [];
for (let i = 0; i < items.length; i++) {
heights.push(items[i].offsetHeight);
}
// 第二阶段:批量写入所有更改
for (let i = 0; i < items.length; i++) {
items[i].style.height = (heights[i] + 10) + 'px';
}
const end = performance.now();
addResult('✅ 正确做法(批量读写)', end - start, 'fast');
}
// 🚀 RAF 优化
function testRAFApproach() {
const items = document.querySelectorAll('.item');
const start = performance.now();
requestAnimationFrame(() => {
// 在 RAF 回调中批量读取
const heights = Array.from(items).map(item => item.offsetHeight);
requestAnimationFrame(() => {
// 在下一个 RAF 中批量写入
items.forEach((item, i) => {
item.style.height = (heights[i] + 10) + 'px';
});
const end = performance.now();
addResult('🚀 RAF 优化', end - start, 'fast');
});
});
}
// 初始化
initItems();
</script>
</body>
</html>
代码解释: 糟糕做法 在循环里读 → 写 → 读 → 写 ,每次迭代触发一次强制同步布局,共 100 次回流 。正确做法 先收集所有读 ,再批量写入 ,只触发 1 次回流 。RAF 优化 利用浏览器的帧同步机制,在第一个 RAF 中读布局,第二个 RAF 中写更改,保证读写分离 。requestAnimationFrame 回调在浏览器重绘前执行,是动画与布局操作的理想时机。
10.4 减少回流与重绘的策略
| 策略 | 说明 | 示例 |
|---|---|---|
| 批量修改 DOM | 合并多次修改 | cssText、DocumentFragment |
| 避免逐条修改样式 | 用类名替代行内样式 | classList.add('active') |
| 分离读写操作 | 先读后写,避免交错 | 批量读 → 批量写 |
| 使用 transform | 动画优先用合成层属性 | translateX 替代 left |
| 使用 will-change | 提示浏览器优化 | will-change: transform |
| 避免 table 布局 | table 回流成本高 | 用 display: grid / flex |
| 虚拟滚动 | 只渲染可见区域 | 大列表用虚拟滚动 |
10.5 渲染性能优化实战
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>渲染性能优化实战</title>
<style>
.demo-container {
max-width: 1000px;
margin: 20px auto;
padding: 20px;
}
.scene {
width: 100%;
height: 400px;
position: relative;
background: #1a1a2e;
overflow: hidden;
border-radius: 8px;
}
.moving-element {
width: 50px;
height: 50px;
background: #e94560;
position: absolute;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-weight: bold;
}
.controls {
margin: 20px 0;
display: flex;
gap: 10px;
flex-wrap: wrap;
}
button {
padding: 10px 20px;
cursor: pointer;
background: #4CAF50;
color: white;
border: none;
border-radius: 4px;
}
.stats {
display: flex;
gap: 20px;
margin: 20px 0;
}
.stat-box {
flex: 1;
padding: 15px;
background: #f9f9f9;
border-radius: 4px;
text-align: center;
}
.fps-meter {
font-size: 24px;
font-weight: bold;
color: #4CAF50;
}
</style>
</head>
<body>
<div class="demo-container">
<h2>动画性能对比</h2>
<div class="stats">
<div class="stat-box">
<div>当前 FPS</div>
<div class="fps-meter" id="fpsDisplay">60</div>
</div>
<div class="stat-box">
<div>动画方法</div>
<div id="methodDisplay">-</div>
</div>
<div class="stat-box">
<div>性能评级</div>
<div id="ratingDisplay">-</div>
</div>
</div>
<div class="controls">
<button onclick="animateWithLeft()">❌ 使用 left(回流)</button>
<button onclick="animateWithTransform()">✅ 使用 transform(合成)</button>
<button onclick="animateWithWASM()">🚀 批量动画 (100个)</button>
<button onclick="stopAnimation()">停止</button>
</div>
<div class="scene" id="scene"></div>
</div>
<script>
const scene = document.getElementById('scene');
const fpsDisplay = document.getElementById('fpsDisplay');
const methodDisplay = document.getElementById('methodDisplay');
const ratingDisplay = document.getElementById('ratingDisplay');
let animationId = null;
let elements = [];
let lastTime = performance.now();
let frameCount = 0;
// 创建单个元素
function createElement() {
const el = document.createElement('div');
el.className = 'moving-element';
el.textContent = '🚀';
el.style.left = '0px';
el.style.top = '175px';
scene.appendChild(el);
return el;
}
// FPS 计算
function updateFPS() {
frameCount++;
const currentTime = performance.now();
if (currentTime - lastTime >= 1000) {
const fps = Math.round(frameCount * 1000 / (currentTime - lastTime));
fpsDisplay.textContent = fps;
// 评级
let rating = '⚡ 极佳';
if (fps < 30) rating = '🐢 卡顿';
else if (fps < 45) rating = '😐 一般';
else if (fps < 55) rating = '👍 良好';
ratingDisplay.textContent = rating;
frameCount = 0;
lastTime = currentTime;
}
animationId = requestAnimationFrame(updateFPS);
}
// ❌ 使用 left(触发回流)
function animateWithLeft() {
stopAnimation();
scene.innerHTML = '';
elements = [createElement()];
methodDisplay.textContent = 'left 属性';
let pos = 0;
const startTime = performance.now();
function animate() {
pos += 5;
if (pos > 900) pos = 0;
elements[0].style.left = pos + 'px'; // 触发回流
updateFPS();
}
animationId = requestAnimationFrame(animate);
}
// ✅ 使用 transform(只触发合成)
function animateWithTransform() {
stopAnimation();
scene.innerHTML = '';
elements = [createElement()];
elements[0].style.willChange = 'transform'; // GPU 加速提示
methodDisplay.textContent = 'transform 属性';
let pos = 0;
function animate() {
pos += 5;
if (pos > 900) pos = 0;
elements[0].style.transform = `translateX(${pos}px)`; // 只触发合成
updateFPS();
}
animationId = requestAnimationFrame(animate);
}
// 🚀 批量动画
function animateWithWASM() {
stopAnimation();
scene.innerHTML = '';
elements = [];
// 创建100个元素
for (let i = 0; i < 100; i++) {
const el = document.createElement('div');
el.className = 'moving-element';
el.style.width = '30px';
el.style.height = '30px';
el.style.left = '0px';
el.style.top = (Math.random() * 350) + 'px';
el.style.willChange = 'transform';
el.style.opacity = '0.7';
scene.appendChild(el);
elements.push({ element: el, x: 0, speed: 1 + Math.random() * 3 });
}
methodDisplay.textContent = '批量 transform (100个)';
function animate() {
elements.forEach(item => {
item.x += item.speed;
if (item.x > 950) item.x = 0;
item.element.style.transform = `translateX(${item.x}px)`;
});
updateFPS();
}
animationId = requestAnimationFrame(animate);
}
function stopAnimation() {
if (animationId) {
cancelAnimationFrame(animationId);
animationId = null;
}
}
</script>
</body>
</html>
代码解释: left 动画 每次修改都会触发回流 ,FPS 下降明显。transform 动画 在合成层 上运行,由 GPU 处理,FPS 稳定在 60。will-change: transform 提前告知浏览器创建独立图层,避免合成时的重排。批量动画 证明即使 100 个元素同时运动,只要用 transform 仍能保持高 FPS。关键 :动画元素尽量减少影响布局的属性(width/height/left/top/margin),改用 transform/opacity/filters。
10.6 本章归纳
几何属性
外观属性
transform/opacity
DOM 修改
修改类型?
回流 Layout
重绘 Paint
合成 Composite
最慢
中等
最快
| 优化策略 | 核心要点 |
|---|---|
| 减少回流 | 用 transform 替代位置属性,批量读写分离 |
| 减少重绘 | 使用类名切换样式,避免频繁修改外观 |
| GPU 加速 | will-change + transform/opacity |
| 批量操作 | DocumentFragment、cssText |
| RAF 时机 | 在 requestAnimationFrame 中操作 DOM |
十一、现代 DOM API
11.1 IntersectionObserver - 交叉观察器
IntersectionObserver API 提供了一种异步检测元素是否进入视口的方法,性能远超传统的滚动事件监听。
名词解析:
- 交叉比例(Intersection Ratio):目标元素可见部分与目标元素总面积的比例
- 根元素(Root):用作视口的元素,默认为浏览器视口
- 根边距(Root Margin):根元素的边距,用于扩大或缩小交叉区域
javascript
// 基本用法
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
console.log('元素进入视口');
// 停止观察
observer.unobserve(entry.target);
}
});
}, {
threshold: 0.5, // 目标元素50%可见时触发
rootMargin: '100px' // 扩大100px的触发区域
});
observer.observe(targetElement);
完整示例:图片懒加载
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>IntersectionObserver 懒加载</title>
<style>
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: Arial, sans-serif;
background: #f5f5f5;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
.info-bar {
position: sticky;
top: 0;
background: rgba(33, 150, 243, 0.95);
color: white;
padding: 15px 20px;
display: flex;
justify-content: space-between;
align-items: center;
z-index: 100;
backdrop-filter: blur(10px);
border-radius: 8px;
margin-bottom: 20px;
}
.stats {
display: flex;
gap: 20px;
}
.stat-item {
display: flex;
align-items: center;
gap: 5px;
}
.image-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 20px;
}
.image-card {
background: white;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
transition: transform 0.3s ease;
}
.image-card:hover {
transform: translateY(-5px);
}
.image-wrapper {
position: relative;
aspect-ratio: 16/9;
background: #e0e0e0;
overflow: hidden;
}
.image-wrapper img {
width: 100%;
height: 100%;
object-fit: cover;
opacity: 0;
transition: opacity 0.3s ease;
}
.image-wrapper img.loaded {
opacity: 1;
}
.placeholder {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
color: #999;
font-size: 48px;
}
.loading-spinner {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 40px;
height: 40px;
border: 4px solid #f3f3f3;
border-top: 4px solid #2196F3;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: translate(-50%, -50%) rotate(0deg); }
100% { transform: translate(-50%, -50%) rotate(360deg); }
}
.image-info {
padding: 15px;
}
.image-info h3 {
margin: 0 0 10px 0;
font-size: 16px;
}
.image-info p {
margin: 0;
color: #666;
font-size: 14px;
}
.status-badge {
position: absolute;
top: 10px;
right: 10px;
padding: 5px 10px;
border-radius: 20px;
font-size: 12px;
font-weight: bold;
}
.status-pending {
background: #ff9800;
color: white;
}
.status-loading {
background: #2196F3;
color: white;
}
.status-loaded {
background: #4CAF50;
color: white;
}
</style>
</head>
<body>
<div class="container">
<div class="info-bar">
<h2>🖼️ 图片懒加载演示</h2>
<div class="stats">
<div class="stat-item">
<span>总计:</span>
<strong id="totalCount">0</strong>
</div>
<div class="stat-item">
<span>已加载:</span>
<strong id="loadedCount">0</strong>
</div>
<div class="stat-item">
<span>待加载:</span>
<strong id="pendingCount">0</strong>
</div>
</div>
</div>
<div class="image-grid" id="imageGrid"></div>
</div>
<script>
const imageGrid = document.getElementById('imageGrid');
const totalCountEl = document.getElementById('totalCount');
const loadedCountEl = document.getElementById('loadedCount');
const pendingCountEl = document.getElementById('pendingCount');
let totalImages = 0;
let loadedImages = 0;
// 生成随机图片URL(使用占位服务)
function getImageUrl(width, height) {
return `https://picsum.photos/${width}/${height}?random=${Math.random()}`;
}
// 创建图片卡片
function createImageCard(index) {
const card = document.createElement('div');
card.className = 'image-card';
card.innerHTML = `
<div class="image-wrapper">
<div class="placeholder">🖼️</div>
<div class="loading-spinner" style="display: none;"></div>
<div class="status-badge status-pending">待加载</div>
<img data-src="${getImageUrl(400, 300)}" alt="图片 ${index}">
</div>
<div class="image-info">
<h3>图片 ${index}</h3>
<p>滚动到此处将自动加载</p>
</div>
`;
return card;
}
// 初始化图片网格
function initImageGrid(count) {
for (let i = 1; i <= count; i++) {
imageGrid.appendChild(createImageCard(i));
}
totalImages = count;
updateStats();
}
// 更新统计
function updateStats() {
totalCountEl.textContent = totalImages;
loadedCountEl.textContent = loadedImages;
pendingCountEl.textContent = totalImages - loadedImages;
}
// 创建 Intersection Observer
const imageObserver = new IntersectionObserver((entries, observer) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const img = entry.target;
const wrapper = img.parentElement;
const spinner = wrapper.querySelector('.loading-spinner');
const placeholder = wrapper.querySelector('.placeholder');
const statusBadge = wrapper.querySelector('.status-badge');
// 更新状态
statusBadge.className = 'status-badge status-loading';
statusBadge.textContent = '加载中';
spinner.style.display = 'block';
// 加载图片
img.src = img.dataset.src;
img.onload = () => {
img.classList.add('loaded');
statusBadge.className = 'status-badge status-loaded';
statusBadge.textContent = '已加载';
spinner.style.display = 'none';
placeholder.style.display = 'none';
loadedImages++;
updateStats();
};
img.onerror = () => {
statusBadge.className = 'status-badge status-pending';
statusBadge.textContent = '加载失败';
spinner.style.display = 'none';
};
// 停止观察已加载的图片
observer.unobserve(img);
}
});
}, {
rootMargin: '100px', // 提前100px开始加载
threshold: 0.01 // 只要1%可见就触发
});
// 初始化并观察图片
initImageGrid(30);
// 观察所有图片元素
setTimeout(() => {
document.querySelectorAll('#imageGrid img').forEach(img => {
imageObserver.observe(img);
});
}, 100);
</script>
</body>
</html>
代码解释: IntersectionObserver 异步回调,不在主线程阻塞,比滚动事件+getBoundingClientRect性能好得多。rootMargin: '100px' 提前100px开始加载,让用户感知不到加载延迟。threshold: 0.01 只需1%可见就触发,适合懒加载。unobserve(img) 避免重复加载。占位符与加载中状态 提升用户体验。生产环境:图片 URL 可改用 CDN 或服务端按需生成。
11.2 MutationObserver - 变化观察器
MutationObserver API 用于监听 DOM 树的变化,替代已废弃的 MutationEvents。
javascript
// 监听 DOM 变化
const observer = new MutationObserver((mutations) => {
mutations.forEach(mutation => {
console.log('变化类型:', mutation.type);
console.log('变化目标:', mutation.target);
});
});
observer.observe(targetNode, {
childList: true, // 监听子节点变化
attributes: true, // 监听属性变化
characterData: true, // 监听文本变化
subtree: true, // 监听所有后代节点
attributeFilter: ['class', 'src'] // 只监听特定属性
});
// 停止观察
observer.disconnect();
完整示例:DOM 变化监控器
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>MutationObserver 监控</title>
<style>
.container {
max-width: 1000px;
margin: 20px auto;
padding: 20px;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
}
.panel {
background: white;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
overflow: hidden;
}
.panel-header {
background: #2196F3;
color: white;
padding: 15px;
font-weight: bold;
}
.panel-content {
padding: 20px;
min-height: 400px;
}
.controls {
margin-bottom: 20px;
display: flex;
flex-wrap: wrap;
gap: 10px;
}
button {
padding: 8px 15px;
cursor: pointer;
background: #4CAF50;
color: white;
border: none;
border-radius: 4px;
}
button:hover {
background: #45a049;
}
#targetElement {
padding: 20px;
background: #f9f9f9;
border: 2px dashed #999;
border-radius: 4px;
min-height: 100px;
}
#targetElement.changed {
background: #fff3e0;
border-color: #ff9800;
}
.log-entry {
padding: 8px;
margin: 5px 0;
background: #f5f5f5;
border-radius: 4px;
font-size: 12px;
font-family: monospace;
}
.log-entry .type {
font-weight: bold;
color: #2196F3;
}
.log-entry .time {
color: #999;
}
</style>
</head>
<body>
<div class="container">
<div class="panel">
<div class="panel-header">🎮 操作面板</div>
<div class="panel-content">
<div class="controls">
<button onclick="addElement()">➕ 添加元素</button>
<button onclick="removeElement()">➖ 删除元素</button>
<button onclick="changeText()">📝 修改文本</button>
<button onclick="changeClass()">🎨 修改类名</button>
<button onclick="changeAttribute()">⚙️ 修改属性</button>
<button onclick="clearLog()">🗑️ 清空日志</button>
</div>
<div id="targetElement">
<p>这是目标元素的初始内容</p>
</div>
</div>
</div>
<div class="panel">
<div class="panel-header">📊 变化日志</div>
<div class="panel-content">
<div id="logContainer"></div>
</div>
</div>
</div>
<script>
const targetElement = document.getElementById('targetElement');
const logContainer = document.getElementById('logContainer');
let changeCount = 0;
// 创建 MutationObserver
const observer = new MutationObserver((mutations) => {
mutations.forEach(mutation => {
changeCount++;
const logEntry = document.createElement('div');
logEntry.className = 'log-entry';
let details = '';
switch(mutation.type) {
case 'childList':
if (mutation.addedNodes.length > 0) {
details = `添加了 ${mutation.addedNodes.length} 个节点`;
}
if (mutation.removedNodes.length > 0) {
details = `删除了 ${mutation.removedNodes.length} 个节点`;
}
break;
case 'attributes':
details = `属性 ${mutation.attributeName} 从 "${mutation.oldValue}" 变为 "${targetElement.getAttribute(mutation.attributeName)}"`;
break;
case 'characterData':
details = `文本内容变化`;
break;
}
logEntry.innerHTML = `
<span class="time">[${new Date().toLocaleTimeString()}]</span>
<span class="type">${mutation.type}</span>
<span>${details}</span>
`;
logContainer.insertBefore(logEntry, logContainer.firstChild);
// 视觉反馈
targetElement.classList.add('changed');
setTimeout(() => targetElement.classList.remove('changed'), 300);
});
});
// 配置观察选项
const config = {
childList: true, // 观察子节点的添加或删除
attributes: true, // 观察属性变化
characterData: true, // 观察文本内容变化
subtree: true, // 观察所有后代节点
attributeOldValue: true // 记录属性旧值
};
// 开始观察
observer.observe(targetElement, config);
// 操作函数
function addElement() {
const p = document.createElement('p');
p.textContent = `新元素 ${Date.now()}`;
targetElement.appendChild(p);
}
function removeElement() {
const lastChild = targetElement.lastElementChild;
if (lastChild && lastChild.tagName === 'P') {
targetElement.removeChild(lastChild);
}
}
function changeText() {
const firstP = targetElement.querySelector('p');
if (firstP) {
firstP.textContent = `修改于 ${new Date().toLocaleTimeString()}`;
}
}
function changeClass() {
targetElement.classList.toggle('highlight');
}
function changeAttribute() {
const currentId = targetElement.id;
const newId = currentId === 'targetElement' ? 'modifiedElement' : 'targetElement';
targetElement.id = newId;
}
function clearLog() {
logContainer.innerHTML = '';
changeCount = 0;
}
// 初始化日志
addLog('MutationObserver 已启动,正在监听目标元素的所有变化...');
</script>
</body>
</html>
代码解释: MutationObserver 回调接收 MutationRecord 数组,每个记录包含 type、target、addedNodes、removedNodes、attributeName、oldValue 等信息。配置项 :childList、attributes、characterData 至少开启其一;subtree: true 递归监听后代;attributeOldValue: true 需与 attributes: true 配合。disconnect() 停止监听,takeRecords() 取出但不清空队列。典型应用:自动保存草稿、注入脚本检测 DOM 变化、调试工具。
11.3 ResizeObserver - 尺寸观察器
ResizeObserver API 用于监听元素尺寸变化,比 window.resize 更精确。
javascript
const resizeObserver = new ResizeObserver(entries => {
entries.forEach(entry => {
const { width, height } = entry.contentRect;
console.log(`元素尺寸: ${width}px × ${height}px`);
});
});
resizeObserver.observe(element);
完整示例:响应式布局监控
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>ResizeObserver 监控</title>
<style>
.container {
max-width: 1000px;
margin: 20px auto;
padding: 20px;
}
.resizable-box {
width: 80%;
min-height: 200px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 24px;
font-weight: bold;
resize: both;
overflow: hidden;
transition: box-shadow 0.3s ease;
}
.resizable-box:hover {
box-shadow: 0 8px 30px rgba(0,0,0,0.3);
}
.size-display {
position: fixed;
bottom: 20px;
right: 20px;
background: rgba(0,0,0,0.8);
color: white;
padding: 15px 20px;
border-radius: 8px;
font-family: monospace;
backdrop-filter: blur(10px);
}
.size-history {
max-width: 600px;
margin: 20px auto;
padding: 15px;
background: #f9f9f9;
border-radius: 8px;
max-height: 200px;
overflow-y: auto;
}
.history-item {
padding: 8px;
margin: 5px 0;
background: white;
border-radius: 4px;
font-family: monospace;
font-size: 12px;
}
</style>
</head>
<body>
<div class="container">
<h2>📏 ResizeObserver 演示</h2>
<p>拖动右下角调整盒子大小,观察尺寸变化实时监控</p>
<div class="resizable-box" id="resizableBox">
拖动我调整大小
</div>
<div class="size-history">
<h3>尺寸变化历史</h3>
<div id="historyLog"></div>
</div>
</div>
<div class="size-display" id="sizeDisplay">
宽: 0px | 高: 0px
</div>
<script>
const box = document.getElementById('resizableBox');
const sizeDisplay = document.getElementById('sizeDisplay');
const historyLog = document.getElementById('historyLog');
// 创建 ResizeObserver
const resizeObserver = new ResizeObserver(entries => {
entries.forEach(entry => {
const { width, height } = entry.contentRect;
const displayWidth = Math.round(width);
const displayHeight = Math.round(height);
// 更新实时显示
sizeDisplay.textContent = `宽: ${displayWidth}px | 高: ${displayHeight}px`;
// 更新盒子内容
box.textContent = `${displayWidth}px × ${displayHeight}px`;
// 添加历史记录
const historyItem = document.createElement('div');
historyItem.className = 'history-item';
historyItem.innerHTML = `
<span>${new Date().toLocaleTimeString()}</span>
<span>宽: ${displayWidth}px</span>
<span>高: ${displayHeight}px</span>
`;
historyLog.insertBefore(historyItem, historyLog.firstChild);
// 限制历史记录数量
while (historyLog.children.length > 10) {
historyLog.removeChild(historyLog.lastChild);
}
});
});
// 开始观察
resizeObserver.observe(box);
// 也观察窗口尺寸
resizeObserver.observe(document.body);
</script>
</body>
</html>
代码解释: ResizeObserver 监听盒模型 尺寸,包含 borderBoxSize、contentRect、contentBoxSize 等信息。contentRect 返回内容区(不含 padding/border),与 getBoundingClientRect 类似但不触发回流。响应式设计 :可根据元素宽度(而非视口宽度)切换布局,适合组件级响应式。注意 :回调可能在同帧内多次触发,可用 requestAnimationFrame 防抖。
11.4 现代选择器与遍历 API
javascript
// matches - 检查元素是否匹配选择器
element.matches('.active'); // true/false
// closest - 查找最近的匹配祖先
element.closest('.container');
// contains - 检查是否包含后代元素
parent.contains(child);
// 便利方法
element.before(insertNode); // 在元素前插入
element.after(insertNode); // 在元素后插入
element.replaceWith(newNode); // 替换元素
element.remove(); // 删除元素(现代写法)
// insertAdjacentHTML/Text/Element
element.insertAdjacentHTML('beforebegin', '<span>前</span>');
element.insertAdjacentHTML('afterbegin', '<span>首子</span>');
element.insertAdjacentHTML('beforeend', '<span>末子</span>');
element.insertAdjacentHTML('afterend', '<span>后</span>');
代码解释: matches 常用于事件委托判断目标。closest 从当前元素向上找,包括自身。contains 判断包含关系,替代 parentNode 链式查找。before/after/replaceWith/remove 比 appendChild/removeChild 更直观。insertAdjacentHTML 按位置插入,不破坏现有引用,比 innerHTML += 更安全。
11.5 本章归纳
| API | 用途 | 优势 |
|---|---|---|
| IntersectionObserver | 懒加载、无限滚动、可见性检测 | 异步、高性能 |
| MutationObserver | DOM 变化监听、调试 | 替代 MutationEvents |
| ResizeObserver | 响应式组件、尺寸监控 | 精确到元素级别 |
| matches/closest | 事件委托、向上查找 | 简化遍历逻辑 |
十二、高级实战案例
12.1 虚拟滚动(Virtual Scroll)
虚拟滚动是一种性能优化技术,只渲染可见区域的列表项,适合处理大量数据。
核心原理:
大数据集
计算可见范围
只渲染可见项
用户滚动
更新可见范围
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>虚拟滚动演示</title>
<style>
.container {
max-width: 800px;
margin: 20px auto;
padding: 20px;
}
.controls {
margin-bottom: 20px;
display: flex;
gap: 10px;
flex-wrap: wrap;
}
button {
padding: 10px 20px;
cursor: pointer;
background: #4CAF50;
color: white;
border: none;
border-radius: 4px;
}
.stats {
display: flex;
gap: 20px;
margin-bottom: 20px;
padding: 15px;
background: #f9f9f9;
border-radius: 4px;
}
.virtual-scroll-container {
height: 600px;
border: 2px solid #ddd;
border-radius: 8px;
overflow-y: auto;
position: relative;
}
.virtual-scroll-content {
position: relative;
}
.virtual-item {
padding: 15px;
border-bottom: 1px solid #eee;
display: flex;
align-items: center;
gap: 15px;
background: white;
}
.virtual-item:nth-child(even) {
background: #f9f9f9;
}
.item-index {
width: 50px;
height: 50px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
}
.item-content {
flex: 1;
}
.item-title {
font-weight: bold;
margin-bottom: 5px;
}
.item-desc {
color: #666;
font-size: 14px;
}
</style>
</head>
<body>
<div class="container">
<h2>🚀 虚拟滚动演示</h2>
<div class="controls">
<button onclick="virtualList.setItemCount(1000)">1,000 项</button>
<button onclick="virtualList.setItemCount(10000)">10,000 项</button>
<button onclick="virtualList.setItemCount(100000)">100,000 项</button>
<button onclick="virtualList.scrollToIndex(500)">跳转到第 500 项</button>
</div>
<div class="stats">
<div>总数据: <strong id="totalCount">0</strong></div>
<div>渲染节点: <strong id="renderedCount">0</strong></div>
<div>可见范围: <strong id="visibleRange">-</strong></div>
</div>
<div class="virtual-scroll-container" id="scrollContainer">
<div class="virtual-scroll-content" id="scrollContent"></div>
</div>
</div>
<script>
class VirtualList {
constructor(options) {
this.container = options.container;
this.content = options.content;
this.itemHeight = options.itemHeight || 80;
this.renderBuffer = options.renderBuffer || 5;
this.totalItems = 0;
this.visibleItems = [];
this.scrollTop = 0;
this.init();
}
init() {
this.container.addEventListener('scroll', () => this.onScroll());
}
setItemCount(count) {
this.totalItems = count;
this.content.style.height = (count * this.itemHeight) + 'px';
this.render();
this.updateStats();
}
onScroll() {
this.scrollTop = this.container.scrollTop;
requestAnimationFrame(() => this.render());
}
getVisibleRange() {
const containerHeight = this.container.clientHeight;
const startIndex = Math.floor(this.scrollTop / this.itemHeight);
const endIndex = Math.ceil((this.scrollTop + containerHeight) / this.itemHeight);
return {
start: Math.max(0, startIndex - this.renderBuffer),
end: Math.min(this.totalItems, endIndex + this.renderBuffer)
};
}
render() {
const { start, end } = this.getVisibleRange();
const fragment = document.createDocumentFragment();
for (let i = start; i < end; i++) {
const item = this.createItem(i);
fragment.appendChild(item);
}
this.content.innerHTML = '';
this.content.appendChild(fragment);
// 调整偏移
const offset = start * this.itemHeight;
this.content.style.paddingTop = offset + 'px';
this.visibleItems = { start, end };
this.updateStats();
}
createItem(index) {
const div = document.createElement('div');
div.className = 'virtual-item';
div.style.position = 'absolute';
div.style.top = (index * this.itemHeight) + 'px';
div.style.width = '100%';
div.style.boxSizing = 'border-box';
div.innerHTML = `
<div class="item-index">${index + 1}</div>
<div class="item-content">
<div class="item-title">数据项 ${index + 1}</div>
<div class="item-desc">这是第 ${index + 1} 条数据的内容描述,虚拟滚动只渲染可见部分。</div>
</div>
`;
return div;
}
scrollToIndex(index) {
const scrollTop = index * this.itemHeight;
this.container.scrollTo({
top: scrollTop,
behavior: 'smooth'
});
}
updateStats() {
document.getElementById('totalCount').textContent = this.totalItems.toLocaleString();
document.getElementById('renderedCount').textContent =
((this.visibleItems.end - this.visibleItems.start) || 0).toLocaleString();
document.getElementById('visibleRange').textContent =
`${this.visibleItems.start + 1} - ${this.visibleItems.end}`;
}
}
// 初始化虚拟列表
const virtualList = new VirtualList({
container: document.getElementById('scrollContainer'),
content: document.getElementById('scrollContent'),
itemHeight: 80,
renderBuffer: 5
});
// 默认显示 1000 项
virtualList.setItemCount(1000);
</script>
</body>
</html>
代码解释: 虚拟滚动 核心思想:容器高度固定 ,内容区设置总高度 撑开滚动条,但只渲染可见区域 + 缓冲区 的节点。getVisibleRange 根据 scrollTop 计算应该渲染的起止索引。paddingTop 把渲染项推到正确位置,避免"跳动"。缓冲区 上下多渲染几项,避免快速滚动时出现白屏。优势 :10 万条数据只需渲染约 20 个 DOM 节点 (视口高度/项高 + 2×缓冲区),大幅降低内存占用与渲染成本。注意:项高必须固定或可预测,动态高度需额外测量逻辑。
12.2 拖放 API(Drag and Drop)
HTML5 拖放 API 允许用户拖动元素并在指定位置放置。
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>拖放 API 演示</title>
<style>
.container {
max-width: 1000px;
margin: 20px auto;
padding: 20px;
}
.dnd-area {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
}
.zone {
min-height: 400px;
padding: 20px;
background: #f9f9f9;
border: 3px dashed #ccc;
border-radius: 8px;
}
.zone.drag-over {
border-color: #2196F3;
background: #e3f2fd;
}
.zone h3 {
margin-top: 0;
text-align: center;
color: #666;
}
.draggable {
padding: 15px;
margin: 10px 0;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border-radius: 8px;
cursor: move;
display: flex;
align-items: center;
gap: 10px;
user-select: none;
}
.draggable:active {
cursor: grabbing;
}
.draggable.dragging {
opacity: 0.5;
}
.draggable .icon {
font-size: 24px;
}
.controls {
margin-bottom: 20px;
display: flex;
gap: 10px;
}
button {
padding: 10px 20px;
cursor: pointer;
background: #4CAF50;
color: white;
border: none;
border-radius: 4px;
}
.log {
margin-top: 20px;
padding: 15px;
background: #263238;
color: #aed581;
border-radius: 4px;
font-family: monospace;
max-height: 200px;
overflow-y: auto;
}
</style>
</head>
<body>
<div class="container">
<h2>🎯 拖放 API 演示</h2>
<div class="controls">
<button onclick="addDraggable()">➕ 添加可拖拽项</button>
<button onclick="resetAll()">🔄 重置</button>
</div>
<div class="dnd-area">
<div class="zone" id="zone1">
<h3>📦 源区域</h3>
<div class="draggable" draggable="true" data-id="1">
<span class="icon">📁</span>
<span>项目文档</span>
</div>
<div class="draggable" draggable="true" data-id="2">
<span class="icon">🖼️</span>
<span>设计素材</span>
</div>
<div class="draggable" draggable="true" data-id="3">
<span class="icon">🎵</span>
<span>音频文件</span>
</div>
</div>
<div class="zone" id="zone2">
<h3>📥 目标区域</h3>
</div>
</div>
<div class="log" id="dragLog"></div>
</div>
<script>
const draggables = document.querySelectorAll('.draggable');
const zones = document.querySelectorAll('.zone');
const dragLog = document.getElementById('dragLog');
let draggedElement = null;
function addLog(message) {
const time = new Date().toLocaleTimeString();
dragLog.innerHTML = `[${time}] ${message}\n` + dragLog.innerHTML;
}
// 为所有可拖拽元素添加事件监听
document.addEventListener('dragstart', (e) => {
if (e.target.classList.contains('draggable')) {
draggedElement = e.target;
e.target.classList.add('dragging');
// 设置拖拽数据
e.dataTransfer.setData('text/plain', e.target.dataset.id);
e.dataTransfer.effectAllowed = 'move';
addLog(`🎯 开始拖拽: ${e.target.querySelector('.icon').nextElementSibling.textContent}`);
}
});
document.addEventListener('dragend', (e) => {
if (e.target.classList.contains('draggable')) {
e.target.classList.remove('dragging');
zones.forEach(zone => zone.classList.remove('drag-over'));
draggedElement = null;
}
});
// 为所有放置区域添加事件监听
zones.forEach(zone => {
zone.addEventListener('dragover', (e) => {
e.preventDefault(); // 允许放置
e.dataTransfer.dropEffect = 'move';
zone.classList.add('drag-over');
});
zone.addEventListener('dragleave', (e) => {
if (e.target === zone) {
zone.classList.remove('drag-over');
}
});
zone.addEventListener('drop', (e) => {
e.preventDefault();
zone.classList.remove('drag-over');
if (draggedElement) {
// 查找放置目标
const dropTarget = e.target.closest('.zone');
if (dropTarget) {
// 移动元素
dropTarget.appendChild(draggedElement);
const zoneName = dropTarget.id === 'zone1' ? '源区域' : '目标区域';
addLog(`📥 放置到: ${zoneName}`);
}
}
});
});
// 添加新的可拖拽项
function addDraggable() {
const icons = ['📄', '📊', '📈', '🎬', '🎮', '📱', '💾', '🔧'];
const names = ['报告', '图表', '数据', '视频', '游戏', '应用', '备份', '工具'];
const randomIndex = Math.floor(Math.random() * icons.length);
const newDraggable = document.createElement('div');
newDraggable.className = 'draggable';
newDraggable.draggable = true;
newDraggable.dataset.id = Date.now();
newDraggable.innerHTML = `
<span class="icon">${icons[randomIndex]}</span>
<span>${names[randomIndex]} ${Math.floor(Math.random() * 100)}</span>
`;
document.getElementById('zone1').appendChild(newDraggable);
addLog(`➕ 添加了新的可拖拽项`);
}
// 重置
function resetAll() {
document.getElementById('zone1').innerHTML = `
<h3>📦 源区域</h3>
<div class="draggable" draggable="true" data-id="1">
<span class="icon">📁</span>
<span>项目文档</span>
</div>
<div class="draggable" draggable="true" data-id="2">
<span class="icon">🖼️</span>
<span>设计素材</span>
</div>
<div class="draggable" draggable="true" data-id="3">
<span class="icon">🎵</span>
<span>音频文件</span>
</div>
`;
document.getElementById('zone2').innerHTML = '<h3>📥 目标区域</h3>';
addLog('🔄 已重置所有内容');
}
</script>
</body>
</html>
代码解释: draggable="true" 开启拖拽。dragstart 时设置 dataTransfer 数据与 effectAllowed(move/copy/link/none)。dragover 必须 e.preventDefault() 才能允许放置,同时设置 dropEffect 反馈视觉。drop 处理实际放置逻辑,常用 appendChild 移动节点。dragend 清理状态。事件委托 :在 document 上监听 dragstart/dragend,避免动态元素重新绑定。dataTransfer 可传递字符串、文件等,适合跨窗口/跨域(需 CORS)。
12.3 无限滚动加载
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>无限滚动演示</title>
<style>
.container {
max-width: 800px;
margin: 0 auto;
padding: 20px;
}
.item-list {
min-height: 100vh;
}
.list-item {
padding: 20px;
margin-bottom: 15px;
background: white;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
display: flex;
align-items: center;
gap: 15px;
animation: fadeIn 0.3s ease;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
.item-avatar {
width: 60px;
height: 60px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 24px;
}
.item-content {
flex: 1;
}
.item-title {
font-weight: bold;
margin-bottom: 5px;
}
.item-desc {
color: #666;
font-size: 14px;
}
.loading {
text-align: center;
padding: 20px;
color: #999;
}
.loading-spinner {
display: inline-block;
width: 30px;
height: 30px;
border: 3px solid #f3f3f3;
border-top: 3px solid #2196F3;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.stats {
position: sticky;
top: 0;
background: rgba(33, 150, 243, 0.95);
color: white;
padding: 15px;
margin: -20px -20px 20px -20px;
display: flex;
justify-content: space-between;
border-radius: 8px 8px 0 0;
}
.end-message {
text-align: center;
padding: 40px;
color: #999;
font-size: 18px;
}
</style>
</head>
<body>
<div class="container">
<div class="stats">
<span>已加载: <strong id="loadedCount">0</strong> 项</span>
<span id="statusText">滚动加载更多</span>
</div>
<div class="item-list" id="itemList"></div>
<div class="loading" id="loadingIndicator" style="display: none;">
<div class="loading-spinner"></div>
<p>正在加载更多内容...</p>
</div>
</div>
<script>
const itemList = document.getElementById('itemList');
const loadingIndicator = document.getElementById('loadingIndicator');
const loadedCountEl = document.getElementById('loadedCount');
const statusTextEl = document.getElementById('statusText');
let currentPage = 0;
let itemsPerPage = 20;
let totalItems = 0;
let isLoading = false;
let hasMore = true;
// 生成模拟数据
function generateItems(page, count) {
const items = [];
const startIndex = page * count;
for (let i = 0; i < count; i++) {
const index = startIndex + i;
items.push({
id: index,
title: `项目 ${index + 1}`,
desc: `这是第 ${index + 1} 项的详细描述内容,展示无限滚动加载效果。`,
avatar: ['👤', '👥', '👨', '👩', '🧑'][index % 5]
});
}
return items;
}
// 创建列表项 DOM
function createItemElement(item) {
const div = document.createElement('div');
div.className = 'list-item';
div.innerHTML = `
<div class="item-avatar">${item.avatar}</div>
<div class="item-content">
<div class="item-title">${item.title}</div>
<div class="item-desc">${item.desc}</div>
</div>
`;
return div;
}
// 加载更多数据
async function loadMore() {
if (isLoading || !hasMore) return;
isLoading = true;
loadingIndicator.style.display = 'block';
statusTextEl.textContent = '正在加载...';
// 模拟网络延迟
await new Promise(resolve => setTimeout(resolve, 1000));
const items = generateItems(currentPage, itemsPerPage);
if (items.length === 0) {
hasMore = false;
loadingIndicator.innerHTML = '<div class="end-message">🎉 已加载全部内容</div>';
} else {
items.forEach(item => {
itemList.appendChild(createItemElement(item));
});
totalItems += items.length;
loadedCountEl.textContent = totalItems;
currentPage++;
statusTextEl.textContent = '滚动加载更多';
}
isLoading = false;
}
// 使用 Intersection Observer 实现无限滚动
const observer = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting) {
loadMore();
}
}, {
rootMargin: '100px', // 提前100px开始加载
threshold: 0.01
});
observer.observe(loadingIndicator);
// 初始加载
loadMore();
</script>
</body>
</html>
代码解释: IntersectionObserver 监听加载指示器 是否进入视口,进入则触发 loadMore。rootMargin: '100px' 提前加载,用户不会感到等待。isLoading 锁防止重复请求。模拟异步 用 setTimeout 等待 1 秒,生产环境换成 fetch/axios。到达末尾 时显示"已加载全部"。优点 :比监听 scroll 事件+scrollTop 计算简单高效,自带防抖。
12.4 本章归纳
| 案例 | 核心技术 | 适用场景 |
|---|---|---|
| 虚拟滚动 | 只渲染可见项 | 大数据列表 |
| 拖放 API | DragEvent + dataTransfer | 看板、文件上传 |
| 无限滚动 | IntersectionObserver | 社交媒体、电商列表 |