Web-Dev-For-Beginners Terrarium 实战:用 DOM 操作与 JavaScript 闭包实现植物拖拽交互
本文以 Web-Dev-For-Beginners 课程 Terrarium(虚拟生态瓶)项目的第三部分为核心,完整讲解如何用原生 JavaScript 通过 DOM 操作让 14 株植物支持"按住即拖、松手即放"的交互。读完后你将掌握三件事:如何用 document.getElementById() 建立脚本与 HTML 元素的引用、如何用闭包为每个可拖拽元素维护独立私有状态(坐标变量)、以及 pointerdown / pointermove / pointerup 三类指针事件如何协同完成一次完整的拖拽生命周期。
DOM 与闭包:交互页面的两根支柱
DOM 是什么
DOM(Document Object Model,文档对象模型)是"组成网页文档结构与内容的那些对象的数据表示"(文档引用 MDN 的定义)。你可以把 DOM 想象成一棵树:它代表了网页文档可以被操作的所有方式,浏览器加载页面时会在内存中构建出这棵对象树,JavaScript 通过 API 访问、编辑、重排和管理它。
上图是一张经典的 DOM 与 HTML 标记对照示意图(引自课程原文)。在原始资料中由 Olfa Nasraoui 提供。
操纵 DOM 正是 JavaScript 框架大量出现的动因之一——历史上围绕 DOM 操作的诸多痛点推动开发者转向框架,但本课刻意反其道而行:我们用 vanilla JS 自己管理 DOM,因为理解了底层机制,之后再看任何框架的虚拟 DOM 优化都会更通透。
闭包是什么
闭包(closure)可以理解为"一个被外层函数包裹的内层函数"——内层函数因此能够访问外层函数的作用域。用更形象的说法:闭包像是给函数配了一个"私有记事本",即使外层函数执行完毕,内层函数仍记得那些局部变量。
课程里给了一个糖果数组的小例子来演示作用域问题:
function displayCandy(){
let candy = ['jellybeans'];
function addCandy(candyType) {
candy.push(candyType)
}
addCandy('gumdrops');
}
displayCandy();
console.log(candy) // 报错:candy 未定义
candy 是 displayCandy 内部的局部变量,外部 console.log 拿不到它。如果希望外部可访问,可以把数组移出闭包使其成为全局变量——但在本例(Terrarium)中我们恰恰希望变量保持私有,这正是闭包的用武之地。
为什么 Terrarium 必须用闭包?因为页面里有 14 株可拖拽的植物,每株都需要独立跟踪自己的坐标状态。若把坐标变量放到全局作用域,14 株植物会互相踩踏同一份数据;而每次调用 dragElement(某株植物) 都会生成一份新的闭包实例,4 个坐标变量 pos1~pos4 就变成该植物私有的、持久存在的状态。
前置条件与脚本接入
开始本课之前,你需要已经完成了 Terrarium 的前两课:index.html 与 style.css 中搭好的页面骨架(左右两侧的植物栏 + 中间的玻璃瓶)。本课结束后,你将能够把植物拖出侧栏、拖入瓶中任意位置。
在 terrarium 目录下新建 script.js,并在 index.html 的 <head> 中引入:
<script src="./script.js" defer></script>
defer 与 async 的区别值得注意(文档原文强调):
defer:脚本只在 HTML 完全加载并解析完成后才执行。本例中我们必须等所有植物元素都解析出来,拖拽脚本才有东西可拖,所以选defer。async:脚本可以在 HTML 解析过程中随时执行,适合与页面内容无时序依赖的独立脚本。
仓库中的成品 solution/index.html 正是这样写的(第 13 行):
<script src="./script.js" defer></script>
第一步:为 14 株植物建立 DOM 引用
先创建对要操作元素的引用。在 script.js 顶部写下 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'));
这里做了什么:访问 document,在 DOM 中按 ID 找到特定元素。还记得第一课 HTML 里给每株植物图片赋予的唯一 id(id="plant1" …)吗?此刻正是兑现那一刻劳动的回报。每找到一株植物,就把它交给 dragElement 函数(下面即将创建),这株元素随即获得(或即将获得)可拖拽能力。
对照仓库中的 solution/index.html 可以看到,左右两个侧栏各放了 7 株植物,每株都是形如这样的结构:
<div class="plant-holder">
<img class="plant" alt="plant" id="plant1" src="./images/plant1.png" />
</div>
为什么按 ID 而不是按 CSS class 引用? 这是课程留的思考题。答案在于语义分工:class 设计用于为一组元素施加统一样式(比如上面 .plant 类同时作用在 14 张图上),而 ID 是页面内唯一标识符,getElementById() 只返回匹配到的第一个元素。JavaScript 要精确定位并单独操作某一个元素时,ID 是唯一且可靠的句柄。
第二步:构建 dragElement 闭包
现在创建核心闭包 dragElement——一个外部函数,内部封装三个函数。在 script.js 的元素声明下方添加:
function dragElement(terrariumElement) {
// 设置 4 个屏幕定位变量
let pos1 = 0,
pos2 = 0,
pos3 = 0,
pos4 = 0;
terrariumElement.onpointerdown = pointerDrag;
}
逐点拆解:
dragElement从脚本顶部的 14 行声明中接收一个terrariumElement对象(某株植物的<img>节点)。pos1~pos4初始化为 0,它们是闭包私有变量,之后每个指针事件都会读写它们。Terrarium 将被这些被拖动的元素"住满",所以应用必须持续跟踪它们的位置——4 个变量分别记录上一帧与当前帧的 X/Y 坐标。terrariumElement.onpointerdown = pointerDrag给该元素挂上pointerdown事件处理器。指针事件(Pointer Events)是 Web API 的一部分,专为跨设备输入设计:onpointerdown在鼠标按键按下或手指触摸到可拖拽元素时触发,且同时适用于桌面浏览器与移动端(文档提示存在少数兼容例外)。
为什么不用浏览器支持更广的 onclick? 这是课程的第二道思考题,答案藏在"拖拽"这个动作本身:click 只描述"按下→松开"一个完整周期的结果,而拖拽需要在按住不放的整个过程中持续跟踪指针移动。onpointerdown 捕获的是"按下"这个起点,后续配合 onpointermove 才能还原出连续轨迹,onclick 在语义上就无法覆盖这段过程。
第三步:pointerDrag —— 捕获拖拽起点
当 onpointerdown 触发时调用 pointerDrag。把它添加在 terrariumElement.onpointerdown = pointerDrag; 这一行的下方(即 dragElement 函数体内):
function pointerDrag(e) {
e.preventDefault();
console.log(e);
pos3 = e.clientX;
pos4 = e.clientY;
document.onpointermove = elementDrag;
document.onpointerup = stopElementDrag;
}
这一小段里发生了四件事:
e.preventDefault():阻止浏览器对指针按下事件的默认行为(例如在拖动图片时触发选中、在页面拖选文字)。不阻止它,浏览器自己的行为会干扰自定义拖拽。文档特意留了一个实验任务:写完整脚本后把这行删掉再试一次,亲眼看看会发生什么。console.log(e):调试手段。在浏览器中打开index.html并用检查器(Elements/Console 面板)观察,点击一株植物时可以看到被捕获的事件对象e,展开它能看到指针事件收集到的大量信息(坐标、指针类型、目标元素等)。pos3 = e.clientX; pos4 = e.clientY:把闭包私有的pos3、pos4设为事件对象的clientX/clientY,即用户按下那一刻指针相对视口的 X/Y 坐标。要精确控制植物在点击、拖动时的表现,就必须持续跟踪这些坐标。- 注册两个 document 级监听:
document.onpointermove = elementDrag;
document.onpointerup = stopElementDrag;
这意味着:植物要跟着指针在整个文档范围内移动;松开手(取消选中植物)时拖拽手势终止。onpointermove 和 onpointerup 与 onpointerdown 属于同一套指针事件 API。把 move/up 监听挂在 document 而不是植物元素上,是为了即使指针快速移出该 <img> 的边界,拖拽跟踪也不中断。
此时若立即运行页面,由于 elementDrag 与 stopElementDrag 尚未定义,控制台的赋值语句不会立即报错(属性赋值只是存了个 undefined 或延迟求值),但拖拽行为尚不完整——补上最后两个函数。
第四步:elementDrag —— 拖拽中的坐标运算
这是闭包中数学最重的一环。在 pointerDrag 的结束大括号之后添加:
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';
}
它大量读写外层函数中初始化的 4 个私有坐标变量,运算逻辑如下:
pos1 = pos3 - e.clientX:水平方向位移 = 上一次记录的 X(pos3)− 当前指针 X。pos2同理处理垂直方向。pos3 = e.clientX; pos4 = e.clientY:把pos3/pos4重置为当前指针坐标,成为下一次pointermove计算的基线。拖动过程中在控制台能看到这四个值的滚动变化。- 改写 CSS 定位:
terrariumElement.style.top = terrariumElement.offsetTop - pos2 + 'px',left同理。元素的新top/left由它当前的offsetTop/offsetLeft减去本次位移得到,字符串拼接'px'后写入行内样式。
offsetTop与offsetLeft是读元素相对其定位父元素当前位置的属性;只有父级存在非static的定位时该关系才成立。
这一点直接呼应了仓库中 solution/style.css 的布局设定,拖拽能成立依赖两处 CSS 前提:
.plant-holder {
position: relative; /* 为每株植物提供局部定位上下文 */
height: 13%;
left: -10px;
}
.plant {
position: absolute; /* 图片脱离文档流,top/left 才生效 */
max-width: 150%;
max-height: 150%;
z-index: 2;
}
从源码结构看,.plant 被设为 position: absolute 正是 style.top / style.left 赋值能立刻改变图片位置的先决条件——若它是静态定位(static),改动 top/left 将被浏览器忽略,整个拖拽体系随之失效。
这套持续重算让植物可以落在屏幕的任意位置,而界面对此不做任何限制:没有"投放区"(drop zone)、没有吸附,你想怎么摆就怎么摆。
第五步:stopElementDrag —— 清理拖拽状态
最后一个函数,添加在 elementDrag 的结束大括号之后:
function stopElementDrag() {
document.onpointerup = null;
document.onpointermove = null;
}
这个短小的函数把 document 上的 onpointerup 与 onpointermove 重置为 null,作用是"解除监听":松手后不再持续计算,你可以重新开始拖同一株植物,或改拖另一株。
思考题:如果不把它们置为 null 会怎样? 可以推断的后果包括:拖动 A 植物结束后,document 上仍挂着 A 的 elementDrag;此时你只是普通地在页面上移动鼠标,A 也会跟着移动(因为 move 监听从未解绑),同时各次拖拽注册的处理器会相互覆盖,行为不可预测。
参考实现与完整代码对照
仓库自带的完整可运行版本位于 solution/script.js,与上面逐段累加的最终形态一致,其中还附有注释说明闭包的动机(文件头部第 18–21 行的注释直接给出闭包定义:闭包是函数与其词法环境的组合,内层函数因此能访问外层作用域)。完整的 dragElement 结构如下,方便你一次性对照检查自己的代码:
function dragElement(terrariumElement) {
// 设置 4 个屏幕定位变量
let pos1 = 0,
pos2 = 0,
pos3 = 0,
pos4 = 0;
terrariumElement.onpointerdown = pointerDrag;
function pointerDrag(e) {
e.preventDefault();
console.log(e);
// 用 pos3 和 pos4 记录初始鼠标位置
pos3 = e.clientX;
pos4 = e.clientY;
// 鼠标移动时开始拖拽
document.onpointermove = elementDrag;
// 鼠标抬起时停止拖拽
document.onpointerup = stopElementDrag;
}
function elementDrag(e) {
// pos1 = 鼠标 X 之前的位置 - 现在的位置
pos1 = pos3 - e.clientX;
// pos2 = 鼠标 Y 之前的位置 - 现在的位置
pos2 = pos4 - e.clientY;
// 将 pos3 重置为鼠标 X 当前位置
pos3 = e.clientX;
// 将 pos4 重置为鼠标 Y 当前位置
pos4 = e.clientY;
console.log(pos1, pos2, pos3, pos4);
// 设置元素新位置:
terrariumElement.style.top = terrariumElement.offsetTop - pos2 + 'px';
terrariumElement.style.left = terrariumElement.offsetLeft - pos1 + 'px';
}
function stopElementDrag() {
// 鼠标松开后停止计算
document.onpointerup = null;
document.onpointermove = null;
}
}
仓库注释同时注明:该可拖拽元素的解法参考了 w3schools 的 Draggable HTML Element 方案,并改用了指针事件。
至此项目完成——你的 Terrarium 已经是一个真正"活起来"的页面:14 株植物各自拥有独立闭包状态,任意点击、拖动、放置均可流畅进行。
挑战:扩展你的拖拽系统
课程结尾给出了进阶挑战,任选其一动手:
- 双击置顶:给闭包再挂一个事件处理器(如
ondblclick),双击某株植物时把它"带到最前面"——用style.zIndex递增即可实现,注意 solution/style.css 中.plant的初始z-index是 2; - 边界约束:在
elementDrag中加入条件判断,禁止植物被拖出 Terrarium 玻璃瓶区域; - 位置持久化:用
localStorage在stopElementDrag中记录各植物最终坐标,刷新后恢复布局; - 鼓励自拟其他创意(悬停高亮、拿起/放下音效等)。
自学延伸:不止指针事件一条路
拖拽在屏幕上看似 trivial,实则方法众多、坑也不少,取决于你想要的效果。文档"复习与自学"部分特别提醒:浏览器还内置了完整的 HTML Drag and Drop API(draggable 属性 + dragstart/dragover/drop 事件族),本课刻意没有使用它,因为那种 API 的交互效果(幽灵镜像、投放区高亮)与本课想要的"元素原地跟手移动"不同——建议在自己的项目里尝试对比两者。此外还可顺藤摸瓜学习:
- 事件委托(event delegation):用单一监听器处理多个元素的交互;
transform属性替代top/left做位移,通常对渲染性能更友好;- 虚拟 DOM 概念:主流框架正是为管理 DOM 而出现的,本课写的"手动 diff"式坐标更新正是它们在底层自动做的事。
配套的课后练习见 assignment.es.md:从 DOM 接口列表中"认领"一个接口,在一个真实网站上找到它的使用实例,写一段带示例的说明文字。其评分标准只有一个维度——是否提交了一段"带示例"的说明段落(优秀档要求成段写作且含示例,合格档为成段但无示例,未提交则不合格)。
小结
| 技术点 | 在本课中的角色 | 关键代码 |
|---|---|---|
| DOM 引用 | 找到 14 株植物 | document.getElementById('plant1') |
| 闭包 | 每株植物私有 pos1~pos4 |
function dragElement(terrariumElement) { let pos1=0, … } |
pointerdown |
拖拽起点、记录初始坐标 | terrariumElement.onpointerdown = pointerDrag |
pointermove |
持续跟手、坐标重算 | pos1 = pos3 - e.clientX; … |
pointerup |
结束拖拽、解除监听 | document.onpointermove = null |
| CSS 定位 | 位移真正生效的前提 | .plant { position: absolute } |
defer |
保证 DOM 就绪后再执行脚本 | <script src="./script.js" defer></script> |
这套"闭包 + 指针事件 + 行内样式写入"的组合,正是从文件拖放上传到看板卡片拖动等众多交互界面共用的底层原理——在 Terrarium 这个项目里,你只用不到 60 行原生 JS 就把它们全部亲手实现了一遍。
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

