連線題:拖拽配對與動態(tài)連線繪制)
簡介這是一份面向前端初學(xué)者與互動教學(xué)開發(fā)者的實戰(zhàn)示例用HTML5 Canvas配合JavaScript與jQuery實現(xiàn)可交互的在線連線題適用于教育測評、課堂練習(xí)或輕量游戲場景。壓縮包共4個文件約9KB包含1個HTML頁面、1個JS邏輯腳本與2個CSS樣式文件分別承擔(dān)頁面結(jié)構(gòu)、連線繪制與交互邏輯、題目與連線樣式呈現(xiàn)結(jié)構(gòu)精簡便于快速閱讀與二次改造。資源覆蓋Canvas繪圖、鼠標(biāo)與觸屏事件處理、坐標(biāo)計算與連線判定、縱向與橫向兩種題型模板、完成后的取值回調(diào)以及基礎(chǔ)性能優(yōu)化思路讀者可據(jù)此理解從繪制選項到校驗答案的完整鏈路。目前已有640人學(xué)習(xí)適合希望提升Canvas交互開發(fā)能力、需要現(xiàn)成連線題方案的前端學(xué)習(xí)者參考。1. 從一道“連不上”的題說起這個 H5 連線題項目到底能干什么前陣子幫一個做在線教育的朋友看他們的練習(xí)模塊有個需求特別典型左邊一列詞語右邊一列釋義讓學(xué)生用鼠標(biāo)或手指把匹配的兩項連起來。聽起來簡單但他們用 DOM 加絕對定位的線去連一到移動端就翻車——滾動一下線就錯位橫豎屏切換直接飛到屏幕外。后來我干脆用 canvas 重寫了一遍也就是這份 H5canvasjs 實現(xiàn)連線題的項目。它干的事很純粹在一個 H5 頁面里渲染左右兩列可拖拽的節(jié)點用戶從左側(cè)節(jié)點按下、拖到右側(cè)節(jié)點松開如果配對正確就畫一條固定的連線錯誤則回彈。整個交互不依賴任何重型框架核心就是原生 canvas 加一點 jquery 做 DOM 操作適合嵌進(jìn)答題頁、單詞配對、知識闖關(guān)這類場景。如果你正在找一個能直接跑、又能隨手改配對邏輯的連線題實現(xiàn)這份源碼值得拆開看看。2. 技術(shù)選型與目錄結(jié)構(gòu)為什么是 canvas 而不是 SVG2.1 選型背后的取舍先說說為什么這個項目用 canvas 畫線而不是 SVG 或者純 DOM。SVG 畫線確實方便一個line標(biāo)簽就能搞定還能直接綁事件。但連線題有個特點線是動態(tài)的、跟隨手指移動的而且數(shù)量可能不少。SVG 在節(jié)點數(shù)量上去之后每次重繪都要走一遍 DOM 樹移動端上手指拖動時的掉幀會很明顯。canvas 就不一樣了它是一塊畫布所有線都在一個繪制上下文里清空重繪的成本極低拖動時的跟手性明顯更好。那 jquery 在這里扮演什么角色其實這個項目里 jquery 只負(fù)責(zé)一件事方便地選取和操作左右兩列的節(jié)點 DOM。因為節(jié)點本身還是用 HTML 元素渲染的這樣才能用 CSS 做樣式、用原生事件做點擊和觸摸。canvas 只負(fù)責(zé)畫線這一層浮在節(jié)點上方或者下方。這種“DOM 管節(jié)點、canvas 管連線”的分層思路是這個項目最值得學(xué)的地方。提示如果你的連線題節(jié)點數(shù)量很少比如少于 10 個SVG 也完全夠用不必為了 canvas 而 canvas。選型要看規(guī)模和交互頻率。2.2 目錄結(jié)構(gòu)長什么樣這份源碼的結(jié)構(gòu)很扁平?jīng)]有復(fù)雜的構(gòu)建工具雙擊 HTML 就能跑。常見做法是分成這么幾塊lianxian/ ├── index.html # 頁面結(jié)構(gòu)左右兩列節(jié)點容器 canvas ├── css/ │ └── style.css # 節(jié)點樣式、布局、選中態(tài) ├── js/ │ ├── jquery.min.js # jquery 庫 │ └── main.js # 核心邏輯初始化、拖拽、連線、判定 └── img/ # 可選節(jié)點圖標(biāo)資源index.html里最關(guān)鍵的是兩個容器和一個 canvas。左側(cè)容器放題目項右側(cè)容器放答案項canvas 覆蓋在它們上面用來畫拖拽中的臨時線和已確認(rèn)的連線。main.js是整個項目的大腦初始化時讀取配置、生成節(jié)點、綁定事件運(yùn)行時處理拖拽和判定。2.3 頁面骨架的關(guān)鍵代碼先看 HTML 骨架理解節(jié)點和畫布是怎么擺的!-- 連線題主容器相對定位作為 canvas 的定位參照 -- div idmatchWrap styleposition: relative; !-- 左側(cè)題目列 -- ul classcol left idleftCol li classnode>function initCanvas() { var $wrap $(#matchWrap); var canvas document.getElementById(lineCanvas); // 讓 canvas 尺寸等于容器尺寸 var w $wrap.width(); var h $wrap.height(); // 處理高清屏實際像素放大 devicePixelRatio 倍 var dpr window.devicePixelRatio || 1; canvas.width w * dpr; canvas.height h * dpr; canvas.style.width w px; canvas.style.height h px; // 縮放上下文讓后續(xù)繪制按邏輯坐標(biāo)走 var ctx canvas.getContext(2d); ctx.scale(dpr, dpr); // 關(guān)鍵canvas 不能攔截鼠標(biāo)事件否則節(jié)點點不動 canvas.style.pointerEvents none; return ctx; }這段代碼里devicePixelRatio的處理是移動端清晰度的關(guān)鍵。很多項目在電腦上看著正常一到手機(jī)上線條就糊就是因為沒做這一步。ctx.scale(dpr, dpr)之后你后面所有繪制都用 CSS 像素坐標(biāo)不用自己乘來乘去。pointerEvents none必須設(shè)否則 canvas 會吃掉所有觸摸事件節(jié)點根本拖不動。3.2 拖拽起點怎么拿到節(jié)點的中心坐標(biāo)用戶按下左側(cè)某個節(jié)點時要記錄這個節(jié)點的中心點作為連線的起點。這里不能直接用節(jié)點的offsetLeft因為節(jié)點在列表里坐標(biāo)是相對父容器的得換算到 canvas 的坐標(biāo)系。// 獲取某個節(jié)點相對于容器中心的坐標(biāo) function getNodeCenter($node) { var $wrap $(#matchWrap); var wrapOffset $wrap.offset(); // 容器在頁面中的位置 var nodeOffset $node.offset(); // 節(jié)點在頁面中的位置 return { x: nodeOffset.left - wrapOffset.left $node.outerWidth() / 2, y: nodeOffset.top - wrapOffset.top $node.outerHeight() / 2 }; }用offset()拿到的是相對文檔的位置兩個一減就得到相對容器的位置再加上自身寬高的一半就是中心點。這個函數(shù)在拖拽過程中會被反復(fù)調(diào)用用來實時更新臨時線的終點。注意如果頁面有滾動offset()依然能正確計算因為它基于文檔坐標(biāo)這也是用 jquery 比手寫getBoundingClientRect省心的地方。3.3 拖拽過程臨時線的繪制與重繪拖拽時每一幀都要清空畫布、重畫所有已確認(rèn)的連線、再畫一條從起點到當(dāng)前手指位置的臨時線。這就是 canvas 的經(jīng)典“清空-重繪”循環(huán)。var ctx; // canvas 上下文 var startPoint null; // 當(dāng)前拖拽起點 var currentPoint null; // 當(dāng)前手指/鼠標(biāo)位置 var matchedLines []; // 已確認(rèn)的連線 [{from, to}] function render() { var canvas document.getElementById(lineCanvas); // 清空整個畫布 ctx.clearRect(0, 0, canvas.width, canvas.height); // 先畫已確認(rèn)的連線 matchedLines.forEach(function(line) { drawLine(line.from, line.to, #4caf50, 3); }); // 再畫拖拽中的臨時線 if (startPoint currentPoint) { drawLine(startPoint, currentPoint, #2196f3, 2); } } // 畫一條線帶圓頭端點 function drawLine(from, to, color, width) { ctx.beginPath(); ctx.moveTo(from.x, from.y); ctx.lineTo(to.x, to.y); ctx.strokeStyle color; ctx.lineWidth width; ctx.lineCap round; ctx.stroke(); }render函數(shù)是整個交互的核心任何狀態(tài)變化都調(diào)它一次。已確認(rèn)的線用綠色臨時線用藍(lán)色視覺上區(qū)分開。lineCap round讓線頭是圓的看起來更柔和。這里要注意clearRect的尺寸用的是canvas.width也就是實際像素但因為前面ctx.scale過其實用邏輯尺寸也行保持一致就好。3.4 事件綁定鼠標(biāo)和觸摸要一起處理移動端和桌面端的事件不一樣桌面是mousedown/mousemove/mouseup移動端是touchstart/touchmove/touchend。這個項目用 jquery 的事件綁定把兩套都接上。// 按下記錄起點 $(#leftCol).on(mousedown touchstart, .node, function(e) { e.preventDefault(); // 阻止移動端默認(rèn)滾動 var point getNodeCenter($(this)); startPoint point; currentPoint point; $(this).addClass(active); // 高亮選中節(jié)點 render(); }); // 移動更新終點 $(document).on(mousemove touchmove, function(e) { if (!startPoint) return; var touch e.originalEvent.touches ? e.originalEvent.touches[0] : e; var $wrap $(#matchWrap); var wrapOffset $wrap.offset(); currentPoint { x: touch.pageX - wrapOffset.left, y: touch.pageY - wrapOffset.top }; render(); }); // 松開判定配對 $(document).on(mouseup touchend, function(e) { if (!startPoint) return; // 找到手指松開位置下的節(jié)點 var touch e.originalEvent.changedTouches ? e.originalEvent.changedTouches[0] : e; var $target $(document.elementFromPoint(touch.clientX, touch.clientY)).closest(.node); // 判定邏輯見下一節(jié) tryMatch($target); startPoint null; currentPoint null; $(.node).removeClass(active); render(); });這里有幾個關(guān)鍵點。e.preventDefault()在touchstart里必須調(diào)否則移動端拖動時會觸發(fā)頁面滾動體驗很差。touchmove里取坐標(biāo)要用e.originalEvent.touches[0]因為 jquery 包裝過的事件對象里沒有touches。松開時用document.elementFromPoint找到手指下方的元素再closest(.node)往上找節(jié)點這樣即使用戶松手時稍微偏一點只要還在節(jié)點范圍內(nèi)就能判定成功。3.5 配對判定data-id 相等才算連對判定邏輯本身很簡單就是比對>function tryMatch($target) { // 松手不在節(jié)點上直接放棄 if (!$target.length) return; // 起點必須是左側(cè)節(jié)點終點必須是右側(cè)節(jié)點 var $start $(.node.active); if (!$start.closest(#leftCol).length) return; if (!$target.closest(#rightCol).length) return; var startId $start.data(id); var endId $target.data(id); if (startId endId) { // 配對成功記錄這條線 matchedLines.push({ from: getNodeCenter($start), to: getNodeCenter($target) }); $start.addClass(matched); $target.addClass(matched); } else { // 配對失敗可以給個抖動反饋 $target.addClass(shake); setTimeout(function() { $target.removeClass(shake); }, 300); } }>// 題目配置id 相同的左右項是一對 var questionData { left: [ { id: 1, text: 蘋果 }, { id: 2, text: 香蕉 }, { id: 3, text: 橘子 } ], right: [ { id: 2, text: 黃色彎月形 }, { id: 3, text: 橙色圓形 }, { id: 1, text: 紅色圓形 } ] }; function buildNodes() { var $left $(#leftCol).empty(); var $right $(#rightCol).empty(); // 右側(cè)順序打亂避免答案位置固定 var rightShuffled questionData.right.slice().sort(function() { return Math.random() - 0.5; }); questionData.left.forEach(function(item) { $left.append(li classnode>// 重置 $(#resetBtn).on(click, function() { matchedLines []; $(.node).removeClass(matched active); render(); }); // 提交收集當(dāng)前配對 $(#submitBtn).on(click, function() { var result matchedLines.map(function(line) { // 這里需要根據(jù)坐標(biāo)反查節(jié)點實際項目建議在 push 時直接存 id return { from: line.fromId, to: line.toId }; }); console.log(提交的配對, result); // 實際項目里這里發(fā) ajax });注意上面代碼里line.fromId是我建議你在push時就存進(jìn)去的比事后根據(jù)坐標(biāo)反查靠譜得多。改造tryMatch時把fromId和toId一起存進(jìn)matchedLines提交時直接取用。5.3 支持一對多和多對一有些題目不是嚴(yán)格一對一比如一個概念可以對應(yīng)多個例子。這時候判定邏輯要改不能只比對單個>// 節(jié)點上加 style="width:16px;margin-left:4px;vertical-align:text-bottom;cursor:text;" />