首页
/ Web-Dev-For-Beginners 苔藓缸项目第 3 课:用 DOM 操作与 JavaScript 闭包实现植物拖拽

Web-Dev-For-Beginners 苔藓缸项目第 3 课:用 DOM 操作与 JavaScript 闭包实现植物拖拽

2026-09-04 09:21:11作者:滑思眉Philip

DOM(文档对象模型)是 Web 开发中连接 HTML 与 JavaScript 的桥梁,而闭包则让每个可交互元素拥有独立的"私有状态"。本篇基于 Web-Dev-For-Beginners 课程库中的苔藓缸(Terrarium)项目第 3 课,完整演示如何用原生 JavaScript 通过指针事件(Pointer Events)和 dragElement 闭包,让 14 株植物可以被拖拽、放置到页面任意位置。读完本篇,你将掌握 document.getElementById 元素选取、onpointerdown/onpointermove/onpointerup 事件链、基于 clientX/clientY 的坐标追踪算法,以及事件监听器清理等可复用的实战模式。

DOM 树表示图:DOM 与引用它的 HTML 标记

图示为 DOM 及其对应 HTML 标记的表示。

一、课程背景:为什么是 DOM 与闭包

按照 MDN 的定义,DOM(Document Object Model)是"Web 上文档的结构与内容所构成的对象的表示"。由于 DOM 操作的复杂性,现代开发中常借助 React、Vue 等框架来托管 DOM,但本课坚持用纯原生 JavaScript 自己管理 DOM,以建立底层认知。

本课同时要引入 JavaScript 闭包 的概念:可以把闭包理解为"用一个函数把若干内层函数包裹起来,使内层函数仍然能访问外层函数的作用域"。需要注意,闭包是一个非常庞大且复杂的话题——本课只聚焦最基本的一点:苔藓缸代码中确实存在闭包,即内层函数(pointerDragelementDragstopElementDrag)与外层函数(dragElement)之间、内层函数可以访问外层作用域的这种构造方式。

可以这样理解 DOM 与闭包在本项目中的分工:

  • DOM 被当作一棵树来看待,它表达了操作 Web 页面文档的所有方式,各种 API 允许程序员用自己喜欢的语言去访问、编辑、修改、重排和管理 DOM 节点;
  • 闭包 让每一株植物都拥有自己独立的坐标变量(pos1~pos4),彼此互不干扰,就像每株植物都有自己的"位置档案"。

前提条件

在开始本课之前,你需要已经完成苔藓缸项目的 HTML 与 CSS(即仓库中 solution/index.htmlsolution/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 元素(idplant1plant14,见 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) 是无法拿到该数组的——因为 candydisplayCandy 的函数级局部变量(闭包的局部),在函数外部根本不可见。

思考题:如何能访问 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。这些是闭包内的局部变量,之后为每个元素添加拖放行为时都围绕它们操作。因为苔藓缸里这些元素会被拖来拖去地摆放,应用程序必须时刻掌握每个元素被放到了哪里。

再注意最后一行:分配给 terrariumElementpointerdown 事件属于专为 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;
}

这里发生了几件事:

  1. e.preventDefault() 阻止指针按下时浏览器默认行为的触发(例如选中图片、触发滚动等),从而让我们更可控地掌握界面行为。

    实验建议:等脚本文件全部构建完成后,回到这里去掉 e.preventDefault() 再试一次——观察拖拽体验有什么不同。

  2. console.log(e) 用于观察事件对象。在浏览器中打开 index.html,点击一株植物,就能看到 e 事件被捕获的样子——深入查看这个事件对象,你会发现一次 pointerdown 事件里携带了多少信息(clientXclientYscreenXpointerId 等)。
  3. pos3 = e.clientXpos4 = e.clientY 把局部变量设为事件对象的坐标值。这些值就是用户点击或触摸植物那一瞬间的 X/Y 坐标(相对于视口)。由于我们要精细控制植物在被点击、被拖拽时的运动,就必须先掌握这个起点坐标。

现在应该更能理解为什么整个应用要构建在一个个大闭包中了——如果不用闭包,你打算如何维持 14 个可拖拽植物各自的独立作用域?(答案:14 次 dragElement(...) 调用会创建 14 个相互独立的闭包实例,每个实例持有自己独立的 pos1~pos4。)

注册 document 级事件

pos4 = e.clientY 下方追加两个指针事件绑定,把初始函数补全:

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

这表示:当植物被移动时,我们希望植物沿指针路径被拖拽;当松开植物(取消选择)时,希望停止拖拽手势。onpointermoveonpointeruponpointerdown 一样,同属一个指针事件 API。

为什么挂在 document 上而不是植物元素本身? 因为拖动过程中指针很快就会离开植物图片的边界;只有把移动与抬起事件绑定到整个文档,才能保证快速移动、甚至移出窗口边界时拖拽不断线。

注意:此时 elementDragstopElementDrag 尚未定义,界面上交互会抛出错误——这是正常的,因为接下来就要补上这两个函数。

六、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;:把 pos3pos4 重置为要素的新 X/Y 坐标,作为下一轮计算的基准。这些变化可以在拖拽过程中从控制台直接观察到(console.log);
  • style.top / style.left 两行:操作植物的 CSS 样式,根据 pos1pos2 计算出植物的新位置——将植物的 offsetTop/offsetLeft 与新的位移量做比较,算出上下左右的新坐标。

offsetTopoffsetLeft 是读取要素位置相对于其父级(最近已定位祖先)的属性的 API。任何没有被定位成 static 的父级元素都可以作为参照。

从源码结构看,这一前提在项目里是被真实满足的:solution/style.css.plant { position: absolute; ... },且其父级 .plant-holder { position: relative; },因此 offsetTop/offsetLeftplant-holder 为参照系,style.top/style.left 的更新才能即时生效。

通过这样重算位置,你可以对苔藓缸和植物的行为做精细调优。

任务:stopElementDrag 函数

完成界面的最后一个任务是:在 elementDrag 的花括号闭合之后追加 stopElementDrag 函数(参考实现见 solution/script.js):

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

这个小小的函数把 onpointeruponpointermove 事件重置为 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.classListDocument.querySelectorWindow.localStorage;进阶级如 IntersectionObserverMutationObserver、Drag and Drop API),完成 300~500 字的技术分析、真实网站案例分析,并附上带注释的可运行代码示例。作业详情见 assignment.ja.md

十、小结:本课程的完整技术链路

回顾一下 script.js 的完整调用链(与 solution/script.js 的结构一一对应):

阶段 触发 函数 关键动作
初始化 脚本加载(defer 14 × dragElement(plantN) 每株植物各建一个独立闭包实例
按下 onpointerdown pointerDrag(e) preventDefault()、记录起点 pos3/pos4、挂起 documentpointermove/pointerup
移动 onpointermove elementDrag(e) 增量坐标运算 → 更新 style.top/left
释放 onpointerup stopElementDrag() 两个 document 级监听器置 null,为下一次拖拽复位

这套"14 个闭包 + 3 个内部函数 + 3 类指针事件"的结构,是理解 DOM 操作与闭包协作的典型样本:闭包提供每株植物独立的私有坐标状态,指针事件提供跨设备的交互生命周期,而 offsetTop/offsetLeftstyle 的写回则让 DOM 树的变化实时反映到画面上。理解了它,你就掌握了文件拖拽上传、看板卡片移动、图库重排等一切"拖放"交互背后的共同原理。

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