首页
/ Web-Dev-For-Beginners 苔藓缸项目(Terrarium):基于 DOM 操作与闭包的植物拖拽交互实现指南

Web-Dev-For-Beginners 苔藓缸项目(Terrarium):基于 DOM 操作与闭包的植物拖拽交互实现指南

2026-09-06 11:59:22作者:庞队千Virginia

本文基于 Web-Dev-For-Beginners 仓库中苔藓缸(Terrarium)项目第三课的意大利语课程文档 3-terrarium/3-intro-to-DOM-and-closures/translations/README.it.md,系统讲解如何仅用原生 JavaScript 实现页面元素的拖拽交互:从用 getElementById 引用 DOM 元素,到用指针事件(Pointer Events)追踪坐标,再到用闭包(Closure)为每个元素维护独立的私有状态。读完后,你将能够独立搭建一套可跨桌面与移动设备工作的拖放系统,并理解仓库 完整解答代码 中每一行的作用。

DOM 树结构及其与 HTML 标记的对应关系示意图

交互苔藓缸的最终效果:14 株植物可从侧边栏拖入拖出、任意摆放

背景:DOM 操作与闭包在本项目中的角色

DOM(Document Object Model,文档对象模型)被 MDN 定义为"构成 Web 文档结构与内容的对象数据在内存中的表示"。浏览器加载 HTML 页面后,会为页面建立这样一棵结构化的"对象树",JavaScript 正是通过操作这棵树来实现页面动态更新。历史上,DOM 操作的复杂性正是许多 JavaScript 框架兴起的直接动因——但本课程的目标是证明:不依赖任何框架,用原生 JavaScript 也能把 DOM 玩明白。

本项目的具体任务是:完成交互苔藓缸,让用户能把页面侧边栏中的 14 株植物拖进、拖出并摆放在屏幕任意位置。同时,本课引入闭包概念:闭包可以理解为一个函数被包裹在另一个函数内部,使内部函数能够访问外部函数的作用域。课程文档特别提示,闭包是一个庞大而复杂的主题,本课只掌握最基础的一点——在苔藓缸代码中,内部函数与外部函数被构造为允许内部函数访问外部函数的作用域。

从课程结构看,这棵 DOM 树代表了页面所有可被操作的方式:开发者通过各种 API 用所选的编程语言访问 DOM,修改、重排并管理它。学完本课前,你需要已经完成了苔藓缸的 HTML 与 CSS 部分(即仓库 3-terrarium/solution/index.html3-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;
}

这里发生了多件事:

  1. 阻止默认行为。用 e.preventDefault() 阻止 pointerdown 上通常会发生的事件默认行为(例如拖过文本时触发选中),从而获得对界面行为的更大控制力。

    实验建议:等整个脚本文件构建完成后,试着去掉 e.preventDefault() 这一行再运行——你会立刻明白它为什么必不可少。

  2. 检查事件对象。把 index.html 在浏览器中打开,点击一株植物,控制台会打印事件对象 e。仔细观察它,你会发现一个 pointerdown 事件收集了多少信息。
  3. 捕获坐标。注意局部变量 pos3pos4 被设置为 e.clientX / e.clientY——这两个值捕获了点击或触摸瞬间植物的 x、y 坐标。由于需要对点击和拖拽过程中植物的行为做精确控制,必须持续追踪它们的坐标。

思考题:现在是否更清楚整个应用为何被构造为一个大的闭包了?如果不是闭包,14 株可拖拽植物各自的作用域将如何维持?

最后,在 pos4 = e.clientY 下方补上另外两个指针事件的处理:

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

这两行表示:当用户移动指针时,植物应跟随指针一起移动;当用户释放植物时,拖拽手势停止。onpointermoveonpointeruponpointerdown 属于同一套 API。此时页面会报错,因为 elementDragstopElementDrag 尚未定义——下一步就来创建它们。

第五步: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.clientXpos2 做同样处理——即计算指针自上次事件以来的位移量;
  • 随后把 pos3pos4 重置为元素的最新 X、Y 坐标,作为下一次计算的基准;
  • 这些变化可以在控制台中通过 console.log 实时观察;
  • 最后修改植物的 CSS 样式:通过比较 offsetTop/offsetLeft 与新的位移量,计算出植物新的上(top)、左(left)坐标。

说明:offsetTopoffsetLeft 是元素相对其父级的位置属性——这里的"父级"指任何不是 static 定位的祖先元素。解答代码的 CSS 中,.plant 设置了 position: absolute,而每株植物的直接祖先 .plant-holderposition: relative(见 3-terrarium/solution/style.css),因此 offset 值相对于该容器计算,style.top / style.left 的写入才能真正生效。

所有这套位置重算,就是为了精确打磨苔藓缸及其植物的拖拽手感。

第六步:stopElementDrag 函数——事件清理

完成交互界面的最后一步:在 elementDrag 的闭合花括号之后添加 stopElementDrag

function stopElementDrag() {
	document.onpointerup = null;
	document.onpointermove = null;
}

这个小函数把 onpointeruponpointermove 重置为 null,使应用可以随时重新出发:再次拖起同一株植物,或开始拖拽另一株新的植物。

思考题:如果不清理(不置为 null)这两个事件,会发生什么?答案是监听器会持续挂着,性能退化,且与其他元素交互时会出现意料之外的行为。

至此,苔藓缸项目完成:14 株植物都可以自由拖放,如仓库中的最终效果图所示。

解答源码对照:闭包在实现中的完整形态

把上面六步拼起来,就是仓库中的 3-terrarium/solution/script.js。对照源码可以看到几个课程正文之外的实现细节:

  • 文件顶部注释明确写道,可拖拽元素的解法借鉴了 w3schools 经典的"可拖拽 HTML 元素"做法(script.js 第 1 行);
  • 函数体上方有一段对闭包定义的注释:"闭包是函数与其外围状态(词法环境)的引用捆绑在一起……换言之,闭包让你从内部函数访问外部函数的作用域"(script.js 第 18–21 行);
  • 三个内部函数 pointerDragelementDragstopElementDrag 都被嵌套在 dragElement 内部(script.js 第 23–64 行)。这意味着每调用一次 dragElement(...),JavaScript 引擎就创建一个新的闭包实例:plant1plant2 各自拥有独立的 pos1pos4 私有变量,互不干扰——这正是"每株植物拥有自己独立的位置记录"这一效果的实现原理。
  • 解答代码中的 elementDrag 还带有注释标明语义:pos1 是"鼠标 X 坐标曾经所在处 − 当前所在处",pos3 是"重置为鼠标 X 的当前位置",与课文的坐标运算解释完全一致。

CSS 侧还有一个值得注意的配合:解答样式表中 .plant 设置了 z-index: 2,而玻璃缸体 .jar-wallsz-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 接口列表,"认领"其中一个接口,在真实网站上找到它被使用的实例,并撰写一段说明它如何被使用的分析。评分标准很直接:有例子的段落为优,无例子为合格,未提交报告为不合格。

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