☰
Web-Dev-For-Beginners 虚拟生态缸第 3 课:用 DOM 操作与 JavaScript 闭包从零实现拖放交互
2026/10/15 7:48:43 网站建设 项目流程

Web-Dev-For-Beginners 虚拟生态缸第 3 课:用 DOM 操作与 JavaScript 闭包从零实现拖放交互

【免费下载链接】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(24 Lessons, 12 Weeks 课程)中 Terrarium(虚拟生态缸)系列的第三课,讲解如何用 Document Object Model(DOM)将 JavaScript 与 HTML 元素连接起来,并用 JavaScript 闭包(closure)为每株植物维护独立的私有状态,最终实现"点击、拖动、放置"的完整拖放(drag-and-drop)交互。读完后,你将掌握document.getElementById()元素定位、pointer events 事件生命周期、clientX/clientY坐标追踪与offsetTop/offsetLeft位置数学、闭包私有状态模式,以及事件监听器的正确清理方式——这些正是文件上传、看板卡片拖动等现代交互界面的底层原理。

本课的丹麦语版文档位于 translations/da/3-terrarium/3-intro-to-DOM-and-closures/README.md(文末附有 AI 翻译免责声明,以英文原版为权威来源),英文原版教程位于 3-terrarium/3-intro-to-DOM-and-closures/README.md,配套参考实现在 3-terrarium/solution/ 目录。本系列前作 第 1 课:HTML 基础 和 第 2 课:CSS 基础 产出的静态页面是本课的直接依赖——如果尚未完成,建议先补齐再进入本节的交互部分。

一、理解 DOM:HTML 与 JavaScript 之间的桥

Document Object Model(DOM)是 JavaScript 与 HTML 元素通信的方式。当浏览器加载一个 HTML 页面时,它会在内存中为该页面构建一棵结构化的树形表示——这就是 DOM。可以把它理解成一棵"家族树":每个 HTML 元素都是家族中的一员,JavaScript 可以访问、修改或重新排列任何一个"成员"。

DOM 操作将静态页面变成交互式网站。按钮悬停变色、内容不刷新页面就更新、元素可以被拖来拖去——背后都是 DOM 操作在起作用。在生态缸项目中,14 张植物图片对应的 DOM 结构如下(源自课程中的 DOM 树示意图):

DOM 的强大之处在于:

  • 提供一种结构化的方式访问页面上的任意元素;
  • 支持不刷新页面就动态更新内容;
  • 允许对点击、拖拽等用户交互做出实时响应;
  • 奠定了现代交互式 Web 应用(以及各类框架)的基础。

对照仓库中的参考实现,上述 DOM 树并非纸上谈兵:3-terrarium/solution/index.html 中,左右两个#plant-selection侧栏(#left-container/#right-container)各含 7 个plant-holder容器,每个容器内是一张class="plant"的图片,id从plant1到plant14——JavaScript 正是靠这些 id 在 DOM 树中精确找到每一株植物。

二、JavaScript 闭包:为每株植物制造"私有记忆口袋"

JavaScript 闭包可以理解为:给一个函数配备一个私有工作区和持久记忆。正如达尔文雀在加拉帕戈斯群岛各自演化出适应环境的特化喙,闭包也会创建出"记得"自己专属上下文的特化函数——即使创建它的外层函数已经执行完毕。

在生态缸里,闭包的价值非常具体:每株植物需要独立地记住自己的坐标状态,且互不干扰。每次调用dragElement(某株植物)都会生成一个独立的闭包实例,其中的pos1~pos4坐标变量属于该次调用私有,14 株植物因此拥有 14 套互不冲突的坐标追踪系统:

用一个简单例子理解闭包

课程先用一个经典计数器把机制讲透:

function createCounter() { let count = 0; // 相当于一个私有变量 function increment() { count++; // 内部函数"记得"外层变量 return count; } return increment; // 把内部函数交出去 } const myCounter = createCounter(); console.log(myCounter()); // 1 console.logMyCounter === undefined ? null : console.log(myCounter()); // 2

上面的第二行应为console.log(myCounter()); // 2。闭包模式的关键:count只在闭包内部存在;返回increment时它保持与私有数据的连接;即使createCounter()执行结束,count的值依然被保存并随每次调用累加。

为什么闭包天然适合拖放

  • 保持每株植物的私有位置变量彼此独立;
  • 在多次拖放事件之间保留坐标数据;
  • 避免不同可拖元素之间发生变量冲突;
  • 产出干净、有组织的代码结构。

从源码结构看,这一判断与参考实现完全吻合:3-terrarium/solution/script.js#L23-L64 中pointerDrag、elementDrag、stopElementDrag三个函数都定义在dragElement函数体内部,let pos1 = 0, pos2 = 0, pos3 = 0, pos4 = 0(script.js#L25-L28)作为闭包级变量被三个内层函数共享读写——这正是教程所描述的"闭包管理整株植物的拖放行为"。

学习提示(与原文一致):闭包是 JavaScript 中的大话题,很多开发者用了很多年才完全吃透理论。本课聚焦实用——你会在构建交互功能时看到闭包自然浮现,理解会随实践加深。

三、准备工作:接入 JavaScript 文件

本课目标是把前两课做好的静态设计变成可交互版本,最终产出:

  • 全部 14 株植物的顺滑拖放;
  • 让植物"记住"位置的坐标追踪;
  • 用原生(vanilla)JavaScript完成的完整交互界面;
  • 采用闭包模式的整洁代码。

步骤 1:创建脚本文件。在你的 terrarium 文件夹中新建script.js。

步骤 2:把脚本挂到 HTML 的<head>中:

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

defer属性为什么重要:

  • 确保JavaScript 等待所有 HTML 加载完毕后才执行;
  • 避免"脚本去查找尚未就绪的元素"这类时序错误;
  • 保证14 个植物元素在执行拖放初始化时都已存在;
  • 优于把脚本放页面底部之外的常见做法,性能更可控。

参考实现中这一行就写在 head 里——见 3-terrarium/solution/index.html#L13:<script src="./script.js" defer></script>。

四、把 JavaScript 连接到 HTML 元素

要让元素可拖,JavaScript 得先在 DOM 里找到它们。这就像图书馆索书系统:有了索书号,就能精确取出那本书并读取它的全部内容。这里使用document.getElementById()——你给出一个 ID,它就定位到 HTML 中那个精确的元素。

步骤 1:为 14 株植物启用拖放,在script.js中添加:

// Enable drag functionality for all 14 plants 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 元素的 JavaScript 引用;把每个元素传入dragElement函数(下一节定义);让每株植物具备拖放条件;从而把 HTML 结构与 JavaScript 功能连接起来。参考实现中这 14 行位于文件开头 3-terrarium/solution/script.js#L3-L16。

为什么用 ID 而不是 class?ID 为特定元素提供唯一标识符,而 CSS 类是为整组元素做样式设计的。当 JavaScript 需要操纵单个元素时,ID 提供了所需的精确性。

顺带一提:逐株调用dragElement()保证了每株植物各自获得独立的拖放行为(独立闭包实例),这是顺滑交互体验的前提。

快速自检:如果两个元素用了相同的 ID 会怎样?为什么getElementById()只返回一个元素?——答案:ID 应当唯一;若出现重复,只会返回文档中的第一个匹配元素。

五、构建 dragElement 闭包

现在创建拖放功能的核心:一个管理每株植物拖放行为的闭包,内部含多个协作函数,共同完成鼠标追踪与位置更新。

步骤 2:编写dragElement主函数(放在植物调用代码之后)。教程分三步往闭包里"填"内层函数,最终形态与参考实现一致:

function dragElement(terrariumElement) { // 初始化 4 个位置追踪变量 let pos1 = 0, // 上一次"旧位置 - 新位置"的水平差 pos2 = 0, // 上一次"旧位置 - 新位置"的垂直差 pos3 = 0, // 当前鼠标 X 坐标 pos4 = 0; // 当前鼠标 Y 坐标 // 设置初始拖放事件监听:用户按下时启动拖拽 terrariumElement.onpointerdown = pointerDrag; function pointerDrag(e) { /* 第六节 */ } function elementDrag(e) { /* 第七节 */ } function stopElementDrag() { /* 第八节 */ } }

位置追踪系统的含义:

  • pos1、pos2:保存新旧鼠标坐标之差(本帧位移量);
  • pos3、pos4:追踪当前鼠标坐标;
  • terrariumElement:参数传入的具体植物元素——这就是闭包"记住"的那个元素,三个内层函数无需再查 DOM 就能操作它;
  • onpointerdown:用户开始拖动时触发的事件。

一个值得注意的细节:教程让你把内层函数写在onpointerdown = pointerDrag这行之后,而 solution/script.js#L29 同样如此——onpointerdown赋值在第 29 行,pointerDrag的函数声明在第 31 行才开始。从源码结构看,这能工作是因为函数声明会被提升(hoisting)到作用域顶部:赋值发生时函数体尚未"执行",但声明已可用。只有当用户真正按下鼠标时,pointerDrag才会被调用,此时一切已就位。

为什么选 pointer events 而不是鼠标事件

事件类型最适合局限
onclick简单按钮点击无法处理拖拽(只有按下/抬起两个点)
onpointerdown鼠标与触摸通吃相对较新,但如今各浏览器均已支持
onmousedown仅限桌面鼠标完全排除移动端用户

Pointer events 对拖放项目的价值:无论鼠标、手指还是手写笔都能工作;在笔记本、平板、手机上手感一致;能处理"连续的移动过程"而不只是点击;产出用户预期中的顺滑体验。这也是"未来导向"的现代写法——不必为鼠标和触摸各写一套代码。

拖放的完整生命周期可概括为下面这个状态机(Ready → 拖拽开始 → 拖动中 → 拖拽结束 → 回到 Ready,循环往复):

六、pointerDrag:捕获拖拽的起点

当用户在某株植物上按下鼠标或手指时,pointerDrag被触发:它捕获初始坐标并启动拖放系统。

步骤 3:在dragElement闭包内、onpointerdown赋值行之后添加:

function pointerDrag(e) { // 阻止浏览器默认行为(如文本选中) e.preventDefault(); // 记录拖拽起始时的鼠标/触摸坐标 pos3 = e.clientX; // 拖拽开始处的 X 坐标 pos4 = e.clientY; // 拖拽开始处的 Y 坐标 // 为拖拽过程挂上文档级监听器 document.onpointermove = elementDrag; document.onpointerup = stopElementDrag; }

逐步来看:阻止可能干扰拖拽的默认行为 → 记录手势起始坐标 → 建立移动/结束监听 → 使系统在整个文档范围内追踪指针移动。参考实现中,solution/script.js#L31-L41 与上面完全同构,且保留了一行console.log(e)用于在 DevTools 里观察事件对象——初学时建议保留这类调试输出,拖放完成后删除即可。

理解 preventDefault

e.preventDefault()一行对顺滑拖拽至关重要。不阻止默认行为,浏览器可能:

  • 在拖拽划过页面时选中文本;
  • 在右键拖动时弹出上下文菜单;
  • 干扰自定义拖拽行为,甚至产生拖拽过程中的视觉伪影。

动手实验:完成本课后可尝试删掉e.preventDefault()再拖一株植物,立刻就能体会这行代码的必要性。

坐标追踪系统

属性测量内容用途
clientX相对视口(viewport)的水平位置追踪左右移动
clientY相对视口的垂直位置追踪上下移动

这两个坐标:提供像素级定位信息;随指针移动实时更新;在不同屏幕尺寸和缩放级别下保持一致;从而支撑顺滑的响应式拖拽。

为什么把 move/up 监听器挂在 document 上

document.onpointermove = elementDrag; document.onpointerup = stopElementDrag;
  • 即使鼠标离开植物元素本身,追踪依然继续;
  • 用户快速移动时不会中断拖拽;
  • 支持整屏范围的顺滑拖放;
  • 能处理光标移出元素(乃至接近窗口边缘)的边界情况。

性能提示:拖拽结束后会移除这些文档级监听器(第八节),以避免内存泄漏和性能损耗。

另外从源码可以观察到一个小细节:这里用的是属性赋值(document.onpointermove = ...)而非addEventListener,同一时刻 document 上只保留一个 move 处理函数——这意味着同一时刻只有一个元素处于"拖拽中",第二个植物被按下时会直接覆盖第一个的处理器。对生态缸这个场景(一次拖一株)恰好够用。

七、elementDrag:追踪移动并换算成位移

步骤 4:在pointerDrag之后添加elementDrag:

function elementDrag(e) { // 计算自上一次事件以来指针移动的距离 pos1 = pos3 - e.clientX; // 水平位移(旧 X - 新 X) pos2 = pos4 - e.clientY; // 垂直位移(旧 Y - 新 Y) // 把"当前位置"刷新为本次坐标,供下一帧计算 pos3 = e.clientX; pos4 = e.clientY; // 将位移应用到元素的位置上 terrariumElement.style.top = (terrariumElement.offsetTop - pos2) + 'px'; terrariumElement.style.left = (terrariumElement.offsetLeft - pos1) + 'px'; }

坐标数学逐步拆解(以一次拖拽从 (100, 50) 到 (110, 60) 为例):

  1. 上一帧pos3=100, pos4=50,本次e.clientX=110, e.clientY=60;
  2. pos1 = 100 - 110 = -10,pos2 = 50 - 60 = -10——负号表示指针向右、向下移动了 10px;
  3. left = offsetLeft - (-10) = offsetLeft + 10,top = offsetTop - (-10) = offsetTop + 10,元素跟着指针向右下移动 10px;
  4. pos3/pos4刷新为 (110, 60),成为下一帧的基准。

参考实现中这行位置更新写为terrariumElement.style.top = terrariumElement.offsetTop - pos2 + 'px'(solution/script.js#L55-L56),数学上等价。

为什么是 offsetTop / offsetLeft:定位上下文证据

offsetTop/offsetLeft返回元素相对最近的已定位(positioned)祖先的距离。生态缸项目恰好满足这个前提,可以从 3-terrarium/solution/style.css#L27-L38 验证:

.plant-holder { position: relative; /* 定位上下文:植物图片的 offset 参考系 */ height: 13%; left: -10px; } .plant { position: absolute; /* 绝对定位,使 top/left 与 offset* 生效 */ max-width: 150%; max-height: 150%; z-index: 2; }

也就是说,每株植物是position: absolute,其offsetParent是外层position: relative的.plant-holder。style.top/left的赋值与offsetTop/offsetLeft的读取使用同一参考系,位移换算才能精确成立——这是前两课 CSS 布局对本课交互的关键铺垫。

CSS 属性控制内容本项目中的用法
top距(定位参考系)顶边的距离拖拽中的垂直位置
left距(定位参考系)左边的距离拖拽中的水平位置

补充要点:offsetTop/offsetLeft在每次修改style.top/left后会立即反映新值(实时刷新),所以"读 offset → 算差值 → 写 style"可以在每一帧安全重复。

设计哲学:这套拖放系统是刻意"不设限"的——没有投放区(drop zone)、没有边界约束。用户可以把植物放在任何地方,对生态缸布局拥有完全的创作自由。想加边界约束见后面的扩展挑战。

八、stopElementDrag:清理监听器

步骤 5:在elementDrag之后添加清理函数:

function stopElementDrag() { // 移除文档级事件监听器 document.onpointerup = null; document.onpointermove = null; }

对应 solution/script.js#L59-L63。

清理为什么必不可少:

  • 防止残留监听器造成内存泄漏;
  • 用户一松手就停止拖拽行为;
  • 让其他元素能独立地被拖拽(document 级处理器被释放);
  • 把系统重置到下一次拖拽的初始状态。

若不做清理:监听器会在拖拽停止后继续运行;无用的监听器不断累积会拖慢页面;与其他元素交互时会出现意外行为;浏览器资源被浪费在无谓的事件处理上。

九、完整代码与参考实现对照

把五个步骤合并后,你的script.js应当是:开头 14 行dragElement(document.getElementById('plantN'))调用 + 一个dragElement函数(内含pos1~pos4、onpointerdown赋值和pointerDrag/elementDrag/stopElementDrag三个内层函数)。可以直接对照仓库中的成品 3-terrarium/solution/script.js:

  • 第 1 行注释说明该拖放方案受经典的"可拖拽 HTML 元素"教程模式启发;
  • 第 18-21 行用注释给出了闭包定义:闭包是"函数与其词法环境(外层作用域引用)的绑定",内层函数借此访问外层函数的作用域;
  • 第 33、53 行的console.log是学习调试辅助,正式代码中可删除。

测试你的交互生态缸

用浏览器打开index.html:

  1. 点击并按住任意一株植物开始拖拽;
  2. 移动鼠标或手指,观察植物顺滑跟随;
  3. 松开,植物停留在新位置;
  4. 反复实验不同布局,熟悉整个交互界面。

达成里程碑:你用原生 JavaScript 和职业开发者日常使用的核心机制,构建了一个完全交互式的应用——这套拖放原理同样支撑着文件上传、看板卡片拖动、照片排序等大量界面。

十、扩展挑战:把拖放系统推向下一阶

Agent 挑战(来自课程)

用编码助手的 Agent 模式完成:为生态缸增加"重置"功能——页面加载时记录每株植物的初始位置,点击重置按钮后用 CSS transition 在 1 秒内把所有植物平滑动画回初始位置。

创意扩展清单

  • 双击置前:双击某株植物把它提到最上层(z-index操作;注意当前 style.css 中所有.plant的z-index都是 2);
  • 悬停视觉反馈:hover 时给植物加轻微辉光;
  • 实现边界:在elementDrag中用容器的offsetWidth/offsetHeight做钳制,防止植物被拖出生态缸;
  • 持久化:用localStorage保存每株植物的最终位置,刷新后恢复;
  • 音效:拾起/放下植物时播放提示音。

每个挑战都会带你接触 DOM 操作、事件处理与体验设计的不同侧面。

十一、横向对比:其他拖放路线与进阶方向

路线最适合学习价值
HTML Drag and Drop API文件上传、正式投放区理解浏览器原生能力
Touch Events移动端专属交互移动优先的开发模式
CSStransform平滑动画性能优化技巧

本课选择 pointer events 是为了最大灵活性;后续可研究的方向包括:事件委托(event delegation)、Intersection Observer(元素进出视口)、MutationObserver(监视 DOM 结构变化)、Web Components(可复用封装组件)、虚拟 DOM(理解框架如何优化 DOM 更新)。浏览器兼容性方面,可用 CanIUse 类工具与 MDN 的浏览器兼容性数据核对 pointer events 的支持情况。

练习机会:用类似拖放机制做一个拼图游戏、带拖放任务管理的看板、可重排照片的图库,或为移动端界面尝试触摸手势。

分阶段练习路线(摘自课程学习路径):

  • 5 分钟:打开 DevTools 控制台输入document.querySelector('body');用innerHTML/textContent改一段页面文字;给按钮挂一个点击监听;在 Elements 面板查看 DOM 树;
  • 1 小时:做一个响应用户点击的交互页面;练习 click/mouseover/keypress 等不同事件类型;用 DOM 操作写一个 to-do 列表或计数器;
  • 一周:完成生态缸拖放项目;掌握事件委托;了解事件循环与异步 JavaScript;用"带私有状态的模块"练习闭包;探索 Intersection Observer;不用框架构建交互组件;
  • 一月:用原生 JavaScript 写一个复杂的单页应用;学习 React/Vue/Angular 并与原生 DOM 操作对照;为开源项目做贡献;掌握 Web Components 与自定义元素。

十二、配套作业与本课要点回顾

课后作业见 DOM Element Investigation 作业(丹麦语版:assignment.md):从 MDN 的 DOM 接口清单中选一个接口(初学可选classList、querySelector、addEventListener、localStorage;进阶可选 IntersectionObserver、MutationObserver、Drag and Drop API、Geolocation;高级可选 Web Components、Canvas API、Web Workers、Service Workers),完成 300-500 词的技术综述、真实网站实现分析、与生态缸方案的对比,并附一个可运行、有注释的代码示例。作业附带五维评分量表(技术理解 / 真实案例分析 / 代码示例 / 写作质量 / 批判性思维),可直接用于自查。

完成本课后的能力清单:

  • DOM:元素选择、属性操纵、树形导航;
  • 事件:基于 pointer events 的跨设备交互处理;
  • 闭包:私有状态管理与函数级持久化;
  • 交互系统:从零实现的完整拖放闭环(开始/移动/结束/清理);
  • 性能意识:监听器正确清理、无内存泄漏;
  • 工程习惯:职业开发中通用的代码组织方式。

这套与看板卡片拖动、拖放式文件上传、照片排序界面、移动端触摸交互同源的机制,也正是后续学习 React、Vue、Angular 等框架(它们建立在这些 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),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询