首页
/ Web-Dev-For-Beginners 生态缸课程:用 DOM 操作与 JavaScript 闭包从零构建可拖拽植物交互界面

Web-Dev-For-Beginners 生态缸课程:用 DOM 操作与 JavaScript 闭包从零构建可拖拽植物交互界面

2026-09-04 19:11:43作者:侯霆垣

本篇指南基于 Web-Dev-For-Beginners 仓库中生态缸(Terrarium)项目的第三课,讲解如何通过 Document Object Model(DOM)定位页面元素,并利用 JavaScript 闭包(Closure)为每个元素维护独立的位置状态,最终实现"把 14 株植物随意拖进玻璃罐"的完整交互效果。读完本篇,你将掌握 getElementById 元素选择、pointerdown/pointermove/pointerup 指针事件链、offsetTop/offsetLeft 坐标换算,以及闭包如何在没有框架的情况下为多个可拖拽元素各自维护私有状态。

DOM 树表示:DOM 结构与引用它的 HTML 标记的对应关系

核心概念:DOM 与闭包

DOM(Document Object Model) 是浏览器把 HTML 文档解析成的数据表示。文档中引用 MDN 的定义:"Document Object Model (DOM) 是网页文档的结构与内容所组成的对象数据表示。" 可以把它想象成一棵树:树根是 document,每个 HTML 标签都是一个节点,JavaScript 沿树查找节点、读取属性、修改样式、插入删除节点,页面就随之变化。围绕 DOM 操作的各种 API 正是许多 JavaScript 框架存在的理由,但本课程坚持用"原生 JavaScript"(vanilla JavaScript)直接管理 DOM。

闭包(Closure) 则是本课的另一主角:一个"被另一个函数携带的函数",使得内层函数可以访问外层函数的作用域变量。在本课程的原话中,JavaScript 闭包是一个广阔而复杂的主题,本课只触及最基本的想法——在生态缸代码中,你会发现闭包:内层函数与外层函数以某种方式组合在一起,使内层函数能够访问外层函数的作用域。

在本课中,我们将完成交互生态缸项目的收尾工作:编写 JavaScript,让页面用户能够随意操纵这些植物。

前置条件与准备工作

开始之前,你需要已经拥有前几课做出的生态缸 HTML 和 CSS(见 3-terrarium 模块总览 中的第一课与第二课)。到本课结束时,你将能够把植物从侧边栏"拖"进玻璃罐,也可以拖出来。

创建脚本文件并在 HTML 中引入:在生态缸文件夹中新建一个名为 script.js 的文件,并在 index.html<head> 部分引入它:

<script src="./script.js" defer></script>

注意:引入外部 JavaScript 文件时使用 defer,可以确保 HTML 完全加载后才运行 JavaScript。你当然也可以用 async 属性,它允许脚本在 HTML 解析期间就执行——但在本场景中,必须在允许"拖动"脚本运行之前,让所有 HTML 元素完全可用,因此 defer 才是正确选择。

仓库中的成品页面正是这样做的,见 solution/index.html<script src="./script.js" defer></script> 位于 <head> 内,而 14 株植物分布在 <main> 下的 #plant-selection 区域。

获取 DOM 元素引用:14 株植物

第一件事,是获取要操纵的元素在 DOM 中的引用。在本课程中,它们是等待在侧边栏中的 14 株植物。在 solution/index.html 中可以看到,左侧 #left-containerplant1plant7,右侧 #right-containerplant8plant14,每株都是形如 <img class="plant" alt="plant" id="plant1" ... /> 的带唯一 id 的图片元素。

script.js 顶部逐一引用它们并传入 dragElement 函数(该函数稍后构建):

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 的函数,于是 HTML 中的元素现在(或很快)就具备了"可拖拽"能力。

✅ 为什么用 id 而不是 CSS 类来引用元素? 你可以回看之前的 CSS 课来回答这个问题:类是为一组元素共享样式设计的,而 id 是全局唯一的标识符,getElementById 因此能精确定位单个元素——若两个元素共用同一 id,该方法只会返回第一个匹配项。

闭包原理:先看懂一个最小示例

现在准备构建 dragElement 这个闭包——一个外层函数,包裹着若干内层函数(本例中共三个)。闭包的价值在于:当一个或多个函数需要进入外层函数的作用域时。文档给出的最小示例是"糖果":

function displayCandy(){
	let candy = ['jellybeans'];
	function addCandy(candyType) {
		candy.push(candyType)
	}
	addCandy('gumdrops');
}
displayCandy();
console.log(candy) // candy 未定义!

在这个示例中,displayCandy 包裹了一个把新糖果类型推入数组的函数 addCandy。运行这段代码后,console.log(candy) 会报错,因为 candy 是局部变量——它只存在于闭包的作用域内部。

✅ 如何让 candy 数组可被外部访问? 试着把它移到闭包外面:这样它变成全局作用域,而不再只服务于闭包的局部作用域。生态缸项目恰恰反过来利用这一点:位置变量必须"私有",每株植物各有一份,互不干扰。

构建 dragElement 外层函数

script.js 的元素声明下方,创建外层函数:

function dragElement(terrariumElement) {
	// 设置 4 个位置变量,用于确定屏幕上的坐标
	let pos1 = 0,
		pos2 = 0,
		pos3 = 0,
		pos4 = 0;
	terrariumElement.onpointerdown = pointerDrag;
}

dragElement 接收来自脚本顶部声明的 terrariumElement 对象,然后为该对象初始化 4 个置 0 的局部位置变量。这些局部变量会在你往闭包里添加"拖拽"和"放下"函数时,被针对每个传入元素分别操纵——因为顶部那 14 行 dragElement(...) 调用会为每株植物各创建一份独立的闭包环境,14 份 pos1pos4 互不串扰。生态缸将被这些被拖动的元素"住满",所以应用必须持续追踪它们被放置的位置。

此外,传入该函数的元素被赋予了 pointerdown 事件。onpointerdown 属于设计来辅助 DOM 管理的 Web API 一部分:当指针(鼠标按键或触摸点)按下时触发。这类事件处理器在现代桌面浏览器与移动浏览器上普遍受支持(可用 CanIUse 查询各浏览器对 onpointerdown 的支持情况)。

✅ 事件处理器 onclick 的浏览器支持更广,为什么这里不用它? 想一想你想要的屏幕交互类型:click 只覆盖"按下并抬起"这一瞬,无法捕捉持续移动的拖拽过程;onpointerdown/onpointermove/onpointerup 才能完整描述"按下 → 拖动 → 松开"的手势生命周期。

内层函数一:pointerDrag 捕捉拖拽起点

terrariumElement 已可拖拽;当 onpointerdown 事件触发时,pointerDrag 被调用。把它加在 terrariumElement.onpointerdown = pointerDrag; 这一行下方(即闭包内部):

function pointerDrag(e) {
	e.preventDefault();
	console.log(e);
	pos3 = e.clientX;
	pos4 = e.clientY;
	document.onpointermove = elementDrag;
	document.onpointerup = stopElementDrag;
}

这里发生了几件事:

  1. e.preventDefault() 阻止默认行为,比如浏览器在拖选时自动选中/高亮文本,从而让代码完全掌控界面行为。完成整个脚本后,试着删掉这一行重新运行——你会直观看到植物被拖动时文本选区被"拖出来"的干扰效果。
  2. console.log(e) 打印事件对象:打开 index.html 并用开发者工具检查,点击植物即可看到一个 pointerdown 事件携带的全部信息(指针类型、坐标、目标元素等),足以让你"挖一挖"一次按下事件到底收集了多少数据。
  3. pos3/pos4 记录按下瞬间的逻辑坐标e.clientX / e.clientY)。你要对植物被点击拖动时的行为做精细控制,所以持续跟踪指针坐标。
  4. document 上挂载后续两个事件
document.onpointermove = elementDrag;
document.onpointerup = stopElementDrag;

现在植物会跟随指针移动(onpointermove),并在你松开时停止移动(onpointerup)。三者同属一个指针事件 API 家族。注意它们挂在 document 而不是植物元素本身——这样即使指针快速移出植物边界,拖拽也不会中断。此时控制台会报 elementDrag/stopElementDrag 未定义的错,因为它们尚未编写,接下来补上。

✅ 是否更清楚整个应用为什么要用一个"大闭包"搭建? 每个 dragElement(plantN) 调用产生的闭包,为该植物私有化了 4 个位置变量与 3 个内层函数——14 株植物各自的拖动状态因此完全隔离。

内层函数二与三:elementDrag 与 stopElementDrag

最后补上两个内层函数,处理"拖动中"与"停止拖动"的行为。期望效果是:任何植物随时可拖、可放在屏幕任意位置。这个界面刻意不做"放置区"限制(没有 drop zone),以便你随心增删重排植物、精确设计自己的生态缸。

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 个初始为 0 的位置变量,其运算逻辑是:

  • 拖动时,pos1 被重置为"上一次的 X 坐标(pos3)减去当前 e.clientX",即本次指针在水平方向移动量;pos2 同理计算垂直方向;
  • 随后把 pos3pos4 刷新为当前 X/Y 坐标,为下一帧计算准备基准——拖动中可在控制台看到 4 个数值不断变化;
  • 最后操纵植物的 CSS 样式:style.topoffsetTop - pos2style.leftoffsetLeft - pos1,即基于元素当前偏移量与本次指针位移,把元素搬到新坐标。

offsetTopoffsetLeft 是元素相对其定位父级(最近的非 static 定位祖先)的位置属性。从仓库源码看,solution/style.css.plant 被声明为 position: absolutez-index: 2,而其父级 .plant-holderposition: relative——这正是 offsetTop/offsetLeft 能正确取值的定位上下文:修改 top/left 时浏览器按相对 .plant-holder 的偏移重新布局,拖动才显得顺滑。

一切位置重算完成之后,把 stopElementDrag 加在 elementDrag 的闭括号之后,收尾整个系统:

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

这个小函数把 document 上的 onpointeruponpointermove 重置为 null,让你可以重新拿起同一株植物继续摆弄,或开始拖动另一株。

✅ 如果不把这些事件设为 null 会怎样? 事件处理器会一直挂在 document 上持续响应,性能与行为都会出问题——这也是"清理"步骤不可省略的原因。

至此项目完成!你建成了一个完整的交互生态缸:

最终完成的交互生态缸:植物可从侧边栏拖入玻璃罐中任意摆放

源码级印证:完整拖拽系统的结构

仓库内的参考答案 solution/script.js 与本课任务代码逐行对应,可作为"标准答案"核对:

  • 文件顶部是 14 行 dragElement(document.getElementById('plantN')) 调用(第 3–16 行);
  • dragElement 函数体内以 let pos1 = 0, pos2 = 0, pos3 = 0, pos4 = 0 初始化位置状态,再挂载 terrariumElement.onpointerdown = pointerDrag(第 23–29 行);
  • 三个内层函数 pointerDrag(第 31–41 行)、elementDrag(第 43–57 行)、stopElementDrag(第 59–63 行)全部定义在 dragElement 内部——从源码结构看,这正是"每次调用 dragElement 生成一套独立私有状态"的闭包模式在代码中的落地;
  • 源码文件开头的注释也自述了实现灵感来源(w3schools 的可拖拽 HTML 元素方案),并附上了闭包的定义性注释:"闭包是函数与其外围状态(词法环境)引用的组合……它让你可以从内层函数访问外层函数的作用域。"

挑战与扩展

  • 挑战任务:给闭包添加新的事件处理器,让植物做更多事。例如,双击植物时把它带到最前面(z-index 操纵)——放开创意,自己设计交互!
  • 自学延伸:把元素在屏幕上拖来拖去看似简单,实则实现方式与陷阱众多。浏览器还有完整的 Drag and Drop API 可用;本课没有采用它,是因为课程想要的"任意位置自由摆放"效果与该 API 的 drop zone 模式不太一致。你可以尝试把指针事件换成 HTML5 拖放 API,看能实现什么。另建议用 CanIUse 查询各浏览器对指针事件的能力支持,并用 DevTools 检查面板直接观察 DOM 树结构(如 document.querySelector('body'))来加深理解。

配套作业

完成本课代码后,仓库为这一课提供了作业 Bekerja sedikit dengan DOM(assignment.ms.md):浏览 MDN 的 DOM 接口列表并任选其一,在真实网页上找到它的使用案例,写一段带例子的说明。评分标准(Rubric)要求:呈现一段含具体示例的段落为"达标";仅有段落而无示例为"需努力";未呈现段落为未达标。

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