系列下篇 :承接 上篇 的 Event 与委托基础,本篇以 轮播图 串联定时器、指示器、无限循环等实战,并扩展到 CustomEvent / EventBus 、passive 监听 以及触摸、拖拽、表单、懒加载等工程场景。
前置阅读:建议先读完上篇 §一~§六,再实现 §七 轮播图。
权威参考:
目录
系列上篇 :JavaScript Event 对象深度解析(上篇) --- Event API、冒泡、委托、原型链、HTMLCollection。
零、导读(下篇)
0.1 下篇覆盖清单
| 模块 | 主题 |
|---|---|
| 轮播图 | 滑动 / 淡入淡出 / 自动播放 / 无限循环克隆 |
| 最佳实践 | passive、{ capture }、代码组织 |
| 自定义事件 | CustomEvent、手写 EventBus |
| 工程专题 | Touch 轮播、拖拽排序、快捷键、右键菜单、表单验证、懒加载、文件上传 |
#mermaid-svg-b0N6p6EfcYjv8E2y{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-b0N6p6EfcYjv8E2y .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-b0N6p6EfcYjv8E2y .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-b0N6p6EfcYjv8E2y .error-icon{fill:#552222;}#mermaid-svg-b0N6p6EfcYjv8E2y .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-b0N6p6EfcYjv8E2y .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-b0N6p6EfcYjv8E2y .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-b0N6p6EfcYjv8E2y .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-b0N6p6EfcYjv8E2y .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-b0N6p6EfcYjv8E2y .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-b0N6p6EfcYjv8E2y .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-b0N6p6EfcYjv8E2y .marker{fill:#333333;stroke:#333333;}#mermaid-svg-b0N6p6EfcYjv8E2y .marker.cross{stroke:#333333;}#mermaid-svg-b0N6p6EfcYjv8E2y svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-b0N6p6EfcYjv8E2y p{margin:0;}#mermaid-svg-b0N6p6EfcYjv8E2y .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-b0N6p6EfcYjv8E2y .cluster-label text{fill:#333;}#mermaid-svg-b0N6p6EfcYjv8E2y .cluster-label span{color:#333;}#mermaid-svg-b0N6p6EfcYjv8E2y .cluster-label span p{background-color:transparent;}#mermaid-svg-b0N6p6EfcYjv8E2y .label text,#mermaid-svg-b0N6p6EfcYjv8E2y span{fill:#333;color:#333;}#mermaid-svg-b0N6p6EfcYjv8E2y .node rect,#mermaid-svg-b0N6p6EfcYjv8E2y .node circle,#mermaid-svg-b0N6p6EfcYjv8E2y .node ellipse,#mermaid-svg-b0N6p6EfcYjv8E2y .node polygon,#mermaid-svg-b0N6p6EfcYjv8E2y .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-b0N6p6EfcYjv8E2y .rough-node .label text,#mermaid-svg-b0N6p6EfcYjv8E2y .node .label text,#mermaid-svg-b0N6p6EfcYjv8E2y .image-shape .label,#mermaid-svg-b0N6p6EfcYjv8E2y .icon-shape .label{text-anchor:middle;}#mermaid-svg-b0N6p6EfcYjv8E2y .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-b0N6p6EfcYjv8E2y .rough-node .label,#mermaid-svg-b0N6p6EfcYjv8E2y .node .label,#mermaid-svg-b0N6p6EfcYjv8E2y .image-shape .label,#mermaid-svg-b0N6p6EfcYjv8E2y .icon-shape .label{text-align:center;}#mermaid-svg-b0N6p6EfcYjv8E2y .node.clickable{cursor:pointer;}#mermaid-svg-b0N6p6EfcYjv8E2y .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-b0N6p6EfcYjv8E2y .arrowheadPath{fill:#333333;}#mermaid-svg-b0N6p6EfcYjv8E2y .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-b0N6p6EfcYjv8E2y .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-b0N6p6EfcYjv8E2y .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-b0N6p6EfcYjv8E2y .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-b0N6p6EfcYjv8E2y .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-b0N6p6EfcYjv8E2y .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-b0N6p6EfcYjv8E2y .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-b0N6p6EfcYjv8E2y .cluster text{fill:#333;}#mermaid-svg-b0N6p6EfcYjv8E2y .cluster span{color:#333;}#mermaid-svg-b0N6p6EfcYjv8E2y div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-b0N6p6EfcYjv8E2y .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-b0N6p6EfcYjv8E2y rect.text{fill:none;stroke-width:0;}#mermaid-svg-b0N6p6EfcYjv8E2y .icon-shape,#mermaid-svg-b0N6p6EfcYjv8E2y .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-b0N6p6EfcYjv8E2y .icon-shape p,#mermaid-svg-b0N6p6EfcYjv8E2y .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-b0N6p6EfcYjv8E2y .icon-shape .label rect,#mermaid-svg-b0N6p6EfcYjv8E2y .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-b0N6p6EfcYjv8E2y .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-b0N6p6EfcYjv8E2y .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-b0N6p6EfcYjv8E2y :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 上篇: Event + 委托
轮播图综合
CustomEvent / EventBus
工程场景专题
0.2 与上篇的衔接
- 轮播图箭头、指示器点击 → 用上篇 事件委托 或 target 判断。
- 触摸滑动轮播 → 结合上篇 MouseEvent 坐标与下篇 passive 配置。
- 无限循环
transitionend→ 理解 Event 对象 与 preventDefault 的边界(动画事件无默认行为)。
七、轮播图完整实现
7.1 轮播图项目结构
#mermaid-svg-jhaPNcELeoFg766k{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-jhaPNcELeoFg766k .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-jhaPNcELeoFg766k .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-jhaPNcELeoFg766k .error-icon{fill:#552222;}#mermaid-svg-jhaPNcELeoFg766k .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-jhaPNcELeoFg766k .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-jhaPNcELeoFg766k .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-jhaPNcELeoFg766k .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-jhaPNcELeoFg766k .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-jhaPNcELeoFg766k .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-jhaPNcELeoFg766k .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-jhaPNcELeoFg766k .marker{fill:#333333;stroke:#333333;}#mermaid-svg-jhaPNcELeoFg766k .marker.cross{stroke:#333333;}#mermaid-svg-jhaPNcELeoFg766k svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-jhaPNcELeoFg766k p{margin:0;}#mermaid-svg-jhaPNcELeoFg766k .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-jhaPNcELeoFg766k .cluster-label text{fill:#333;}#mermaid-svg-jhaPNcELeoFg766k .cluster-label span{color:#333;}#mermaid-svg-jhaPNcELeoFg766k .cluster-label span p{background-color:transparent;}#mermaid-svg-jhaPNcELeoFg766k .label text,#mermaid-svg-jhaPNcELeoFg766k span{fill:#333;color:#333;}#mermaid-svg-jhaPNcELeoFg766k .node rect,#mermaid-svg-jhaPNcELeoFg766k .node circle,#mermaid-svg-jhaPNcELeoFg766k .node ellipse,#mermaid-svg-jhaPNcELeoFg766k .node polygon,#mermaid-svg-jhaPNcELeoFg766k .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-jhaPNcELeoFg766k .rough-node .label text,#mermaid-svg-jhaPNcELeoFg766k .node .label text,#mermaid-svg-jhaPNcELeoFg766k .image-shape .label,#mermaid-svg-jhaPNcELeoFg766k .icon-shape .label{text-anchor:middle;}#mermaid-svg-jhaPNcELeoFg766k .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-jhaPNcELeoFg766k .rough-node .label,#mermaid-svg-jhaPNcELeoFg766k .node .label,#mermaid-svg-jhaPNcELeoFg766k .image-shape .label,#mermaid-svg-jhaPNcELeoFg766k .icon-shape .label{text-align:center;}#mermaid-svg-jhaPNcELeoFg766k .node.clickable{cursor:pointer;}#mermaid-svg-jhaPNcELeoFg766k .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-jhaPNcELeoFg766k .arrowheadPath{fill:#333333;}#mermaid-svg-jhaPNcELeoFg766k .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-jhaPNcELeoFg766k .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-jhaPNcELeoFg766k .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-jhaPNcELeoFg766k .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-jhaPNcELeoFg766k .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-jhaPNcELeoFg766k .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-jhaPNcELeoFg766k .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-jhaPNcELeoFg766k .cluster text{fill:#333;}#mermaid-svg-jhaPNcELeoFg766k .cluster span{color:#333;}#mermaid-svg-jhaPNcELeoFg766k div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-jhaPNcELeoFg766k .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-jhaPNcELeoFg766k rect.text{fill:none;stroke-width:0;}#mermaid-svg-jhaPNcELeoFg766k .icon-shape,#mermaid-svg-jhaPNcELeoFg766k .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-jhaPNcELeoFg766k .icon-shape p,#mermaid-svg-jhaPNcELeoFg766k .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-jhaPNcELeoFg766k .icon-shape .label rect,#mermaid-svg-jhaPNcELeoFg766k .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-jhaPNcELeoFg766k .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-jhaPNcELeoFg766k .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-jhaPNcELeoFg766k :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 轮播图组件
HTML结构
CSS样式
JavaScript逻辑
图片容器
指示按钮
左右箭头
绝对定位布局
过渡动画效果
响应式适配
状态管理
切换逻辑
自动播放
鼠标交互
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>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
ul, ol {
list-style: none;
}
img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
/* 轮播图容器 */
.carousel-container {
position: relative;
width: 900px;
height: 400px;
margin: 50px auto;
overflow: hidden;
border-radius: 10px;
box-shadow: 0 10px 40px rgba(0,0,0,0.2);
}
/* 图片列表 */
.carousel-images {
position: relative;
width: 100%;
height: 100%;
}
.carousel-images li {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: none;
}
.carousel-images li.active {
display: block;
animation: slideIn 0.5s ease-in-out;
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateX(50px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
/* 指示按钮 */
.carousel-indicators {
position: absolute;
bottom: 20px;
right: 30px;
display: flex;
gap: 10px;
z-index: 10;
}
.carousel-indicators li {
width: 12px;
height: 12px;
border-radius: 50%;
background: rgba(255,255,255,0.5);
cursor: pointer;
transition: all 0.3s;
}
.carousel-indicators li.active {
background: #fff;
transform: scale(1.2);
}
/* 左右箭头 */
.carousel-arrow {
position: absolute;
top: 50%;
transform: translateY(-50%);
width: 50px;
height: 50px;
background: rgba(0,0,0,0.3);
color: white;
font-size: 24px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.3s;
z-index: 10;
user-select: none;
}
.carousel-arrow:hover {
background: rgba(0,0,0,0.6);
}
.carousel-arrow.prev {
left: 20px;
}
.carousel-arrow.next {
right: 20px;
}
/* 响应式 */
@media (max-width: 920px) {
.carousel-container {
width: 95%;
height: 300px;
}
}
</style>
</head>
<body>
<h1 style="text-align: center;">轮播图完整实现</h1>
<div class="carousel-container" id="carousel">
<!-- 图片列表 -->
<ul class="carousel-images">
<li class="active">
<img src="https://picsum.photos/900/400?random=1" alt="轮播图1">
</li>
<li>
<img src="https://picsum.photos/900/400?random=2" alt="轮播图2">
</li>
<li>
<img src="https://picsum.photos/900/400?random=3" alt="轮播图3">
</li>
<li>
<img src="https://picsum.photos/900/400?random=4" alt="轮播图4">
</li>
<li>
<img src="https://picsum.photos/900/400?random=5" alt="轮播图5">
</li>
</ul>
<!-- 指示按钮 -->
<ol class="carousel-indicators">
<li class="active" data-index="0"></li>
<li data-index="1"></li>
<li data-index="2"></li>
<li data-index="3"></li>
<li data-index="4"></li>
</ol>
<!-- 左右箭头 -->
<div class="carousel-arrow prev"><</div>
<div class="carousel-arrow next">></div>
</div>
<script>
// ===== 核心逻辑(详见下方【代码注释】) =====
(function() {
// 轮播图类
class Carousel {
constructor(element, options = {}) {
this.container = element;
this.images = element.querySelectorAll('.carousel-images li');
this.indicators = element.querySelectorAll('.carousel-indicators li');
this.prevArrow = element.querySelector('.carousel-arrow.prev');
this.nextArrow = element.querySelector('.carousel-arrow.next');
// 配置选项
this.options = {
autoplay: options.autoplay !== false,
interval: options.interval || 3000,
pauseOnHover: options.pauseOnHover !== false
};
// 状态
this.currentIndex = 0;
this.timer = null;
this.isPaused = false;
this.init();
}
init() {
// 绑定事件
this.bindEvents();
// 启动自动播放
if (this.options.autoplay) {
this.startAutoplay();
}
}
bindEvents() {
// 指示按钮点击
this.indicators.forEach((indicator, index) => {
indicator.addEventListener('click', () => {
this.goto(index);
});
});
// 左右箭头点击
this.prevArrow.addEventListener('click', () => this.prev());
this.nextArrow.addEventListener('click', () => this.next());
// 鼠标悬停暂停
if (this.options.pauseOnHover) {
this.container.addEventListener('mouseenter', () => this.pause());
this.container.addEventListener('mouseleave', () => this.resume());
}
}
goto(index) {
// 移除当前活动状态
this.images[this.currentIndex].classList.remove('active');
this.indicators[this.currentIndex].classList.remove('active');
// 更新索引
this.currentIndex = index;
// 添加新的活动状态
this.images[this.currentIndex].classList.add('active');
this.indicators[this.currentIndex].classList.add('active');
}
next() {
let nextIndex = this.currentIndex + 1;
if (nextIndex >= this.images.length) {
nextIndex = 0;
}
this.goto(nextIndex);
}
prev() {
let prevIndex = this.currentIndex - 1;
if (prevIndex < 0) {
prevIndex = this.images.length - 1;
}
this.goto(prevIndex);
}
startAutoplay() {
this.timer = setInterval(() => {
if (!this.isPaused) {
this.next();
}
}, this.options.interval);
}
pause() {
this.isPaused = true;
}
resume() {
this.isPaused = false;
}
destroy() {
clearInterval(this.timer);
// 移除事件监听器...
}
}
// 初始化轮播图
const carousel = new Carousel(document.getElementById('carousel'), {
autoplay: true,
interval: 3000,
pauseOnHover: true
});
// 将实例挂载到 window 上,便于调试
window.carousel = carousel;
})();
</script>
</body>
</html>
【代码注释】
Carousel 类职责划分
| 方法 | 职责 |
|---|---|
constructor |
缓存 DOM 引用、合并 options、初始化 currentIndex / timer |
init |
调 bindEvents,按配置启动自动播放 |
bindEvents |
指示器 click、箭头 click、mouseenter/mouseleave |
goto(index) |
切换 active 类(图片 + 指示器同步) |
next / prev |
索引 ±1,首尾循环(% length 思想) |
startAutoplay |
setInterval 调 next |
pause / resume |
仅改 isPaused 标志,不销毁定时器 |
destroy |
clearInterval,组件卸载时防内存泄漏 |
状态与 CSS 的配合
- 所有图片
li叠放或横排,仅li.active显示(opacity或transform由 CSS 决定)。 - JS 只改索引和 class,动画交给 CSS
transition,符合「结构与行为分离」。
自动播放逻辑
javascript
setInterval(() => { if (!this.isPaused) this.next(); }, interval);
- 悬停:
mouseenter→pause();mouseleave→resume()。 - 比「悬停时 clearInterval、离开再重建」更简单,且不会反复创建定时器。
配置项设计
autoplay、interval、pauseOnHover通过options传入,便于复用到多个轮播实例。
易错点
- 忘记在
destroy里clearInterval→ 单页应用路由切换后仍后台轮播。 goto时未同步更新指示器active,会出现「图与点不一致」。
课堂对应
- 分步实现:布局 → 指示器 → 箭头 → 定时器;本示例为滑动版合一,便于对照复习。
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>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
ul, ol {
list-style: none;
}
img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.fade-carousel {
position: relative;
width: 900px;
height: 400px;
margin: 50px auto;
border-radius: 10px;
overflow: hidden;
box-shadow: 0 10px 40px rgba(0,0,0,0.2);
}
.fade-carousel .images {
position: relative;
width: 100%;
height: 100%;
}
.fade-carousel .images li {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
opacity: 0;
transition: opacity 0.6s ease-in-out;
}
.fade-carousel .images li.active {
opacity: 1;
z-index: 1;
}
.fade-carousel .indicators {
position: absolute;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
display: flex;
gap: 10px;
z-index: 10;
}
.fade-carousel .indicators li {
width: 40px;
height: 4px;
background: rgba(255,255,255,0.5);
cursor: pointer;
transition: all 0.3s;
}
.fade-carousel .indicators li.active {
background: #fff;
}
.fade-carousel .arrow {
position: absolute;
top: 50%;
transform: translateY(-50%);
width: 50px;
height: 50px;
background: rgba(0,0,0,0.3);
color: white;
font-size: 30px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.3s;
z-index: 10;
}
.fade-carousel .arrow:hover {
background: rgba(0,0,0,0.6);
}
.fade-carousel .arrow.prev {
left: 0;
}
.fade-carousel .arrow.next {
right: 0;
}
</style>
</head>
<body>
<h1 style="text-align: center;">淡入淡出效果轮播图</h1>
<div class="fade-carousel" id="fadeCarousel">
<ul class="images">
<li class="active">
<img src="https://picsum.photos/900/400?random=10" alt="1">
</li>
<li>
<img src="https://picsum.photos/900/400?random=11" alt="2">
</li>
<li>
<img src="https://picsum.photos/900/400?random=12" alt="3">
</li>
<li>
<img src="https://picsum.photos/900/400?random=13" alt="4">
</li>
</ul>
<ol class="indicators">
<li class="active" data-index="0"></li>
<li data-index="1"></li>
<li data-index="2"></li>
<li data-index="3"></li>
</ol>
<div class="arrow prev"><</div>
<div class="arrow next">></div>
</div>
<script>
// ===== 核心逻辑(详见下方【代码注释】) =====
(function() {
const carousel = document.getElementById('fadeCarousel');
const images = carousel.querySelectorAll('.images li');
const indicators = carousel.querySelectorAll('.indicators li');
const prevArrow = carousel.querySelector('.arrow.prev');
const nextArrow = carousel.querySelector('.arrow.next');
let index = 0;
const duration = 3000;
let timer = null;
let isPaused = false;
function goto(newIndex) {
images[index].classList.remove('active');
indicators[index].classList.remove('active');
index = newIndex;
images[index].classList.add('active');
indicators[index].classList.add('active');
}
function next() {
let newIndex = index + 1;
if (newIndex >= images.length) {
newIndex = 0;
}
goto(newIndex);
}
function prev() {
let newIndex = index - 1;
if (newIndex < 0) {
newIndex = images.length - 1;
}
goto(newIndex);
}
// 指示按钮
indicators.forEach((indicator, i) => {
indicator.addEventListener('click', () => goto(i));
});
// 箭头
prevArrow.addEventListener('click', prev);
nextArrow.addEventListener('click', next);
// 自动播放
function startAutoplay() {
timer = setInterval(() => {
if (!isPaused) {
next();
}
}, duration);
}
function stopAutoplay() {
clearInterval(timer);
}
// 鼠标悬停
carousel.addEventListener('mouseenter', () => {
isPaused = true;
});
carousel.addEventListener('mouseleave', () => {
isPaused = false;
});
startAutoplay();
})();
</script>
</body>
</html>
【代码注释】
与 §7.2 滑动版的差异
| 维度 | 滑动版 §7.2 | 淡入淡出 §7.3 |
|---|---|---|
| 布局 | 图片横向排列,translateX 移动 |
图片绝对定位叠放同一区域 |
| 切换 | 移动轨道 | 改 opacity + transition |
| 事件逻辑 | 相同(索引、active、定时器) |
相同 |
CSS 关键
css
.images li { position: absolute; opacity: 0; transition: opacity 0.6s; }
.images li.active { opacity: 1; z-index: 1; }
- 所有
li叠在同一容器,非active透明但仍占位(与display:none不同)。 transition作用于opacity,浏览器可 GPU 合成,动画较顺滑。
为什么不用 display: none/block
display不参与过渡动画,切换会「硬切」。opacity可过渡;若需隐藏后不可聚焦,可再配合visibility或pointer-events。
JS 逻辑(与滑动版一致)
goto(i):旧项去active,新项加active。setInterval+ 悬停暂停:同一套事件模型,证明动画可换、事件架构可复用。
市面应用
- 京东/天猫首页 Banner、网易新闻头图:淡入淡出 + 指示器 + 自动播放是标配。
7.4 轮播图应用场景
| 应用场景 | 特点 |
|---|---|
| 电商网站首页 | 展示商品促销信息 |
| 新闻门户 | 展示重要新闻图片 |
| 企业官网 | 展示产品或服务 |
| 旅游网站 | 展示景点图片 |
| 房地产网站 | 展示楼盘图片 |
7.5 无限循环轮播:DOM 克隆方案
§7.2 滑动版在首尾切换时会出现跳回动画 (从最后一张瞬间跳至第一张)。工业级方案使用 "首尾各克隆一帧" 消除这个跳变。
#mermaid-svg-Fl4LWLYVjtXUYvCV{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-Fl4LWLYVjtXUYvCV .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-Fl4LWLYVjtXUYvCV .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-Fl4LWLYVjtXUYvCV .error-icon{fill:#552222;}#mermaid-svg-Fl4LWLYVjtXUYvCV .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-Fl4LWLYVjtXUYvCV .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-Fl4LWLYVjtXUYvCV .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-Fl4LWLYVjtXUYvCV .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-Fl4LWLYVjtXUYvCV .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-Fl4LWLYVjtXUYvCV .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-Fl4LWLYVjtXUYvCV .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-Fl4LWLYVjtXUYvCV .marker{fill:#333333;stroke:#333333;}#mermaid-svg-Fl4LWLYVjtXUYvCV .marker.cross{stroke:#333333;}#mermaid-svg-Fl4LWLYVjtXUYvCV svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-Fl4LWLYVjtXUYvCV p{margin:0;}#mermaid-svg-Fl4LWLYVjtXUYvCV .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-Fl4LWLYVjtXUYvCV .cluster-label text{fill:#333;}#mermaid-svg-Fl4LWLYVjtXUYvCV .cluster-label span{color:#333;}#mermaid-svg-Fl4LWLYVjtXUYvCV .cluster-label span p{background-color:transparent;}#mermaid-svg-Fl4LWLYVjtXUYvCV .label text,#mermaid-svg-Fl4LWLYVjtXUYvCV span{fill:#333;color:#333;}#mermaid-svg-Fl4LWLYVjtXUYvCV .node rect,#mermaid-svg-Fl4LWLYVjtXUYvCV .node circle,#mermaid-svg-Fl4LWLYVjtXUYvCV .node ellipse,#mermaid-svg-Fl4LWLYVjtXUYvCV .node polygon,#mermaid-svg-Fl4LWLYVjtXUYvCV .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-Fl4LWLYVjtXUYvCV .rough-node .label text,#mermaid-svg-Fl4LWLYVjtXUYvCV .node .label text,#mermaid-svg-Fl4LWLYVjtXUYvCV .image-shape .label,#mermaid-svg-Fl4LWLYVjtXUYvCV .icon-shape .label{text-anchor:middle;}#mermaid-svg-Fl4LWLYVjtXUYvCV .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-Fl4LWLYVjtXUYvCV .rough-node .label,#mermaid-svg-Fl4LWLYVjtXUYvCV .node .label,#mermaid-svg-Fl4LWLYVjtXUYvCV .image-shape .label,#mermaid-svg-Fl4LWLYVjtXUYvCV .icon-shape .label{text-align:center;}#mermaid-svg-Fl4LWLYVjtXUYvCV .node.clickable{cursor:pointer;}#mermaid-svg-Fl4LWLYVjtXUYvCV .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-Fl4LWLYVjtXUYvCV .arrowheadPath{fill:#333333;}#mermaid-svg-Fl4LWLYVjtXUYvCV .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-Fl4LWLYVjtXUYvCV .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-Fl4LWLYVjtXUYvCV .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-Fl4LWLYVjtXUYvCV .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-Fl4LWLYVjtXUYvCV .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-Fl4LWLYVjtXUYvCV .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-Fl4LWLYVjtXUYvCV .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-Fl4LWLYVjtXUYvCV .cluster text{fill:#333;}#mermaid-svg-Fl4LWLYVjtXUYvCV .cluster span{color:#333;}#mermaid-svg-Fl4LWLYVjtXUYvCV div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-Fl4LWLYVjtXUYvCV .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-Fl4LWLYVjtXUYvCV rect.text{fill:none;stroke-width:0;}#mermaid-svg-Fl4LWLYVjtXUYvCV .icon-shape,#mermaid-svg-Fl4LWLYVjtXUYvCV .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-Fl4LWLYVjtXUYvCV .icon-shape p,#mermaid-svg-Fl4LWLYVjtXUYvCV .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-Fl4LWLYVjtXUYvCV .icon-shape .label rect,#mermaid-svg-Fl4LWLYVjtXUYvCV .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-Fl4LWLYVjtXUYvCV .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-Fl4LWLYVjtXUYvCV .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-Fl4LWLYVjtXUYvCV :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 克隆 imgN(作为第 0 帧)
img1
img2
...
imgN
克隆 img1(作为第 N+1 帧)
核心思路
| 位置 | 内容 | 作用 |
|---|---|---|
| 索引 0(克隆帧) | 原始最后一张的副本 | 向左滑到头时用 |
| 索引 1 ~ N(真实帧) | 原始 N 张图 | 正常显示 |
| 索引 N+1(克隆帧) | 原始第一张的副本 | 向右滑到头时用 |
当滑到克隆帧后,关闭过渡 → 瞬间跳到对应真实帧 → 重新开启过渡,用户完全感知不到跳变。
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>无限循环轮播(DOM 克隆方案)</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
ul, ol { list-style: none; }
.infinite-carousel {
position: relative;
width: 800px;
height: 340px;
margin: 50px auto;
border-radius: 10px;
overflow: hidden;
box-shadow: 0 8px 32px rgba(0,0,0,0.18);
}
.track-wrap {
overflow: hidden;
width: 100%;
height: 100%;
}
.track {
display: flex;
height: 100%;
/* 宽度由 JS 动态计算 */
transition: transform 0.45s cubic-bezier(.4,0,.2,1);
}
.track li {
flex: 0 0 800px;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
font-size: 3rem;
font-weight: bold;
color: #fff;
user-select: none;
}
.track li:nth-child(1) { background: #e74c3c; }
.track li:nth-child(2) { background: #3498db; }
.track li:nth-child(3) { background: #2ecc71; }
.track li:nth-child(4) { background: #f39c12; }
.track li:nth-child(5) { background: #9b59b6; }
.indicators {
position: absolute;
bottom: 14px;
left: 50%;
transform: translateX(-50%);
display: flex;
gap: 8px;
z-index: 10;
}
.indicators li {
width: 10px;
height: 10px;
background: rgba(255,255,255,0.5);
border-radius: 50%;
cursor: pointer;
transition: background 0.3s;
}
.indicators li.active { background: #fff; }
.arrow {
position: absolute;
top: 50%;
transform: translateY(-50%);
width: 44px;
height: 44px;
background: rgba(0,0,0,0.35);
color: #fff;
font-size: 24px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
border-radius: 50%;
z-index: 10;
transition: background 0.2s;
}
.arrow:hover { background: rgba(0,0,0,0.6); }
.prev { left: 12px; }
.next { right: 12px; }
</style>
</head>
<body>
<div class="infinite-carousel" id="carousel">
<div class="track-wrap">
<ul class="track" id="track">
<li>① 红</li>
<li>② 蓝</li>
<li>③ 绿</li>
<li>④ 橙</li>
<li>⑤ 紫</li>
</ul>
</div>
<ol class="indicators" id="indicators"></ol>
<div class="arrow prev" id="prev">‹</div>
<div class="arrow next" id="next">›</div>
</div>
<script>
// ===== 核心逻辑(详见下方【代码注释】) =====
(function () {
const carousel = document.getElementById('carousel');
const track = document.getElementById('track');
const dotsWrap = document.getElementById('indicators');
const prevBtn = document.getElementById('prev');
const nextBtn = document.getElementById('next');
const ITEM_W = 800; // 单张宽度(px)
const DURATION = 3000; // 自动播放间隔(ms)
// ── 1. 读取原始帧,克隆首尾 ──────────────────────────────
const realItems = Array.from(track.children);
const total = realItems.length; // 真实帧数量
// 克隆最后一帧插到最前;克隆第一帧追加到最后
const cloneHead = realItems[total - 1].cloneNode(true);
const cloneTail = realItems[0].cloneNode(true);
track.insertBefore(cloneHead, track.firstChild);
track.appendChild(cloneTail);
// track 宽度 = (total + 2) × ITEM_W
track.style.width = (total + 2) * ITEM_W + 'px';
// ── 2. 状态变量 ────────────────────────────────────────────
let curReal = 0; // 当前真实帧索引(0-based, 0 ~ total-1)
let isAnimating = false;
// ── 3. 生成指示器 ──────────────────────────────────────────
realItems.forEach((_, i) => {
const li = document.createElement('li');
if (i === 0) li.classList.add('active');
li.addEventListener('click', () => goto(i));
dotsWrap.appendChild(li);
});
const dots = Array.from(dotsWrap.children);
// ── 4. 核心:translate 移动到指定帧 ────────────────────────
// 克隆帧占据索引 0,真实帧从索引 1 开始
function setTranslate(idx, withTransition) {
track.style.transition = withTransition
? 'transform 0.45s cubic-bezier(.4,0,.2,1)'
: 'none';
track.style.transform = `translateX(${-(idx + 1) * ITEM_W}px)`;
}
// 初始定位到第一张真实帧(track 中索引 1)
setTranslate(0, false);
function updateDots() {
dots.forEach((d, i) => d.classList.toggle('active', i === curReal));
}
function goto(realIdx) {
if (isAnimating) return;
isAnimating = true;
curReal = realIdx;
updateDots();
setTranslate(curReal, true);
}
// ── 5. 过渡结束:检测克隆帧并无动画跳转 ────────────────────
track.addEventListener('transitionend', function () {
if (curReal === -1) {
// 刚从真实帧[0]向左滑入了克隆的"最后帧"(track 索引 0)
curReal = total - 1;
setTranslate(curReal, false);
} else if (curReal === total) {
// 刚从真实帧[total-1]向右滑入了克隆的"第一帧"(track 索引 total+1)
curReal = 0;
setTranslate(curReal, false);
}
isAnimating = false;
updateDots();
});
function prev() {
if (isAnimating) return;
isAnimating = true;
curReal -= 1;
// curReal 可能为 -1(需要显示克隆的最后帧)
if (curReal < 0) {
// 先滑到克隆帧(track 索引 0),transitionend 再跳到真实末帧
updateDots(); // -1 时 dots 无 active,transitionend 会修正
track.style.transition = 'transform 0.45s cubic-bezier(.4,0,.2,1)';
track.style.transform = `translateX(0px)`; // track 第 0 项 = 克隆末帧
} else {
updateDots();
setTranslate(curReal, true);
isAnimating = false; // 正常帧无需等 transitionend 就能解锁
}
}
function next() {
if (isAnimating) return;
isAnimating = true;
curReal += 1;
if (curReal >= total) {
// 滑到克隆的首帧(track 最后一项),transitionend 再跳到真实首帧
track.style.transition = 'transform 0.45s cubic-bezier(.4,0,.2,1)';
track.style.transform = `translateX(${-(total + 1) * ITEM_W}px)`;
} else {
updateDots();
setTranslate(curReal, true);
isAnimating = false;
}
}
prevBtn.addEventListener('click', prev);
nextBtn.addEventListener('click', next);
// ── 6. 自动播放 ────────────────────────────────────────────
let timer = setInterval(next, DURATION);
carousel.addEventListener('mouseenter', () => clearInterval(timer));
carousel.addEventListener('mouseleave', () => { timer = setInterval(next, DURATION); });
})();
</script>
</body>
</html>
【代码注释】
DOM 结构(克隆后)
track: [ cloneLast | img1 | img2 | ... | imgN | cloneFirst ]
索引: 0 1 2 ... N N+1
- 初始
translateX(-ITEM_W):视觉上落在真实第 1 张(索引 1)。 - 用户始终以为在 0~N-1 之间循环,实际轨道多了首尾各一张「假帧」。
setTranslate(idx, withTransition)
withTransition === true:正常滑动,有过渡动画。withTransition === false:transition: none,用于瞬间复位(用户看不见跳转)。
transitionend 无缝复位
| 滑入位置 | 用户看到 | 复位动作 |
|---|---|---|
| 索引 0(克隆末帧) | 像从第 1 张往前滑 | 关过渡 → setTranslate(N-1, false) |
| 索引 N+1(克隆首帧) | 像从最后一张往后滑 | 关过渡 → setTranslate(0, false) |
isAnimating 锁
- 过渡进行中忽略
prev/next点击,防止快速连点导致translateX与curReal不同步。
prev() 在 curReal === 0 时的特殊处理
- 先滑到克隆末帧(
translateX(0)),等transitionend再无动画跳到真实末帧。 - 若直接用
setTranslate(-1)会算错偏移。
与 §7.2 简单取模的对比
- §7.2:
nextIndex >= length时置 0 → 最后一张到第一张会闪回。 - §7.5:视觉连续,工业级 Banner 标配;Swiper
loop: true同源思路。
延伸
- 真实项目还需:触摸
touchmove+passive: false(§8.5)、懒加载大图、只有 2 张图时退化策略。
【本章小结】轮播图
| 步骤 | 技术点 |
|---|---|
| 布局 | 相对定位容器 + 绝对定位图片层 |
| 指示器/箭头 | click + 索引切换 active |
| 自动播放 | setInterval + 悬停 mouseenter/mouseleave 暂停 |
| 淡入淡出 | opacity/transition 替代滑动 transform |
| 无限循环 | 克隆首尾帧 + transitionend 无动画跳转 |
八、最佳实践总结
8.1 Event 对象使用建议
| 建议 | 说明 |
|---|---|
| 命名规范 | 事件对象参数通常命名为 event 或 e |
| 及时阻止 | 需要阻止默认行为时尽早调用 preventDefault() |
| 合理委托 | 大量相似元素优先使用事件委托 |
| 清理监听器 | 组件销毁时记得移除事件监听器 |
8.2 事件处理性能优化
javascript
// ===== 核心逻辑(详见下方【代码注释】) =====
// ✅ 使用事件委托
container.addEventListener('click', function(event) {
if (event.target.matches('.item')) {
// 处理逻辑
}
});
// ✅ 防抖处理频繁事件
function debounce(func, wait) {
let timeout;
return function() {
clearTimeout(timeout);
timeout = setTimeout(() => func.apply(this, arguments), wait);
};
}
window.addEventListener('resize', debounce(handleResize, 200));
// ✅ 节流处理
function throttle(func, wait) {
let lastTime = 0;
return function() {
const now = Date.now();
if (now - lastTime >= wait) {
func.apply(this, arguments);
lastTime = now;
}
};
}
window.addEventListener('scroll', throttle(handleScroll, 100));
【代码注释】
三类优化手段
| 手段 | 适用事件 | 原理 | 本段代码 |
|---|---|---|---|
| 事件委托 | click 等冒泡事件 |
一个监听器代理 N 个子节点 | container + target.matches |
| 防抖 debounce | resize、input |
停止触发 wait ms 后才执行 | clearTimeout + setTimeout |
| 节流 throttle | scroll、mousemove |
每 wait ms 最多执行一次 | 时间戳差值判断 |
委托片段
javascript
container.addEventListener('click', function(event) {
if (event.target.matches('.item')) { /* ... */ }
});
matches支持 CSS 选择器;更复杂可用closest('.item')。- 绑定在稳定父节点 上:表格
tbody、列表ul、卡片容器。
防抖片段
- 窗口
resize停止拖动 200ms 后才handleResize,避免布局计算执行几十次。 - 搜索框
input防抖减少请求次数(常 300ms)。
节流片段
scroll每 100ms 最多执行一次handleScroll,保证滚动流畅又更新吸顶状态。- 与防抖区别:防抖「等安静」,节流「限频率」。
性能数据直觉
- 1000 个
addEventListener:1000 份回调引用 + 派发时遍历监听器列表。 - 1 个委托:派发 1 次,回调里 O(1) 判断 target。
易错点
- 对
scroll做防抖会导致「停滚后才触发」,吸顶/懒加载应用节流。 - 防抖/节流函数内
this与arguments需用apply保留(示例已写)。
8.3 代码组织建议
javascript
// ===== 核心逻辑(详见下方【代码注释】) =====
// ✅ 使用类组织代码
class Carousel {
constructor(element, options) {
this.element = element;
this.options = { ...defaultOptions, ...options };
this.init();
}
init() {
this.cacheElements();
this.bindEvents();
this.start();
}
cacheElements() {
this.images = this.element.querySelectorAll('.image');
this.indicators = this.element.querySelectorAll('.indicator');
}
bindEvents() {
// 事件绑定
}
start() {
// 启动逻辑
}
}
【代码注释】
类封装的分层思想
constructor → init() → cacheElements() → bindEvents() → start()
| 阶段 | 目的 |
|---|---|
constructor |
接收根 DOM + options,合并默认配置 |
cacheElements |
一次性 querySelector,避免重复查询 |
bindEvents |
集中管理所有监听器,卸载时可统一 remove |
start |
启动自动播放、初始化状态 |
对比「面向过程」写法的优势
- 多个轮播实例:
new Carousel('#a')、new Carousel('#b')互不干扰。 - 配置扩展:
{ interval: 5000, loop: 'clone' }只改构造参数。 - 测试友好:可对实例调
next()/goto(2)做单元测试。
options 合并模式
javascript
this.options = { ...defaultOptions, ...options };
- 调用方只传想改的字段,其余走默认(与 §7.2 一致)。
工程化延伸
- 再拆
destroy():清除定时器 +removeEventListener,SPA 路由必备。 - 可发布为 Web Component 或 Vue/React 组件,内部仍是一套
bindEvents逻辑。
与 §7.2 的关系
- §7.2 是完整可运行 的类实现;本节是骨架模板,强调架构而非动画细节。
8.4 轮播图功能清单
| 功能 | 实现要点 |
|---|---|
| 自动播放 | 使用 setInterval |
| 鼠标悬停暂停 | mouseenter/mouseleave 事件 |
| 指示按钮点击 | 点击跳转到对应图片 |
| 左右箭头切换 | 上一张/下一张逻辑 |
| 无限循环 | DOM 克隆首尾帧 + transitionend 跳转 |
| 过渡动画 | CSS transition 或 animation |
8.5 passive 事件监听:移动端性能优化
为什么需要 passive?
浏览器在触发 touchstart / touchmove / wheel 时,需要等待 JS 执行完毕才能确认是否调用了 preventDefault()(因为调用后会取消默认滚动)。这段等待时间会造成可感知的滚动卡顿(通常 50~200 ms)。
JS主线程 浏览器渲染线程 用户手指 JS主线程 浏览器渲染线程 用户手指 #mermaid-svg-K6XPKwcS3bgCyo9L{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-K6XPKwcS3bgCyo9L .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-K6XPKwcS3bgCyo9L .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-K6XPKwcS3bgCyo9L .error-icon{fill:#552222;}#mermaid-svg-K6XPKwcS3bgCyo9L .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-K6XPKwcS3bgCyo9L .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-K6XPKwcS3bgCyo9L .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-K6XPKwcS3bgCyo9L .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-K6XPKwcS3bgCyo9L .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-K6XPKwcS3bgCyo9L .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-K6XPKwcS3bgCyo9L .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-K6XPKwcS3bgCyo9L .marker{fill:#333333;stroke:#333333;}#mermaid-svg-K6XPKwcS3bgCyo9L .marker.cross{stroke:#333333;}#mermaid-svg-K6XPKwcS3bgCyo9L svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-K6XPKwcS3bgCyo9L p{margin:0;}#mermaid-svg-K6XPKwcS3bgCyo9L .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-K6XPKwcS3bgCyo9L text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-K6XPKwcS3bgCyo9L .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-K6XPKwcS3bgCyo9L .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-K6XPKwcS3bgCyo9L .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-K6XPKwcS3bgCyo9L .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-K6XPKwcS3bgCyo9L #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-K6XPKwcS3bgCyo9L .sequenceNumber{fill:white;}#mermaid-svg-K6XPKwcS3bgCyo9L #sequencenumber{fill:#333;}#mermaid-svg-K6XPKwcS3bgCyo9L #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-K6XPKwcS3bgCyo9L .messageText{fill:#333;stroke:none;}#mermaid-svg-K6XPKwcS3bgCyo9L .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-K6XPKwcS3bgCyo9L .labelText,#mermaid-svg-K6XPKwcS3bgCyo9L .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-K6XPKwcS3bgCyo9L .loopText,#mermaid-svg-K6XPKwcS3bgCyo9L .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-K6XPKwcS3bgCyo9L .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-K6XPKwcS3bgCyo9L .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-K6XPKwcS3bgCyo9L .noteText,#mermaid-svg-K6XPKwcS3bgCyo9L .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-K6XPKwcS3bgCyo9L .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-K6XPKwcS3bgCyo9L .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-K6XPKwcS3bgCyo9L .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-K6XPKwcS3bgCyo9L .actorPopupMenu{position:absolute;}#mermaid-svg-K6XPKwcS3bgCyo9L .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-K6XPKwcS3bgCyo9L .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-K6XPKwcS3bgCyo9L .actor-man circle,#mermaid-svg-K6XPKwcS3bgCyo9L line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-K6XPKwcS3bgCyo9L :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 等待 JS 是否调用 preventDefault() touchmove 执行监听器 执行完毕(50~200ms) 才能开始滚动
加上 { passive: true } 后,浏览器提前得知 你不会调用 preventDefault(),可与 JS 并行立即开始滚动,卡顿消失。
用法对比
javascript
// ===== 核心逻辑(详见下方【代码注释】) =====
// ❌ 普通监听 --- 浏览器须等待 JS 确认
window.addEventListener('touchmove', onTouchMove);
// ✅ passive 监听 --- 浏览器立即滚动,不等 JS
window.addEventListener('touchmove', onTouchMove, { passive: true });
// ✅ 同时指定捕获 + passive
element.addEventListener('wheel', onWheel, { capture: false, passive: true });
// ⚠️ 如果确实需要 preventDefault,不能加 passive
// 例如:自定义下拉刷新阻止默认滚动时
element.addEventListener('touchstart', function(e) {
if (isRefreshing) e.preventDefault();
}, { passive: false }); // 显式写 false,Chrome DevTools 不再警告
【代码注释】
浏览器为什么「等 JS」
- 滚动、缩放等默认行为由合成线程 处理;若监听器可能
preventDefault(),浏览器必须等主线程 JS 跑完才能决定滚不滚 → 卡顿(移动端常 50~150ms)。
passive: true 的契约
javascript
window.addEventListener('touchmove', onTouchMove, { passive: true });
- 语义:本监听器不会调用
preventDefault()。 - 浏览器可并行开始滚动,不必等回调结束。
passive: false 何时必须
- 自定义横向轮播、下拉刷新、全屏拖拽:需要在
touchmove里preventDefault()阻止纵向滚动 → 必须显式passive: false。 - Chrome 会对未声明的
touchstart/touchmove打印警告,提醒开发者表态。
与 capture 组合
javascript
element.addEventListener('wheel', onWheel, { capture: false, passive: true });
- 选项对象可同时写
capture、passive、once。
违规后果
passive: true下调用preventDefault()→ 被忽略 + Console 报错,滚动无法被阻止。
轮播触摸策略(实战)
| 手势 | 监听 | passive | 行为 |
|---|---|---|---|
| 纵向滚页面 | touchmove on document |
true |
不阻塞滚动 |
| 横向滑轮播 | touchmove on carousel |
false |
可 preventDefault 防页面横滚 |
面试一句话
- passive 是性能优化契约,不是语法糖;解决「监听器阻塞滚动」问题。
passive 兼容性速查
| 场景 | 推荐写法 |
|---|---|
监听 scroll / wheel 仅读取位置 |
{ passive: true } |
监听 touchstart 判断方向后再决定 |
{ passive: false } + 尽早 preventDefault |
监听 touchmove 自定义拖拽 |
{ passive: false } |
监听 touchmove 仅读取坐标 |
{ passive: true } |
九、自定义事件与发布-订阅模式
名词 :CustomEvent 是浏览器内置的自定义事件 API,允许开发者创建并派发任意命名的事件,配合
dispatchEvent实现组件间解耦通信。MDN 参考 :CustomEvent | EventTarget.dispatchEvent
9.1 CustomEvent 基础
浏览器原生提供 CustomEvent 构造函数,可在任意 EventTarget(包括普通 DOM 元素)上创建和派发自定义事件。
javascript
// ===== 核心逻辑(详见下方【代码注释】) =====
// 创建自定义事件,携带业务数据
const event = new CustomEvent('cart:add', {
bubbles: true, // 允许冒泡(可被父元素捕获)
cancelable: true, // 允许 preventDefault
detail: { // 业务数据挂载在 detail 字段
productId: 42,
productName: 'iPhone',
price: 6999
}
});
// 在目标元素上派发
document.querySelector('#addToCartBtn').dispatchEvent(event);
// 在任意父级监听(利用冒泡)
document.addEventListener('cart:add', (e) => {
console.log('加购商品:', e.detail.productName);
console.log('价格:', e.detail.price);
});
【代码注释】
CustomEvent 与原生 Event 的关系
CustomEvent继承Event,多一个只读属性detail承载业务数据。- 用法:
new CustomEvent(类型字符串, 配置对象)→element.dispatchEvent(event)手动派发。
配置项说明
| 选项 | 本示例 | 作用 |
|---|---|---|
bubbles: true |
允许冒泡 | 父组件可在容器上统一 addEventListener('cart:add') |
cancelable: true |
允许取消 | 监听里可 preventDefault() 拦截「加购」 |
detail: { productId, ... } |
业务载荷 | 监听方通过 event.detail 取数据 |
命名建议
'cart:add'、'dialog:close':命名空间风格,避免与原生click等冲突。
监听与派发
javascript
// 派发
btn.dispatchEvent(new CustomEvent('cart:add', { detail: {...}, bubbles: true }));
// 监听(可在祖先节点)
document.addEventListener('cart:add', (e) => console.log(e.detail.productId));
与 EventBus 的分工
- CustomEvent:需要 DOM 树、冒泡、与 UI 节点绑定的场景(Web Component、原生组件通信)。
- EventBus:纯 JS 模块间、无 DOM 关系的消息(见 §9.2)。
易错点
- 忘记
dispatchEvent,事件不会自动触发。 detail传引用对象时,监听方修改会影响派发方,深拷贝视业务而定。
9.2 手写发布-订阅(EventBus)
当组件间没有直接 DOM 关系(兄弟组件、跨层级)时,可借助一个中间对象(EventBus)实现解耦通信------这正是 Vue 2 $emit/$on、Node.js EventEmitter 的底层思想。
javascript
// ===== 核心逻辑(详见下方【代码注释】) =====
class EventBus {
constructor() {
this._events = Object.create(null); // 避免原型污染
}
/** 订阅事件 */
on(type, listener) {
if (!this._events[type]) {
this._events[type] = [];
}
this._events[type].push(listener);
return this; // 支持链式调用
}
/** 发布事件 */
emit(type, ...args) {
const listeners = this._events[type];
if (!listeners) return false;
listeners.slice().forEach(fn => fn(...args)); // slice 防止监听器内部 off 影响当前循环
return true;
}
/** 取消订阅 */
off(type, listener) {
if (!this._events[type]) return this;
this._events[type] = this._events[type].filter(fn => fn !== listener);
return this;
}
/** 一次性订阅 */
once(type, listener) {
const wrapper = (...args) => {
listener(...args);
this.off(type, wrapper);
};
return this.on(type, wrapper);
}
}
// 使用示例
const bus = new EventBus();
// 模块 A:订阅登录成功事件
bus.on('user:login', (user) => {
console.log('欢迎', user.name, '!更新头部导航...');
});
// 模块 B:订阅一次
bus.once('user:login', (user) => {
console.log('首次登录,发放新人优惠券...');
});
// 登录服务:发布
bus.emit('user:login', { name: '张三', role: 'admin' });
【代码注释】
数据结构
javascript
this._events = Object.create(null);
// { 'user:login': [fn1, fn2], 'cart:add': [fn3] }
- 键:事件类型字符串;值:监听器数组(同一事件可多人订阅)。
on(type, listener)
- 若
type首次出现,初始化为[],再push。 - 返回
this支持链式:bus.on('a', fn).on('b', fn2)。
emit(type, ...args)
javascript
(this._events[type] || []).slice().forEach(fn => fn.apply(this, args));
slice()拷贝 :防止某监听器内部off自己导致遍历索引错乱(经典防御)。apply(this, args):让监听器里this指向 bus,与 Node EventEmitter 一致。
off(type, listener)
- 用
indexOf+splice删除指定回调;不传listener可扩展为清空该 type(视实现而定)。
once(type, listener)
- 包装函数
wrapper:先执行listener,再off(type, wrapper)。 - 利用闭包保存原 listener,保证只触发一次。
为何 Object.create(null)
- 普通
{}有toString、constructor等原型属性;事件名若叫'toString'会踩坑。 null原型对象没有继承属性,做字典更安全。
与 CustomEvent 对比
| CustomEvent | EventBus | |
|---|---|---|
| 依赖 DOM | ✅ 需要节点 dispatch | ❌ 纯内存 |
| 冒泡 | ✅ 可选 | ❌ |
| 典型场景 | 组件 DOM 子树通信 | 跨模块、全局状态通知 |
市面应用
- Vue 2:
new Vue()当 bus,$on/$emit(Vue 3 推荐 mitt / pinia)。 - Node.js:
EventEmitter几乎同一套 API 设计。
9.3 CustomEvent vs EventBus 对比
| 对比维度 | CustomEvent(DOM 原生) | EventBus(手写/库) |
|---|---|---|
| 依赖 | 必须有 DOM 元素 | 纯 JS,不依赖 DOM |
| 传播 | 支持冒泡/捕获 | 无传播,仅精确匹配 |
| 数据携带 | event.detail |
直接作为参数传递 |
| 适用场景 | 组件内部/跨层 DOM | 任意模块,无 DOM 约束 |
| 框架类比 | --- | Vue $emit/$on、Node EventEmitter |
【本章小结】自定义事件与发布-订阅
| 知识点 | 核心要点 |
|---|---|
| CustomEvent | new CustomEvent(type, { detail, bubbles, cancelable }) |
| 派发 | element.dispatchEvent(event) |
| 发布-订阅核心 | on(订阅)、emit(发布)、off(取消)、once(一次) |
| 解耦价值 | 发布者与订阅者无直接引用,降低模块耦合 |
| 注意事项 | 及时 off 防止内存泄漏;once 用于一次性场景 |
十、工程场景专题实战
本章将前九章的理论映射到高频工程场景,每个案例均完整可运行,并标注了对应真实产品的使用场景。
10.1 触摸手势轮播(移动端 Touch 滑动)
场景: 电商 App、新闻 App 的商品图片/Banner 支持左右手指滑动切换。TouchEvent 三件套:touchstart、touchmove、touchend。
核心名词
| 属性 | 说明 |
|---|---|
event.touches |
当前接触屏幕的所有触摸点列表 |
event.changedTouches |
本次事件中发生变化的触摸点(touchend 时用这个) |
touch.clientX/Y |
触摸点相对于视口的坐标 |
touch.identifier |
触摸点唯一 ID,多指触控时区分每根手指 |
html
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>触摸手势轮播</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { background: #111; display: flex; flex-direction: column;
align-items: center; justify-content: center; min-height: 100vh; }
.carousel-wrap {
width: 360px; position: relative; overflow: hidden;
border-radius: 12px; user-select: none;
}
.slide-track {
display: flex;
transition: transform 0.35s cubic-bezier(.25,.46,.45,.94);
will-change: transform; /* 提示 GPU 合成层,减少重排 */
}
.slide {
flex-shrink: 0; width: 360px; height: 220px;
display: flex; align-items: center; justify-content: center;
font-size: 32px; font-weight: bold; color: #fff; border-radius: 12px;
}
/* 指示器 */
.dots {
display: flex; gap: 8px; justify-content: center; margin-top: 14px;
}
.dot {
width: 8px; height: 8px; border-radius: 50%; background: rgba(255,255,255,.4);
transition: background .25s, transform .25s; cursor: pointer;
}
.dot.active { background: #fff; transform: scale(1.3); }
/* 进度条提示(可选视觉增强)*/
.progress {
position: absolute; bottom: 0; left: 0;
height: 3px; background: rgba(255,255,255,.6);
transition: width .35s linear;
}
</style>
</head>
<body>
<div class="carousel-wrap" id="touchCarousel">
<div class="slide-track" id="track">
<div class="slide" style="background:linear-gradient(135deg,#667eea,#764ba2)">🎵 音乐</div>
<div class="slide" style="background:linear-gradient(135deg,#f093fb,#f5576c)">📱 手机</div>
<div class="slide" style="background:linear-gradient(135deg,#4facfe,#00f2fe)">💻 电脑</div>
<div class="slide" style="background:linear-gradient(135deg,#43e97b,#38f9d7)">🎮 游戏</div>
</div>
<div class="progress" id="progressBar"></div>
</div>
<div class="dots" id="dots"></div>
<script>
const track = document.getElementById('track');
const dotsEl = document.getElementById('dots');
const progress = document.getElementById('progressBar');
const wrap = document.getElementById('touchCarousel');
const slides = track.querySelectorAll('.slide');
const total = slides.length;
let current = 0;
let autoTimer = null;
// ── 生成指示器 ──
slides.forEach((_, i) => {
const d = document.createElement('div');
d.className = 'dot' + (i === 0 ? ' active' : '');
d.addEventListener('click', () => goTo(i));
dotsEl.appendChild(d);
});
function goTo(index, animated = true) {
// 关闭/开启过渡
track.style.transition = animated
? 'transform 0.35s cubic-bezier(.25,.46,.45,.94)'
: 'none';
current = (index + total) % total;
track.style.transform = `translateX(${-current * 360}px)`;
// 更新指示器
dotsEl.querySelectorAll('.dot').forEach((d, i) => {
d.classList.toggle('active', i === current);
});
// 更新进度条(可选)
progress.style.width = `${((current + 1) / total) * 100}%`;
}
// ── 自动播放 ──
function startAuto() {
autoTimer = setInterval(() => goTo(current + 1), 3500);
}
function stopAuto() { clearInterval(autoTimer); }
startAuto();
// ── 触摸事件核心逻辑 ──
let startX = 0; // 手指按下时的 X 坐标
let startY = 0; // 用于判断是否是水平滑动
let isDragging = false;
let isHorizontal = null; // null=未判断,true=水平,false=垂直
wrap.addEventListener('touchstart', (e) => {
stopAuto();
startX = e.touches[0].clientX;
startY = e.touches[0].clientY;
isDragging = true;
isHorizontal = null;
// 关闭过渡,实时跟随手指
track.style.transition = 'none';
}, { passive: true }); // passive:true 告知浏览器不会 preventDefault,允许原生滚动优化
wrap.addEventListener('touchmove', (e) => {
if (!isDragging) return;
const dx = e.touches[0].clientX - startX;
const dy = e.touches[0].clientY - startY;
// 首次移动时判断方向(水平 or 垂直),避免误触
if (isHorizontal === null) {
isHorizontal = Math.abs(dx) > Math.abs(dy);
}
if (!isHorizontal) return; // 垂直滑动不处理
e.preventDefault(); // 阻止垂直方向的滚动干扰(需 passive:false)
// 实时偏移:当前帧的基础偏移 + 手指位移
const baseOffset = -current * 360;
track.style.transform = `translateX(${baseOffset + dx}px)`;
}, { passive: false }); // 需要 preventDefault,故 passive:false
wrap.addEventListener('touchend', (e) => {
if (!isDragging || !isHorizontal) { isDragging = false; startAuto(); return; }
isDragging = false;
const dx = e.changedTouches[0].clientX - startX;
const THRESHOLD = 60; // 滑动超过 60px 才切换
if (dx < -THRESHOLD) {
goTo(current + 1); // 向左滑 → 下一张
} else if (dx > THRESHOLD) {
goTo(current - 1); // 向右滑 → 上一张
} else {
goTo(current); // 未达阈值,弹回当前帧
}
startAuto();
});
// 鼠标悬停暂停(PC 测试用)
wrap.addEventListener('mouseenter', stopAuto);
wrap.addEventListener('mouseleave', startAuto);
// 初始化
goTo(0);
</script>
</body>
</html>
【代码注释】
事件链路(触摸轮播)
| 阶段 | 事件 | 关键操作 |
|---|---|---|
| 手指按下 | touchstart |
记录 startX;passive: true 不阻塞滚动优化 |
| 手指移动 | touchmove |
算 deltaX;横向 时 preventDefault(需 passive: false) |
| 手指抬起 | touchend |
用 changedTouches[0] 算最终位移,决定是否 goTo 下一帧 |
passive 在本例中的分工
touchstart+{ passive: true }:不向浏览器承诺会阻止默认行为,列表页纵向滚动更顺滑。touchmove+{ passive: false }:允许在判定为横向滑动 时e.preventDefault(),避免页面跟着左右晃。
isHorizontal 判断
- 比较
|deltaX|与|deltaY|:横向主导才拦截默认行为并拖动轨道;纵向主导则交给浏览器滚动。 - 避免「想上下滚页面却被轮播抢走」的体验问题。
changedTouches vs touches
touchend时e.touches已空(手指已离开),末次坐标在e.changedTouches[0]。touchstart/touchmove用e.touches[0]。
THRESHOLD(如 60px)
- 位移不足:视为误触,
goTo回弹当前帧。 - 产品可调 50~80px,与动画时长、屏宽有关。
易错点
- 全程
passive: true会导致preventDefault无效,横向滑不动轮播。 - 未区分横/竖向,会误伤页面纵向滚动。
真实网站场景
淘宝商品图画廊、朋友圈九宫格、抖音全屏滑动,均为此套 Touch + 阈值 + 可选 preventDefault 模型。
10.2 拖拽排序(DragEvent 完整实现)
场景: 看板(Trello/飞书多维表格)拖拽卡片排序、待办列表重新排序、网页组件拖拽布局。
DragEvent 事件序列
目标容器 被拖元素 目标容器 被拖元素 #mermaid-svg-fpUece1jsj7CEmYK{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-fpUece1jsj7CEmYK .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-fpUece1jsj7CEmYK .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-fpUece1jsj7CEmYK .error-icon{fill:#552222;}#mermaid-svg-fpUece1jsj7CEmYK .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-fpUece1jsj7CEmYK .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-fpUece1jsj7CEmYK .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-fpUece1jsj7CEmYK .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-fpUece1jsj7CEmYK .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-fpUece1jsj7CEmYK .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-fpUece1jsj7CEmYK .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-fpUece1jsj7CEmYK .marker{fill:#333333;stroke:#333333;}#mermaid-svg-fpUece1jsj7CEmYK .marker.cross{stroke:#333333;}#mermaid-svg-fpUece1jsj7CEmYK svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-fpUece1jsj7CEmYK p{margin:0;}#mermaid-svg-fpUece1jsj7CEmYK .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-fpUece1jsj7CEmYK text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-fpUece1jsj7CEmYK .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-fpUece1jsj7CEmYK .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-fpUece1jsj7CEmYK .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-fpUece1jsj7CEmYK .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-fpUece1jsj7CEmYK #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-fpUece1jsj7CEmYK .sequenceNumber{fill:white;}#mermaid-svg-fpUece1jsj7CEmYK #sequencenumber{fill:#333;}#mermaid-svg-fpUece1jsj7CEmYK #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-fpUece1jsj7CEmYK .messageText{fill:#333;stroke:none;}#mermaid-svg-fpUece1jsj7CEmYK .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-fpUece1jsj7CEmYK .labelText,#mermaid-svg-fpUece1jsj7CEmYK .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-fpUece1jsj7CEmYK .loopText,#mermaid-svg-fpUece1jsj7CEmYK .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-fpUece1jsj7CEmYK .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-fpUece1jsj7CEmYK .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-fpUece1jsj7CEmYK .noteText,#mermaid-svg-fpUece1jsj7CEmYK .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-fpUece1jsj7CEmYK .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-fpUece1jsj7CEmYK .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-fpUece1jsj7CEmYK .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-fpUece1jsj7CEmYK .actorPopupMenu{position:absolute;}#mermaid-svg-fpUece1jsj7CEmYK .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-fpUece1jsj7CEmYK .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-fpUece1jsj7CEmYK .actor-man circle,#mermaid-svg-fpUece1jsj7CEmYK line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-fpUece1jsj7CEmYK :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} dragstart(开始拖拽) drag(拖拽中,持续触发) dragenter(进入目标区域) dragover(在目标区域上方,持续) drop(松开鼠标) dragend(拖拽结束)
html
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<title>拖拽排序</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: "Microsoft YaHei", sans-serif; background: #f0f2f5;
padding: 40px; min-height: 100vh; }
h2 { text-align: center; color: #333; margin-bottom: 24px; font-size: 20px; }
.board {
display: flex; gap: 20px; justify-content: center; flex-wrap: wrap;
}
.column {
background: #ebecf0; border-radius: 8px; padding: 12px;
width: 240px; min-height: 300px;
}
.column-title {
font-weight: bold; color: #333; margin-bottom: 12px;
padding: 0 4px; font-size: 15px;
}
.card {
background: #fff; border-radius: 6px; padding: 12px 14px;
margin-bottom: 8px; cursor: grab; user-select: none;
box-shadow: 0 1px 3px rgba(0,0,0,.12);
transition: box-shadow .2s, opacity .2s, transform .15s;
font-size: 14px; color: #333; line-height: 1.5;
border-left: 3px solid transparent;
}
.card:active { cursor: grabbing; }
.card.dragging {
opacity: .4; transform: scale(.98);
box-shadow: none;
}
/* 拖拽悬浮时,目标插入位置的视觉提示 */
.card.drag-over-above { border-top: 2px solid #1a73e8; }
.card.drag-over-below { border-bottom: 2px solid #1a73e8; }
/* 列高亮 */
.column.drag-over {
background: #dde5f5;
outline: 2px dashed #1a73e8;
}
.drop-hint {
height: 40px; border: 2px dashed #1a73e8; border-radius: 6px;
display: none; align-items: center; justify-content: center;
color: #1a73e8; font-size: 13px; margin-bottom: 8px; background: #e8f0fe;
}
.column.drag-over .drop-hint { display: flex; }
/* 优先级标签 */
.tag {
display: inline-block; padding: 2px 6px; border-radius: 3px;
font-size: 11px; font-weight: bold; margin-top: 6px;
}
.tag-high { background: #fde8e6; color: #ea4a36; }
.tag-medium { background: #fff3cd; color: #e67e00; }
.tag-low { background: #e6f9ee; color: #34a853; }
</style>
</head>
<body>
<h2>任务看板(拖拽排序)</h2>
<div class="board" id="board">
<div class="column" id="col-todo" data-status="todo">
<div class="column-title">📋 待处理</div>
<div class="drop-hint">+ 拖到这里</div>
<div class="card" draggable="true" data-id="1">
设计首页原型图
<br><span class="tag tag-high">高优先级</span>
</div>
<div class="card" draggable="true" data-id="2">
整理需求文档
<br><span class="tag tag-medium">中优先级</span>
</div>
<div class="card" draggable="true" data-id="3">
技术选型调研
<br><span class="tag tag-low">低优先级</span>
</div>
</div>
<div class="column" id="col-doing" data-status="doing">
<div class="column-title">🔄 进行中</div>
<div class="drop-hint">+ 拖到这里</div>
<div class="card" draggable="true" data-id="4">
开发登录模块
<br><span class="tag tag-high">高优先级</span>
</div>
<div class="card" draggable="true" data-id="5">
接口联调
<br><span class="tag tag-medium">中优先级</span>
</div>
</div>
<div class="column" id="col-done" data-status="done">
<div class="column-title">✅ 已完成</div>
<div class="drop-hint">+ 拖到这里</div>
<div class="card" draggable="true" data-id="6">
项目环境搭建
<br><span class="tag tag-low">低优先级</span>
</div>
</div>
</div>
<script>
let draggingCard = null; // 正在拖拽的 card 元素
let sourceColumn = null; // 拖拽起始的列
// ── 利用事件委托监听所有 card 的拖拽事件 ──
document.addEventListener('dragstart', (e) => {
const card = e.target.closest('.card');
if (!card) return;
draggingCard = card;
sourceColumn = card.closest('.column');
card.classList.add('dragging');
// 通过 dataTransfer 传递数据(跨浏览器标准方式)
e.dataTransfer.setData('text/plain', card.dataset.id);
e.dataTransfer.effectAllowed = 'move';
});
document.addEventListener('dragend', (e) => {
const card = e.target.closest('.card');
if (!card) return;
card.classList.remove('dragging');
draggingCard = null;
// 清除所有高亮状态
document.querySelectorAll('.column').forEach(c => c.classList.remove('drag-over'));
document.querySelectorAll('.card').forEach(c => {
c.classList.remove('drag-over-above', 'drag-over-below');
});
});
// ── 目标区域(列)的事件 ──
document.querySelectorAll('.column').forEach(column => {
column.addEventListener('dragenter', (e) => {
e.preventDefault();
column.classList.add('drag-over');
});
column.addEventListener('dragover', (e) => {
e.preventDefault(); // 必须阻止默认行为,否则 drop 不触发
e.dataTransfer.dropEffect = 'move';
if (!draggingCard) return;
// 找到鼠标下方最近的 card,决定插入到其上方还是下方
const afterCard = getAfterCard(column, e.clientY);
// 清除旧的悬停样式
column.querySelectorAll('.card').forEach(c => {
c.classList.remove('drag-over-above', 'drag-over-below');
});
if (afterCard) {
afterCard.classList.add('drag-over-above');
}
});
column.addEventListener('dragleave', (e) => {
// 只有真正离开列容器才移除高亮(避免子元素触发干扰)
if (!column.contains(e.relatedTarget)) {
column.classList.remove('drag-over');
}
});
column.addEventListener('drop', (e) => {
e.preventDefault();
column.classList.remove('drag-over');
if (!draggingCard) return;
const afterCard = getAfterCard(column, e.clientY);
// 将拖拽的 card 插入到目标位置
if (afterCard) {
column.insertBefore(draggingCard, afterCard);
} else {
// 插入到列末尾(drop hint 之后)
column.appendChild(draggingCard);
}
console.log(`卡片 #${draggingCard.dataset.id} → 列: ${column.dataset.status}`);
});
});
/**
* 计算应该插入到哪个 card 的上方
* 通过比较每个 card 的中心 Y 坐标与鼠标 Y 坐标,找到第一个"在鼠标下方"的 card
*/
function getAfterCard(column, mouseY) {
const cards = [...column.querySelectorAll('.card:not(.dragging)')];
return cards.reduce((closest, card) => {
const rect = card.getBoundingClientRect();
const offset = mouseY - (rect.top + rect.height / 2); // 负值:鼠标在 card 上方
if (offset < 0 && offset > closest.offset) {
return { offset, element: card };
}
return closest;
}, { offset: -Infinity }).element;
}
</script>
</body>
</html>
【代码注释】
DragEvent 标准序列
dragstart → drag(多次)→ dragenter → dragover(多次)→ drop → dragend
| 事件 | 必须调用 | 作用 |
|---|---|---|
dragover |
preventDefault() |
否则浏览器认为不可放置,drop 永远不会触发 |
dragstart |
setData('text/plain', id) |
把卡片 id 写入 DataTransfer,供 drop 读取 |
drop |
getData + DOM 插入 |
根据 id 移动节点到计算出的位置 |
getAfterCard 算法
- 遍历列内卡片,找「卡片垂直中心 ≤ 鼠标 clientY」且距离最近的一张。
- 插入到该卡之后;若都没有则插到列底。
- 这是 Trello/Notion 列内排序的常见算法。
dragleave 防闪烁
javascript
if (!column.contains(e.relatedTarget)) { /* 移除高亮 */ }
- 鼠标从卡片移到卡片内子元素时,会误触
dragleave;检查relatedTarget是否仍在列内。
dataTransfer
- 同源页面用
text/plain即可;跨应用拖文件用files类型(见 §10.7)。
易错点
- 只在
drop里preventDefault不够,dragover必须持续阻止。 dragstart忘记setData,drop拿不到 id。
真实网站场景
Trello 看板、飞书多维表格、Notion 数据库视图、GitHub Projects 列内拖拽。
10.3 键盘快捷键管理器
场景: 富文本编辑器(如语雀、飞书文档)的 Ctrl+B 加粗、Ctrl+Z 撤销;网页游戏的 WASD 移动;全站搜索的 / 快捷键。
KeyboardEvent 关键属性
| 属性 | 说明 | 示例 |
|---|---|---|
e.key |
按键的可打印值 | 'a'、'Enter'、'ArrowLeft' |
e.code |
物理按键代码(与语言无关) | 'KeyA'、'Enter'、'ArrowLeft' |
e.ctrlKey |
Ctrl 是否按下 | true/false |
e.shiftKey |
Shift 是否按下 | true/false |
e.altKey |
Alt 是否按下 | true/false |
e.metaKey |
Meta/Cmd 是否按下(Mac) | true/false |
e.repeat |
是否是长按重复触发 | true/false |
e.keyvse.code:e.key受语言/输入法影响(切换到中文后某些键值会变),e.code代表物理位置永远不变------快捷键系统应优先用e.key,游戏 WASD 移动用e.code。
html
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<title>键盘快捷键管理器</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: "Microsoft YaHei", sans-serif; background: #1e1e1e;
color: #d4d4d4; padding: 32px; }
h2 { color: #fff; margin-bottom: 24px; font-size: 18px; }
.editor-area {
width: 100%; max-width: 700px; min-height: 200px;
background: #252526; border: 1px solid #404040;
border-radius: 6px; padding: 16px; font-size: 15px;
line-height: 1.7; outline: none; color: #d4d4d4;
resize: vertical;
}
.toolbar {
display: flex; gap: 8px; margin-bottom: 12px; flex-wrap: wrap;
}
.toolbar button {
padding: 6px 14px; border-radius: 4px; border: 1px solid #555;
background: #3c3c3c; color: #d4d4d4; cursor: pointer; font-size: 13px;
transition: background .15s;
}
.toolbar button:hover { background: #4c4c4c; }
/* 快捷键提示浮层 */
.shortcut-toast {
position: fixed; top: 20px; right: 20px;
background: rgba(30,30,30,.95); color: #d4d4d4;
border: 1px solid #555; border-radius: 8px;
padding: 12px 18px; font-size: 13px; z-index: 9999;
opacity: 0; transform: translateY(-10px);
transition: opacity .2s, transform .2s; pointer-events: none;
min-width: 200px;
}
.shortcut-toast.show { opacity: 1; transform: translateY(0); }
.shortcut-toast kbd {
background: #555; padding: 2px 6px; border-radius: 3px;
font-family: monospace; font-size: 12px; margin: 0 2px;
}
/* 快捷键帮助面板 */
.help-panel {
display: none; position: fixed; top: 50%; left: 50%;
transform: translate(-50%, -50%); background: #252526;
border: 1px solid #555; border-radius: 10px; padding: 28px 32px;
z-index: 10000; min-width: 380px; box-shadow: 0 8px 40px rgba(0,0,0,.6);
}
.help-panel.open { display: block; }
.help-panel h3 { color: #fff; margin-bottom: 18px; font-size: 16px; }
.help-row {
display: flex; justify-content: space-between; align-items: center;
padding: 8px 0; border-bottom: 1px solid #3c3c3c; font-size: 14px;
}
.help-row:last-child { border-bottom: none; }
.status-bar {
margin-top: 10px; font-size: 12px; color: #888;
display: flex; gap: 16px;
}
</style>
</head>
<body>
<h2>⌨️ 键盘快捷键管理器演示</h2>
<div class="toolbar">
<button onclick="execCmd('bold')"><b>B</b> 加粗</button>
<button onclick="execCmd('italic')"><i>I</i> 斜体</button>
<button onclick="execCmd('underline')"><u>U</u> 下划线</button>
<button onclick="document.execCommand('undo')">↩ 撤销</button>
<button onclick="document.getElementById('helpPanel').classList.toggle('open')">? 快捷键帮助</button>
</div>
<div class="editor-area" id="editor" contenteditable="true">
在这里输入文字,然后选中后按快捷键试试:<br>
• Ctrl+B:加粗 • Ctrl+I:斜体 • Ctrl+U:下划线<br>
• Ctrl+Z:撤销 • Ctrl+S:保存(阻止浏览器默认另存为)<br>
• Ctrl+/:显示快捷键帮助 • Escape:关闭帮助面板<br>
• /(斜杠):聚焦搜索框(仿 GitHub / Notion)
</div>
<div class="status-bar">
<span id="charCount">字符数: 0</span>
<span id="lastAction">最后操作: ---</span>
</div>
<!-- 操作提示浮层 -->
<div class="shortcut-toast" id="toast"></div>
<!-- 快捷键帮助面板 -->
<div class="help-panel" id="helpPanel">
<h3>⌨️ 快捷键列表</h3>
<div class="help-row"><span>加粗</span><span><kbd>Ctrl</kbd>+<kbd>B</kbd></span></div>
<div class="help-row"><span>斜体</span><span><kbd>Ctrl</kbd>+<kbd>I</kbd></span></div>
<div class="help-row"><span>下划线</span><span><kbd>Ctrl</kbd>+<kbd>U</kbd></span></div>
<div class="help-row"><span>撤销</span><span><kbd>Ctrl</kbd>+<kbd>Z</kbd></span></div>
<div class="help-row"><span>保存</span><span><kbd>Ctrl</kbd>+<kbd>S</kbd></span></div>
<div class="help-row"><span>快捷键帮助</span><span><kbd>Ctrl</kbd>+<kbd>/</kbd></span></div>
<div class="help-row"><span>关闭面板</span><span><kbd>Esc</kbd></span></div>
<div class="help-row"><span>聚焦搜索</span><span><kbd>/</kbd></span></div>
<button onclick="document.getElementById('helpPanel').classList.remove('open')"
style="margin-top:16px;width:100%;padding:8px;background:#1a73e8;color:#fff;
border:none;border-radius:4px;cursor:pointer;font-size:14px;">关闭</button>
</div>
<script>
// ════════════════════════════════
// 快捷键管理器(ShortcutManager)
// ════════════════════════════════
const ShortcutManager = {
_shortcuts: new Map(), // 快捷键注册表
/**
* 注册快捷键
* @param {string} combo - 组合键描述,如 "ctrl+b"、"ctrl+shift+z"、"/"
* @param {Function} handler - 触发时执行的函数
* @param {Object} options - { preventDefault: true, description: '' }
*/
register(combo, handler, options = {}) {
this._shortcuts.set(combo.toLowerCase(), { handler, options });
return this;
},
unregister(combo) {
this._shortcuts.delete(combo.toLowerCase());
},
/** 将 KeyboardEvent 解析为标准 combo 字符串 */
_parseEvent(e) {
const parts = [];
if (e.ctrlKey || e.metaKey) parts.push('ctrl'); // 兼容 Mac Cmd
if (e.altKey) parts.push('alt');
if (e.shiftKey) parts.push('shift');
// 使用 e.key 的小写,特殊键直接用 e.key
const key = e.key.toLowerCase();
if (!['control', 'alt', 'shift', 'meta'].includes(key)) {
parts.push(key);
}
return parts.join('+');
},
/** 在目标元素上启动监听 */
attach(target = document) {
target.addEventListener('keydown', (e) => {
const combo = this._parseEvent(e);
const binding = this._shortcuts.get(combo);
if (binding) {
const { handler, options } = binding;
if (options.preventDefault !== false) e.preventDefault();
handler(e, combo);
}
});
}
};
// ── 注册快捷键 ──
ShortcutManager
.register('ctrl+b', () => { execCmd('bold'); showToast('加粗'); }, { preventDefault: true })
.register('ctrl+i', () => { execCmd('italic'); showToast('斜体'); }, { preventDefault: true })
.register('ctrl+u', () => { execCmd('underline'); showToast('下划线'); }, { preventDefault: true })
.register('ctrl+z', () => { document.execCommand('undo'); showToast('撤销'); })
.register('ctrl+s', () => {
showToast('已保存 ✓(阻止了浏览器默认的另存为)');
// 实际项目:调用 API 保存内容
})
.register('ctrl+/', () => {
document.getElementById('helpPanel').classList.toggle('open');
showToast('快捷键帮助');
})
.register('escape', () => {
document.getElementById('helpPanel').classList.remove('open');
})
.register('/', (e) => {
// 只在非输入框聚焦时拦截(避免在编辑器内输入 / 被拦截)
if (e.target === document.getElementById('editor')) return;
e.preventDefault();
document.getElementById('editor').focus();
showToast('搜索框已聚焦(/ 快捷键)');
}, { preventDefault: false }) // 交给 handler 内部决定是否 preventDefault
.attach(document);
// ── 工具函数 ──
function execCmd(cmd) {
document.getElementById('editor').focus();
document.execCommand(cmd);
updateStatus(`${cmd} 格式化`);
}
let toastTimer = null;
function showToast(msg) {
const toast = document.getElementById('toast');
toast.innerHTML = `执行:<strong>${msg}</strong>`;
toast.classList.add('show');
clearTimeout(toastTimer);
toastTimer = setTimeout(() => toast.classList.remove('show'), 2000);
updateStatus(msg);
}
function updateStatus(action) {
document.getElementById('lastAction').textContent = `最后操作: ${action}`;
const text = document.getElementById('editor').innerText;
document.getElementById('charCount').textContent = `字符数: ${text.length}`;
}
// 字符计数
document.getElementById('editor').addEventListener('input', () => {
updateStatus('输入内容');
});
</script>
</body>
</html>
【代码注释】
ShortcutManager 架构
| 方法 | 作用 |
|---|---|
register(key, handler) |
Map 存 "ctrl+b" → 回调 |
_parseEvent(e) |
把 KeyboardEvent 转成规范字符串 |
handleKeydown(e) |
匹配后执行;可 preventDefault 阻止浏览器默认 |
_parseEvent 规则
- 收集
ctrl/alt/shift/meta(Mac 的 ⌘ 用metaKey映射为ctrl统一处理)。 - 主键用
e.key.toLowerCase(),但跳过 修饰键本身(避免单独按 Ctrl 触发ctrl)。 - 拼接为
"ctrl+shift+b"形式,与注册键一致才触发。
跨平台
- Windows:
ctrlKey;Mac 常用metaKey(⌘)代替 Ctrl → 示例里统一成ctrl便于一套配置两端用(VSCode/Notion 同款策略)。
条件拦截(/ 搜索)
javascript
if (e.target === editor) return;
- 焦点在输入框/编辑器内时,不抢键;否则
preventDefault并聚焦全局搜索。 - 体现「同一按键在不同焦点下不同行为」。
与 §1.5 KeyboardEvent 的衔接
- 必读
key/code;废弃keyCode。 - 修饰键用
ctrlKey等布尔属性,不要只靠key。
易错点
- 忘记
preventDefault,浏览器默认快捷键(如 Ctrl+F)仍会触发。 - 未过滤输入框,导致用户无法正常打字。
真实网站场景
VSCode 命令面板、GitHub / 搜索、飞书 Ctrl+K 链接、Notion 斜杠命令。
10.4 右键自定义菜单(ContextMenu)
场景: 网页在线图片编辑器(右键菜单复制/裁剪)、文件管理器(右键新建/删除)、数据表格(右键插入行)。
html
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<title>右键自定义菜单</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: "Microsoft YaHei", sans-serif; background: #f0f2f5;
min-height: 100vh; padding: 40px; user-select: none; }
h2 { color: #333; margin-bottom: 16px; }
p { color: #666; margin-bottom: 24px; font-size: 14px; }
/* 内容区 */
.file-grid {
display: grid; grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
gap: 16px; max-width: 600px;
}
.file-item {
background: #fff; border-radius: 8px; padding: 16px 12px;
text-align: center; cursor: default; border: 2px solid transparent;
transition: border-color .15s, background .15s; font-size: 13px; color: #333;
}
.file-item:hover { border-color: #1a73e8; background: #e8f0fe; }
.file-item.selected { border-color: #1a73e8; background: #d2e3fc; }
.file-item .icon { font-size: 36px; margin-bottom: 8px; }
/* ── 右键菜单 ── */
.ctx-menu {
position: fixed; z-index: 9999;
background: #fff; border-radius: 8px;
box-shadow: 0 4px 20px rgba(0,0,0,.18);
padding: 6px 0; min-width: 180px;
opacity: 0; transform: scale(.95);
pointer-events: none;
transition: opacity .12s, transform .12s;
}
.ctx-menu.open {
opacity: 1; transform: scale(1); pointer-events: auto;
}
.ctx-item {
display: flex; align-items: center; gap: 10px;
padding: 9px 16px; font-size: 14px; color: #333;
cursor: pointer; transition: background .1s;
}
.ctx-item:hover { background: #f0f8ff; color: #1a73e8; }
.ctx-item.danger:hover { background: #fde8e6; color: #ea4a36; }
.ctx-item .icon { font-size: 16px; width: 20px; text-align: center; }
.ctx-item .shortcut { margin-left: auto; font-size: 11px; color: #999; }
.ctx-divider {
height: 1px; background: #f0f0f0; margin: 4px 0;
}
.ctx-sub-label {
padding: 4px 16px; font-size: 11px; color: #999; text-transform: uppercase;
}
</style>
</head>
<body>
<h2>文件管理器(右键点击文件)</h2>
<p>在文件上右键:弹出针对文件的操作菜单。在空白处右键:弹出新建菜单。</p>
<div class="file-grid" id="fileGrid">
<div class="file-item" data-type="folder" data-name="项目文档">
<div class="icon">📁</div>项目文档
</div>
<div class="file-item" data-type="image" data-name="封面图.jpg">
<div class="icon">🖼️</div>封面图.jpg
</div>
<div class="file-item" data-type="file" data-name="需求文档.docx">
<div class="icon">📄</div>需求文档.docx
</div>
<div class="file-item" data-type="file" data-name="数据分析.xlsx">
<div class="icon">📊</div>数据分析.xlsx
</div>
<div class="file-item" data-type="code" data-name="index.js">
<div class="icon">📝</div>index.js
</div>
</div>
<!-- 右键菜单(文件) -->
<div class="ctx-menu" id="fileMenu">
<div class="ctx-sub-label" id="menuTitle">文件操作</div>
<div class="ctx-item" onclick="doAction('open')">
<span class="icon">📂</span> 打开 <span class="shortcut">Enter</span>
</div>
<div class="ctx-item" onclick="doAction('preview')">
<span class="icon">👁️</span> 预览
</div>
<div class="ctx-divider"></div>
<div class="ctx-item" onclick="doAction('copy')">
<span class="icon">📋</span> 复制 <span class="shortcut">Ctrl+C</span>
</div>
<div class="ctx-item" onclick="doAction('cut')">
<span class="icon">✂️</span> 剪切 <span class="shortcut">Ctrl+X</span>
</div>
<div class="ctx-item" onclick="doAction('rename')">
<span class="icon">✏️</span> 重命名 <span class="shortcut">F2</span>
</div>
<div class="ctx-divider"></div>
<div class="ctx-item" onclick="doAction('download')">
<span class="icon">⬇️</span> 下载
</div>
<div class="ctx-item" onclick="doAction('share')">
<span class="icon">🔗</span> 复制链接
</div>
<div class="ctx-divider"></div>
<div class="ctx-item danger" onclick="doAction('delete')">
<span class="icon">🗑️</span> 删除 <span class="shortcut">Del</span>
</div>
</div>
<!-- 右键菜单(空白处)-->
<div class="ctx-menu" id="blankMenu">
<div class="ctx-item" onclick="doAction('newFolder')">
<span class="icon">📁</span> 新建文件夹
</div>
<div class="ctx-item" onclick="doAction('newFile')">
<span class="icon">📄</span> 新建文件
</div>
<div class="ctx-divider"></div>
<div class="ctx-item" onclick="doAction('paste')">
<span class="icon">📌</span> 粘贴 <span class="shortcut">Ctrl+V</span>
</div>
<div class="ctx-divider"></div>
<div class="ctx-item" onclick="doAction('refresh')">
<span class="icon">🔄</span> 刷新
</div>
</div>
<script>
let activeMenu = null;
let targetItem = null; // 当前右键的文件项
// ── 阻止浏览器默认右键菜单,弹出自定义菜单 ──
document.addEventListener('contextmenu', (e) => {
e.preventDefault(); // 阻止浏览器默认菜单
closeAll();
const item = e.target.closest('.file-item');
if (item) {
// 右键点击在文件上:弹出文件菜单
targetItem = item;
item.classList.add('selected');
document.getElementById('menuTitle').textContent = item.dataset.name;
showMenu('fileMenu', e.clientX, e.clientY);
} else {
// 右键点击在空白处:弹出空白菜单
targetItem = null;
showMenu('blankMenu', e.clientX, e.clientY);
}
});
// ── 点击其他地方关闭菜单 ──
document.addEventListener('click', closeAll);
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') closeAll();
});
// ── 菜单定位:边界检测,防止超出视口 ──
function showMenu(id, x, y) {
const menu = document.getElementById(id);
activeMenu = menu;
// 先显示(不可见),获取尺寸
menu.style.left = x + 'px';
menu.style.top = y + 'px';
menu.classList.add('open');
// 边界检测:防止菜单超出视口右/下边缘
const rect = menu.getBoundingClientRect();
const vw = window.innerWidth;
const vh = window.innerHeight;
if (rect.right > vw) menu.style.left = (x - rect.width) + 'px';
if (rect.bottom > vh) menu.style.top = (y - rect.height) + 'px';
}
function closeAll() {
document.querySelectorAll('.ctx-menu').forEach(m => m.classList.remove('open'));
document.querySelectorAll('.file-item').forEach(i => i.classList.remove('selected'));
activeMenu = null;
targetItem = null;
}
function doAction(action) {
const name = targetItem?.dataset.name || '未知文件';
const msg = {
open: `打开:${name}`,
preview: `预览:${name}`,
copy: `已复制:${name}`,
cut: `已剪切:${name}`,
rename: `重命名:${name}(输入新名称)`,
download: `开始下载:${name}`,
share: `链接已复制!`,
delete: `删除:${name}(需二次确认)`,
newFolder:`新建文件夹`,
newFile: `新建文件`,
paste: `粘贴文件`,
refresh: `刷新目录`,
}[action] || action;
alert(msg); // 实际项目替换为真实操作
closeAll();
}
</script>
</body>
</html>
【代码注释】
contextmenu 事件链路
- 用户右键 → 触发
contextmenu(MouseEvent,button === 2)。 document或目标上preventDefault()→ 关闭系统菜单(唯一可靠方式)。showMenu(x, y, target)显示自定义 DOM 菜单。
坐标定位
- 优先
e.clientX / e.clientY(相对视口),菜单position: fixed。 - 先显示再
getBoundingClientRect()做边界修正:超出右/下则向左/上展开。
事件委托识别文件项
javascript
const item = e.target.closest('.file-item');
- 点在图标、文件名文字上时,
target可能是子节点;closest向上找到业务节点。 - 与 §4.3 TodoList 委托同一模式。
关闭菜单
document监听click:点击空白关闭;菜单内操作需stopPropagation避免立刻关闭。
易错点
- 只在
click里判断右键不够,必须用contextmenu+preventDefault。 - 忘记边界检测,菜单在屏幕边缘被裁切。
真实网站场景
飞书云文档文件树、腾讯文档、CodeSandbox 文件右键菜单。
10.5 表单完整验证系统(多事件协作)
场景: 注册/登录表单需要:输入时实时校验(input)、离开时确认校验(blur)、提交时全量校验(submit)、焦点提示(focus)。
html
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<title>表单多事件验证系统</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: "Microsoft YaHei", sans-serif; background: #f0f2f5;
min-height: 100vh; display: flex; align-items: center; justify-content: center; }
.form-card {
background: #fff; border-radius: 12px; padding: 36px 40px;
width: 420px; box-shadow: 0 4px 24px rgba(0,0,0,.1);
}
h2 { font-size: 22px; color: #333; margin-bottom: 28px; text-align: center; }
.form-group { margin-bottom: 20px; position: relative; }
label {
display: block; font-size: 13px; color: #666; margin-bottom: 6px; font-weight: 500;
}
input {
width: 100%; height: 44px; padding: 0 12px; font-size: 14px;
border: 1.5px solid #d9d9d9; border-radius: 6px; outline: none;
transition: border-color .2s, box-shadow .2s; color: #333;
}
input:focus {
border-color: #1a73e8;
box-shadow: 0 0 0 3px rgba(26,115,232,.12);
}
input.valid { border-color: #34a853; }
input.invalid { border-color: #ea4a36; }
input.invalid:focus { box-shadow: 0 0 0 3px rgba(234,74,54,.12); }
.field-msg {
font-size: 12px; margin-top: 5px; min-height: 16px;
transition: color .2s; display: flex; align-items: center; gap: 4px;
}
.field-msg.error { color: #ea4a36; }
.field-msg.success { color: #34a853; }
.field-msg.hint { color: #999; }
/* 密码强度条 */
.strength-bar {
display: flex; gap: 4px; margin-top: 6px;
}
.strength-seg {
height: 4px; flex: 1; border-radius: 2px; background: #eee;
transition: background .3s;
}
.strength-label { font-size: 11px; color: #999; margin-top: 4px; }
/* 提交按钮 */
.submit-btn {
width: 100%; height: 44px; background: #1a73e8; color: #fff;
border: none; border-radius: 6px; font-size: 15px; cursor: pointer;
font-weight: 500; transition: background .2s; margin-top: 8px;
}
.submit-btn:hover:not(:disabled) { background: #1557b0; }
.submit-btn:disabled { background: #aaa; cursor: not-allowed; }
.required { color: #ea4a36; margin-left: 2px; }
/* 字符计数 */
.char-count { position: absolute; right: 12px; top: 38px; font-size: 11px; color: #bbb; }
</style>
</head>
<body>
<div class="form-card">
<h2>创建账户</h2>
<form id="registerForm" novalidate>
<div class="form-group">
<label>用户名 <span class="required">*</span></label>
<input type="text" id="username" name="username" placeholder="3~20位字符" maxlength="20" autocomplete="off">
<div class="char-count" id="usernameCount">0/20</div>
<div class="field-msg hint" id="usernameMsg">用户名只能包含字母、数字和下划线</div>
</div>
<div class="form-group">
<label>邮箱 <span class="required">*</span></label>
<input type="email" id="email" name="email" placeholder="your@email.com" autocomplete="off">
<div class="field-msg hint" id="emailMsg">请输入有效的邮箱地址</div>
</div>
<div class="form-group">
<label>密码 <span class="required">*</span></label>
<input type="password" id="password" name="password" placeholder="至少 8 位">
<div class="strength-bar" id="strengthBar">
<div class="strength-seg" id="s1"></div>
<div class="strength-seg" id="s2"></div>
<div class="strength-seg" id="s3"></div>
<div class="strength-seg" id="s4"></div>
</div>
<div class="strength-label" id="strengthLabel">密码强度</div>
<div class="field-msg hint" id="passwordMsg">包含大写、小写、数字、特殊字符</div>
</div>
<div class="form-group">
<label>确认密码 <span class="required">*</span></label>
<input type="password" id="confirm" name="confirm" placeholder="再次输入密码">
<div class="field-msg hint" id="confirmMsg">两次密码必须一致</div>
</div>
<button class="submit-btn" type="submit" id="submitBtn">立即注册</button>
</form>
</div>
<script>
// ── 校验规则定义 ──
const RULES = {
username: {
required: true,
pattern: /^[a-zA-Z0-9_]{3,20}$/,
messages: {
required: '用户名不能为空',
pattern: '3~20位,只允许字母、数字、下划线',
}
},
email: {
required: true,
pattern: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
messages: {
required: '邮箱不能为空',
pattern: '邮箱格式不正确',
}
},
password: {
required: true,
minLength: 8,
messages: {
required: '密码不能为空',
minLength: '密码至少 8 位',
}
},
confirm: {
required: true,
match: 'password',
messages: {
required: '请确认密码',
match: '两次输入的密码不一致',
}
}
};
// ── 单字段校验函数 ──
function validateField(name) {
const input = document.getElementById(name);
const msgEl = document.getElementById(name + 'Msg');
const rule = RULES[name];
const value = input.value.trim();
let error = null;
if (rule.required && !value) {
error = rule.messages.required;
} else if (rule.pattern && !rule.pattern.test(input.value)) {
error = rule.messages.pattern;
} else if (rule.minLength && value.length < rule.minLength) {
error = rule.messages.minLength;
} else if (rule.match) {
const matchValue = document.getElementById(rule.match).value;
if (value !== matchValue) error = rule.messages.match;
}
if (error) {
input.className = 'invalid';
msgEl.className = 'field-msg error';
msgEl.textContent = '⚠ ' + error;
return false;
} else if (value) {
input.className = 'valid';
msgEl.className = 'field-msg success';
msgEl.textContent = '✓ 通过';
return true;
} else {
input.className = '';
msgEl.className = 'field-msg hint';
msgEl.textContent = { username: '用户名只能包含字母、数字和下划线',
email: '请输入有效的邮箱地址',
password: '包含大写、小写、数字、特殊字符',
confirm: '两次密码必须一致' }[name];
return false;
}
}
// ── 密码强度计算 ──
function calcStrength(pw) {
let score = 0;
if (pw.length >= 8) score++;
if (/[A-Z]/.test(pw)) score++;
if (/[0-9]/.test(pw)) score++;
if (/[^a-zA-Z0-9]/.test(pw)) score++;
return score;
}
const COLORS = ['', '#ea4a36', '#fbbc04', '#34a853', '#1a73e8'];
const LABELS = ['', '弱', '中', '强', '极强'];
function updateStrength(pw) {
const score = calcStrength(pw);
['s1','s2','s3','s4'].forEach((id, i) => {
document.getElementById(id).style.background = i < score ? COLORS[score] : '#eee';
});
document.getElementById('strengthLabel').textContent =
pw ? `密码强度:${LABELS[score]}` : '密码强度';
}
// ── 绑定多种事件 ──
// 1. focus:聚焦时显示提示,清除错误样式
document.querySelectorAll('input').forEach(input => {
input.addEventListener('focus', () => {
if (input.classList.contains('invalid')) {
// 不在 focus 时清除错误,避免重复校验体验割裂
}
});
});
// 2. input:实时反馈(字符计数、密码强度)
document.getElementById('username').addEventListener('input', (e) => {
document.getElementById('usernameCount').textContent =
`${e.target.value.length}/20`;
// 输入时如果已标红,实时反馈(体验更流畅)
if (e.target.classList.contains('invalid')) validateField('username');
});
document.getElementById('password').addEventListener('input', (e) => {
updateStrength(e.target.value);
if (e.target.classList.contains('invalid')) validateField('password');
// 密码变化时同步校验确认密码
if (document.getElementById('confirm').value) validateField('confirm');
});
// 3. blur:失焦时校验(最重要的校验时机)
['username', 'email', 'password', 'confirm'].forEach(name => {
document.getElementById(name).addEventListener('blur', () => {
validateField(name);
});
});
// 4. submit:提交时全量校验
document.getElementById('registerForm').addEventListener('submit', (e) => {
e.preventDefault(); // 阻止表单默认提交(页面刷新)
const fields = ['username', 'email', 'password', 'confirm'];
const results = fields.map(name => validateField(name));
if (results.every(Boolean)) {
const btn = document.getElementById('submitBtn');
btn.disabled = true;
btn.textContent = '注册中...';
// 模拟 API 请求
setTimeout(() => {
btn.textContent = '✓ 注册成功!';
btn.style.background = '#34a853';
}, 1500);
} else {
// 滚动到第一个错误字段
const firstError = document.querySelector('input.invalid');
firstError?.scrollIntoView({ behavior: 'smooth', block: 'center' });
firstError?.focus();
}
});
</script>
</body>
</html>
【代码注释】
多事件分工(标准表单 UX)
| 事件 | 触发时机 | 本示例职责 |
|---|---|---|
input |
值变化 | 字段已标红后,改对立即去红(实时反馈) |
blur |
失焦 | 单字段校验,填完即知对错 |
focus |
获焦 | 可展示提示文案(可选) |
submit |
提交 | preventDefault + 全量校验,失败则拦截 |
submit + preventDefault
- 阻止表单默认整页 GET/POST 刷新。
- 校验通过后由 JS 发
fetch/axios,与 §3.3 表单演示一致。
字段联动(确认密码)
javascript
// 改密码后,若确认密码已有内容,重新校验确认密码
if (confirm.value) validateField('confirm');
- 解决「先填确认密码、后改密码」仍显示旧的「不一致」问题。
与 Ant Design 的对应
validateTrigger: ['onBlur', 'onChange']≈ 本例blur+input。onFinish前全量校验 ≈submit兜底。
易错点
- 只做
submit校验:用户填完 5 个框才发现第一个错 → 体验差。 input里每次都全表单校验:性能差、干扰输入 → 应单字段。
真实网站场景
注册/登录/支付页;Ant Design Form、Element Plus Form 底层触发器策略。
10.6 IntersectionObserver 懒加载与无限滚动
场景: 电商图片列表懒加载(页面打开只加载首屏图片);无限滚动(滚动到底部自动加载下一页)。IntersectionObserver 是现代浏览器替代 scroll + getBoundingClientRect 的高性能 API。
核心名词
| 术语 | 说明 |
|---|---|
| IntersectionObserver | 异步观察目标元素与根元素(或视口)相交状态变化的 API |
| threshold | 触发回调的相交比例阈值,0 表示刚进入,1 表示完全进入 |
| rootMargin | 扩展/收缩根元素的判定边界,类似 CSS margin(可提前加载) |
| entry.isIntersecting | 目标元素当前是否与根元素相交(进入视口) |
| entry.intersectionRatio | 当前相交比例(0~1) |
html
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<title>IntersectionObserver 懒加载与无限滚动</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: "Microsoft YaHei", sans-serif; background: #f0f2f5; }
.header {
position: sticky; top: 0; z-index: 100; background: #fff;
padding: 14px 24px; box-shadow: 0 2px 8px rgba(0,0,0,.08);
display: flex; justify-content: space-between; align-items: center;
}
.header h2 { font-size: 18px; color: #333; }
.header .stats { font-size: 13px; color: #888; }
.product-grid {
display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 16px; padding: 24px; max-width: 1000px; margin: 0 auto;
}
/* 商品卡片 */
.product-card {
background: #fff; border-radius: 10px; overflow: hidden;
box-shadow: 0 2px 8px rgba(0,0,0,.06); transition: transform .2s, box-shadow .2s;
}
.product-card:hover {
transform: translateY(-4px);
box-shadow: 0 8px 20px rgba(0,0,0,.12);
}
/* 图片懒加载 */
.lazy-img {
width: 100%; height: 160px; display: block; object-fit: cover;
background: #f0f0f0; /* 占位背景 */
transition: opacity .3s;
opacity: 0; /* 初始透明 */
}
.lazy-img.loaded { opacity: 1; } /* 加载完成后显示 */
/* 图片骨架屏 */
.img-skeleton {
width: 100%; height: 160px; background: linear-gradient(
90deg, #f0f0f0 25%, #e8e8e8 50%, #f0f0f0 75%
);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
}
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
.card-body { padding: 12px; }
.card-name { font-size: 14px; color: #333; margin-bottom: 6px;
overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
.card-price { font-size: 18px; font-weight: bold; color: #ea4a36; }
.card-price::before { content: '¥'; font-size: 13px; }
/* 无限滚动哨兵 */
.sentinel {
height: 60px; display: flex; align-items: center; justify-content: center;
color: #999; font-size: 14px; margin: 0 24px 24px;
}
.sentinel .spinner {
width: 24px; height: 24px; border: 3px solid #eee;
border-top-color: #1a73e8; border-radius: 50%;
animation: spin .8s linear infinite; margin-right: 8px;
}
@keyframes spin { to { transform: rotate(360deg); } }
</style>
</head>
<body>
<div class="header">
<h2>商品列表(懒加载 + 无限滚动)</h2>
<span class="stats" id="stats">已加载:0 件</span>
</div>
<div class="product-grid" id="grid"></div>
<div class="sentinel" id="sentinel">
<div class="spinner"></div>加载中...
</div>
<script>
// ── 模拟商品数据生成 ──
const COLORS = ['#667eea','#f093fb','#4facfe','#43e97b','#ffecd2','#a18cd1','#fda085','#96fbc4'];
const NAMES = ['无线耳机','智能手表','机械键盘','游戏鼠标','移动硬盘','便携音箱','充电宝','网络摄像头'];
function generateProducts(start, count) {
return Array.from({ length: count }, (_, i) => ({
id: start + i,
name: NAMES[(start + i) % NAMES.length] + ` Pro ${start + i}`,
price: (Math.random() * 500 + 50).toFixed(0),
color: COLORS[(start + i) % COLORS.length],
// 真实图片 URL(替换为真实 CDN)
imgSrc: null // 用背景色模拟
}));
}
// ── 渲染商品卡片(含懒加载占位)──
function renderCard(product) {
const card = document.createElement('div');
card.className = 'product-card';
card.innerHTML = `
<div class="img-skeleton" id="skeleton-${product.id}"></div>
<img class="lazy-img"
data-src="https://picsum.photos/seed/${product.id}/200/160"
alt="${product.name}"
id="img-${product.id}"
style="display:none">
<div class="card-body">
<div class="card-name">${product.name}</div>
<div class="card-price">${product.price}</div>
</div>
`;
return card;
}
// ── 图片懒加载观察器 ──
const imageObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (!entry.isIntersecting) return;
const img = entry.target;
const skeleton = document.getElementById('skeleton-' + img.id.replace('img-', ''));
// 设置真实 src,触发图片加载
img.src = img.dataset.src;
img.style.display = 'block';
img.onload = () => {
img.classList.add('loaded'); // 淡入显示
skeleton?.remove(); // 移除骨架屏
};
img.onerror = () => {
// 加载失败:显示占位色块
skeleton?.remove();
img.style.background = img.closest('.product-card')?.dataset?.color || '#eee';
img.style.display = 'block';
img.classList.add('loaded');
};
imageObserver.unobserve(img); // 加载后取消观察,节省性能
});
}, {
rootMargin: '100px 0px', // 提前 100px 开始加载(在进入视口之前)
threshold: 0 // 只要进入 rootMargin 范围就触发
});
// ── 无限滚动观察器 ──
let page = 0;
let loading = false;
let allLoaded = false;
const PAGE_SIZE = 12;
const sentinelObserver = new IntersectionObserver((entries) => {
const entry = entries[0];
if (entry.isIntersecting && !loading && !allLoaded) {
loadNextPage();
}
}, {
rootMargin: '200px 0px' // 提前 200px 触发,用户感知不到加载延迟
});
async function loadNextPage() {
loading = true;
// 模拟网络延迟
await new Promise(r => setTimeout(r, 800));
const products = generateProducts(page * PAGE_SIZE, PAGE_SIZE);
const grid = document.getElementById('grid');
products.forEach(product => {
const card = renderCard(product);
card.dataset.color = product.color;
grid.appendChild(card);
// 将图片注册到懒加载观察器
const img = card.querySelector('.lazy-img');
imageObserver.observe(img);
});
page++;
loading = false;
// 更新统计
document.getElementById('stats').textContent = `已加载:${page * PAGE_SIZE} 件`;
// 模拟第 5 页后没有更多数据
if (page >= 5) {
allLoaded = true;
document.getElementById('sentinel').innerHTML = '── 没有更多商品了 ──';
sentinelObserver.disconnect();
}
}
// 开始观察哨兵元素
sentinelObserver.observe(document.getElementById('sentinel'));
</script>
</body>
</html>
【代码注释】
两个 Observer 分工
| Observer | 观察谁 | 作用 |
|---|---|---|
imageObserver |
每张 img[data-src] |
进入视口(或提前 100px)时把 data-src 赋给 src |
sentinelObserver |
列表底部空 div |
进入视口时加载下一页数据(无限滚动) |
rootMargin: '100px 0px'
- 扩大「视为进入视口」的区域,提前加载。
- 用户滚到时图片已下载,减少白块闪烁。
unobserve(img)
- 加载完成后不再观察该节点,减少回调次数与内存占用。
对比 scroll 监听
scroll+getBoundingClientRect:滚动时每帧算位置,主线程压力大。IntersectionObserver:浏览器异步计算相交,仅在交叉状态变化时回调,不阻塞滚动渲染。
哨兵(Sentinel)模式
- 列表末尾放空
div,进入视口 → 请求下一页 → 追加 DOM → 哨兵被推到更下方,循环。
易错点
- 忘记
unobserve,列表很长时观察器过多。 - 图片无占位高度,加载后页面跳动(CLS)→ 需固定宽高或骨架屏。
真实网站场景
微博/Twitter/淘宝列表;React 可用 react-intersection-observer 封装同一 API。
10.7 文件拖拽上传(DragEvent + FileReader)
场景: 头像上传、文档管理器、图片编辑器------允许用户从系统文件管理器拖拽文件到网页区域直接上传,比 <input type="file"> 体验更好。
html
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<title>文件拖拽上传</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: "Microsoft YaHei", sans-serif; background: #f0f2f5;
min-height: 100vh; padding: 40px; }
h2 { color: #333; margin-bottom: 24px; font-size: 20px; }
/* 拖拽区域 */
.drop-zone {
border: 2px dashed #d9d9d9; border-radius: 12px; padding: 48px 24px;
text-align: center; transition: all .2s; cursor: pointer;
background: #fafafa; max-width: 600px;
}
.drop-zone:hover { border-color: #1a73e8; background: #e8f0fe; }
.drop-zone.drag-active { border-color: #1a73e8; background: #e8f0fe;
transform: scale(1.01); box-shadow: 0 0 0 4px rgba(26,115,232,.12); }
.drop-zone.drag-reject { border-color: #ea4a36; background: #fde8e6; }
.drop-icon { font-size: 52px; margin-bottom: 16px; }
.drop-title { font-size: 17px; color: #333; margin-bottom: 8px; font-weight: 500; }
.drop-desc { font-size: 13px; color: #999; }
.drop-desc b { color: #1a73e8; cursor: pointer; }
.drop-desc b:hover { text-decoration: underline; }
/* 文件列表 */
.file-list { margin-top: 24px; max-width: 600px; }
.file-item {
background: #fff; border-radius: 8px; padding: 14px 16px;
margin-bottom: 10px; display: flex; align-items: center; gap: 12px;
box-shadow: 0 1px 4px rgba(0,0,0,.08);
}
.file-thumb {
width: 52px; height: 52px; border-radius: 6px; object-fit: cover;
flex-shrink: 0; background: #f0f0f0;
}
.file-thumb.generic { display: flex; align-items: center; justify-content: center;
font-size: 24px; }
.file-info { flex: 1; min-width: 0; }
.file-name { font-size: 14px; color: #333; font-weight: 500;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.file-size { font-size: 12px; color: #999; margin-top: 4px; }
/* 进度条 */
.progress-wrap { width: 100%; height: 6px; background: #f0f0f0;
border-radius: 3px; margin-top: 6px; overflow: hidden; }
.progress-fill { height: 100%; border-radius: 3px; transition: width .3s ease;
background: #1a73e8; }
.progress-fill.done { background: #34a853; }
.progress-fill.error { background: #ea4a36; }
.file-status { font-size: 12px; margin-top: 4px; }
.file-status.done { color: #34a853; }
.file-status.error { color: #ea4a36; }
.file-status.uploading { color: #1a73e8; }
.remove-btn {
background: none; border: none; cursor: pointer; color: #bbb;
font-size: 18px; padding: 4px; flex-shrink: 0;
transition: color .15s;
}
.remove-btn:hover { color: #ea4a36; }
/* 隐藏的文件输入 */
#fileInput { display: none; }
.summary { margin-top: 16px; font-size: 13px; color: #666; }
</style>
</head>
<body>
<h2>文件拖拽上传</h2>
<!-- 拖拽区域(同时支持点击选择) -->
<div class="drop-zone" id="dropZone">
<div class="drop-icon">📁</div>
<div class="drop-title">将文件拖拽到此处</div>
<div class="drop-desc">
或 <b onclick="document.getElementById('fileInput').click()">点击选择文件</b>
· 支持图片、文档(最大 10MB)
</div>
</div>
<input type="file" id="fileInput" multiple
accept="image/*,.pdf,.doc,.docx,.xls,.xlsx,.txt">
<div class="file-list" id="fileList"></div>
<div class="summary" id="summary"></div>
<script>
const dropZone = document.getElementById('dropZone');
const fileInput = document.getElementById('fileInput');
const fileList = document.getElementById('fileList');
const summary = document.getElementById('summary');
const MAX_SIZE = 10 * 1024 * 1024; // 10MB
const ACCEPT_TYPES = ['image/', 'application/pdf', 'text/',
'application/msword', 'application/vnd.openxmlformats',
'application/vnd.ms-excel'];
let uploadedCount = 0;
// ── 拖拽进入:高亮 ──
dropZone.addEventListener('dragenter', (e) => {
e.preventDefault();
// 检查是否有文件类型(文件拖入时 items 有 kind==='file')
const hasFiles = [...e.dataTransfer.items].some(i => i.kind === 'file');
dropZone.classList.toggle('drag-active', hasFiles);
dropZone.classList.toggle('drag-reject', !hasFiles);
});
// ── 拖拽悬浮:必须 preventDefault 才能触发 drop ──
dropZone.addEventListener('dragover', (e) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'copy';
});
// ── 拖拽离开:取消高亮 ──
dropZone.addEventListener('dragleave', (e) => {
if (!dropZone.contains(e.relatedTarget)) {
dropZone.classList.remove('drag-active', 'drag-reject');
}
});
// ── 放置:处理文件 ──
dropZone.addEventListener('drop', (e) => {
e.preventDefault();
dropZone.classList.remove('drag-active', 'drag-reject');
const files = [...e.dataTransfer.files];
handleFiles(files);
});
// ── 点击选择文件 ──
fileInput.addEventListener('change', (e) => {
handleFiles([...e.target.files]);
fileInput.value = ''; // 重置,允许重复选同一文件
});
// ── 统一处理文件 ──
function handleFiles(files) {
files.forEach(file => {
if (file.size > MAX_SIZE) {
showFileItem(file, 'error', `文件过大(${formatSize(file.size)}),最大 10MB`);
return;
}
const isAccepted = ACCEPT_TYPES.some(t => file.type.startsWith(t));
if (!isAccepted) {
showFileItem(file, 'error', '不支持的文件类型');
return;
}
showFileItem(file, 'pending');
});
}
// ── 渲染文件项 + 模拟上传 ──
function showFileItem(file, status, errMsg = '') {
const id = 'file-' + Date.now() + Math.random().toString(36).slice(2, 6);
const item = document.createElement('div');
item.className = 'file-item';
item.id = id;
const isImage = file.type.startsWith('image/');
const icon = file.type.includes('pdf') ? '📄'
: file.type.includes('word') ? '📝'
: file.type.includes('excel') || file.type.includes('sheet') ? '📊'
: '📁';
item.innerHTML = `
${isImage
? `<img class="file-thumb" id="thumb-${id}" src="" alt="">`
: `<div class="file-thumb generic">${icon}</div>`}
<div class="file-info">
<div class="file-name" title="${file.name}">${file.name}</div>
<div class="file-size">${formatSize(file.size)}</div>
<div class="progress-wrap">
<div class="progress-fill" id="prog-${id}" style="width:0%"></div>
</div>
<div class="file-status uploading" id="status-${id}">
${status === 'error' ? errMsg : '准备上传...'}
</div>
</div>
<button class="remove-btn" onclick="removeItem('${id}')" title="移除">×</button>
`;
fileList.appendChild(item);
// 图片预览(FileReader)
if (isImage) {
const reader = new FileReader();
reader.onload = (e) => {
document.getElementById('thumb-' + id).src = e.target.result;
};
reader.readAsDataURL(file); // 读取为 Base64 Data URL
}
if (status === 'error') {
document.getElementById('prog-' + id).classList.add('error');
document.getElementById('prog-' + id).style.width = '100%';
document.getElementById('status-' + id).className = 'file-status error';
return;
}
// 模拟上传进度
simulateUpload(id);
}
function simulateUpload(id) {
let progress = 0;
const progEl = document.getElementById('prog-' + id);
const statusEl = document.getElementById('status-' + id);
if (!progEl) return;
statusEl.textContent = '上传中...';
const timer = setInterval(() => {
// 模拟随机进度增量
progress += Math.random() * 18 + 5;
progress = Math.min(progress, 99);
progEl.style.width = progress + '%';
if (progress >= 99) {
clearInterval(timer);
setTimeout(() => {
progEl.style.width = '100%';
progEl.classList.add('done');
statusEl.className = 'file-status done';
statusEl.textContent = '✓ 上传成功';
uploadedCount++;
updateSummary();
}, 200);
}
}, 120);
}
function removeItem(id) {
document.getElementById(id)?.remove();
updateSummary();
}
function updateSummary() {
const total = fileList.children.length;
const done = fileList.querySelectorAll('.progress-fill.done').length;
summary.textContent = total ? `共 ${total} 个文件,已成功 ${done} 个` : '';
}
function formatSize(bytes) {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
return (bytes / 1024 / 1024).toFixed(2) + ' MB';
}
</script>
</body>
</html>
【代码注释】
拖文件上传 vs §10.2 拖卡片排序
| 维度 | §10.2 列表排序 | §10.7 文件拖入 |
|---|---|---|
| 数据源 | setData('text/plain', id) |
dataTransfer.files |
| 关键事件 | dragover + preventDefault |
同上 + drop 读 files |
| 预览 | 移动 DOM 节点 | FileReader 本地预览 |
items vs files 陷阱
dragenter/dragover:可用items判断类型(如是否含图片)。files仅在drop时才有完整文件列表 ------过早读files常为空。
FileReader.readAsDataURL
- 异步读取为 Base64,在
onload里赋给<img src>。 - 适合即时预览 ;真正上传仍应用
FormData+fetch。
dropEffect = 'copy'
- 在
dragover中设置,系统光标显示「复制」而非「移动」。
fileInput.value = ''
- 重置后再次选择同一文件 仍会触发
change(否则浏览器认为值未变)。
易错点
- 未过滤非图片类型、未限制大小。
- 大文件 Base64 预览占内存 → 大文件可用
URL.createObjectURL(file)。
真实网站场景
语雀附件、腾讯文档图片、飞书多维表格文件上传。
总结
知识点回顾(思维导图)
#mermaid-svg-KHpWQvgDpttUS6DM{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-KHpWQvgDpttUS6DM .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-KHpWQvgDpttUS6DM .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-KHpWQvgDpttUS6DM .error-icon{fill:#552222;}#mermaid-svg-KHpWQvgDpttUS6DM .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-KHpWQvgDpttUS6DM .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-KHpWQvgDpttUS6DM .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-KHpWQvgDpttUS6DM .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-KHpWQvgDpttUS6DM .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-KHpWQvgDpttUS6DM .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-KHpWQvgDpttUS6DM .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-KHpWQvgDpttUS6DM .marker{fill:#333333;stroke:#333333;}#mermaid-svg-KHpWQvgDpttUS6DM .marker.cross{stroke:#333333;}#mermaid-svg-KHpWQvgDpttUS6DM svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-KHpWQvgDpttUS6DM p{margin:0;}#mermaid-svg-KHpWQvgDpttUS6DM .edge{stroke-width:3;}#mermaid-svg-KHpWQvgDpttUS6DM .section--1 rect,#mermaid-svg-KHpWQvgDpttUS6DM .section--1 path,#mermaid-svg-KHpWQvgDpttUS6DM .section--1 circle,#mermaid-svg-KHpWQvgDpttUS6DM .section--1 polygon,#mermaid-svg-KHpWQvgDpttUS6DM .section--1 path{fill:hsl(240, 100%, 76.2745098039%);}#mermaid-svg-KHpWQvgDpttUS6DM .section--1 text{fill:#ffffff;}#mermaid-svg-KHpWQvgDpttUS6DM .node-icon--1{font-size:40px;color:#ffffff;}#mermaid-svg-KHpWQvgDpttUS6DM .section-edge--1{stroke:hsl(240, 100%, 76.2745098039%);}#mermaid-svg-KHpWQvgDpttUS6DM .edge-depth--1{stroke-width:17;}#mermaid-svg-KHpWQvgDpttUS6DM .section--1 line{stroke:hsl(60, 100%, 86.2745098039%);stroke-width:3;}#mermaid-svg-KHpWQvgDpttUS6DM .disabled,#mermaid-svg-KHpWQvgDpttUS6DM .disabled circle,#mermaid-svg-KHpWQvgDpttUS6DM .disabled text{fill:lightgray;}#mermaid-svg-KHpWQvgDpttUS6DM .disabled text{fill:#efefef;}#mermaid-svg-KHpWQvgDpttUS6DM .section-0 rect,#mermaid-svg-KHpWQvgDpttUS6DM .section-0 path,#mermaid-svg-KHpWQvgDpttUS6DM .section-0 circle,#mermaid-svg-KHpWQvgDpttUS6DM .section-0 polygon,#mermaid-svg-KHpWQvgDpttUS6DM .section-0 path{fill:hsl(60, 100%, 73.5294117647%);}#mermaid-svg-KHpWQvgDpttUS6DM .section-0 text{fill:black;}#mermaid-svg-KHpWQvgDpttUS6DM .node-icon-0{font-size:40px;color:black;}#mermaid-svg-KHpWQvgDpttUS6DM .section-edge-0{stroke:hsl(60, 100%, 73.5294117647%);}#mermaid-svg-KHpWQvgDpttUS6DM .edge-depth-0{stroke-width:14;}#mermaid-svg-KHpWQvgDpttUS6DM .section-0 line{stroke:hsl(240, 100%, 83.5294117647%);stroke-width:3;}#mermaid-svg-KHpWQvgDpttUS6DM .disabled,#mermaid-svg-KHpWQvgDpttUS6DM .disabled circle,#mermaid-svg-KHpWQvgDpttUS6DM .disabled text{fill:lightgray;}#mermaid-svg-KHpWQvgDpttUS6DM .disabled text{fill:#efefef;}#mermaid-svg-KHpWQvgDpttUS6DM .section-1 rect,#mermaid-svg-KHpWQvgDpttUS6DM .section-1 path,#mermaid-svg-KHpWQvgDpttUS6DM .section-1 circle,#mermaid-svg-KHpWQvgDpttUS6DM .section-1 polygon,#mermaid-svg-KHpWQvgDpttUS6DM .section-1 path{fill:hsl(80, 100%, 76.2745098039%);}#mermaid-svg-KHpWQvgDpttUS6DM .section-1 text{fill:black;}#mermaid-svg-KHpWQvgDpttUS6DM .node-icon-1{font-size:40px;color:black;}#mermaid-svg-KHpWQvgDpttUS6DM .section-edge-1{stroke:hsl(80, 100%, 76.2745098039%);}#mermaid-svg-KHpWQvgDpttUS6DM .edge-depth-1{stroke-width:11;}#mermaid-svg-KHpWQvgDpttUS6DM .section-1 line{stroke:hsl(260, 100%, 86.2745098039%);stroke-width:3;}#mermaid-svg-KHpWQvgDpttUS6DM .disabled,#mermaid-svg-KHpWQvgDpttUS6DM .disabled circle,#mermaid-svg-KHpWQvgDpttUS6DM .disabled text{fill:lightgray;}#mermaid-svg-KHpWQvgDpttUS6DM .disabled text{fill:#efefef;}#mermaid-svg-KHpWQvgDpttUS6DM .section-2 rect,#mermaid-svg-KHpWQvgDpttUS6DM .section-2 path,#mermaid-svg-KHpWQvgDpttUS6DM .section-2 circle,#mermaid-svg-KHpWQvgDpttUS6DM .section-2 polygon,#mermaid-svg-KHpWQvgDpttUS6DM .section-2 path{fill:hsl(270, 100%, 76.2745098039%);}#mermaid-svg-KHpWQvgDpttUS6DM .section-2 text{fill:#ffffff;}#mermaid-svg-KHpWQvgDpttUS6DM .node-icon-2{font-size:40px;color:#ffffff;}#mermaid-svg-KHpWQvgDpttUS6DM .section-edge-2{stroke:hsl(270, 100%, 76.2745098039%);}#mermaid-svg-KHpWQvgDpttUS6DM .edge-depth-2{stroke-width:8;}#mermaid-svg-KHpWQvgDpttUS6DM .section-2 line{stroke:hsl(90, 100%, 86.2745098039%);stroke-width:3;}#mermaid-svg-KHpWQvgDpttUS6DM .disabled,#mermaid-svg-KHpWQvgDpttUS6DM .disabled circle,#mermaid-svg-KHpWQvgDpttUS6DM .disabled text{fill:lightgray;}#mermaid-svg-KHpWQvgDpttUS6DM .disabled text{fill:#efefef;}#mermaid-svg-KHpWQvgDpttUS6DM .section-3 rect,#mermaid-svg-KHpWQvgDpttUS6DM .section-3 path,#mermaid-svg-KHpWQvgDpttUS6DM .section-3 circle,#mermaid-svg-KHpWQvgDpttUS6DM .section-3 polygon,#mermaid-svg-KHpWQvgDpttUS6DM .section-3 path{fill:hsl(300, 100%, 76.2745098039%);}#mermaid-svg-KHpWQvgDpttUS6DM .section-3 text{fill:black;}#mermaid-svg-KHpWQvgDpttUS6DM .node-icon-3{font-size:40px;color:black;}#mermaid-svg-KHpWQvgDpttUS6DM .section-edge-3{stroke:hsl(300, 100%, 76.2745098039%);}#mermaid-svg-KHpWQvgDpttUS6DM .edge-depth-3{stroke-width:5;}#mermaid-svg-KHpWQvgDpttUS6DM .section-3 line{stroke:hsl(120, 100%, 86.2745098039%);stroke-width:3;}#mermaid-svg-KHpWQvgDpttUS6DM .disabled,#mermaid-svg-KHpWQvgDpttUS6DM .disabled circle,#mermaid-svg-KHpWQvgDpttUS6DM .disabled text{fill:lightgray;}#mermaid-svg-KHpWQvgDpttUS6DM .disabled text{fill:#efefef;}#mermaid-svg-KHpWQvgDpttUS6DM .section-4 rect,#mermaid-svg-KHpWQvgDpttUS6DM .section-4 path,#mermaid-svg-KHpWQvgDpttUS6DM .section-4 circle,#mermaid-svg-KHpWQvgDpttUS6DM .section-4 polygon,#mermaid-svg-KHpWQvgDpttUS6DM .section-4 path{fill:hsl(330, 100%, 76.2745098039%);}#mermaid-svg-KHpWQvgDpttUS6DM .section-4 text{fill:black;}#mermaid-svg-KHpWQvgDpttUS6DM .node-icon-4{font-size:40px;color:black;}#mermaid-svg-KHpWQvgDpttUS6DM .section-edge-4{stroke:hsl(330, 100%, 76.2745098039%);}#mermaid-svg-KHpWQvgDpttUS6DM .edge-depth-4{stroke-width:2;}#mermaid-svg-KHpWQvgDpttUS6DM .section-4 line{stroke:hsl(150, 100%, 86.2745098039%);stroke-width:3;}#mermaid-svg-KHpWQvgDpttUS6DM .disabled,#mermaid-svg-KHpWQvgDpttUS6DM .disabled circle,#mermaid-svg-KHpWQvgDpttUS6DM .disabled text{fill:lightgray;}#mermaid-svg-KHpWQvgDpttUS6DM .disabled text{fill:#efefef;}#mermaid-svg-KHpWQvgDpttUS6DM .section-5 rect,#mermaid-svg-KHpWQvgDpttUS6DM .section-5 path,#mermaid-svg-KHpWQvgDpttUS6DM .section-5 circle,#mermaid-svg-KHpWQvgDpttUS6DM .section-5 polygon,#mermaid-svg-KHpWQvgDpttUS6DM .section-5 path{fill:hsl(0, 100%, 76.2745098039%);}#mermaid-svg-KHpWQvgDpttUS6DM .section-5 text{fill:black;}#mermaid-svg-KHpWQvgDpttUS6DM .node-icon-5{font-size:40px;color:black;}#mermaid-svg-KHpWQvgDpttUS6DM .section-edge-5{stroke:hsl(0, 100%, 76.2745098039%);}#mermaid-svg-KHpWQvgDpttUS6DM .edge-depth-5{stroke-width:-1;}#mermaid-svg-KHpWQvgDpttUS6DM .section-5 line{stroke:hsl(180, 100%, 86.2745098039%);stroke-width:3;}#mermaid-svg-KHpWQvgDpttUS6DM .disabled,#mermaid-svg-KHpWQvgDpttUS6DM .disabled circle,#mermaid-svg-KHpWQvgDpttUS6DM .disabled text{fill:lightgray;}#mermaid-svg-KHpWQvgDpttUS6DM .disabled text{fill:#efefef;}#mermaid-svg-KHpWQvgDpttUS6DM .section-6 rect,#mermaid-svg-KHpWQvgDpttUS6DM .section-6 path,#mermaid-svg-KHpWQvgDpttUS6DM .section-6 circle,#mermaid-svg-KHpWQvgDpttUS6DM .section-6 polygon,#mermaid-svg-KHpWQvgDpttUS6DM .section-6 path{fill:hsl(30, 100%, 76.2745098039%);}#mermaid-svg-KHpWQvgDpttUS6DM .section-6 text{fill:black;}#mermaid-svg-KHpWQvgDpttUS6DM .node-icon-6{font-size:40px;color:black;}#mermaid-svg-KHpWQvgDpttUS6DM .section-edge-6{stroke:hsl(30, 100%, 76.2745098039%);}#mermaid-svg-KHpWQvgDpttUS6DM .edge-depth-6{stroke-width:-4;}#mermaid-svg-KHpWQvgDpttUS6DM .section-6 line{stroke:hsl(210, 100%, 86.2745098039%);stroke-width:3;}#mermaid-svg-KHpWQvgDpttUS6DM .disabled,#mermaid-svg-KHpWQvgDpttUS6DM .disabled circle,#mermaid-svg-KHpWQvgDpttUS6DM .disabled text{fill:lightgray;}#mermaid-svg-KHpWQvgDpttUS6DM .disabled text{fill:#efefef;}#mermaid-svg-KHpWQvgDpttUS6DM .section-7 rect,#mermaid-svg-KHpWQvgDpttUS6DM .section-7 path,#mermaid-svg-KHpWQvgDpttUS6DM .section-7 circle,#mermaid-svg-KHpWQvgDpttUS6DM .section-7 polygon,#mermaid-svg-KHpWQvgDpttUS6DM .section-7 path{fill:hsl(90, 100%, 76.2745098039%);}#mermaid-svg-KHpWQvgDpttUS6DM .section-7 text{fill:black;}#mermaid-svg-KHpWQvgDpttUS6DM .node-icon-7{font-size:40px;color:black;}#mermaid-svg-KHpWQvgDpttUS6DM .section-edge-7{stroke:hsl(90, 100%, 76.2745098039%);}#mermaid-svg-KHpWQvgDpttUS6DM .edge-depth-7{stroke-width:-7;}#mermaid-svg-KHpWQvgDpttUS6DM .section-7 line{stroke:hsl(270, 100%, 86.2745098039%);stroke-width:3;}#mermaid-svg-KHpWQvgDpttUS6DM .disabled,#mermaid-svg-KHpWQvgDpttUS6DM .disabled circle,#mermaid-svg-KHpWQvgDpttUS6DM .disabled text{fill:lightgray;}#mermaid-svg-KHpWQvgDpttUS6DM .disabled text{fill:#efefef;}#mermaid-svg-KHpWQvgDpttUS6DM .section-8 rect,#mermaid-svg-KHpWQvgDpttUS6DM .section-8 path,#mermaid-svg-KHpWQvgDpttUS6DM .section-8 circle,#mermaid-svg-KHpWQvgDpttUS6DM .section-8 polygon,#mermaid-svg-KHpWQvgDpttUS6DM .section-8 path{fill:hsl(150, 100%, 76.2745098039%);}#mermaid-svg-KHpWQvgDpttUS6DM .section-8 text{fill:black;}#mermaid-svg-KHpWQvgDpttUS6DM .node-icon-8{font-size:40px;color:black;}#mermaid-svg-KHpWQvgDpttUS6DM .section-edge-8{stroke:hsl(150, 100%, 76.2745098039%);}#mermaid-svg-KHpWQvgDpttUS6DM .edge-depth-8{stroke-width:-10;}#mermaid-svg-KHpWQvgDpttUS6DM .section-8 line{stroke:hsl(330, 100%, 86.2745098039%);stroke-width:3;}#mermaid-svg-KHpWQvgDpttUS6DM .disabled,#mermaid-svg-KHpWQvgDpttUS6DM .disabled circle,#mermaid-svg-KHpWQvgDpttUS6DM .disabled text{fill:lightgray;}#mermaid-svg-KHpWQvgDpttUS6DM .disabled text{fill:#efefef;}#mermaid-svg-KHpWQvgDpttUS6DM .section-9 rect,#mermaid-svg-KHpWQvgDpttUS6DM .section-9 path,#mermaid-svg-KHpWQvgDpttUS6DM .section-9 circle,#mermaid-svg-KHpWQvgDpttUS6DM .section-9 polygon,#mermaid-svg-KHpWQvgDpttUS6DM .section-9 path{fill:hsl(180, 100%, 76.2745098039%);}#mermaid-svg-KHpWQvgDpttUS6DM .section-9 text{fill:black;}#mermaid-svg-KHpWQvgDpttUS6DM .node-icon-9{font-size:40px;color:black;}#mermaid-svg-KHpWQvgDpttUS6DM .section-edge-9{stroke:hsl(180, 100%, 76.2745098039%);}#mermaid-svg-KHpWQvgDpttUS6DM .edge-depth-9{stroke-width:-13;}#mermaid-svg-KHpWQvgDpttUS6DM .section-9 line{stroke:hsl(0, 100%, 86.2745098039%);stroke-width:3;}#mermaid-svg-KHpWQvgDpttUS6DM .disabled,#mermaid-svg-KHpWQvgDpttUS6DM .disabled circle,#mermaid-svg-KHpWQvgDpttUS6DM .disabled text{fill:lightgray;}#mermaid-svg-KHpWQvgDpttUS6DM .disabled text{fill:#efefef;}#mermaid-svg-KHpWQvgDpttUS6DM .section-10 rect,#mermaid-svg-KHpWQvgDpttUS6DM .section-10 path,#mermaid-svg-KHpWQvgDpttUS6DM .section-10 circle,#mermaid-svg-KHpWQvgDpttUS6DM .section-10 polygon,#mermaid-svg-KHpWQvgDpttUS6DM .section-10 path{fill:hsl(210, 100%, 76.2745098039%);}#mermaid-svg-KHpWQvgDpttUS6DM .section-10 text{fill:black;}#mermaid-svg-KHpWQvgDpttUS6DM .node-icon-10{font-size:40px;color:black;}#mermaid-svg-KHpWQvgDpttUS6DM .section-edge-10{stroke:hsl(210, 100%, 76.2745098039%);}#mermaid-svg-KHpWQvgDpttUS6DM .edge-depth-10{stroke-width:-16;}#mermaid-svg-KHpWQvgDpttUS6DM .section-10 line{stroke:hsl(30, 100%, 86.2745098039%);stroke-width:3;}#mermaid-svg-KHpWQvgDpttUS6DM .disabled,#mermaid-svg-KHpWQvgDpttUS6DM .disabled circle,#mermaid-svg-KHpWQvgDpttUS6DM .disabled text{fill:lightgray;}#mermaid-svg-KHpWQvgDpttUS6DM .disabled text{fill:#efefef;}#mermaid-svg-KHpWQvgDpttUS6DM .section-root rect,#mermaid-svg-KHpWQvgDpttUS6DM .section-root path,#mermaid-svg-KHpWQvgDpttUS6DM .section-root circle,#mermaid-svg-KHpWQvgDpttUS6DM .section-root polygon{fill:hsl(240, 100%, 46.2745098039%);}#mermaid-svg-KHpWQvgDpttUS6DM .section-root text{fill:#ffffff;}#mermaid-svg-KHpWQvgDpttUS6DM .section-root span{color:#ffffff;}#mermaid-svg-KHpWQvgDpttUS6DM .section-2 span{color:#ffffff;}#mermaid-svg-KHpWQvgDpttUS6DM .icon-container{height:100%;display:flex;justify-content:center;align-items:center;}#mermaid-svg-KHpWQvgDpttUS6DM .edge{fill:none;}#mermaid-svg-KHpWQvgDpttUS6DM .mindmap-node-label{dy:1em;alignment-baseline:middle;text-anchor:middle;dominant-baseline:middle;text-align:center;}#mermaid-svg-KHpWQvgDpttUS6DM :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} Day16 核心
Event对象
target
currentTarget
MouseEvent
KeyboardEvent
传播与控制
捕获阶段 capture
currentTarget逐级变化
stopPropagation
stopImmediatePropagation
preventDefault
模式
事件委托
passive监听
DOM
原型链
HTMLCollection
NodeList
实战
轮播图滑动
淡入淡出
无限循环克隆方案
| 序号 | 主题 | 关键 API / 概念 |
|---|---|---|
| 1 | Event 对象 | type、timeStamp、target、eventPhase |
| 2 | 冒泡控制 | stopPropagation、stopImmediatePropagation |
| 3 | 默认行为 | preventDefault(非 return false) |
| 4 | 事件委托 | 父级监听 + event.target 匹配 |
| 5 | 原型链 | EventTarget → Node → Element → 具体元素 |
| 6 | 集合类型 | HTMLCollection 动态 / NodeList 静态 |
| 7 | 轮播图 | 索引、定时器、指示器、箭头、淡入淡出 |
| 7+ | 无限循环 | 克隆首尾帧 + transitionend 无感跳转 |
| 8+ | passive | { passive: true } 消除移动端 touch/wheel 滚动卡顿 |
学习建议
- 先拆后合:按课堂步骤分别实现布局、指示器、箭头、自动播放,再合并为 §7.2。
- 对比 Day15:Day15 讲「有哪些事件」,Day16 讲「事件对象与如何用事件做组件」。
- 调试习惯 :在回调里
console.log(event.target, event.currentTarget)观察差异;加capture: true后再对比。 - 面试准备 :委托原理、
targetvscurrentTarget(含捕获阶段)、两种 stop 的区别、passive作用为必背。 - 进阶挑战 :将 §7.5 克隆方案改造为支持触摸滑动(
touchstart/touchmove+passive: false)的移动端轮播。
下篇为 Day16 技术博客拆分版(第 2/2 篇)。Event 基础见 上篇;完整合集见 JavaScript_Event对象与轮播图实战.md。