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