首页
/ Web-Dev-For-Beginners 盆栽工坊第三课:基于 DOM 与 JavaScript 闭包实现原生拖拽交互

Web-Dev-For-Beginners 盆栽工坊第三课:基于 DOM 与 JavaScript 闭包实现原生拖拽交互

2026-09-07 11:07:51作者:何举烈Damon

本文是 Web-Dev-For-Beginners 课程「盆栽工坊(Terrarium Project)」系列的第三课技术指南,围绕 HTML 与 CSS 之上最关键的一层展开:如何用 JavaScript 通过 Document Object Model(DOM)把静态页面变成可交互应用。你将沿着「DOM 元素查找 → 闭包管理状态 → Pointer 事件 → 拖拽数学 → 事件清理」的完整链路,为 14 株植物实现自由拖放,最终掌握一套不依赖任何框架、可直接迁移到文件上传、看板卡片等场景的纯原生拖拽方案,并理解闭包为何是管理独立状态的天然工具。

本课对应的英文原版位于 3-terrarium/3-intro-to-DOM-and-closures/README.md,仓库内的完整可运行实现可在 3-terrarium/solution/index.html3-terrarium/solution/script.js3-terrarium/solution/style.css 中直接查看。

课程背景与最终目标

Terrarium 工坊共分三部分:第一部分用 HTML 搭建了植物选择区与玻璃罐结构,第二部分用 CSS(绝对定位 + z-index)把植物和罐子绘制成形。本课(第三部分)的核心任务是把静态设计「激活」为交互界面——当用户在浏览器中打开 index.html 时,可以用鼠标、手指或触控笔任意拖拽左右两栏里的 14 株植物,把它们放进中央的玻璃罐或放在任意位置,实时看到位置更新。

本课将要构建的能力清单:

  • 平滑的拖放(drag and drop)行为,覆盖全部 14 株植物;
  • 坐标跟踪系统,让每株植物独立记住自己的当前位置;
  • 只使用纯 JavaScript 完成的完整交互界面;
  • 借助闭包模式组织的干净、可复用代码。

在仓库的 3-terrarium/solution/script.js 中,这套方案的最终形态已经完整存在(该方案创意参考了通用的可拖拽 HTML 元素做法),后面的每一小节都会指出它与教程代码的对应位置,方便对照学习。

理解 DOM:JavaScript 与页面的桥梁

Document Object Model(DOM)是浏览器为 HTML 页面在内存中建立的树形结构化表示,也是 JavaScript 与 HTML 元素通信的标准通道。当浏览器解析完一份 HTML 后,会把每个标签转换成一个可编程的节点对象,节点之间按父子关系组成一棵「家族树」:document 之下是 htmlhtml 之下分支为 headbodybody 里再挂出 h1、各个 div 容器,一直细化到具体的 <img> 叶子节点。JavaScript 可以沿着这棵树访问任意节点、修改它的属性、或重新组织它的结构。

DOM 树与对应 HTML 标记的对照示意图

DOM 之所以强大,体现在四个方面:

  • 提供结构化的访问能力,让页面中的每个元素都可通过脚本精确触达;
  • 允许在不刷新页面的前提下动态更新内容;
  • 支持对点击、拖拽等交互的实时响应;
  • 构成现代交互式 Web 应用的底层基础。

本项目的 DOM 结构

对照 3-terrarium/solution/index.html 可以看到,本课要操作的 DOM 树大体如下:

  • #pageheader(标题「My Terrarium」)与 main
  • main 下是 #plant-selection,内含左右两个选择区 #left-container#right-container,各存放 7 个 .plant-holder,每个 holder 里是一个带唯一 idplant1plant14)的 <img class="plant">
  • 中央 #terrarium 区由 .jar-top.jar-walls.dirt.jar-bottom 等 div 拼成玻璃罐外观。

需要操作的对象是那 14 张植物图片,它们共享 CSS 类 .plant,却拥有彼此不同的 id——这直接决定了后面选择元素的方式。

准备阶段:创建并接入 JavaScript 文件

动手写交互逻辑前,先完成两个准备步骤。

第 1 步:创建脚本文件。在盆栽项目文件夹中新建 script.js

第 2 步:在 HTML 中引入脚本。参考 3-terrarium/solution/index.html#L13 的做法,在 <head> 中加入 script 标签:

<script src="./script.js" defer></script>

defer 属性在这里至关重要,它带来四重保证:

  • 确保 JavaScript 会等待整个 HTML 解析完成后才执行;
  • 防止脚本过早查找尚未加载到 DOM 的元素而报错;
  • 保证全部植物元素都已就绪、可被交互绑定;
  • 性能上优于把脚本堆在页面底部再手工等待的老办法。

⚠️ 注意:defer 能规避绝大多数时序类 bug。若省略它,脚本可能在 HTML 元素就位之前就尝试访问它们,从而抛出空引用错误。

连接 JS 与 HTML:通过 ID 精确定位元素

要让植物可拖拽,脚本首先必须在 DOM 中找到它们。这就像图书馆检索系统:掌握唯一编号后,就能精确定位到对应的书。这里使用的工具是 document.getElementById()——传入 ID 字符串,返回唯一匹配的元素对象。

为全部 14 株植物启用拖拽

script.js 中写入以下调用(与 3-terrarium/solution/script.js#L3-L16 完全一致):

// 为全部 14 株植物启用拖拽功能
dragElement(document.getElementById('plant1'));
dragElement(document.getElementById('plant2'));
dragElement(document.getElementById('plant3'));
dragElement(document.getElementById('plant4'));
dragElement(document.getElementById('plant5'));
dragElement(document.getElementById('plant6'));
dragElement(document.getElementById('plant7'));
dragElement(document.getElementById('plant8'));
dragElement(document.getElementById('plant9'));
dragElement(document.getElementById('plant10'));
dragElement(document.getElementById('plant11'));
dragElement(document.getElementById('plant12'));
dragElement(document.getElementById('plant13'));
dragElement(document.getElementById('plant14'));

这段代码的作用链非常清晰:

  • 定位:用每个元素的唯一 id 在 DOM 中找到对应 <img>
  • 持有:拿到指向该 HTML 元素的 JavaScript 引用;
  • 传入:把元素交给接下来要实现的 dragElement() 工厂函数;
  • 准备:让每株植物都具备可拖放能力;
  • 打通:把 HTML 结构层与 JavaScript 行为层连接起来。

🎯 为什么用 ID 而不用 class?ID 为单个元素提供唯一标识,适合 JavaScript 做精确控制;class 是给一组元素做样式分组用的。当需要逐株植物独立操作时,ID 既精确又高效。

💡 每株植物都单独调用一次 dragElement(),是为了保证每株植物拿到一份彼此隔离的拖拽行为,这正是实现平滑多对象交互的关键。

思考题:如果两个元素拥有相同 ID,getElementById() 会返回什么?答案:ID 必须唯一,重复时只返回文档中第一个匹配的元素,因此请务必保证 plant1plant14 各不相同。

创建 dragElement:一个为每株植物量身定制的闭包

接下来实现核心工厂函数 dragElement()。它接收一个元素作为参数,返回并保存一组仅供该元素使用的私有状态与内部函数——也就是一个标准的 JavaScript 闭包。用闭包管理拖拽逻辑的理由是:它允许创建跨越多次函数调用的「私有」变量,让每株植物拥有独立、互不干扰的坐标跟踪系统

用一个计数器理解闭包

先看一个最小化示例(本课原始文档即以此引出概念):

function createCounter() {
    let count = 0;              // 相当于一个私有变量

    function increment() {      // 内部函数可以读写外部变量
        count++;
        return count;
    }

    return increment;           // 返回内部函数,连同它捕获的环境
}

const myCounter = createCounter();
console.log(myCounter());       // 1
console.log(myCounter());       // 2

这个模式揭示了闭包的全部要点:

  • 创建了只存在于该闭包内的私有变量 count
  • 内部函数能够访问并修改外层作用域里的变量(闭包机制本身);
  • 即使外部函数返回,内部函数依然保有对这份私有数据的引用;
  • 即使 createCounter() 早已执行完毕,count 仍然存活并持续累计。

为什么闭包恰好适合拖拽

对本项目而言,每株植物都必须在两次指针事件之间记住自己的坐标。闭包给出了一石四鸟的解法:

  • 为每株植物独立维护私有位置变量;
  • 跨拖拽事件保存坐标数据;
  • 避免多个可拖拽元素之间的变量相互覆盖冲突;
  • 产出干净、条理清晰的代码结构。

🎯 学习目标:此刻不需要背诵闭包的全部理论细节,重点体会它如何帮我们组织代码、为拖拽功能保存状态即可。

拖拽状态机:完整的生命周期

一次拖拽本质上是一个小型状态机,其流转如下:

  • 就绪(Ready):页面加载完成,植物可被按下;
  • 开始拖拽(DragStart):用户按下(pointerdown),此刻要捕获起始坐标并为文档挂上监听器;
  • 拖拽中(Dragging):指针移动(pointermove),反复执行「计算位移 → 更新元素位置」;
  • 拖拽结束(DragEnd):用户松开(pointerup),移除文档级监听器并清理状态,回到就绪等待下一次拖拽。

后面三个函数分别对应 DragStart、Dragging、DragEnd 这三个阶段。

第一步:dragElement 函数骨架

在全部植物声明之下加入以下函数(对应 3-terrarium/solution/script.js#L23-L29):

function dragElement(terrariumElement) {
    // 初始化位置跟踪变量
    let pos1 = 0,  // 上一次鼠标的 X 坐标
        pos2 = 0,  // 上一次鼠标的 Y 坐标
        pos3 = 0,  // 当前鼠标的 X 坐标
        pos4 = 0;  // 当前鼠标的 Y 坐标

    // 设置初始的拖拽事件监听器
    terrariumElement.onpointerdown = pointerDrag;
}

坐标系统各变量的职责如下表:

变量 含义 用途
pos1pos2 新旧鼠标坐标之差 elementDrag 中换算元素位移量
pos3pos4 当前鼠标坐标 作为每次位移计算的基准点
terrariumElement 被拖拽的植物元素 工厂函数的入参,闭包捕获的对象
onpointerdown 按下事件句柄 触发一次拖拽的起点

闭包在这里起到的作用:为每株植物创建一套私有坐标;在整段拖拽生命周期内维持它们;保证每株植物只跟踪自己的位置;通过 dragElement 对外暴露干净的接口。

为什么选 Pointer 事件而非 click / mouse 事件

细心的读者会问:为什么用 onpointerdown 而不是更常见的 onclick?三种事件类型的取舍如下:

事件类型 最适合的场景 局限性
onclick 简单的按钮点击 不支持拖拽(只有按下-松开一下,无过程)
onpointerdown 鼠标 + 触摸 较新,但已获得广泛浏览器支持
onmousedown 仅桌面鼠标 不覆盖移动端

Pointer 事件对当前项目是理想选择,因为:

  • 不论用户用鼠标、手指还是触控笔都能正常工作;
  • 在笔记本、平板、手机上体验一致,无需分别实现 touch/mouse 两套逻辑;
  • 管理的是真实的拖拽过程,而不只是一次点击;
  • 提供了现代 Web 应用用户所期待的那种顺滑感。

💡 Pointer Events 是把多类输入设备统一处理的标准方向,一份代码同时拿到鼠标与触摸支持。

pointerDrag:启动一次拖拽

当用户按下某株植物时,pointerDrag 介入。它在 dragElement 闭包内部、紧随 terrariumElement.onpointerdown = pointerDrag; 之后定义(对应 3-terrarium/solution/script.js#L31-L41):

function pointerDrag(e) {
    // 阻止浏览器默认行为(例如拖拽时选中文本)
    e.preventDefault();

    // 捕获鼠标/触摸的起始位置
    pos3 = e.clientX;  // 拖拽开始的 X 坐标
    pos4 = e.clientY;  // 拖拽开始的 Y 坐标

    // 为拖拽过程挂载事件监听器
    document.onpointermove = elementDrag;
    document.onpointerup = stopElementDrag;
}

逐步拆解这段代码的四个动作:

  • 拦截会干扰拖拽的浏览器默认行为;
  • 记录用户开始拖拽时的精确坐标;
  • 建立负责跟踪后续指针移动的事件监听;
  • 准备在整个文档范围内追踪鼠标/手指的移动。

preventDefault 为什么不能省

e.preventDefault() 是实现平滑拖拽的关键一行。没有它,浏览器可能:

  • 在指针扫过页面时选中文本
  • 在右键拖拽时弹出上下文菜单
  • 干扰我们自定义的拖拽行为;
  • 拖拽过程中产生视觉残留或跳动

🔍 建议实验:完成本课后试着删掉 e.preventDefault(),观察拖拽手感发生什么变化,你会立刻明白这行代码的价值。

clientX / clientY 坐标系统

e.clientXe.clientY 提供指针的精确坐标:

属性 测量内容 应用
clientX 相对视口(viewport)的水平位置 跟踪左右方向移动
clientY 相对视口的垂直位置 跟踪上下方向移动

这些坐标具备像素级精度,随指针移动实时更新,在不同屏幕尺寸与缩放级别下保持一致,因此能支撑流畅响应式的拖拽。

为什么监听器挂在整个 document 上

注意 pointerDrag 把移动与松开事件挂到了 document,而不是植物元素自身:

document.onpointermove = elementDrag;
document.onpointerup = stopElementDrag;

文档级监听的四大理由:

  • 即使指针快速移出植物元素,跟踪仍不中断;
  • 防止快速甩动时拖拽被意外终止;
  • 保证全屏范围内拖拽都顺滑;
  • 覆盖指针滑出浏览器窗口的边界情形。

⚡ 性能提示:拖拽结束后必须移除这些文档级监听器,否则会累积无用的处理器,造成内存泄漏与性能劣化(见 stopElementDrag)。

elementDrag:位移计算与元素跟随

拖拽进行中,elementDrag 负责把指针的移动量换算成元素在页面上的位移。它定义在 pointerDrag 的右花括号之后(对应 3-terrarium/solution/script.js#L43-L57):

function elementDrag(e) {
    // 计算自上次事件以来指针移动的距离
    pos1 = pos3 - e.clientX;  // 水平位移
    pos2 = pos4 - e.clientY;  // 垂直位移

    // 更新当前位置跟踪基准
    pos3 = e.clientX;         // 新的当前 X
    pos4 = e.clientY;         // 新的当前 Y

    // 将位移应用到元素位置
    terrariumElement.style.top = (terrariumElement.offsetTop - pos2) + 'px';
    terrariumElement.style.left = (terrariumElement.offsetLeft - pos1) + 'px';
}

拖拽数学的逐步拆解

每次 pointermove 事件,代码执行一轮完整计算:

  1. 测量指针从上一帧到当前帧的坐标差:pos1 = pos3 - e.clientXpos2 = pos4 - e.clientY
  2. 换算出元素应跟随移动的距离;
  3. 更新 pos3pos4 为当前坐标,作为下一帧的比较基准;
  4. 写出元素新的 CSS 位置:style.topoffsetTop 减去垂直位移,style.leftoffsetLeft 减去水平位移。

例如:指针从 (100, 50) 移到 (110, 60),则 pos1 = 10pos2 = 10,元素即向右、向下各移动 10px;下一次指针移动到 (120, 65) 时,基准 pos3/pos4 已是 (110, 60),于是继续精确增量地跟随,而不会出现跳变。

offsetTop / offsetLeft 与 top / left 的关系

拖拽系统实际读写两组 CSS 位置相关属性:

CSS 属性 控制什么 本项目中的用法
top 元素距包含块上边缘的距离 设置元素垂直位置
left 元素距包含块左边缘的距离 设置元素水平位置
offsetTop 元素当前距最近定位祖先上边缘的距离 读取当前位置(只读)
offsetLeft 元素当前距最近定位祖先左边缘的距离 读取当前位置(只读)

理解要点:

  • offsetTop / offsetLeft 表示相对最近的有定位(positioned)祖先的距离,且会在样式改变后立即同步刷新
  • 本项目的植物使用绝对定位(见 3-terrarium/solution/style.css.plant { position: absolute; }),因此读写 top/left 与 offsetTop/offsetLeft 是对同一套坐标系的镜像操作;
  • 仓库实现中实际逻辑与教程代码一致,不同之处仅在于多了几行 console.log 用于调试(如 3-terrarium/solution/script.js#L33 打印事件对象、3-terrarium/solution/script.js#L53 打印每帧坐标),方便初学者在 DevTools 中观察拖拽过程。

stopElementDrag:事件清理与资源释放

用户松开指针后,拖拽进入收尾阶段。stopElementDrag 定义在 elementDrag 的右花括号之后(对应 3-terrarium/solution/script.js#L59-L63):

function stopElementDrag() {
    // 移除文档级事件监听器
    document.onpointerup = null;
    document.onpointermove = null;
}

清理这一步的价值经常被初学者忽略,但至关重要:

  • 防止残留监听器造成内存泄漏;
  • 用户松手后立即停止拖拽行为;
  • 让其他植物随后可以独立开始自己的拖拽(否则旧监听器会与新拖拽互相干扰);
  • 为下一次拖拽重置事件系统。

若省略清理,会出现一系列问题:监听器在拖拽结束后继续存活;随拖拽次数增加处理器不断累积、性能下滑;与其他元素交互时产生无法预期的副作用;浏览器资源被白白消耗在处理多余事件上。

🎯 设计哲学:本套拖拽方案刻意保持「自由」——不设任何放置区域(drop zone)或边界限制,用户可以随心所欲地把植物放到任何位置,把布局的创造力完全交给使用者。

组装完整系统并验证效果

把上述函数全部收进 dragElement 闭包后,得到的就是 3-terrarium/solution/script.js 中的完整实现。整个闭包共同完成:

  • 为每株植物独立维护私有位置状态;
  • 走完一次拖拽从按下到松开的完整生命周期;
  • 提供全屏范围内平滑、实时响应的移动;
  • 及时清理资源、杜绝内存泄漏;
  • 交付直觉化、富有创造性的盆栽布置界面。

测试步骤

直接双击打开 index.html(或 3-terrarium/solution/index.html),依次验证:

  1. 按住任意一株植物,拖拽随之启动;
  2. 移动鼠标或手指,观察植物平滑跟随指针;
  3. 松开,植物停留在新位置;
  4. 反复尝试不同摆放,探索自由布局。

盆栽工坊完成态:植物被拖入玻璃罐并自由摆放的最终效果

完成标准自检:能否解释闭包如何为每株植物维持独立状态?为什么坐标差计算是顺滑移动的前提?忘记清理事件监听会发生什么?这套模式能否平滑扩展到更复杂的交互?代码质量层面,应确认设计模块化(每株植物一个闭包实例)、事件管理正确(有挂载就有卸载)、桌面与移动端皆可用、无内存泄漏与冗余计算。

动手拓展:把拖拽技能再推进一步

原版挑战:一键复位

用 GitHub Copilot Agent 模式完成以下增强(思路如下,可自行在本地实现):

为盆栽新增「复位」功能:页面加载时记录所有植物的初始位置;点击复位按钮后,使用 CSS 变换(transform)在 1 秒内让全部植物平滑动画回到侧边栏的原始位置。

更多创意扩展

  • 双击置顶:双击植物时通过 z-index 提升其层级(仓库 CSS 已将 .plant 设为 z-index: 2,罐壁为 1、泥土为 -1,可在此基础上调整);
  • 悬停反馈:为植物添加轻微的高亮光晕,提示可拖拽性;
  • 边界约束:实现边界检测,防止植物被拖出页面/罐体范围;
  • 状态持久化:用 localStorage 记住每株植物的落点,刷新后恢复布局;
  • 音效反馈:拾起与放下植物时播放短音效。

每一类扩展都会训练你处理 DOM 操作、事件系统与用户体验设计的组合能力。

从纯手写走向现代框架的进阶路径

本课实现的是与 DOM 的「直接对话」,而现代前端技术栈大多建立在这些基础之上:

  • 事件委托(Event Delegation):用一个监听器高效管理多个元素;
  • Intersection Observer / MutationObserver:监听元素可见性与 DOM 结构变化;
  • Web Components / 自定义元素:封装可复用的组件单元;
  • 虚拟 DOM 与框架:理解 React、Vue、Angular 为何以及如何优化 DOM 更新。

此外,如果希望对比不同实现路线,还可以研究 HTML 原生 Drag and Drop API(适合文件上传、正式放置区场景)、Touch Events(移动端专项交互)与 CSS transform(做流畅动画)等替代方案。

5 分钟热身:打开浏览器 DevTools 控制台,试试 document.querySelector('body');用 innerHTMLtextContent 修改页面文本;给按钮或链接挂一个 click 监听器;在 Elements 面板里查看 DOM 树结构。

长期路线图参考:第一小时完成课后测试并做一次带 click/mouseover/keypress 的交互页;第一周内完成盆栽拖拽项目、掌握事件委托、了解 event loop 与异步、用闭包构建有状态模块、接触 Intersection Observer 等现代 API;一个月后尝试用纯 JS 搭建 SPA、学习一个框架并与原生 DOM 操作对比、为开源项目做贡献、并向他人讲授 DOM 与 JavaScript 基础——这些方向都能加深你对本课两个核心主题(DOM 操作与闭包)的掌握。

关联作业

本课的配套作业要求围绕某个 DOM 接口做深度调研与写作:先选一个感兴趣的接口(入门可选 Element.classListDocument.querySelector()Element.addEventListener()Window.localStorage;进阶可选 Intersection Observer、MutationObserver、Drag and Drop API、Geolocation;深入可选 Web Components、Canvas、Web/Service Workers),再撰写 300–500 词的文档式分析、寻找真实应用案例、附上带注释的可运行示例,并反思该接口如何能改进盆栽项目。完整要求与评分标准见 translations/bg/3-terrarium/3-intro-to-DOM-and-closures/assignment.md

登录后查看全文
热门项目推荐
相关项目推荐

项目优选

收起
kernelkernel
deepin linux kernel
C
33
18
ops-transformerops-transformer
本项目是CANN提供的transformer类大模型算子库,实现网络在NPU上加速计算。
C++
1.13 K
2.75 K
pytorchpytorch
作为 Ascend for PyTorch 社区的核心组件,TorchNPU 是昇腾专为 PyTorch 打造的深度学习适配插件,使 PyTorch 框架能够直接调用昇腾 NPU,为开发者提供昇腾 AI 处理器的超强算力。
Python
857
1.35 K
docsdocs
暂无描述
Markdown
897
5.8 K
kernelkernel
openEuler内核是openEuler操作系统的核心,既是系统性能与稳定性的基石,也是连接处理器、设备与服务的桥梁。
C
529
593
ops-nnops-nn
本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。
C++
915
1.83 K
jiuwenswarmjiuwenswarm
JiuwenSwarm 是一款基于openJiuwen开发的智能AI Agent,它能够将大语言模型的强大能力,通过你日常使用的各类通讯应用,直接延伸至你的指尖。
Python
3.58 K
1.01 K
ops-mathops-math
本项目是CANN提供的数学类基础计算算子库,实现网络在NPU上加速计算。
C++
1.35 K
1.46 K
cann-learning-hubcann-learning-hub
CANN 学习中心仓,支持在线互动运行、边学边练,提供教程、示例与优化方案,一站式助力昇腾开发者快速上手。
Jupyter Notebook
1.01 K
515
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
547
388