首页
/ Web-Dev-For-Beginners Terrarium 第三部分实战:用原生 JavaScript 的 DOM 操作与闭包实现可拖拽交互

Web-Dev-For-Beginners Terrarium 第三部分实战:用原生 JavaScript 的 DOM 操作与闭包实现可拖拽交互

2026-09-06 12:05:19作者:蔡怀权

本文基于 Web-Dev-For-Beginners 课程仓库中 Terrarium(虚拟生态缸)项目的第三讲——DOM 操作与闭包,带你完整走一遍如何用原生 JavaScript 把静态的植物页面变成可交互界面:从 document.getElementById 引用 14 株植物、用指针事件捕获坐标、到用闭包为每株植物维护独立的位置状态,最后以仓库中的官方参考实现收尾。读完后,你将不依赖任何框架,独立实现"按住植物拖动到生态缸任意位置"这一完整交互。

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

一、什么是 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 编写——本讲要把那份静态设计变成可交互界面。本讲结束后,你将可以拖动植物在生态缸内任意移动。

准备步骤:

  1. 在 terrarium 文件夹中新建一个名为 script.js 的文件;
  2. 把如下标签放进 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) 会得到"未定义"——因为 candydisplayCandy 的局部变量,也就是所谓"闭包局部",外部作用域看不到它。

自检:怎样才能访问到 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 只覆盖"按下再松开"的完整点击,而拖拽需要在按下的瞬间就开始追踪位移——这正是 onpointerdownonclick 的本质区别。三者的对比可以这样理解:

事件 适用场景 局限
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.clientXpos4 = e.clientY:把局部变量设置为指针按下瞬间的 x/y 坐标。之所以要精确跟踪坐标,是因为拖拽过程中需要用它们计算元素的增量位移。
  • document.onpointermove / document.onpointerup:把移动与停止两个处理器挂在整个 document 上,而不是植物元素本身——这样即使指针移出植物图像的范围,拖动依然连续;松开指针时则终止拖动手势。onpointermoveonpointeruponpointerdown 同属一套指针事件 API。

按课程的分步写法,此刻 elementDragstopElementDrag 尚未编写,逐行运行到此处界面中会出现报错,这正是"接下来要写它们"的信号。而在最终交付的实现里,这两个函数作为函数声明存在于同一闭包内部,由于函数声明会被提升(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';
}

这里的运算逻辑值得逐步拆解:

  1. 计算位移pos1 被重新赋值为 pos3 - e.clientX,即"上一次记录的水平坐标 − 当前水平坐标",得到自上一次事件以来的水平移动量;pos2 对垂直方向做同样的事。
  2. 滚动基线:把 pos3pos4 重置为当前 e.clientXe.clientY,为下一次 pointermove 事件提供新的比较基准。拖拽过程中打开控制台,可以实时看到四个变量的变化。
  3. 写回样式:读取元素的 offsetTopoffsetLeft,减去刚才算出的位移量,写回 style.topstyle.left,完成一次增量移动。

offsetTopoffsetLeft 是相对"父级定位参照物"而言的元素位置:该参照物可以是任何未设为 static 定位的上层元素。

这套"位移 → 更新基线 → 写回样式"的循环在每次 pointermove 时重复执行,配合浏览器逐帧刷新,就形成了平滑跟随指针的拖动效果。也正因为每一步都基于上一帧重新计算,位置始终能被精微调校——无论生态缸还是其中的植物。

八、第六步:stopElementDrag 函数——事件清理

elementDrag 的右花括号之后,添加最后一个内部函数,完成闭包:

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

这个函数在指针抬起时把 onpointeruponpointermove 两个文档级处理器复位为 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 效果

按如下方式测试你的交互式 terrarium:

  1. 在浏览器中打开 index.html
  2. 按住任意植物不放——pointerDrag 触发,控制台打印事件对象;
  3. 移动鼠标或手指,植物平滑跟随——elementDrag 每帧更新 top/left
  4. 松开指针——stopElementDrag 复位文档级处理器,拖动结束;
  5. 换一株植物重复操作,确认各植物互不干扰。

若一切正常,恭喜:你已经用原生 JavaScript 的 DOM 操作与闭包,完成了一个完全可交互的网页应用。

十一、扩展挑战与自研方向

课程给出两个继续深入的方向:

  • 挑战:在闭包中追加新的事件处理器,让植物做更多事。例如:双击植物时把它"带到前面"(z-index 层级操作)——鼓励自由发挥。
  • 自研调研:浏览器其实提供了一整套专门的 [HTML Drag and Drop API](文档引用),它面向"文件拖入、固定放置区"这类场景;本讲想要的"任意位置自由放置"与其效果不完全一致,因此未采用。建议在自己的项目中试一试该 API,看看它适合解决什么问题。

学习资源方面:指针事件的细节可查阅 W3C 的 Pointer Events 规范与 MDN 的 Pointer Events 文档;动手前用 CanIUse 核对所用 API 的浏览器支持情况,是贯穿本课程的方法论。

十二、课后作业

本讲配套的作业是 DOM 元素探究作业:从 DOM 接口清单中选择一个方向(如 classListquerySelectorIntersectionObserver、Drag and Drop API、Web Components 等,按初/中/高级分层),完成 300–500 词的技术分析,对比它在本讲 terrarium 场景下的适用性,并附一个可运行、带注释、面向初学者的代码示例。作业还给出了明确的评分维度(技术理解、真实案例分析、代码示例、写作质量、批判性思维),可作为自查清单。

至此,Terrarium 项目三部曲收官:HTML 搭结构、CSS 做样式、JavaScript 的 DOM 操作与闭包赋予交互。掌握这套"引用元素 → 绑定指针事件 → 用闭包隔离状态 → 增量坐标运算 → 事件清理"的模式后,你就拥有了实现文件拖放、看板卡片移动、图片墙重排等大量真实交互的原生能力基础。

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