Web-Dev-For-Beginners Terrarium 第三部分实战:用原生 JavaScript 的 DOM 操作与闭包实现可拖拽交互
本文基于 Web-Dev-For-Beginners 课程仓库中 Terrarium(虚拟生态缸)项目的第三讲——DOM 操作与闭包,带你完整走一遍如何用原生 JavaScript 把静态的植物页面变成可交互界面:从 document.getElementById 引用 14 株植物、用指针事件捕获坐标、到用闭包为每株植物维护独立的位置状态,最后以仓库中的官方参考实现收尾。读完后,你将不依赖任何框架,独立实现"按住植物拖动到生态缸任意位置"这一完整交互。
一、什么是 DOM,为什么本讲选择自己管理它
DOM(Document Object Model,文档对象模型)是 JavaScript 与 HTML 元素之间的桥梁。引用 MDN 的表述:"The Document Object Model (DOM) is the data representation of the objects that comprise the structure and content of a document on the web."(DOM 是构成网页文档结构与内容的那些对象的数据表示。)
可以把 DOM 理解为一棵树:它表示控制网页文档的所有方法。程序员可以使用所选语言编写各种 API,通过这棵树访问、编辑、修改、重排和管理页面内容。
在真实开发中,正因为 DOM 操作繁琐且容易出错,不少开发者会选择引入 JavaScript 框架来代为管理 DOM;但本课程的立场是:我们先自己动手管理它,理解底层机制后再谈框架。本讲同时会引入 JavaScript 闭包(closure) 的概念——让内部函数可以访问外部函数的作用域,而本讲正是用闭包来组织 DOM 操作代码。
本讲要完成的目标:为交互式 terrarium 项目编写 JavaScript,让用户可以在页面上直接操纵(拖动)植物,从而完成整个 terrarium 项目。
二、前置条件与文件准备
开始前必须满足一个前提:你已经完成了前两个 terrarium 课程的 HTML 和 CSS 编写——本讲要把那份静态设计变成可交互界面。本讲结束后,你将可以拖动植物在生态缸内任意移动。
准备步骤:
- 在 terrarium 文件夹中新建一个名为
script.js的文件; - 把如下标签放进
index.html的<head>部分:
<script src="./script.js" defer></script>
这里 defer 属性的用意值得展开:使用 defer 时,浏览器会等 HTML 文件完全加载(解析)后才执行该 JavaScript。HTML 规范中还有一个 async 属性,它允许脚本在 HTML 解析过程中就执行——但对本项目而言,关键在于:必须在所有 HTML 元素解析完成之前不执行拖动脚本,否则 document.getElementById('plant1') 之类的调用会拿不到元素。仓库官方解答中的 index.html 正是这样引入脚本的:
<script src="./script.js" defer></script>
三、第一步:为 DOM 元素建立引用
要操纵 DOM,第一件事是建立对目标元素的引用。在 terrarium 页面中,操作对象是侧边栏上待命的 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'));
这里发生了什么?我们通过文档查找特定 id 的元素——也就是说,"看"向 DOM 以获取这些元素。还记得 HTML 第一课中给每株植物图像(如 id="plant1")都赋予了唯一 Id 吗?这里的付出开始产生回报:识别每个元素后,把它传给即将定义的 dragElement 函数,于是这些 HTML 元素就获得了(或者说即将获得)可被拖动的能力。
自检:为什么用 Id 而不是 CSS 类来引用元素?Id 在文档中应当唯一,getElementById 因此总能精确定位到某一个元素;而 CSS 类天生面向"一组元素"的样式复用,选择器可能命中多个节点。若两个元素重复了同一个 Id,getElementById() 也只返回第一个——这正是 Id 语义约束的体现。
四、第二步:理解闭包(closure)
在写 dragElement 之前,先用一个小例子理解闭包。闭包在"一个或多个函数需要访问外部函数作用域"时非常有用:
function displayCandy(){
let candy = ['jellybeans'];
function addCandy(candyType) {
candy.push(candyType);
}
addCandy('gumdrops');
}
displayCandy();
console.log(candy)
在这个例子中,displayCandy 函数内部包裹了一个向已存在数组中追加新糖果类型的函数 addCandy。执行这段代码后,在全局作用域直接 console.log(candy) 会得到"未定义"——因为 candy 是 displayCandy 的局部变量,也就是所谓"闭包局部",外部作用域看不到它。
自检:怎样才能访问到 candy 数组?把数组声明移出闭包即可——这样它既在闭包内可用,也在全局作用域可用。这也从反面说明了闭包的价值:局部变量被函数"记住"了,但不泄漏到外面。
五、第三步:dragElement 闭包——私有位置状态与事件入口
现在创建包裹内部函数的外部函数 dragElement(本讲中它内部共有 3 个函数)。在 script.js 的元素声明下方添加:
function dragElement(terrariumElement) {
//set 4 positions for positioning on the screen
let pos1 = 0,
pos2 = 0,
pos3 = 0,
pos4 = 0;
terrariumElement.onpointerdown = pointerDrag;
}
dragElement 从脚本顶部的声明中接收 terrariumElement 对象,然后把函数内的几个局部位置变量置为 0。当你为每个元素添加拖放功能时,pos1~pos4 就是每株植物各自独立的局部状态:植物被拖进生态缸后,应用必须持续跟踪它被放置的位置,而这些变量正是跟踪用的"坐标簿"。
这里闭包的作用一目了然:每调用一次 dragElement(...),JavaScript 就创建一套独立的 pos1~pos4 环境。14 株植物对应 14 个互不干扰的坐标状态——如果把这些变量放到全局,任何一次拖动都会污染其他植物的数据。
另外,onpointerdown 属于 Web API 中的 Pointer Events 家族,它在指针按钮被按下、或元素被触摸时触发,在绝大多数桌面与移动浏览器上都受支持。
自检:事件处理器 onclick 的跨浏览器支持更广,为什么这里不用它?想一想我们想捕获的交互类型:click 只覆盖"按下再松开"的完整点击,而拖拽需要在按下的瞬间就开始追踪位移——这正是 onpointerdown 与 onclick 的本质区别。三者的对比可以这样理解:
| 事件 | 适用场景 | 局限 |
|---|---|---|
onclick |
简单按钮点击 | 无法处理拖拽过程(只反映点击与释放) |
onpointerdown |
鼠标与触摸统一处理 | 相对较新,但如今支持已很完善 |
onmousedown |
仅桌面鼠标 | 完全排除移动端用户 |
六、第四步:pointerDrag 函数——阻止默认行为并捕获起点坐标
当用户在植物上按下指针时,onpointerdown 事件启动并调用 pointerDrag。按课程流程,先在 terrariumElement.onpointerdown = pointerDrag; 这一行下方添加函数的前半部分:
function pointerDrag(e) {
e.preventDefault();
console.log(e);
pos3 = e.clientX;
pos4 = e.clientY;
}
紧接着,在 pos4 = e.clientY 下方再追加两行,为指针的移动与抬起绑定文档级处理器,完成初始化:
document.onpointermove = elementDrag;
document.onpointerup = stopElementDrag;
这段代码依次做了几件事:
e.preventDefault():阻止指针按下时浏览器默认会发生的行为(例如拖选文本、触发元素默认的拖拽 ghost 等),让我们对界面行为有更强的控制力。课程建议:等脚本写完后再回来把这行删掉试一次,观察拖动体验的变化。console.log(e):在浏览器中打开index.html检查界面,点击植物就能看到被捕获的事件对象e——仔细翻阅它,你会发现一次pointerdown事件携带的信息量远超想象。pos3 = e.clientX、pos4 = e.clientY:把局部变量设置为指针按下瞬间的 x/y 坐标。之所以要精确跟踪坐标,是因为拖拽过程中需要用它们计算元素的增量位移。document.onpointermove/document.onpointerup:把移动与停止两个处理器挂在整个 document 上,而不是植物元素本身——这样即使指针移出植物图像的范围,拖动依然连续;松开指针时则终止拖动手势。onpointermove与onpointerup和onpointerdown同属一套指针事件 API。
按课程的分步写法,此刻 elementDrag 与 stopElementDrag 尚未编写,逐行运行到此处界面中会出现报错,这正是"接下来要写它们"的信号。而在最终交付的实现里,这两个函数作为函数声明存在于同一闭包内部,由于函数声明会被提升(hoisting),引用先于定义也不会报错——可以从 solution/script.js 的源码结构中验证这一点。
七、第五步: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';
}
这里的运算逻辑值得逐步拆解:
- 计算位移:
pos1被重新赋值为pos3 - e.clientX,即"上一次记录的水平坐标 − 当前水平坐标",得到自上一次事件以来的水平移动量;pos2对垂直方向做同样的事。 - 滚动基线:把
pos3、pos4重置为当前e.clientX、e.clientY,为下一次pointermove事件提供新的比较基准。拖拽过程中打开控制台,可以实时看到四个变量的变化。 - 写回样式:读取元素的
offsetTop与offsetLeft,减去刚才算出的位移量,写回style.top与style.left,完成一次增量移动。
offsetTop和offsetLeft是相对"父级定位参照物"而言的元素位置:该参照物可以是任何未设为static定位的上层元素。
这套"位移 → 更新基线 → 写回样式"的循环在每次 pointermove 时重复执行,配合浏览器逐帧刷新,就形成了平滑跟随指针的拖动效果。也正因为每一步都基于上一帧重新计算,位置始终能被精微调校——无论生态缸还是其中的植物。
八、第六步:stopElementDrag 函数——事件清理
在 elementDrag 的右花括号之后,添加最后一个内部函数,完成闭包:
function stopElementDrag() {
document.onpointerup = null;
document.onpointermove = null;
}
这个函数在指针抬起时把 onpointerup 与 onpointermove 两个文档级处理器复位为 null,从而结束本次拖动,让下一次拖动(无论继续移动当前植物还是改抓另一株)都能从干净的状态重新开始。
自检:如果不清空这两个处理器会怎样?onpointermove 会一直挂在 document 上持续触发 elementDrag,即使用户早已松手——无用的坐标计算反复执行,页面任意位置的移动都可能触发样式写入,浪费性能并干扰交互。
设计说明:本讲有意不设置 drop zone(放置区)——界面对"把植物放哪、加多少、怎么排"不做限制,用户可以把植物放在屏幕任意位置,自由设计自己的生态缸。
九、参考实现:仓库 solution 中的完整闭包结构
本讲所有步骤在仓库官方解答 3-terrarium/solution/script.js 中都有完整对应。从源码结构看,dragElement 函数体(L23–L64) 把三个内部函数完整嵌套在闭包内部,与课程分步讲解一一对应:
function dragElement(terrariumElement) {
//set 4 positions for positioning on the screen
let pos1 = 0, pos2 = 0, pos3 = 0, pos4 = 0;
terrariumElement.onpointerdown = pointerDrag;
function pointerDrag(e) {
e.preventDefault();
console.log(e);
// get the initial mouse cursor position for pos3 and pos4
pos3 = e.clientX;
pos4 = e.clientY;
// when the mouse moves, start the drag
document.onpointermove = elementDrag;
// when the mouse is lifted, stop the drag
document.onpointerup = stopElementDrag;
}
function elementDrag(e) {
// pos1 = where the Xmouse WAS - where it IS
pos1 = pos3 - e.clientX;
pos2 = pos4 - e.clientY;
//reset pos3 to current location of Xmouse
pos3 = e.clientX;
pos4 = e.clientY;
console.log(pos1, pos2, pos3, pos4);
// set the element's new position:
terrariumElement.style.top = terrariumElement.offsetTop - pos2 + 'px';
terrariumElement.style.left = terrariumElement.offsetLeft - pos1 + 'px';
}
function stopElementDrag() {
// stop calculating when mouse is released
document.onpointerup = null;
document.onpointermove = null;
}
}
文件顶部还保留了说明性注释,引用了 MDN 对闭包的定义("闭包是函数与其词法环境引用的组合……闭包让内部函数可以访问外部函数的作用域"),并注明"创建一个闭包,以便跟踪被拖动的元素";同时文件头部注明了该可拖拽方案的思想来源,方便读者追溯进一步学习。文件开头则是与第三节一致的 14 行 dragElement(document.getElementById('plantN')) 调用——每次调用都会为对应植物构建一套独立的 pos1~pos4 私有坐标环境,这正是"用闭包管理每株植物状态"的落地形态。
十、验证成果
按如下方式测试你的交互式 terrarium:
- 在浏览器中打开
index.html; - 按住任意植物不放——
pointerDrag触发,控制台打印事件对象; - 移动鼠标或手指,植物平滑跟随——
elementDrag每帧更新top/left; - 松开指针——
stopElementDrag复位文档级处理器,拖动结束; - 换一株植物重复操作,确认各植物互不干扰。
若一切正常,恭喜:你已经用原生 JavaScript 的 DOM 操作与闭包,完成了一个完全可交互的网页应用。
十一、扩展挑战与自研方向
课程给出两个继续深入的方向:
- 挑战:在闭包中追加新的事件处理器,让植物做更多事。例如:双击植物时把它"带到前面"(z-index 层级操作)——鼓励自由发挥。
- 自研调研:浏览器其实提供了一整套专门的 [HTML Drag and Drop API](文档引用),它面向"文件拖入、固定放置区"这类场景;本讲想要的"任意位置自由放置"与其效果不完全一致,因此未采用。建议在自己的项目中试一试该 API,看看它适合解决什么问题。
学习资源方面:指针事件的细节可查阅 W3C 的 Pointer Events 规范与 MDN 的 Pointer Events 文档;动手前用 CanIUse 核对所用 API 的浏览器支持情况,是贯穿本课程的方法论。
十二、课后作业
本讲配套的作业是 DOM 元素探究作业:从 DOM 接口清单中选择一个方向(如 classList、querySelector、IntersectionObserver、Drag and Drop API、Web Components 等,按初/中/高级分层),完成 300–500 词的技术分析,对比它在本讲 terrarium 场景下的适用性,并附一个可运行、带注释、面向初学者的代码示例。作业还给出了明确的评分维度(技术理解、真实案例分析、代码示例、写作质量、批判性思维),可作为自查清单。
至此,Terrarium 项目三部曲收官:HTML 搭结构、CSS 做样式、JavaScript 的 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 StartedRust0623
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

