實戰(zhàn):拖拽繪制、坐標換算與判定邏輯)
簡介這份資源面向前端初學者與互動教學應用開發(fā)者提供一套基于HTML5 Canvas與JavaScript的連線題完整實現(xiàn)方案可用于在線測驗、教育游戲或認知測試場景。壓縮包共4個文件包含1個html頁面、1個js邏輯腳本和2個css樣式文件整體僅9KB結構輕量便于快速集成與二次修改。核心內容圍繞Canvas繪圖、jQuery事件處理、連線判定算法、縱向與橫向兩種題型模板、取值回調及交互反饋展開并涉及坐標計算與性能優(yōu)化思路。已有640人學習適合想通過小案例掌握Canvas交互開發(fā)、事件綁定與答題邏輯設計的讀者參考可從中獲取可運行的連線題原型、模板切換思路與回調驗證機制作為課程作業(yè)或項目練手的起點。1. 從一道“連不上”的題說起這個 H5 連線題項目到底能干什么前陣子幫一個做在線教育的朋友看他們新上的互動題學生端反饋“拖拽沒反應、線畫不出來、對錯判斷全靠猜”。翻完代碼發(fā)現(xiàn)問題不在業(yè)務邏輯而在 canvas 的坐標系和事件綁定上——這也是很多 H5 連線題項目的通病。今天要拆的這個項目就是一套用 H5 canvas js 實現(xiàn)的連線題方案核心解決三件事把左右兩列選項畫到畫布上、讓手指或鼠標能拖出一條跟隨的線、松手時判斷連到了哪個目標并給出對錯反饋。它適合誰正在做題庫、互動課件、輕量級答題頁的開發(fā)者尤其是想用 jquery 快速搭原型、又不想引入重型圖形庫的場景。整套代碼不依賴框架瀏覽器打開就能跑改起來也直接。2. 技術選型與整體架構為什么是 canvas 而不是 DOM 連線2.1 選型對比DOM SVG 與 canvas 的取舍做連線題第一反應可能是用 DOM 元素加 SVG 畫線畢竟元素好定位、事件好綁定。但這個項目選了 canvas原因很實際連線題的核心交互是“拖拽過程中實時重繪一條跟隨線”如果每次 mousemove 都去操作 SVG path 的 d 屬性在低端安卓機上會明顯掉幀。canvas 是一整塊畫布清屏加重繪的成本可控而且連線題的視覺元素簡單——就是圓點、文字、線段不需要 DOM 的可訪問性和樣式繼承。常見做法是選項文字用 canvas 的 fillText 繪制錨點用 arc 畫圓連線用 moveTo/lineTo 描邊。這樣整個題目就是一張畫布事件只綁在 canvas 上通過坐標反查命中了哪個錨點。代價是文字換行、樣式微調得自己算但對連線題這種固定布局來說反而更可控。2.2 項目目錄結構與模塊劃分這個項目沒有構建工具就是一個靜態(tài)頁面加幾個 js 文件結構很扁平lianxian/ ├── index.html # 頁面入口放 canvas 容器 ├── css/ │ └── style.css # 畫布尺寸、頁面基礎樣式 ├── js/ │ ├── jquery.min.js # 事件綁定與 DOM 操作 │ ├── question.js # 題目數(shù)據(jù)與配置 │ ├── canvas.js # 畫布初始化、繪制錨點與文字 │ ├── line.js # 連線繪制與拖拽邏輯 │ └── judge.js # 連線結果判定與反饋 └── images/ # 對錯圖標等靜態(tài)資源question.js 里存題目數(shù)據(jù)canvas.js 負責把數(shù)據(jù)畫出來line.js 處理交互judge.js 做判定。模塊之間通過全局的題目對象和畫布上下文通信沒有用模塊化打包好處是新手直接改文件就能看到效果壞處是變量都掛在 window 上改的時候要注意命名別沖突。2.3 數(shù)據(jù)驅動題目配置長什么樣題目數(shù)據(jù)決定了畫布上畫什么、連什么算對。一個典型的配置如下// question.js var questionData { left: [ { id: l1, text: 蘋果, x: 80, y: 120 }, { id: l2, text: 香蕉, x: 80, y: 200 }, { id: l3, text: 橘子, x: 80, y: 280 } ], right: [ { id: r1, text: 紅色, x: 420, y: 120 }, { id: r2, text: 黃色, x: 420, y: 200 }, { id: r3, text: 橙色, x: 420, y: 280 } ], // 正確答案映射左 id 對應右 id answer: { l1: r1, l2: r2, l3: r3 } };x、y 是錨點在畫布上的坐標單位是像素。answer 對象存正確配對判定時拿用戶連的右 id 和這里比對。改題目只需要改這個文件不用動畫布邏輯。注意坐標要跟 canvas 的實際寬高匹配如果 css 里把 canvas 縮放顯示坐標換算會出問題后面避坑章節(jié)會細說。3. 核心繪制鏈路從清屏到錨點、文字、連線的完整實現(xiàn)3.1 畫布初始化與高清屏適配canvas 在移動端有個經(jīng)典坑css 尺寸和畫布實際像素不一致導致線條模糊。項目里做了 devicePixelRatio 適配// canvas.js function initCanvas() { var canvas document.getElementById(questionCanvas); var ctx canvas.getContext(2d); var dpr window.devicePixelRatio || 1; var cssWidth 500; var cssHeight 400; // 實際像素放大 dpr 倍 canvas.width cssWidth * dpr; canvas.height cssHeight * dpr; canvas.style.width cssWidth px; canvas.style.height cssHeight px; // 縮放上下文讓后續(xù)繪制按 css 像素走 ctx.scale(dpr, dpr); return { canvas: canvas, ctx: ctx, width: cssWidth, height: cssHeight }; }dpr 是設備像素比普通屏是 1高清屏可能是 2 或 3。canvas.width 設成 css 寬乘 dpr再用 ctx.scale 把繪制坐標系縮回 css 像素這樣后面所有坐標都按 500x400 來寫不用到處乘 dpr。如果跳過這步在高清屏上文字和線條會發(fā)虛這是血淚經(jīng)驗。3.2 繪制錨點與文字fillText 與 arc 的配合錨點是連線的起點和終點視覺上是個小圓點旁邊帶文字。繪制函數(shù)遍歷題目數(shù)據(jù)// canvas.js function drawAnchors(ctx, data) { ctx.clearRect(0, 0, 500, 400); // 清屏 ctx.font 16px sans-serif; ctx.textAlign center; ctx.textBaseline middle; // 畫左側 data.left.forEach(function (item) { ctx.beginPath(); ctx.arc(item.x, item.y, 8, 0, Math.PI * 2); ctx.fillStyle #4a90d9; ctx.fill(); ctx.fillStyle #333; ctx.fillText(item.text, item.x - 40, item.y); }); // 畫右側 data.right.forEach(function (item) { ctx.beginPath(); ctx.arc(item.x, item.y, 8, 0, Math.PI * 2); ctx.fillStyle #d9534f; ctx.fill(); ctx.fillStyle #333; ctx.fillText(item.text, item.x 40, item.y); }); }clearRect 每次重繪前清空整塊畫布這是 canvas 動畫的基本節(jié)奏。arc 的第三個參數(shù)是半徑這里錨點半徑 8 像素命中判定時也用這個值加一點容差。文字用 fillText 畫在錨點旁邊左側文字往左偏 40 像素右側往右偏避免壓住圓點。textAlign 和 textBaseline 設成 center/middle讓文字以給定坐標為中心省去手動算寬高。3.3 拖拽連線mousedown 到 mousemove 的狀態(tài)機連線的交互本質是個小狀態(tài)機按下時判斷有沒有命中錨點移動時更新臨時線終點松開時做判定。用 jquery 綁事件// line.js var dragging false; var startAnchor null; var currentPos { x: 0, y: 0 }; $(canvas).on(mousedown touchstart, function (e) { var pos getEventPos(e); var anchor hitTest(pos, questionData.left); // 只允許從左往右連 if (anchor) { dragging true; startAnchor anchor; currentPos pos; } }); $(canvas).on(mousemove touchmove, function (e) { if (!dragging) return; currentPos getEventPos(e); redraw(); // 重繪所有錨點 當前拖拽線 }); $(canvas).on(mouseup touchend, function (e) { if (!dragging) return; var pos getEventPos(e); var endAnchor hitTest(pos, questionData.right); if (endAnchor) { saveConnection(startAnchor.id, endAnchor.id); } dragging false; startAnchor null; redraw(); });getEventPos 負責把鼠標或觸摸事件的坐標換算成畫布坐標觸摸事件要取 touches[0]。hitTest 遍歷錨點數(shù)組算事件坐標到錨點圓心的距離小于半徑加容差就算命中。mousedown 只檢測左側錨點保證連線方向是從左到右避免用戶從右往左連造成判定混亂。mousemove 里調 redraw把已保存的連線和當前拖拽的臨時線一起畫出來。3.4 坐標換算getBoundingClientRect 的正確用法事件坐標和畫布坐標不是一回事頁面滾動、canvas 在頁面中的位置都會影響// line.js function getEventPos(e) { var rect canvas.getBoundingClientRect(); var clientX, clientY; if (e.type.indexOf(touch) 0) { clientX e.originalEvent.touches[0].clientX; clientY e.originalEvent.touches[0].clientY; } else { clientX e.clientX; clientY e.clientY; } return { x: clientX - rect.left, y: clientY - rect.top }; }getBoundingClientRect 返回 canvas 相對視口的位置clientX 減去 rect.left 就是畫布內坐標。注意這里沒有乘 dpr因為前面 ctx.scale 已經(jīng)把繪制坐標系縮回 css 像素事件坐標也是 css 像素兩邊一致。如果頁面有縮放或 canvas 被 css transform 過這套換算會偏得用更復雜的矩陣變換但常規(guī)布局夠用。4. 判定與反饋連線結果怎么算對、怎么給用戶后悔藥4.1 連線存儲與重繪已連的線不能丟用戶可能連多條線每條都要記住并在重繪時畫出來// line.js var connections []; // { from: l1, to: r1 } function saveConnection(fromId, toId) { // 同一個左錨點只保留最后一條線 connections connections.filter(function (c) { return c.from ! fromId; }); connections.push({ from: fromId, to: toId }); } function drawConnections(ctx) { connections.forEach(function (c) { var from findAnchor(c.from, questionData.left); var to findAnchor(c.to, questionData.right); ctx.beginPath(); ctx.moveTo(from.x, from.y); ctx.lineTo(to.x, to.y); ctx.strokeStyle #4a90d9; ctx.lineWidth 2; ctx.stroke(); }); }saveConnection 里先過濾掉同一個左錨點的舊連線保證一個左項只連一個右項這是連線題的基本規(guī)則。drawConnections 根據(jù) id 找到錨點坐標moveTo 到起點、lineTo 到終點、stroke 描邊。重繪順序是先畫連線再畫錨點這樣錨點圓點蓋在線段端點上視覺更干凈。4.2 判定邏輯遍歷 answer 映射逐條比對判定就是拿用戶連的結果和正確答案比// judge.js function judgeAnswer() { var correct 0; var total Object.keys(questionData.answer).length; connections.forEach(function (c) { if (questionData.answer[c.from] c.to) { correct; } }); var allRight correct total connections.length total; showResult(allRight, correct, total); return allRight; }total 是正確答案的條目數(shù)correct 是連對的條數(shù)。allRight 要求連對數(shù)量等于總數(shù)且連線數(shù)量也等于總數(shù)防止用戶少連幾條卻因為沒連錯而誤判為全對。showResult 負責在頁面上顯示對錯提示可以彈層也可以改文字。4.3 對錯反饋與重置給用戶一次重來的機會判定完要給出視覺反饋并且允許重置// judge.js function showResult(allRight, correct, total) { var tip document.getElementById(resultTip); if (allRight) { tip.textContent 全部連對共 total 題; tip.style.color #5cb85c; } else { tip.textContent 連對 correct / total 再試試; tip.style.color #d9534f; } } // 重置按鈕 $(#resetBtn).on(click, function () { connections []; document.getElementById(resultTip).textContent ; redraw(); });反饋文案直接告訴用戶連對幾條比單純說“錯誤”更有指導性。重置就是把 connections 清空、提示清空、重繪畫布。這里用 jquery 綁按鈕事件跟 canvas 事件分開邏輯清晰。注意 connections 是全局變量重置時直接賦空數(shù)組別用 length0 之外的花招簡單直接。5. 避坑與排查連線題最容易翻車的五個地方5.1 現(xiàn)象移動端拖拽時頁面跟著滾動原因touchmove 默認會觸發(fā)頁面滾動canvas 上的拖拽被頁面滾動搶走。解決在 touchmove 事件里調 e.preventDefault()并且給 canvas 加 css 的 touch-action: none。注意 preventDefault 要放在判斷 dragging 之后否則會阻止正常滾動。5.2 現(xiàn)象高清屏上線條和文字發(fā)虛原因只設了 canvas 的 css 尺寸沒設實際像素尺寸瀏覽器把低分辨率畫布拉伸顯示。解決按 devicePixelRatio 放大 canvas.width/height再用 ctx.scale 縮回邏輯坐標參考 3.1 的 initCanvas。5.3 現(xiàn)象點擊錨點沒反應或者命中位置偏移原因事件坐標沒減去 canvas 的 getBoundingClientRect().left/top或者頁面滾動后 rect 變了沒重新取。解決每次事件里實時調 getBoundingClientRect別緩存觸摸事件記得取 touches[0] 而不是 e 本身。5.4 現(xiàn)象連完一條線再連同一起點舊線還在原因saveConnection 沒做去重connections 里堆了多條同 from 的記錄。解決push 之前先 filter 掉 from 相同的舊記錄保證一個左錨點只有一條線。5.5 現(xiàn)象判定全對但用戶其實少連了原因判定只統(tǒng)計連對的條數(shù)沒校驗連線總數(shù)。解決allRight 條件里加上 connections.length total兩個條件都滿足才算全對。6. 二次開發(fā)改造從固定題目到動態(tài)題庫的落地思路這套代碼跑通之后最自然的改造方向是把它接進真實題庫?,F(xiàn)在的題目數(shù)據(jù)寫死在 question.js 里實際項目里題目通常來自接口。我的做法是保留繪制和交互邏輯不動只把 questionData 的賦值改成異步獲取// 改造從接口拉題目 function loadQuestion(qid) { $.get(/api/question/ qid, function (res) { questionData res.data; // 結構保持 left/right/answer connections []; redraw(); }); }接口返回的數(shù)據(jù)結構跟原來保持一致這樣 canvas.js、line.js、judge.js 一行都不用改。這是數(shù)據(jù)驅動的價值——只要契約不變渲染層就是穩(wěn)定的。如果接口字段名不同加一層適配函數(shù)轉換即可別去改繪制代碼。第二個改造點是錨點坐標?,F(xiàn)在 x、y 是寫死的題目一多、選項一長就會重疊。常見做法是根據(jù)選項數(shù)量動態(tài)算坐標左側 x 固定y 按 index 均分畫布高度右側同理。文字太長的話fillText 之前先用 measureText 量寬度超過閾值就截斷加省略號或者換行繪制。這些都屬于渲染層的增強不影響判定邏輯。第三個改造點是判定反饋的豐富度。現(xiàn)在只有文字提示可以加對錯圖標、把連錯的線標紅、連對的線標綠。實現(xiàn)方式是在 drawConnections 里根據(jù) judge 結果給每條線不同的 strokeStyle。注意判定和繪制要解耦先算出每條線的對錯狀態(tài)存起來再統(tǒng)一重繪別在繪制函數(shù)里現(xiàn)算否則重繪頻繁時性能會抖。最后一個容易忽略的點是移動端手勢沖突。如果頁面本身可以上下滾動canvas 又占了大半屏用戶想滾動頁面卻觸發(fā)了連線。我的習慣是給 canvas 加一個“編輯模式”開關非編輯模式下不綁拖拽事件用戶點一下“開始連線”才激活。這樣滾動和連線互不干擾體驗上更接近真實答題場景。從那以后我每次做 canvas 交互都強制先跑一遍坐標換算和 dpr 適配再寫業(yè)務邏輯——這兩步翻車后面全白搭。希望幫到你。本文還有配套的精品資源點擊獲取