Web-Dev-For-Beginners Terrarium 第三部分实战:用原生 JavaScript 的 DOM 操作与闭包实现可拖拽交互
【免费下载链接】Web-Dev-For-Beginners24 Lessons, 12 Weeks, Get Started as a Web Developer项目地址: https://gitcode.com/GitHub_Trending/we/Web-Dev-For-Beginners
本文基于 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 操作与闭包赋予交互。掌握这套"引用元素 → 绑定指针事件 → 用闭包隔离状态 → 增量坐标运算 → 事件清理"的模式后,你就拥有了实现文件拖放、看板卡片移动、图片墙重排等大量真实交互的原生能力基础。
【免费下载链接】Web-Dev-For-Beginners24 Lessons, 12 Weeks, Get Started as a Web Developer项目地址: https://gitcode.com/GitHub_Trending/we/Web-Dev-For-Beginners
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考