Web-Dev-For-Beginners Terrarium 实战:用 HTML、纯 CSS 玻璃罐与 JavaScript 闭包实现可拖拽电子生态缸
本篇基于 Web-Dev-For-Beginners 仓库中 Terrarium 项目的完整解答展开,讲解如何用少量 HTML、CSS 和 JavaScript 搭建一个可交互的"电子生态缸"(Terrarium):14 株植物可以被鼠标或手指自由拖拽摆放,玻璃罐完全由 CSS 绘制。读完后你将掌握 DOM 元素选取、指针事件生命周期、闭包状态管理这三项核心能力,并按仓库给出的步骤把项目发布到 Azure Static Web Apps。
项目定位:一堂拖放式"代码冥想"
solution/README.md 将本项目描述为 "A small drag and drop code-meditation"——用一点 HTML、JS 和 CSS 构建一个 Web 界面、为它做样式、并加上交互。它是 Terrarium 课程三个阶段(HTML 结构、CSS 样式、DOM 与闭包)的完整成品,对应源码目录为 3-terrarium/solution/:
| 文件 | 职责 |
|---|---|
| index.html | 页面骨架:导航、标题、左右植物栏、玻璃罐、页脚 |
| style.css | 全部视觉呈现:玻璃罐、土壤、植物定位、响应式导航 |
| script.js | 拖拽系统:指针事件 + 闭包坐标追踪 |
images/ |
14 张手绘植物素材(plant1.png ~ plant14.png) |
整个方案不依赖任何框架,全部为原生 Web 技术,这正是它作为教学样本的价值所在。
页面骨架:index.html 的结构设计
index.html 的 <head> 做了四件事:声明 viewport meta 保证移动端适配、引入 Font Awesome 6.4.2 图标库、链接 ./style.css、以及以 defer 属性加载 ./script.js:
<link rel="stylesheet" href="./style.css" />
<script src="./script.js" defer></script>
defer 属性的意义在于:脚本会等待整个 HTML 解析完毕后才执行,从而保证 script.js 顶部那 14 行 dragElement(document.getElementById('plantN')) 调用运行时,所有植物元素已经存在于 DOM 中,不会出现"元素未就绪"的取值错误。
<body> 采用语义化布局,结构为:
<div id="page">
<nav>…五个带图标的导航项…</nav>
<header><h1>My Terrarium</h1>…</header>
<main>
<section id="plant-selection">
<div id="left-container" class="container">…plant1 ~ plant7…</div>
<div id="right-container" class="container">…plant8 ~ plant14…</div>
</section>
<section id="terrarium-section">
<div id="terrarium">
<div class="jar-top"></div>
<div class="jar-walls">
<div class="jar-glossy-long"></div>
<div class="jar-glossy-short"></div>
</div>
<div class="dirt"></div>
<div class="jar-bottom"></div>
</div>
</section>
</main>
<footer>…</footer>
</div>
两个关键设计点:
- 每株植物都有唯一 ID(
plant1至plant14),并被包在.plant-holder容器内。ID 保证document.getElementById()能精确定位单个元素——这与 DOM 课程中"用 ID 而非 class 操作单个元素"的建议一致。 - 玻璃罐由空的
<div>组成:jar-top、jar-walls、dirt、jar-bottom以及罐壁上的两条高光jar-glossy-long/jar-glossy-short,全部依赖 CSS 定位渲染,没有任何图片。
纯 CSS 玻璃罐:style.css 的视觉实现
style.css 中的玻璃罐部分受 Jakub Mandra 的玻璃罐作品启发(README 致谢中注明)。核心思路是:所有部件都用 position: absolute 锚定在 #terrarium 内部,靠 bottom/left 百分比对齐,再用 opacity 制造玻璃的半透明感。
罐壁是罐体的主体,占宽 60%、高 80%,浅青色加圆角,透明度 0.5:
.jar-walls {
height: 80%;
width: 60%;
background: #d1e1df;
border-radius: 1rem;
position: absolute;
bottom: 0.5%;
left: 20%;
opacity: 0.5;
z-index: 1;
}
土壤层用深棕色 #3a241d,底部圆角,并且刻意设置 z-index: -1,让它被植物(z-index: 2)压在后面、又盖在罐壁之上:
.dirt {
width: 60%;
height: 5%;
background: #3a241d;
position: absolute;
border-radius: 0 0 1rem 1rem;
bottom: 1%;
left: 20%;
opacity: 0.7;
z-index: -1;
}
罐口(.jar-top)、罐底(.jar-bottom)分别定位在罐壁上沿与最底部;两条 #ddfbff 的细长高光模拟玻璃反光。
可拖拽的前提藏在植物样式里——.plant 必须是绝对定位,JavaScript 修改 top/left 才有效果:
.plant {
position: absolute;
max-width: 150%;
max-height: 150%;
z-index: 2;
}
两侧植物栏 .container 用 left: 0 / right: 0 绝对贴边、高 100%,.plant-holder 以相对定位给出每格 13% 的高度。样式表末尾还包含一段 @media (max-width: 700px) 响应式规则:窄屏下导航由横向 flex 转为纵向排列,图标链接的字号与内边距同步缩小。
拖拽系统:script.js 中的闭包与指针事件
script.js 是整个项目的交互核心,代码结构分两段:前 16 行为 14 株植物逐一绑定拖拽能力,随后定义 dragElement 函数。文件头部注释明确说明:拖拽方案参考了 w3schools 的经典实现,且刻意用闭包(closure)来追踪每个被拖元素的状态。
为什么需要闭包
每次调用 dragElement(terrariumElement) 都会在函数内部生成一组"私有"变量:
function dragElement(terrariumElement) {
let pos1 = 0, pos2 = 0, pos3 = 0, pos4 = 0;
terrariumElement.onpointerdown = pointerDrag;
function pointerDrag(e) { ... }
function elementDrag(e) { ... }
function stopElementDrag() { ... }
}
pos1~pos4 四个局部变量被内部的 pointerDrag、elementDrag、stopElementDrag 三个函数共同引用。由于每次调用 dragElement 都会创建一套全新的局部变量,plant1 和 plant2 的坐标状态互不干扰——这正是闭包"函数与其词法环境绑定"的典型应用,也是 DOM 课程中"每株植物拥有自己的记忆口袋"这一比喻的具体实现。
拖拽生命周期:三个内部函数
1. pointerDrag——按下起点(pointerdown)
function pointerDrag(e) {
e.preventDefault();
pos3 = e.clientX; // 记录指针 X 起点
pos4 = e.clientY; // 记录指针 Y 起点
document.onpointermove = elementDrag;
document.onpointerup = stopElementDrag;
}
e.preventDefault()阻止浏览器默认行为,否则拖拽过程中会触发文本选中、图片默认拖拽等干扰;clientX/clientY是相对视口的坐标,保证在不同屏幕尺寸与缩放下计算一致;- 注意 move 与 up 事件挂在整个 document 上而非植物元素本身:这样即使指针快速移出植物边界,拖拽依然连续不断。
2. elementDrag——移动计算(pointermove)
function elementDrag(e) {
pos1 = pos3 - e.clientX; // 与上一次事件相比,X 方向位移
pos2 = pos4 - e.clientY; // Y 方向位移
pos3 = e.clientX; // 把当前坐标作为下一轮的基准
pos4 = e.clientY;
terrariumElement.style.top = terrariumElement.offsetTop - pos2 + 'px';
terrariumElement.style.left = terrariumElement.offsetLeft - pos1 + 'px';
}
计算逻辑是"增量式"的:每次 pointermove 只计算自上一次事件以来的位移,再叠加到 offsetTop/offsetLeft(元素相对定位父容器的当前位置)上,写入内联 style.top/style.left。由于 CSS 中 .plant 是 position: absolute,这两个属性立即生效,实现跟手移动。
3. stopElementDrag——释放清理(pointerup)
function stopElementDrag() {
document.onpointerup = null;
document.onpointermove = null;
}
把 document 级监听器置空,是避免"松手后指针移动仍持续计算、多个元素监听器叠加"这类问题的手段,从源码结构看这也是课程反复强调的"事件清理"实践。
为什么选指针事件而非鼠标事件
从 script.js 使用 onpointerdown / onpointermove / onpointerup 的写法可以确认:方案面向鼠标、触屏、手写笔统一输入。对比 onmousedown(仅桌面鼠标)或 onclick(只有按下-抬起,无法表达持续位移),Pointer Events 是一套跨设备的现代写法,与课程中给出的事件选型建议(click 适合按钮、pointerdown 兼顾鼠标与触摸)相符。
部署上线:用 Azure Static Web Apps 发布
solution/README.md 给出了把成品发布到 Web 的完整流程,使用的服务是 Azure Static Web Apps,步骤如下:
- Fork 本仓库——在自己的 GitHub 账号下复制一份完整代码;
- 点击 README 中提供的 "Deploy to Azure" 徽章按钮,跳转至 Azure 门户的 Static Web App 创建入口;
- 按向导创建应用,其中两个关键配置:
- App root(应用根目录)必须设置为
/solution,或者你自己代码库的根目录。因为本仓库是课程仓库,成品代码位于3-terrarium/solution子目录而非仓库顶层,若不做此设置,构建服务会找不到index.html; - 本应用没有 API,无需配置 API 相关项;
- 向导会在你的 fork 仓库中自动生成一个
.github文件夹,其中的工作流文件供 Azure Static Web Apps 的构建服务自动构建并发布到一个新的 URL。
- App root(应用根目录)必须设置为
从源码结构看,该部署方式成立的前提也很直观:整个方案是纯静态站点——只有 HTML、CSS、JS、图片,唯一的运行时外部依赖是 index.html 中通过 CDN 引入的 Font Awesome 图标样式表,没有任何后端请求,因此静态托管即可完整运行。
致谢与可继续扩展的方向
按 solution/README.md 的 Credits 部分:项目由 Jen Looper 编写,纯 CSS 玻璃罐的设计灵感来自 Jakub Mandra 的玻璃罐作品,植物素材为 Jen Looper 使用 Procreate 手绘。
若想在成品基础上继续练习,DOM 课程的作业列出了一组与现有代码自然衔接的扩展点:双击植物提升 z-index 使其置顶、为拖拽添加边界约束防止植物移出画面、用 localStorage 保存植物位置实现"记住我的布置"、以及给植物添加拾取/放置时的音效。这些扩展都只需要修改 script.js 的事件函数与 style.css 的样式,不涉及框架或构建工具,适合作为巩固 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 StartedRust0624
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
