Day16_JavaScript 轮播图与事件工程实战(下篇)

系列下篇 :承接 上篇 的 Event 与委托基础,本篇以 轮播图 串联定时器、指示器、无限循环等实战,并扩展到 CustomEvent / EventBuspassive 监听 以及触摸、拖拽、表单、懒加载等工程场景。

前置阅读:建议先读完上篇 §一~§六,再实现 §七 轮播图。

权威参考


目录

系列上篇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">&lt;</div>
        <div class="carousel-arrow next">&gt;</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、箭头 clickmouseenter/mouseleave
goto(index) 切换 active 类(图片 + 指示器同步)
next / prev 索引 ±1,首尾循环(% length 思想)
startAutoplay setIntervalnext
pause / resume 仅改 isPaused 标志,不销毁定时器
destroy clearInterval,组件卸载时防内存泄漏

状态与 CSS 的配合

  • 所有图片 li 叠放或横排, li.active 显示(opacitytransform 由 CSS 决定)。
  • JS 只改索引和 class,动画交给 CSS transition,符合「结构与行为分离」。

自动播放逻辑

javascript 复制代码
setInterval(() => { if (!this.isPaused) this.next(); }, interval);
  • 悬停:mouseenterpause()mouseleaveresume()
  • 比「悬停时 clearInterval、离开再重建」更简单,且不会反复创建定时器。

配置项设计

  • autoplayintervalpauseOnHover 通过 options 传入,便于复用到多个轮播实例。

易错点

  • 忘记在 destroyclearInterval → 单页应用路由切换后仍后台轮播。
  • 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">&lt;</div>
        <div class="arrow next">&gt;</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 可过渡;若需隐藏后不可聚焦,可再配合 visibilitypointer-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 === falsetransition: none,用于瞬间复位(用户看不见跳转)。

transitionend 无缝复位

滑入位置 用户看到 复位动作
索引 0(克隆末帧) 像从第 1 张往前滑 关过渡 → setTranslate(N-1, false)
索引 N+1(克隆首帧) 像从最后一张往后滑 关过渡 → setTranslate(0, false)

isAnimating

  • 过渡进行中忽略 prev/next 点击,防止快速连点导致 translateXcurReal 不同步。

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 对象使用建议

建议 说明
命名规范 事件对象参数通常命名为 evente
及时阻止 需要阻止默认行为时尽早调用 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 resizeinput 停止触发 wait ms 后才执行 clearTimeout + setTimeout
节流 throttle scrollmousemove 每 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 做防抖会导致「停滚后才触发」,吸顶/懒加载应用节流
  • 防抖/节流函数内 thisarguments 需用 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 事件监听:移动端性能优化

参考MDN --- EventTarget.addEventListener() options.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 何时必须

  • 自定义横向轮播、下拉刷新、全屏拖拽:需要在 touchmovepreventDefault() 阻止纵向滚动 → 必须显式 passive: false
  • Chrome 会对未声明的 touchstart/touchmove 打印警告,提醒开发者表态。

capture 组合

javascript 复制代码
element.addEventListener('wheel', onWheel, { capture: false, passive: true });
  • 选项对象可同时写 capturepassiveonce

违规后果

  • 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)

  • 普通 {}toStringconstructor 等原型属性;事件名若叫 '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 三件套:touchstarttouchmovetouchend

核心名词
属性 说明
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 记录 startXpassive: 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

  • touchende.touches 已空(手指已离开),末次坐标在 e.changedTouches[0]
  • touchstart / touchmovee.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)。

易错点

  • 只在 droppreventDefault 不够,dragover 必须持续阻止
  • dragstart 忘记 setDatadrop 拿不到 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.key vs e.codee.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 规则

  1. 收集 ctrl / alt / shift / meta(Mac 的 ⌘ 用 metaKey 映射为 ctrl 统一处理)。
  2. 主键用 e.key.toLowerCase(),但跳过 修饰键本身(避免单独按 Ctrl 触发 ctrl)。
  3. 拼接为 "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 事件链路

  1. 用户右键 → 触发 contextmenuMouseEventbutton === 2)。
  2. document 或目标上 preventDefault()关闭系统菜单(唯一可靠方式)。
  3. 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>
    &nbsp;·&nbsp; 支持图片、文档(最大 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 同上 + dropfiles
预览 移动 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 对象 typetimeStamptargeteventPhase
2 冒泡控制 stopPropagationstopImmediatePropagation
3 默认行为 preventDefault(非 return false
4 事件委托 父级监听 + event.target 匹配
5 原型链 EventTargetNodeElement → 具体元素
6 集合类型 HTMLCollection 动态 / NodeList 静态
7 轮播图 索引、定时器、指示器、箭头、淡入淡出
7+ 无限循环 克隆首尾帧 + transitionend 无感跳转
8+ passive { passive: true } 消除移动端 touch/wheel 滚动卡顿

学习建议

  1. 先拆后合:按课堂步骤分别实现布局、指示器、箭头、自动播放,再合并为 §7.2。
  2. 对比 Day15:Day15 讲「有哪些事件」,Day16 讲「事件对象与如何用事件做组件」。
  3. 调试习惯 :在回调里 console.log(event.target, event.currentTarget) 观察差异;加 capture: true 后再对比。
  4. 面试准备 :委托原理、target vs currentTarget(含捕获阶段)、两种 stop 的区别、passive 作用为必背。
  5. 进阶挑战 :将 §7.5 克隆方案改造为支持触摸滑动(touchstart / touchmove + passive: false)的移动端轮播。

下篇为 Day16 技术博客拆分版(第 2/2 篇)。Event 基础见 上篇;完整合集见 JavaScript_Event对象与轮播图实战.md

相关推荐
沄媪2 小时前
CSRF 跨站请求伪造
前端·ctf·csrf
IT大白鼠2 小时前
ICMP协议详解:从基础原理到网络应用实践
网络
会Tk矩阵群控的小木3 小时前
云控系统在TikTok多账号管理中的核心应用与技术实现
开发语言·php·开源软件·个人开发·tk矩阵
kyriewen3 小时前
我关掉了Copilot:因为我写的代码出现在了别人的建议里
前端·javascript·ai编程
摇滚侠3 小时前
Java 零基础全套教程,反射机制,笔记 187-188
java·开发语言·笔记
Ulyanov3 小时前
用声明式语法重新定义Python桌面UI:QML+PySide6现代开发入门(一)
开发语言·python·算法·ui·系统仿真·雷达电子对抗仿真
云登指纹浏览器3 小时前
静态IP和动态IP哪个好:跨境电商代理选型指南
网络·网络协议·tcp/ip
超梦dasgg4 小时前
Java 生产环境第三方对接安全保障方案
java·开发语言·安全