首页
/ Web-Dev-For-Beginners 盆栽盒專案 Part 3:用 DOM 操作與 JavaScript 閉包實現植物拖曳互動

Web-Dev-For-Beginners 盆栽盒專案 Part 3:用 DOM 操作與 JavaScript 閉包實現植物拖曳互動

2026-09-06 12:10:14作者:滑思眉Philip

本文基於 Web-Dev-For-Beginners(24 Lessons, 12 Weeks, Get Started as a Web Developer)課程專案的第三堂課,講解如何結合 Document Object Model(DOM)與 JavaScript 閉包(Closure),為「虛擬盆栽盒(Terrarium)」專案實現純原生 JavaScript 的植物拖曳功能。讀完本篇,你將掌握 document.getElementById 元素查找、onpointerdown/onpointermove/onpointerup 指針事件生命週期、座標數學計算與閉包私有狀態管理這四項核心能力,並能復現一個可拖曳的互動網頁應用。

DOM 樹結構示意圖

專案背景:DOM 與閉包為什麼要結合

操作 DOM 是網頁開發的一項關鍵技能。按照 MDN 文件的定義,「Document Object Model (DOM) 元素能根據網頁文件的結構與內容來呈現物件」。現代 JavaScript 框架透過封裝大幅降低了 DOM 操作的門檻,但理解底層機制仍是必要功課——本專案選擇親手管理 DOM,不依賴任何框架。

與 DOM 搭配的另一個核心概念是 JavaScript 閉包:可以想像成一個函式被包在另一個函式中,使內部函式仍能存取外部函式作用域中的變數。閉包是一個廣闊且複雜的主題,本課只觸及建立盆栽盒所需的最基礎概念:內部函式與外部函式建立一項關係,允許內部函式存取外部函式的變數等作用域

在本專案中,閉包的用途非常具體:盆栽盒頁面中有 14 株植物,每一株都需要獨立追蹤自己的拖曳座標。把整棵樹想像成一棵樹狀結構,多樣的 Web API 讓開發者可以存取、編輯、編排 DOM 元素——而閉包則為「每一株植物」提供了互相隔離的私有狀態空間。

開始之前:建立 script.js 並用 defer 匯入

這堂課的前置條件是盆栽盒的 HTML 與 CSS 已編輯完成(對應課程中的 Part 1 與 Part 2)。這堂課的新內容是:新增拖曳植物進出盆栽罐的功能。

在專案資料夾中新增檔案 script.js,並在 HTML 檔 <head> 部分匯入:

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

這裡有兩個關鍵細節:

  • defer 屬性的作用:讓 JavaScript 檔案只有在 HTML 被完全載入並解析完後才執行。你也可以改用 async 屬性,允許 JavaScript 在解析 HTML 檔時就被執行。本專案必須選擇 defer,因為必須確保 HTML 元件被完整建立後才能對植物綁定拖曳事件,否則 document.getElementById('plant1') 將取得 null
  • 檔案位置:在完整解 solution/index.html 中可以看到,該 <script> 標籤確實位於 <head> 區塊(第 13 行),緊接在樣式表引用之後,且 14 株植物的 <img> 元素都分布在 <body> 中、帶有不同的 idplant1plant14),這正是 defer 保證的「先有 DOM、後跑腳本」順序。

課題一:用 ID 定位 14 株植物

我們要做的第一件事是建立 DOM 下、要被操控的物件的連結。在專案中,有 14 株植物放在罐子外,等待被拖曳。在 script.js 中逐一把它們交給待編輯的函式 dragElement

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'));

發生了什麼事?你正以 DOM 搜尋網頁檔內的物件,以 Id 作為搜尋依據。回想 HTML 課程中的做法:每一株植物有一個專屬的 Id(id="plant1" 等),現在就可以使用它。在辨別完每一株植物物件後,將其傳遞給 dragElement 函式,讓該 HTML 物件變成可拖曳的。

為什麼用 ID 而不是 class 作為物件參考? 因為 ID 在文件內是唯一的,getElementById 精確返回單一元素;而 CSS class 的設計目的是對一組元素套用樣式,若以 class 查找會得到多元素的集合,不適合「逐一綁定獨立拖曳行為」的場景。

閉包基礎:先用一個糖果例子建立直覺

在建立拖曳閉包之前,先看一個最小閉包例子,理解「內部函式存取外部函式變數」的語意:

function displayCandy(){
	let candy = ['jellybeans'];
	function addCandy(candyType) {
		candy.push(candyType)
	}
	addCandy('gumdrops');
}
displayCandy();
console.log(candy) // ReferenceError: candy 是函式的本地變數,全域不可見

這個例子中,函式 displayCandy 包住另一個函式 addCandy,新增新的糖果樣式到已存在的陣列當中。執行這段程式後,在全域作用域讀取 candy 會被認作未定義,因為它是 displayCandy 的本地變數——這正說明閉包建立了「外部不可見、內部可存取」的私有狀態。

思考題:你能讓陣列 candy 被全域存取嗎?把它移到閉包外面試試——這時陣列變成全域變數,等於取消了閉包內的存取限制。

課題二:建立 dragElement 閉包的外層函式

現在準備建立 dragElement 閉包——一個包在外部函式內的內部函式組。在 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;
}

逐行拆解:

元素 說明
terrariumElement 參數 由呼叫端傳入的具體 DOM 元素(某株植物的 <img>
pos1 ~ pos4 四個初始值為 0 的本地變數,用於追蹤座標:pos3/pos4 記錄滑鼠目前位置,pos1/pos2 記錄位移差值
terrariumElement.onpointerdown = pointerDrag; 為該元素綁定 pointerdown 事件處理器

四個 pos 變數是本地變數,給每一個進到拖曳函式內的物件獨立操控——因為每次呼叫 dragElement(...) 都會產生一份新的閉包實例,plant1 的 pos1 與 plant2 的 pos1 互不干擾,網頁應用得以持續追蹤每個物件的位置。

pointerdown 是管理 DOM 的其中一項 Web API 事件:當滑鼠按下(或觸控開始)時觸發。這個事件處理器在網頁與行動瀏覽器上皆有支援(可用 CanIUse 查詢支援情況),只有少數例外。

思考題:onclick 事件處理器支援更多的瀏覽器,為什麼不用它?關鍵在於我們建立的互動類型——拖曳需要「按下 → 移動 → 放開」三段式追蹤,onclick 只能感知「按下並放開」的結果,無法取得移動過程中的座標。

課題三:pointerDrag —— 捕捉拖曳起點

terrariumElement 已經準備好被拖曳了。當觸發 onpointerdown 事件時,函式 pointerDrag 參與其中。在 terrariumElement.onpointerdown = pointerDrag; 下方新增:

function pointerDrag(e) {
	e.preventDefault();
	console.log(e);
	pos3 = e.clientX;
	pos4 = e.clientY;
}

這裡發生了多件事:

  1. e.preventDefault() 取消 pointerdown 的預設行為(例如瀏覽器開始選取文字、啟動原生拖曳 ghost 影像),讓你能操作更多的介面行為。建議實驗:回到程式碼中刪掉這行並執行,觀察拖曳時文字被選取、畫面出現殘影的現象。
  2. console.log(e):用瀏覽器打開 index.html 調查介面,點擊植物時可看見事件物件 e 被輸出——pointerdown 事件攜帶大量資訊(座標、目標元素、指針類型等)。
  3. 座標記錄:本地變數 pos3pos4 被設定為 e.clientXe.clientY,取得按下植物瞬間相對於視口的 x 與 y 座標。為了全面控制植物行為,拖曳過程中會持續更新這些座標。

思考題:如果把整個網頁應用建立在一個大閉包下,程式碼會比較清楚嗎?如果不是,你有其他方法管理這 14 株可拖曳的植物嗎?(答案方向:每株植物一個閉包實例,正是本課程採用的模組化策略。)

接著,在 pos4 = e.clientY 下方增加兩行事件處理,掛載到文件層級:

document.onpointermove = elementDrag;
document.onpointerup = stopElementDrag;

現在游標拖曳時植物跟著游標走、放開時停止。onpointermoveonpointeruponpointerdown 屬於同一族指針事件 API。

為什麼把 move/up 掛在 document 上而不是植物元素本身? 拖曳過程中游標經常快速離開植物元素範圍,若只監聽元素自身,游標一脫離元素拖曳就中斷。掛在文件層級可以在整個視口範圍內持續追蹤。

此時執行程式會看到錯誤訊息——因為函式 elementDragstopElementDrag 還沒建立。

課題四:elementDrag 與 stopElementDrag —— 移動計算與清理

新增兩條內部函式在閉包中,處理拖曳與停止拖曳事件。設計目標是:可以拖曳任何一株植物並放在螢幕上的任一地方,介面不強制盆栽盒的配置格式,你可以自由增加、移除與移動盆栽罐內的植物。

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 同理對應垂直方向;
  • 再把 pos3pos4 更新到新的 XY 座標點,作為下一次 move 事件的基準;
  • 最後寫入植物的 CSS 定位:style.topstyle.left 的增量取自 offsetTop/offsetLeft(元素相對於定位父級的目前偏移)減去位移量。你可以在 console 觀察數值在拖曳中持續更新。

offsetTopoffsetLeft 反映物件與其父關係物件的定位關係。父關係物件可以是任何元素,只要它的定位屬性不為 static。本專案的樣式表中 .plant 設定了 position: absolute(見 solution/style.css),因此 top/left 可以直接用於定位計算。

這些座標點的計算式讓你成功校整了植物與盆栽盒之間的行為。

最後在 elementDrag 的正下方新增 stopElementDrag

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

這條小函式重置 onpointeruponpointermove 事件,讓你可以重新開始該植物的拖曳,或是拖曳新的植物。

思考題:如果不將這些事件設為空值會發生什麼事?——elementDrag 會一直掛在 document 上持續執行,即使沒有人在拖曳,滑鼠任意移動都會重複計算與寫入樣式,造成不必要的 DOM 更新與效能浪費,甚至影響其他元素的互動。

完整實作解讀:從 solution 源碼印證閉包結構

課程中的解 solution/script.js 完整實現了上述四個步驟,可作為逐步比對的依據:

dragElement(document.getElementById('plant1'));
// ... plant2 到 plant14 同類呼叫 ...

/* "A closure is the combination of a function bundled together (enclosed)
with references to its surrounding state (the lexical environment).
In other words, a closure gives you access to an outer function's scope
from an inner function." Create a closure so that you can track the dragged element*/

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 = where the Ymouse WAS - where it IS
		pos2 = pos4 - e.clientY;
		//reset pos3 to current location of Xmouse
		pos3 = e.clientX;
		//reset pos4 to current location of Ymouse
		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;
	}
}

從源碼結構可以觀察到閉包成立的完整條件:

  1. pointerDragelementDragstopElementDrag 三個內部函式都宣告在 dragElement 函式體內;
  2. 它們直接讀寫外層函式的本地變數 pos1~pos4 與參數 terrariumElement
  3. 每個內部函式都被賦值為事件處理器(onpointerdown 等),在 dragElement 返回後仍然存活並持續引用這些外部變數——這正是「閉包=函式+其詞法環境引用」的語意。

對應到 HTML 側,solution/index.html 中 14 個 <img class="plant" id="plantN"> 元素分佈在左右兩個 #left-container / #right-container 區塊中,中間是由純 CSS 繪製的玻璃罐(#terrarium 內的 .jar-walls.dirt 等元素,樣式見 solution/style.css)。拖曳完成後,植物可以被放置在視口中任意位置,形成最終的盆栽盒畫面:

盆栽盒專案完成成果圖

挑戰:為閉包加入新的事件處理器

課程的挑戰題是:新增新的事件處理器到閉包中,讓你能對植物做更多事情。舉例來說,雙擊植物讓它排列到最上層(操作 z-index)。發揮創意即可——由於每個閉包實例都綁定各自的元素,新增如 ondblclick 這類處理器的成本極低。

複習與自學

在螢幕上拖曳物件看似簡單,但依照不同的目的與實現方法會遭遇到不同的問題。可以嘗試的方向:

  • HTML Drag and Drop API:本課程刻意沒有使用它,而是自訂 pointer 事件來建立不一樣的實現方法。可以試著將原生拖曳 API 套用到專案中比較兩者的差異(draggabledragstart/dragover/drop 等);
  • Pointer Events 規範:W3C 的 Pointer Events 文件與 MDN 的 Pointer events 參考是深入 pointerdown/pointermove/pointerup 的正式途徑;
  • 瀏覽器相容性:養成用 CanIUse 檢查網頁 API 瀏覽器相容性的習慣,本專案的 onpointerdown 系列事件在主流網頁與行動瀏覽器上均有支援,但仍有少數例外。

作業:用 DOM 做更多事

本課配套作業是用 DOM 做更多事:調查其中一項 DOM 的元素,從 MDN 的 DOM 介面清單中挑選一項,在網路上尋找一個使用該元素的網頁並解釋其用法。評量標準為「完整的評論文章附帶例子(優良)、評論文章不帶例子(普通)、評論文章不完整(待改進)」。

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

项目优选

收起
kernelkernel
deepin linux kernel
C
33
18
ops-transformerops-transformer
本项目是CANN提供的transformer类大模型算子库,实现网络在NPU上加速计算。
C++
1.13 K
2.75 K
pytorchpytorch
作为 Ascend for PyTorch 社区的核心组件,TorchNPU 是昇腾专为 PyTorch 打造的深度学习适配插件,使 PyTorch 框架能够直接调用昇腾 NPU,为开发者提供昇腾 AI 处理器的超强算力。
Python
857
1.35 K
docsdocs
暂无描述
Markdown
897
5.8 K
kernelkernel
openEuler内核是openEuler操作系统的核心,既是系统性能与稳定性的基石,也是连接处理器、设备与服务的桥梁。
C
529
593
ops-nnops-nn
本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。
C++
915
1.83 K
jiuwenswarmjiuwenswarm
JiuwenSwarm 是一款基于openJiuwen开发的智能AI Agent,它能够将大语言模型的强大能力,通过你日常使用的各类通讯应用,直接延伸至你的指尖。
Python
3.58 K
1.01 K
ops-mathops-math
本项目是CANN提供的数学类基础计算算子库,实现网络在NPU上加速计算。
C++
1.35 K
1.46 K
cann-learning-hubcann-learning-hub
CANN 学习中心仓,支持在线互动运行、边学边练,提供教程、示例与优化方案,一站式助力昇腾开发者快速上手。
Jupyter Notebook
1.01 K
515
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
547
388