Web-Dev-For-Beginners 苔藓缸项目(Terrarium):基于 DOM 操作与闭包的植物拖拽交互实现指南
本文基于 Web-Dev-For-Beginners 仓库中苔藓缸(Terrarium)项目第三课的意大利语课程文档 3-terrarium/3-intro-to-DOM-and-closures/translations/README.it.md,系统讲解如何仅用原生 JavaScript 实现页面元素的拖拽交互:从用 getElementById 引用 DOM 元素,到用指针事件(Pointer Events)追踪坐标,再到用闭包(Closure)为每个元素维护独立的私有状态。读完后,你将能够独立搭建一套可跨桌面与移动设备工作的拖放系统,并理解仓库 完整解答代码 中每一行的作用。
背景:DOM 操作与闭包在本项目中的角色
DOM(Document Object Model,文档对象模型)被 MDN 定义为"构成 Web 文档结构与内容的对象数据在内存中的表示"。浏览器加载 HTML 页面后,会为页面建立这样一棵结构化的"对象树",JavaScript 正是通过操作这棵树来实现页面动态更新。历史上,DOM 操作的复杂性正是许多 JavaScript 框架兴起的直接动因——但本课程的目标是证明:不依赖任何框架,用原生 JavaScript 也能把 DOM 玩明白。
本项目的具体任务是:完成交互苔藓缸,让用户能把页面侧边栏中的 14 株植物拖进、拖出并摆放在屏幕任意位置。同时,本课引入闭包概念:闭包可以理解为一个函数被包裹在另一个函数内部,使内部函数能够访问外部函数的作用域。课程文档特别提示,闭包是一个庞大而复杂的主题,本课只掌握最基础的一点——在苔藓缸代码中,内部函数与外部函数被构造为允许内部函数访问外部函数的作用域。
从课程结构看,这棵 DOM 树代表了页面所有可被操作的方式:开发者通过各种 API 用所选的编程语言访问 DOM,修改、重排并管理它。学完本课前,你需要已经完成了苔藓缸的 HTML 与 CSS 部分(即仓库 3-terrarium/solution/index.html 与 3-terrarium/solution/style.css 所呈现的静态设计);学完本课,静态设计将变成可交互的应用。
准备工作:创建 script.js 并理解 defer 属性
在 terrarium 文件夹中新建一个名为 script.js 的文件,并在 HTML 的 <head> 区域引入它:
<script src="./script.js" defer></script>
这里有一个关键细节:外部 JavaScript 文件在 HTML 中引入时应使用 defer 属性,它确保 JavaScript 只在 HTML 文件完全加载并解析完毕后才执行。也可以使用 async 属性,它允许脚本在 HTML 解析过程中就执行——但对本项目而言,必须让所有 HTML 元素(14 株植物)完全就绪之后,才能运行拖拽脚本,否则脚本会找不到要操作的目标元素。
仓库解答代码印证了这一做法:3-terrarium/solution/index.html 中正是以 <script src="./script.js" defer></script> 的形式在 <head> 中引入脚本。
第一步:通过 ID 引用 DOM 元素
首先要建立对要操作元素的引用。在本项目中,它们是等待在左右两条侧边栏里的 14 株植物。在 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'));
这段代码在做什么?它引用 document,在其 DOM 树中查找具有特定 ID 的元素。还记得 HTML 课程中为每株植物的图片设置了独立 ID(id="plant1")吗?这里正是用上了。找到每个元素后,把它传给一个名为 dragElement 的函数(稍后创建),元素由此启用(即将启用)拖拽能力。
思考题:为什么元素按 ID 而非 CSS 类来引用?答案与 CSS 课程相关——ID 在文档中是唯一的,可以精确定位单个元素;类则用于为一组元素提供共享样式,
getElementById()返回的是唯一的元素引用。若两个元素重复使用了同一个 ID,getElementById()只会返回其中第一个。
对照仓库解答代码可以看到实际页面结构:3-terrarium/solution/index.html 中,植物是 <img class="plant" id="plant1"> 这样的元素,被包裹在 .plant-holder 容器内,并分置于 #left-container(植物 1–7)与 #right-container(植物 8–14)两个侧栏中。ID 的唯一性保证了 14 次调用各自锁定一株植物。
第二步:用一个小例子理解闭包
在写真正的闭包前,先理解这个模式。闭包在"一个或多个函数需要访问更外层函数作用域"时很有用。看一个例子:
function displayCandy(){
let candy = ['jellybeans'];
function addCandy(candyType) {
candy.push(candyType)
}
addCandy('gumdrops');
}
displayCandy();
console.log(candy)
在这个例子中,displayCandy 包裹了一个向已有数组中插入新糖果类型的内部函数。如果运行这段代码,console.log(candy) 会报错——因为 candy 是局部变量(属于闭包的作用域),在函数外部无法访问。
思考题:如何让数组
candy可被外部访问?把它移出闭包即可——这样它就不再是闭包局部作用域中的变量,而变成全局变量。
理解了这一点,就可以构建本项目的核心:dragElement 闭包——一个外部函数,内部包裹一个或多个内部函数(本项目共有三个)。
第三步:编写 dragElement 外层函数
在 script.js 的元素声明下方创建函数:
function dragElement(terrariumElement) {
//设置 4 个屏幕定位坐标
let pos1 = 0,
pos2 = 0,
pos3 = 0,
pos4 = 0;
terrariumElement.onpointerdown = pointerDrag;
}
dragElement 从脚本顶部的声明处获得它的 terrariumElement 对象,然后为传入的对象设置 4 个初始为 0 的局部坐标。这些局部变量就是后续为每个元素添加拖拽能力时,在闭包内部逐一操作的变量。苔藓缸由这些被拖动的元素填充,应用必须持续追踪它们所在的位置。
另外,传给这个函数的 terrariumElement 被指派了一个按压识别事件(pointerdown),它属于 Web API 的一部分,专门帮助开发者管理 DOM。onpointerdown 在按键被按下、或(在触屏上)元素被触摸时触发。这个事件处理器在桌面浏览器和移动设备浏览器上都可用,只有极少数例外。
思考题:
onclick事件处理器在浏览器间的兼容性更好,为什么这里不用它?关键在于你要创建的确切交互形态:拖拽需要捕获"按下"这一手势起点并持续追踪移动,而 click 事件只描述一次完整的"按下并抬起",无法在按下瞬间介入。
第四步:pointerDrag 函数——捕获拖拽起点
当 onpointerdown 事件产生时,会调用 pointerDrag 函数。把它加在 terrariumElement.onpointerdown = pointerDrag; 这一行的正下方:
function pointerDrag(e) {
e.preventDefault();
console.log(e);
pos3 = e.clientX;
pos4 = e.clientY;
}
这里发生了多件事:
- 阻止默认行为。用
e.preventDefault()阻止 pointerdown 上通常会发生的事件默认行为(例如拖过文本时触发选中),从而获得对界面行为的更大控制力。实验建议:等整个脚本文件构建完成后,试着去掉
e.preventDefault()这一行再运行——你会立刻明白它为什么必不可少。 - 检查事件对象。把
index.html在浏览器中打开,点击一株植物,控制台会打印事件对象e。仔细观察它,你会发现一个 pointerdown 事件收集了多少信息。 - 捕获坐标。注意局部变量
pos3和pos4被设置为e.clientX/e.clientY——这两个值捕获了点击或触摸瞬间植物的 x、y 坐标。由于需要对点击和拖拽过程中植物的行为做精确控制,必须持续追踪它们的坐标。
思考题:现在是否更清楚整个应用为何被构造为一个大的闭包了?如果不是闭包,14 株可拖拽植物各自的作用域将如何维持?
最后,在 pos4 = e.clientY 下方补上另外两个指针事件的处理:
document.onpointermove = elementDrag;
document.onpointerup = stopElementDrag;
这两行表示:当用户移动指针时,植物应跟随指针一起移动;当用户释放植物时,拖拽手势停止。onpointermove 与 onpointerup 和 onpointerdown 属于同一套 API。此时页面会报错,因为 elementDrag 和 stopElementDrag 尚未定义——下一步就来创建它们。
第五步:elementDrag 函数——坐标运算与位置更新
闭包通过再增加两个内部函数完成:一个处理拖拽过程中的移动,一个处理停止拖拽。期望的行为是随时可以把任意植物拖到屏幕任意位置。这套界面刻意做得比较"宽松"(没有释放区域等限制),让你可以随心添加、移除、重新摆放植物,精确设计自己的苔藓缸。
在 pointerDrag 的闭合花括号之后添加 elementDrag:
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';
}
这段代码对外层函数中初始化为 0 的四个位置变量做了大量修改。逐步拆解:
- 拖拽过程中,
pos1被重新赋值为pos3(之前记录的e.clientX)减去当前的e.clientX,pos2做同样处理——即计算指针自上次事件以来的位移量; - 随后把
pos3和pos4重置为元素的最新 X、Y 坐标,作为下一次计算的基准; - 这些变化可以在控制台中通过
console.log实时观察; - 最后修改植物的 CSS 样式:通过比较
offsetTop/offsetLeft与新的位移量,计算出植物新的上(top)、左(left)坐标。
说明:
offsetTop和offsetLeft是元素相对其父级的位置属性——这里的"父级"指任何不是static定位的祖先元素。解答代码的 CSS 中,.plant设置了position: absolute,而每株植物的直接祖先.plant-holder是position: relative(见 3-terrarium/solution/style.css),因此 offset 值相对于该容器计算,style.top/style.left的写入才能真正生效。
所有这套位置重算,就是为了精确打磨苔藓缸及其植物的拖拽手感。
第六步:stopElementDrag 函数——事件清理
完成交互界面的最后一步:在 elementDrag 的闭合花括号之后添加 stopElementDrag:
function stopElementDrag() {
document.onpointerup = null;
document.onpointermove = null;
}
这个小函数把 onpointerup 和 onpointermove 重置为 null,使应用可以随时重新出发:再次拖起同一株植物,或开始拖拽另一株新的植物。
思考题:如果不清理(不置为 null)这两个事件,会发生什么?答案是监听器会持续挂着,性能退化,且与其他元素交互时会出现意料之外的行为。
至此,苔藓缸项目完成:14 株植物都可以自由拖放,如仓库中的最终效果图所示。
解答源码对照:闭包在实现中的完整形态
把上面六步拼起来,就是仓库中的 3-terrarium/solution/script.js。对照源码可以看到几个课程正文之外的实现细节:
- 文件顶部注释明确写道,可拖拽元素的解法借鉴了 w3schools 经典的"可拖拽 HTML 元素"做法(script.js 第 1 行);
- 函数体上方有一段对闭包定义的注释:"闭包是函数与其外围状态(词法环境)的引用捆绑在一起……换言之,闭包让你从内部函数访问外部函数的作用域"(script.js 第 18–21 行);
- 三个内部函数
pointerDrag、elementDrag、stopElementDrag都被嵌套在dragElement内部(script.js 第 23–64 行)。这意味着每调用一次dragElement(...),JavaScript 引擎就创建一个新的闭包实例:plant1和plant2各自拥有独立的pos1–pos4私有变量,互不干扰——这正是"每株植物拥有自己独立的位置记录"这一效果的实现原理。 - 解答代码中的
elementDrag还带有注释标明语义:pos1是"鼠标 X 坐标曾经所在处 − 当前所在处",pos3是"重置为鼠标 X 的当前位置",与课文的坐标运算解释完全一致。
CSS 侧还有一个值得注意的配合:解答样式表中 .plant 设置了 z-index: 2,而玻璃缸体 .jar-walls 是 z-index: 1(见 style.css 第 42–52 行),这让植物在拖拽时可以浮于缸体之上,视觉上"走进"玻璃缸。
延伸挑战:为闭包添加新的事件处理器
课程给出的挑战是:给闭包添加一个新的事件处理器,对植物做更多事——例如双击一株植物让它移到最前(foreground)。从源码结构看,这对应的正是 z-index 操作:给植物元素绑定双击事件,将其 z-index 提升为当前最大值即可。文档鼓励你放开 creativity,自行设计交互。
自学习路径与课后作业
拖拽看似简单,但实现方式众多、陷阱也多,取决于你想要哪种效果。事实上浏览器提供了一整套 HTML Drag and Drop API,可以自行尝试。本课没有使用它,是因为想要的交互效果略有不同——不过建议你在自己的项目上试验这套 API,看看能做出什么。
关于指针事件的更多细节,可查阅 W3C 的 Pointer Events 规范文档与 MDN 的 Pointer Events 文档;在实现任何浏览器功能前,习惯用 CanIUse 查询该功能的浏览器支持情况。
课后作业见 assignment.it.md,要求"进一步与 DOM 打交道":浏览 MDN 的 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

