首页
/ Web-Dev-For-Beginners Terrarium 实战:用 DOM 操作与 JavaScript 闭包实现植物拖拽交互

Web-Dev-For-Beginners Terrarium 实战:用 DOM 操作与 JavaScript 闭包实现植物拖拽交互

2026-09-06 15:33:46作者:余洋婵Anita

本文以 Web-Dev-For-Beginners 课程 Terrarium(虚拟生态瓶)项目的第三部分为核心,完整讲解如何用原生 JavaScript 通过 DOM 操作让 14 株植物支持"按住即拖、松手即放"的交互。读完后你将掌握三件事:如何用 document.getElementById() 建立脚本与 HTML 元素的引用、如何用闭包为每个可拖拽元素维护独立私有状态(坐标变量)、以及 pointerdown / pointermove / pointerup 三类指针事件如何协同完成一次完整的拖拽生命周期。

Terrarium 最终成品:用户可以用鼠标或手指把任意植物拖到屏幕任意位置

DOM 与闭包:交互页面的两根支柱

DOM 是什么

DOM(Document Object Model,文档对象模型)是"组成网页文档结构与内容的那些对象的数据表示"(文档引用 MDN 的定义)。你可以把 DOM 想象成一棵树:它代表了网页文档可以被操作的所有方式,浏览器加载页面时会在内存中构建出这棵对象树,JavaScript 通过 API 访问、编辑、重排和管理它。

DOM 树结构示意:HTML 标记与 DOM 节点之间的对应关系

上图是一张经典的 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 未定义

candydisplayCandy 内部的局部变量,外部 console.log 拿不到它。如果希望外部可访问,可以把数组移出闭包使其成为全局变量——但在本例(Terrarium)中我们恰恰希望变量保持私有,这正是闭包的用武之地。

为什么 Terrarium 必须用闭包?因为页面里有 14 株可拖拽的植物,每株都需要独立跟踪自己的坐标状态。若把坐标变量放到全局作用域,14 株植物会互相踩踏同一份数据;而每次调用 dragElement(某株植物) 都会生成一份新的闭包实例,4 个坐标变量 pos1~pos4 就变成该植物私有的、持久存在的状态。

前置条件与脚本接入

开始本课之前,你需要已经完成了 Terrarium 的前两课:index.htmlstyle.css 中搭好的页面骨架(左右两侧的植物栏 + 中间的玻璃瓶)。本课结束后,你将能够把植物拖出侧栏、拖入瓶中任意位置。

在 terrarium 目录下新建 script.js,并在 index.html<head> 中引入:

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

deferasync 的区别值得注意(文档原文强调):

  • 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 里给每株植物图片赋予的唯一 idid="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;
}

逐点拆解:

  1. dragElement 从脚本顶部的 14 行声明中接收一个 terrariumElement 对象(某株植物的 <img> 节点)。
  2. pos1~pos4 初始化为 0,它们是闭包私有变量,之后每个指针事件都会读写它们。Terrarium 将被这些被拖动的元素"住满",所以应用必须持续跟踪它们的位置——4 个变量分别记录上一帧与当前帧的 X/Y 坐标。
  3. 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;
}

这一小段里发生了四件事:

  1. e.preventDefault():阻止浏览器对指针按下事件的默认行为(例如在拖动图片时触发选中、在页面拖选文字)。不阻止它,浏览器自己的行为会干扰自定义拖拽。文档特意留了一个实验任务:写完整脚本后把这行删掉再试一次,亲眼看看会发生什么。
  2. console.log(e):调试手段。在浏览器中打开 index.html 并用检查器(Elements/Console 面板)观察,点击一株植物时可以看到被捕获的事件对象 e,展开它能看到指针事件收集到的大量信息(坐标、指针类型、目标元素等)。
  3. pos3 = e.clientX; pos4 = e.clientY:把闭包私有的 pos3pos4 设为事件对象的 clientX / clientY,即用户按下那一刻指针相对视口的 X/Y 坐标。要精确控制植物在点击、拖动时的表现,就必须持续跟踪这些坐标。
  4. 注册两个 document 级监听
document.onpointermove = elementDrag;
document.onpointerup = stopElementDrag;

这意味着:植物要跟着指针在整个文档范围内移动;松开手(取消选中植物)时拖拽手势终止。onpointermoveonpointeruponpointerdown 属于同一套指针事件 API。把 move/up 监听挂在 document 而不是植物元素上,是为了即使指针快速移出该 <img> 的边界,拖拽跟踪也不中断。

此时若立即运行页面,由于 elementDragstopElementDrag 尚未定义,控制台的赋值语句不会立即报错(属性赋值只是存了个 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 个私有坐标变量,运算逻辑如下:

  1. pos1 = pos3 - e.clientX:水平方向位移 = 上一次记录的 X(pos3)− 当前指针 X。pos2 同理处理垂直方向。
  2. pos3 = e.clientX; pos4 = e.clientY:把 pos3/pos4 重置为当前指针坐标,成为下一次 pointermove 计算的基线。拖动过程中在控制台能看到这四个值的滚动变化。
  3. 改写 CSS 定位terrariumElement.style.top = terrariumElement.offsetTop - pos2 + 'px'left 同理。元素的新 top/left 由它当前的 offsetTop/offsetLeft 减去本次位移得到,字符串拼接 'px' 后写入行内样式。

offsetTopoffsetLeft 是读元素相对其定位父元素当前位置的属性;只有父级存在非 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 上的 onpointeruponpointermove 重置为 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 玻璃瓶区域;
  • 位置持久化:用 localStoragestopElementDrag 中记录各植物最终坐标,刷新后恢复布局;
  • 鼓励自拟其他创意(悬停高亮、拿起/放下音效等)。

自学延伸:不止指针事件一条路

拖拽在屏幕上看似 trivial,实则方法众多、坑也不少,取决于你想要的效果。文档"复习与自学"部分特别提醒:浏览器还内置了完整的 HTML Drag and Drop APIdraggable 属性 + 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 就把它们全部亲手实现了一遍。

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