简介这是一份面向前端初学者与互动教学开发者的实战示例用HTML5 Canvas配合JavaScript与jQuery实现可交互的在线连线题适用于教育测评、课堂练习或轻量游戏场景。压缩包共4个文件约9KB包含1个HTML页面、1个JS逻辑脚本与2个CSS样式文件分别承担页面结构、连线绘制与交互逻辑、题目与连线样式呈现结构精简便于快速阅读与二次改造。资源覆盖Canvas绘图、鼠标与触屏事件处理、坐标计算与连线判定、纵向与横向两种题型模板、完成后的取值回调以及基础性能优化思路读者可据此理解从绘制选项到校验答案的完整链路。目前已有640人学习适合希望提升Canvas交互开发能力、需要现成连线题方案的前端学习者参考。1. 从一道“连不上”的题说起这个 H5 连线题项目到底能干什么前阵子帮一个做在线教育的朋友看他们的练习模块有个需求特别典型左边一列词语右边一列释义让学生用鼠标或手指把匹配的两项连起来。听起来简单但他们用 DOM 加绝对定位的线去连一到移动端就翻车——滚动一下线就错位横竖屏切换直接飞到屏幕外。后来我干脆用 canvas 重写了一遍也就是这份 H5canvasjs 实现连线题的项目。它干的事很纯粹在一个 H5 页面里渲染左右两列可拖拽的节点用户从左侧节点按下、拖到右侧节点松开如果配对正确就画一条固定的连线错误则回弹。整个交互不依赖任何重型框架核心就是原生 canvas 加一点 jquery 做 DOM 操作适合嵌进答题页、单词配对、知识闯关这类场景。如果你正在找一个能直接跑、又能随手改配对逻辑的连线题实现这份源码值得拆开看看。2. 技术选型与目录结构为什么是 canvas 而不是 SVG2.1 选型背后的取舍先说说为什么这个项目用 canvas 画线而不是 SVG 或者纯 DOM。SVG 画线确实方便一个line标签就能搞定还能直接绑事件。但连线题有个特点线是动态的、跟随手指移动的而且数量可能不少。SVG 在节点数量上去之后每次重绘都要走一遍 DOM 树移动端上手指拖动时的掉帧会很明显。canvas 就不一样了它是一块画布所有线都在一个绘制上下文里清空重绘的成本极低拖动时的跟手性明显更好。那 jquery 在这里扮演什么角色其实这个项目里 jquery 只负责一件事方便地选取和操作左右两列的节点 DOM。因为节点本身还是用 HTML 元素渲染的这样才能用 CSS 做样式、用原生事件做点击和触摸。canvas 只负责画线这一层浮在节点上方或者下方。这种“DOM 管节点、canvas 管连线”的分层思路是这个项目最值得学的地方。提示如果你的连线题节点数量很少比如少于 10 个SVG 也完全够用不必为了 canvas 而 canvas。选型要看规模和交互频率。2.2 目录结构长什么样这份源码的结构很扁平没有复杂的构建工具双击 HTML 就能跑。常见做法是分成这么几块lianxian/ ├── index.html # 页面结构左右两列节点容器 canvas ├── css/ │ └── style.css # 节点样式、布局、选中态 ├── js/ │ ├── jquery.min.js # jquery 库 │ └── main.js # 核心逻辑初始化、拖拽、连线、判定 └── img/ # 可选节点图标资源index.html里最关键的是两个容器和一个 canvas。左侧容器放题目项右侧容器放答案项canvas 覆盖在它们上面用来画拖拽中的临时线和已确认的连线。main.js是整个项目的大脑初始化时读取配置、生成节点、绑定事件运行时处理拖拽和判定。2.3 页面骨架的关键代码先看 HTML 骨架理解节点和画布是怎么摆的!-- 连线题主容器相对定位作为 canvas 的定位参照 -- div idmatchWrap styleposition: relative; !-- 左侧题目列 -- 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; // 缩放上下文让后续绘制按逻辑坐标走 var ctx canvas.getContext(2d); ctx.scale(dpr, dpr); // 关键canvas 不能拦截鼠标事件否则节点点不动 canvas.style.pointerEvents none; return ctx; }这段代码里devicePixelRatio的处理是移动端清晰度的关键。很多项目在电脑上看着正常一到手机上线条就糊就是因为没做这一步。ctx.scale(dpr, dpr)之后你后面所有绘制都用 CSS 像素坐标不用自己乘来乘去。pointerEvents none必须设否则 canvas 会吃掉所有触摸事件节点根本拖不动。3.2 拖拽起点怎么拿到节点的中心坐标用户按下左侧某个节点时要记录这个节点的中心点作为连线的起点。这里不能直接用节点的offsetLeft因为节点在列表里坐标是相对父容器的得换算到 canvas 的坐标系。// 获取某个节点相对于容器中心的坐标 function getNodeCenter($node) { var $wrap $(#matchWrap); var wrapOffset $wrap.offset(); // 容器在页面中的位置 var nodeOffset $node.offset(); // 节点在页面中的位置 return { x: nodeOffset.left - wrapOffset.left $node.outerWidth() / 2, y: nodeOffset.top - wrapOffset.top $node.outerHeight() / 2 }; }用offset()拿到的是相对文档的位置两个一减就得到相对容器的位置再加上自身宽高的一半就是中心点。这个函数在拖拽过程中会被反复调用用来实时更新临时线的终点。注意如果页面有滚动offset()依然能正确计算因为它基于文档坐标这也是用 jquery 比手写getBoundingClientRect省心的地方。3.3 拖拽过程临时线的绘制与重绘拖拽时每一帧都要清空画布、重画所有已确认的连线、再画一条从起点到当前手指位置的临时线。这就是 canvas 的经典“清空-重绘”循环。var ctx; // canvas 上下文 var startPoint null; // 当前拖拽起点 var currentPoint null; // 当前手指/鼠标位置 var matchedLines []; // 已确认的连线 [{from, to}] function render() { var canvas document.getElementById(lineCanvas); // 清空整个画布 ctx.clearRect(0, 0, canvas.width, canvas.height); // 先画已确认的连线 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函数是整个交互的核心任何状态变化都调它一次。已确认的线用绿色临时线用蓝色视觉上区分开。lineCap round让线头是圆的看起来更柔和。这里要注意clearRect的尺寸用的是canvas.width也就是实际像素但因为前面ctx.scale过其实用逻辑尺寸也行保持一致就好。3.4 事件绑定鼠标和触摸要一起处理移动端和桌面端的事件不一样桌面是mousedown/mousemove/mouseup移动端是touchstart/touchmove/touchend。这个项目用 jquery 的事件绑定把两套都接上。// 按下记录起点 $(#leftCol).on(mousedown touchstart, .node, function(e) { e.preventDefault(); // 阻止移动端默认滚动 var point getNodeCenter($(this)); startPoint point; currentPoint point; $(this).addClass(active); // 高亮选中节点 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; // 找到手指松开位置下的节点 var touch e.originalEvent.changedTouches ? e.originalEvent.changedTouches[0] : e; var $target $(document.elementFromPoint(touch.clientX, touch.clientY)).closest(.node); // 判定逻辑见下一节 tryMatch($target); startPoint null; currentPoint null; $(.node).removeClass(active); render(); });这里有几个关键点。e.preventDefault()在touchstart里必须调否则移动端拖动时会触发页面滚动体验很差。touchmove里取坐标要用e.originalEvent.touches[0]因为 jquery 包装过的事件对象里没有touches。松开时用document.elementFromPoint找到手指下方的元素再closest(.node)往上找节点这样即使用户松手时稍微偏一点只要还在节点范围内就能判定成功。3.5 配对判定data-id 相等才算连对判定逻辑本身很简单就是比对>function tryMatch($target) { // 松手不在节点上直接放弃 if (!$target.length) return; // 起点必须是左侧节点终点必须是右侧节点 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(); // 右侧顺序打乱避免答案位置固定 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(); }); // 提交收集当前配对 $(#submitBtn).on(click, function() { var result matchedLines.map(function(line) { // 这里需要根据坐标反查节点实际项目建议在 push 时直接存 id return { from: line.fromId, to: line.toId }; }); console.log(提交的配对, result); // 实际项目里这里发 ajax });注意上面代码里line.fromId是我建议你在push时就存进去的比事后根据坐标反查靠谱得多。改造tryMatch时把fromId和toId一起存进matchedLines提交时直接取用。5.3 支持一对多和多对一有些题目不是严格一对一比如一个概念可以对应多个例子。这时候判定逻辑要改不能只比对单个>// 节点上加 style="width:16px;margin-left:4px;vertical-align:text-bottom;cursor:text;" />
阅读完成 · 觉得有帮助?