Web-Dev-For-Beginners 盆栽工坊第三课:基于 DOM 与 JavaScript 闭包实现原生拖拽交互
本文是 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.html、3-terrarium/solution/script.js 与 3-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 之下是 html,html 之下分支为 head 与 body,body 里再挂出 h1、各个 div 容器,一直细化到具体的 <img> 叶子节点。JavaScript 可以沿着这棵树访问任意节点、修改它的属性、或重新组织它的结构。
DOM 之所以强大,体现在四个方面:
- 提供结构化的访问能力,让页面中的每个元素都可通过脚本精确触达;
- 允许在不刷新页面的前提下动态更新内容;
- 支持对点击、拖拽等交互的实时响应;
- 构成现代交互式 Web 应用的底层基础。
本项目的 DOM 结构
对照 3-terrarium/solution/index.html 可以看到,本课要操作的 DOM 树大体如下:
#page→header(标题「My Terrarium」)与main;main下是#plant-selection,内含左右两个选择区#left-container与#right-container,各存放 7 个.plant-holder,每个 holder 里是一个带唯一id(plant1–plant14)的<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 必须唯一,重复时只返回文档中第一个匹配的元素,因此请务必保证 plant1–plant14 各不相同。
创建 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;
}
坐标系统各变量的职责如下表:
| 变量 | 含义 | 用途 |
|---|---|---|
pos1、pos2 |
新旧鼠标坐标之差 | 在 elementDrag 中换算元素位移量 |
pos3、pos4 |
当前鼠标坐标 | 作为每次位移计算的基准点 |
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.clientX 与 e.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 事件,代码执行一轮完整计算:
- 测量指针从上一帧到当前帧的坐标差:
pos1 = pos3 - e.clientX、pos2 = pos4 - e.clientY; - 换算出元素应跟随移动的距离;
- 更新
pos3、pos4为当前坐标,作为下一帧的比较基准; - 写出元素新的 CSS 位置:
style.top取offsetTop减去垂直位移,style.left取offsetLeft减去水平位移。
例如:指针从 (100, 50) 移到 (110, 60),则 pos1 = 10、pos2 = 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),依次验证:
- 按住任意一株植物,拖拽随之启动;
- 移动鼠标或手指,观察植物平滑跟随指针;
- 松开,植物停留在新位置;
- 反复尝试不同摆放,探索自由布局。
完成标准自检:能否解释闭包如何为每株植物维持独立状态?为什么坐标差计算是顺滑移动的前提?忘记清理事件监听会发生什么?这套模式能否平滑扩展到更复杂的交互?代码质量层面,应确认设计模块化(每株植物一个闭包实例)、事件管理正确(有挂载就有卸载)、桌面与移动端皆可用、无内存泄漏与冗余计算。
动手拓展:把拖拽技能再推进一步
原版挑战:一键复位
用 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');用 innerHTML 或 textContent 修改页面文本;给按钮或链接挂一个 click 监听器;在 Elements 面板里查看 DOM 树结构。
长期路线图参考:第一小时完成课后测试并做一次带 click/mouseover/keypress 的交互页;第一周内完成盆栽拖拽项目、掌握事件委托、了解 event loop 与异步、用闭包构建有状态模块、接触 Intersection Observer 等现代 API;一个月后尝试用纯 JS 搭建 SPA、学习一个框架并与原生 DOM 操作对比、为开源项目做贡献、并向他人讲授 DOM 与 JavaScript 基础——这些方向都能加深你对本课两个核心主题(DOM 操作与闭包)的掌握。
关联作业
本课的配套作业要求围绕某个 DOM 接口做深度调研与写作:先选一个感兴趣的接口(入门可选 Element.classList、Document.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。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0627
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00

