Web-Dev-For-Beginners 苔藓缸项目第 3 课:用 DOM 操作与 JavaScript 闭包实现植物拖拽
DOM(文档对象模型)是 Web 开发中连接 HTML 与 JavaScript 的桥梁,而闭包则让每个可交互元素拥有独立的"私有状态"。本篇基于 Web-Dev-For-Beginners 课程库中的苔藓缸(Terrarium)项目第 3 课,完整演示如何用原生 JavaScript 通过指针事件(Pointer Events)和 dragElement 闭包,让 14 株植物可以被拖拽、放置到页面任意位置。读完本篇,你将掌握 document.getElementById 元素选取、onpointerdown/onpointermove/onpointerup 事件链、基于 clientX/clientY 的坐标追踪算法,以及事件监听器清理等可复用的实战模式。
图示为 DOM 及其对应 HTML 标记的表示。
一、课程背景:为什么是 DOM 与闭包
按照 MDN 的定义,DOM(Document Object Model)是"Web 上文档的结构与内容所构成的对象的表示"。由于 DOM 操作的复杂性,现代开发中常借助 React、Vue 等框架来托管 DOM,但本课坚持用纯原生 JavaScript 自己管理 DOM,以建立底层认知。
本课同时要引入 JavaScript 闭包 的概念:可以把闭包理解为"用一个函数把若干内层函数包裹起来,使内层函数仍然能访问外层函数的作用域"。需要注意,闭包是一个非常庞大且复杂的话题——本课只聚焦最基本的一点:苔藓缸代码中确实存在闭包,即内层函数(pointerDrag、elementDrag、stopElementDrag)与外层函数(dragElement)之间、内层函数可以访问外层作用域的这种构造方式。
可以这样理解 DOM 与闭包在本项目中的分工:
- DOM 被当作一棵树来看待,它表达了操作 Web 页面文档的所有方式,各种 API 允许程序员用自己喜欢的语言去访问、编辑、修改、重排和管理 DOM 节点;
- 闭包 让每一株植物都拥有自己独立的坐标变量(
pos1~pos4),彼此互不干扰,就像每株植物都有自己的"位置档案"。
前提条件
在开始本课之前,你需要已经完成苔藓缸项目的 HTML 与 CSS(即仓库中 solution/index.html 和 solution/style.css 所呈现的状态)。本课完成后,你将能够把植物从两侧的边栏拖拽进苔藓缸,或从缸内拖出、随意摆放。
二、准备工作:创建并引入 script.js
任务:引入脚本文件
在 terrarium 文件夹内创建 script.js 文件,并在 index.html 的 <head> 分区中引入它:
<script src="./script.js" defer></script>
注:为了让 JavaScript 只在 HTML 文件完全加载完成后才执行,引入外部脚本文件时应使用
defer属性。也可以改用async属性——它允许脚本在 HTML 解析过程中执行;但就本项目而言,重要的是在运行拖拽脚本之前,HTML 元素必须已经完整可用,因此选用defer。
这一点在参考实现 solution/index.html 中可以直接印证:第 13 行正是 <script src="./script.js" defer></script>。如果没有 defer,脚本可能在 14 个 img.plant 元素(id 从 plant1 到 plant14,见 solution/index.html)渲染完成前就执行 document.getElementById,导致取到 null 并报错。
三、DOM 要素:获取 14 株植物的引用
首先要做的是:创建对要在 DOM 中操作的元素的引用。在我们的场景里,就是当前驻留在两侧边栏里的 14 株植物。
任务:逐一注册可拖拽元素
在 script.js 中写入以下 14 行调用(与参考实现 solution/script.js 的开头部分一致):
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'));
这里发生了什么? 这段代码通过 DOM 查询文档、找到拥有特定 ID 的元素。还记得在 HTML 第一课中,我们为每株植物的 <img> 赋予了唯一 ID(id="plant1" … id="plant14")吗?现在正是利用那次付出的地方。识别出每个元素后,就把它作为参数传给名为 dragElement 的函数,从而让 HTML 中的这个元素变为可拖拽的。
✅ 为什么用 ID 而不是 CSS class 来定位元素? ID 在文档中是唯一的标识符,而 class 是为成组元素做样式设计的。当 JavaScript 需要精确操作单个元素时,getElementById 提供的唯一性和精确性是 class 选择器给不了的(这也是 CSS 课程中讲到的 ID 与 class 语义差异的直接延伸)。
四、闭包:构建 dragElement 外层函数
现在准备创建 dragElement 闭包了——它是包裹内部函数(本例中共 3 个)的外层函数。
闭包的最小示例
当需要一个或多个内层函数访问外层函数作用域时,闭包非常有用。示例如下(忠实保留课程文档的写法):
function displayCandy(){
let candy = ['jellybeans'];
function addCandy(candyType) {
candy.push(candyType)
}
addCandy('gumdrops');
}
displayCandy();
console.log(candy)
在这个例子中,displayCandy 函数包裹了一个用于向数组中压入新糖果类型的内层函数。执行这段代码后,在函数外 console.log(candy) 是无法拿到该数组的——因为 candy 是 displayCandy 的函数级局部变量(闭包的局部),在函数外部根本不可见。
✅ 思考题:如何能访问 candy 数组? 尝试把它移到闭包外部。这样数组就变成了全局变量,不再只在闭包的局部作用域内可用(代价是失去封装性——这正是 dragElement 闭包用局部变量避免 14 株植物的状态互相串扰的原因)。
任务:创建 dragElement 函数
在 script.js 的元素声明(上述 14 行调用)下方创建函数:
function dragElement(terrariumElement) {
// 放置 4 个用于记录屏幕位置的变量
let pos1 = 0,
pos2 = 0,
pos3 = 0,
pos4 = 0;
terrariumElement.onpointerdown = pointerDrag;
}
dragElement 接收脚本开头声明处传入的 terrariumElement 对象,并把传给它的对象的"局部位置"初始化为 0。这些是闭包内的局部变量,之后为每个元素添加拖放行为时都围绕它们操作。因为苔藓缸里这些元素会被拖来拖去地摆放,应用程序必须时刻掌握每个元素被放到了哪里。
再注意最后一行:分配给 terrariumElement 的 pointerdown 事件属于专为 DOM 管理而设计的 Web API 的一部分。onpointerdown 在用户按下按钮、或手指触摸到可拖拽元素时触发;该事件处理器(除少数例外情况外)在桌面浏览器和移动浏览器上都能工作。
✅ 为什么不用 onclick? onclick 虽是跨浏览器兼容的事件处理器,但它只描述"按下并释放"这一瞬间的点击;而我们需要的是一种持续跟踪指针移动轨迹的交互——按下、移动、释放的完整生命周期,这正是指针事件(Pointer Events)的设计目的。
五、pointerDrag 函数:捕获拖拽起点
当 onpointerdown 事件触发时,pointerDrag 函数被调用。紧跟在 terrariumElement.onpointerdown = pointerDrag; 这一行的下方(参考实现中位于 solution/script.js)添加该函数:
function pointerDrag(e) {
e.preventDefault();
console.log(e);
pos3 = e.clientX;
pos4 = e.clientY;
}
这里发生了几件事:
e.preventDefault()阻止指针按下时浏览器默认行为的触发(例如选中图片、触发滚动等),从而让我们更可控地掌握界面行为。实验建议:等脚本文件全部构建完成后,回到这里去掉
e.preventDefault()再试一次——观察拖拽体验有什么不同。console.log(e)用于观察事件对象。在浏览器中打开index.html,点击一株植物,就能看到e事件被捕获的样子——深入查看这个事件对象,你会发现一次pointerdown事件里携带了多少信息(clientX、clientY、screenX、pointerId等)。pos3 = e.clientX、pos4 = e.clientY把局部变量设为事件对象的坐标值。这些值就是用户点击或触摸植物那一瞬间的 X/Y 坐标(相对于视口)。由于我们要精细控制植物在被点击、被拖拽时的运动,就必须先掌握这个起点坐标。
✅ 现在应该更能理解为什么整个应用要构建在一个个大闭包中了——如果不用闭包,你打算如何维持 14 个可拖拽植物各自的独立作用域?(答案:14 次 dragElement(...) 调用会创建 14 个相互独立的闭包实例,每个实例持有自己独立的 pos1~pos4。)
注册 document 级事件
在 pos4 = e.clientY 下方追加两个指针事件绑定,把初始函数补全:
document.onpointermove = elementDrag;
document.onpointerup = stopElementDrag;
这表示:当植物被移动时,我们希望植物沿指针路径被拖拽;当松开植物(取消选择)时,希望停止拖拽手势。onpointermove 与 onpointerup 和 onpointerdown 一样,同属一个指针事件 API。
为什么挂在 document 上而不是植物元素本身? 因为拖动过程中指针很快就会离开植物图片的边界;只有把移动与抬起事件绑定到整个文档,才能保证快速移动、甚至移出窗口边界时拖拽不断线。
注意:此时 elementDrag 和 stopElementDrag 尚未定义,界面上交互会抛出错误——这是正常的,因为接下来就要补上这两个函数。
六、elementDrag 与 stopElementDrag:完成拖拽闭环
再补充 2 个内部函数,处理"拖拽植物"与"停止拖拽"时发生的事,闭包即告完成。我们期望的行为是:任意一株植物在任何时候都能被拖拽,放到屏幕的任意位置。这个界面相当"无主见"(non-opinionated)——没有掉落区限制,允许你通过添加、移除、重排植物,把苔藓缸设计成自己喜欢的样子。
任务:elementDrag 函数
把 elementDrag 加在 pointerDrag 的右花括号之后(参考实现见 solution/script.js):
function elementDrag(e) {
pos1 = pos3 - e.clientX;
pos2 = pos4 - e.clientY;
pos3 = e.clientX;
pos4 = e.clientY;
console.log(pos1, pos2, pos3, pos4);
terrariumElement.style.top = terrariumElement.offsetTop - pos2 + 'px';
terrariumElement.style.left = terrariumElement.offsetLeft - pos1 + 'px';
}
这个函数大量改动了外层函数中作为局部变量设定的初始位置 1~4。逐步拆解:
pos1 = pos3 - e.clientX:把pos1重新指定为"上一次记录的 X 坐标(pos3,上一轮存的是e.clientX)减去当前e.clientX",即本次水平位移量;pos2同理计算垂直位移量;pos3 = e.clientX; pos4 = e.clientY;:把pos3、pos4重置为要素的新 X/Y 坐标,作为下一轮计算的基准。这些变化可以在拖拽过程中从控制台直接观察到(console.log);style.top/style.left两行:操作植物的 CSS 样式,根据pos1与pos2计算出植物的新位置——将植物的offsetTop/offsetLeft与新的位移量做比较,算出上下左右的新坐标。
offsetTop与offsetLeft是读取要素位置相对于其父级(最近已定位祖先)的属性的 API。任何没有被定位成static的父级元素都可以作为参照。
从源码结构看,这一前提在项目里是被真实满足的:solution/style.css 中 .plant { position: absolute; ... },且其父级 .plant-holder { position: relative; },因此 offsetTop/offsetLeft 以 plant-holder 为参照系,style.top/style.left 的更新才能即时生效。
通过这样重算位置,你可以对苔藓缸和植物的行为做精细调优。
任务:stopElementDrag 函数
完成界面的最后一个任务是:在 elementDrag 的花括号闭合之后追加 stopElementDrag 函数(参考实现见 solution/script.js):
function stopElementDrag() {
document.onpointerup = null;
document.onpointermove = null;
}
这个小小的函数把 onpointerup 和 onpointermove 事件重置为 null,从而让玩家可以重新拿起一株植物继续拖动,或者开始拖拽另一株新植物。
✅ 如果不把这些事件设为 null 会怎样? 文档级监听器会永久残留:每次拖拽结束后 elementDrag 仍会被每次指针移动反复触发,坐标状态不断被污染,性能也会因无谓的事件处理持续退化。清理监听器是事件驱动代码的基本卫生习惯。
至此项目完成!
恭喜!一株株可以随意拖放植物、独一无二的苔藓缸完成了。
七、挑战:给闭包追加新事件
向闭包中添加新的事件处理器,让植物还能做更多的事。例如:双击一株植物把它移到最前面(操作 z-index)。发挥你的想象力。
八、复学与自学方向
在屏幕上拖拽元素听起来是小事,但实现方式很多,且随目标效果不同各有陷阱:
- HTML Drag and Drop API:浏览器内建的原生拖放接口。本模块没有使用它(因为我们需要的是自由位置拖拽而非"源→目标"式拖放),但可以在自己的项目里试试这个 API 能做到什么;
- Pointer Events 细节:可查阅 W3C 的 Pointer Events 规范与 MDN 的指针事件文档深入了解;
- 浏览器兼容性:养成在任何功能上先查 CanIUse.com 的习惯(本项目选用的
onpointerdown等指针事件在桌面与移动浏览器中支持度良好,仅有少数例外)。
九、后续作业:DOM 要素调研
本课配套作业是"更深入地玩转 DOM":从 MDN 的 DOM 接口列表中挑选一个接口(入门级如 Element.classList、Document.querySelector、Window.localStorage;进阶级如 IntersectionObserver、MutationObserver、Drag and Drop API),完成 300~500 字的技术分析、真实网站案例分析,并附上带注释的可运行代码示例。作业详情见 assignment.ja.md。
十、小结:本课程的完整技术链路
回顾一下 script.js 的完整调用链(与 solution/script.js 的结构一一对应):
| 阶段 | 触发 | 函数 | 关键动作 |
|---|---|---|---|
| 初始化 | 脚本加载(defer) |
14 × dragElement(plantN) |
每株植物各建一个独立闭包实例 |
| 按下 | onpointerdown |
pointerDrag(e) |
preventDefault()、记录起点 pos3/pos4、挂起 document 级 pointermove/pointerup |
| 移动 | onpointermove |
elementDrag(e) |
增量坐标运算 → 更新 style.top/left |
| 释放 | onpointerup |
stopElementDrag() |
两个 document 级监听器置 null,为下一次拖拽复位 |
这套"14 个闭包 + 3 个内部函数 + 3 类指针事件"的结构,是理解 DOM 操作与闭包协作的典型样本:闭包提供每株植物独立的私有坐标状态,指针事件提供跨设备的交互生命周期,而 offsetTop/offsetLeft 与 style 的写回则让 DOM 树的变化实时反映到画面上。理解了它,你就掌握了文件拖拽上传、看板卡片移动、图库重排等一切"拖放"交互背后的共同原理。
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 StartedRust0624
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

