恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
H5 Canvas连线题开发指南:从坐标到触屏的完整实现
首页
资讯中心
/
H5 Canvas连线题开发指南:从坐标到触屏的完整实现
H5 Canvas连线题开发指南:从坐标到触屏的完整实现
发布时间:2026/10/14 21:59:41
简介H5canvasjs实现连线题是一份基于Canvas与JavaScript/jQuery的互动连线题前端源码主要面向在线教育、测评练习和趣味互动场景适合需要快速搭建可视化连线功能的初中级Web开发者也可作为前端课堂的实用练手项目。资源包约9KB共4个文件由2个CSS样式表、1个HTML页面和1个JS脚本构成其中CSS负责界面样式与两种连线模板的布局差异HTML承载页面骨架JS则集中处理拖拽、绘图和答案校验等交互逻辑。目前已有640人学习下载。虽然包体精简但功能覆盖了连线题核心流程支持纵向与横向两种题型模板通过鼠标拖拽实时绘制连线利用坐标计算完成点线命中判断并在连线完成后通过取值回调校验答案、返回结果该过程完整展示了Canvas绘图、jQuery事件绑定与原生JavaScript算法协同工作的典型思路。对于想通过短小案例切入Canvas实际应用、理解前端事件流或轻量组件开发的开发者这是一份紧凑、清晰且可直接运行修改的参考样例。1. 用 H5canvasjs 做连线题难的不是画线是“连得准”用 H5canvasjs 做连线题乍一听就是按下鼠标、拖一条线、松手判断三步搞定。真做起来你会发现画线是最简单的麻烦全在边角里坐标基准变了线就飘鼠标在画布外松开事件就丢触屏一拖页面跟着滚Retina 屏上线条还发虚。连线题是 H5 交互题里出现频率最高的题型小到课堂随测、大到职业评测都在用值得花半天到一天时间做扎实适合要在页面上快速嵌入答题组件的前端开发。下面按我搭这套组件的顺序把交互模型、绘制逻辑、判定阈值和踩过的坑一次讲清楚。2. 连线题的需求拆解交互模型、canvas 选型和画布初始化2.1 拖拽式还是点选式先定交互模型再写代码连线题看着是一个题型拆开看有两种完全不同的交互路径。第一种是拖拽式用户按住左侧某个选项拖出去画布上实时出现一条跟随手指的曲线松手落在右侧选项上完成连接。第二种是点选式用户点一下左侧选项它先高亮再点右侧选项两点之间补一条连线。两者在代码上的差别比想象中大事件模型和状态机都不一样动手前要先定下来。维度拖拽式点选式操作路径按下—拖动—松手点左—点右触屏体验手势直觉但和页面滚动冲突更稳误触少事件需求pointermove 持续跟踪只需要 click状态复杂度高随时中断低状态单一适用场景桌面端答题器、游戏化测验H5 长表单、移动端量表我在做模拟项目X的答题组件时默认用的是拖拽式但给配置项留了开关移动端页面自动切到点选式。这么设计不是偷懒而是同一套题型在不同终端上的手势成本不一样桌面端拖拽比点选快触屏上点选比拖拽稳。拖拽式的代码核心是一个“临时线”状态按下时创建移动时更新坐标松手时要么确认成正式连线要么丢弃。点选式则是维护一个“当前选中项”的引用第二次点击后直接生成正式连线。后面几章讲的逻辑都围绕拖拽式展开点选式只要把事件换掉、复用绘制和判定函数就行。2.2 为什么是 canvas 而不是 SVG状态管理比画线本身更重要连线题每条线本质上是一条二次贝塞尔曲线。用 SVG 画曲线很容易每个 path 是一个真实 DOM 节点自带鼠标事件删除时直接 remove 就行。但连线题的交互状态是动态的临时线随鼠标每秒变化几十次确认线又要随时改颜色、被撤销。把这些状态散落在几十个 SVG 节点上调试起来非常痛苦每次增删都要跟 DOM 节点打交道。canvas 的做法正相反它是“一块画布 一个重绘函数”。所有已经确认的连线存在一个数组里临时线单独存一个对象每次画面变化就清空画布、把数组里所有线重画一遍。连线题的线数量通常在 20 条以内全量重绘的性能毫无压力代码却简单得多这就是我选择 canvas 的核心理由。如果做的是静态成绩展示页线画完就定格SVG 完全够用但只要涉及动态交互、多次重绘canvas 的重绘模型优势非常明显。canvas 的代价是没有自带命中检测想知道鼠标点在哪个选项上得自己写距离计算。我的做法是不在 canvas 里反算图像命中左右选项仍然用 DOM 渲染canvas 用绝对定位铺在答题区上方当“线图层”。判定时拿一份选项的 getBoundingClientRect 做几何计算两边都不折腾。这样文字换行、键盘 Tab 操作都保持原生行为canvas 只负责画线。2.3 画布初始化和高分屏适配setTransform 是必修课画布初始化第一步是把 canvas 铺到答题区上尺寸跟着容器走。下面是初始化函数直接照抄能跑function setupCanvas(canvas, container) { const ctx canvas.getContext(2d) const rect container.getBoundingClientRect() // dpr 是设备像素比Retina 屏上通常为 2 const DPR window.devicePixelRatio || 1 // 物理像素画布的实际绘制分辨率 canvas.width Math.round(rect.width * DPR) canvas.height Math.round(rect.height * DPR) // CSS 像素布局时占的尺寸防止画布溢出容器 canvas.style.width rect.width px canvas.style.height rect.height px // 之后所有绘制都用逻辑坐标宽度 1px 就真的对应一个物理像素 ctx.setTransform(DPR, 0, 0, DPR, 0, 0) return ctx }这段代码里最关键的是最后一行。canvas 的 width 和 height 属性是物理像素样式里的宽高是 CSS 像素。不乘以 DPR 时Retina 屏会把一个逻辑像素映射成两个物理点浏览器再把画布拉伸回 CSS 尺寸结果就是所有线条发虚。setTransform 之后ctx 的坐标系统一改成逻辑像素画线宽度、圆弧半径都用和 DOM 一致的数值不用维护两套单位。容器尺寸变化时记得重新调用 setupCanvas把旧 canvas 的宽高重置一下再重设 transform。很多“线越画越粗”的怪现象都是 resize 后忘了重置 transform坐标系被叠加放大导致的。提示答题区容器不要用 border 边框画线后再测坐标canvas 的 0 点是画布左上角布局时让 canvas 的定位全部归零避免后续每个坐标都做额外减法。3. 核心交互实现数据结构、事件绑定、绘制与落点判定3.1 题目数据结构与 DOM 渲染把“题”和“图”分开存连线题必须把配置数据和运行时状态分开。配置数据是静态的左侧有哪些选项、右侧有哪些选项、正确答案是哪些配对运行时状态是用户画出来的线。我常用的结构是这样const QUIZ { left: [ { id: A, text: 字符串拼接 }, { id: B, text: 数组去重 }, { id: C, text: 事件冒泡 } ], right: [ { id: 1, text: sort Set }, { id: 2, text: 模板字符串 }, { id: 3, text: stopPropagation } ], answer: [ [A, 2], [B, 1], [C, 3] ] } // 用户在页面上画出的所有正式连线 let drawnLines [] // 正在拖动、还没松手的临时线 let pendingLine null渲染时把 left 和 right 分别渲染成两列 DOM 元素每个元素带>function renderOptions(container, items, side) { container.innerHTML items.map(item div classoption ${side}-option>canvas.addEventListener(pointerdown, onPointerDown) window.addEventListener(pointermove, onPointerMove) window.addEventListener(pointerup, onPointerUp) function posFromEvent(e) { // 兼容 mouse / touch / pen 三类事件源 const clientX e.clientX ?? e.changedTouches?.[0]?.clientX const clientY e.clientY ?? e.changedTouches?.[0]?.clientY const rect canvas.getBoundingClientRect() return { x: clientX - rect.left, y: clientY - rect.top } } function onPointerDown(e) { const leftItem findOptionByPoint(e, left) if (!leftItem) return pendingLine { fromId: leftItem.id, start: centerOf(leftItem.el) } canvas.setPointerCapture(e.pointerId) e.preventDefault() }posFromEvent 是整段代码里最容易被低估的函数。e.offsetX 拿的是事件 target 内部的偏移但当用户按住选项 DOM 时事件 target 是选项元素不是 canvasoffsetX 相对那个元素算画出的线会整体错位。用 clientX 减掉 canvas.getBoundingClientRect().left得到的是相对画布视口的坐标滚动和缩放后依然准确。setPointerCapture 这行值得多说一句按下后把指针捕获交给 canvas之后浏览器把所有 pointer 事件都发给 canvas即使手指滑出画布也能继续跟踪。配合 window 上的 up 监听双保险基本能覆盖所有奇奇怪怪的松手位置。需要兼容老浏览器的做法也不复杂posFromEvent 里已经做了 null 合并事件绑定改成 mousedown touchstart 两组即可其他逻辑完全复用。3.3 绘制曲线与状态刷新临时线、确认线、正确与错误配色连线的绘制用二次贝塞尔曲线起点是左侧选项中心终点是右侧选项中心控制点放在曲线中间。直线在选项文字多时很难看曲线更接近手写连线题的视觉。绘制函数复用度高核心代码如下function drawCurve(from, to, color, lineWidth 2) { ctx.beginPath() ctx.strokeStyle color ctx.lineWidth lineWidth ctx.lineCap round // 控制点取两点水平方向的中点 const controlX (from.x to.x) / 2 ctx.moveTo(from.x, from.y) ctx.quadraticCurveTo(controlX, from.y, to.x, to.y) ctx.stroke() } function render() { ctx.clearRect(0, 0, canvas.clientWidth, canvas.clientHeight) // 1. 先画已确认的线状态来自 drawnLines drawnLines.forEach(line { const from centerOf(optionById(line.fromId).el) const to centerOf(optionById(line.toId).el) drawCurve(from, to, line.correct ? #22c55e : #ef4444) }) // 2. 再画临时线正在拖动的用浅色 if (pendingLine pendingLine.current) { drawCurve(pendingLine.start, pendingLine.current, #94a3b8, 1.5) } }render 每次都被完整调用不做局部擦除。连线题场景下几十条线全部重画是毫秒级完成换来的是代码极好维护状态变了就调用 render()不可能出现半条残留线。这里有个小规则先画已确认线、再画临时线视觉上临时线永远压在上面体验更跟手。二次贝塞尔曲线的控制点直接取水平中点起点和终点高度差不大时曲线自然如果选项排布造成起点和终点垂直落差很大控制点可以挪到两者垂直方向的中点避免曲线拧成夸张的弓形。曲线方向是“从左往右”画的如果允许用户从右侧发起连线要把 drawCurve 的起终点交换逻辑统一否则逆向重绘时控制点方向会出错。我的做法是只允许左侧按下发起连线右侧只能作为终点。3.4 落点判定与自动吸附阈值参数真的不是玄学松手时要做一次命中检测判断当前鼠标位置落在哪个右侧选项上。最稳妥的判定不是矩形是否包含点而是中心点距离处理靠近选项边缘的松手动作时矩形判定太严格。我用的函数如下function getDropTarget(pos) { let best null let bestDist Infinity document.querySelectorAll(.right-option).forEach(el { const rect el.getBoundingClientRect() const cx rect.left rect.width / 2 const cy rect.top rect.height / 2 const dist Math.hypot(pos.x - cx, pos.y - cy) // 阈值取选项高度的 0.6 倍距离最近者优先 if (dist rect.height * 0.6 dist bestDist) { best el bestDist dist } }) return best }0.6 倍高度这个系数是我在真实页面上调出来的不是凭空取的。小于 0.5用户拖到选项边缘松手时判定失败体验很差大于 0.8相邻两个选项很近时会吸附错。如果一列排了 4 个以上选项、间距变窄要把系数降到 0.5宁可严一点也不能吸错。判定完成后还要检查这个终点是否已经有连线有则先删除旧的再连新的避免一个选项接多条线。注意命中检测的对象是选项 DOM位置要实时从 getBoundingClientRect 读取不能用页面初始化时缓存的位置。浏览器窗口变窄、字体缩放都可能改变选项位置缓存坐标会让命中区和实际显示错位。4. 连线题常见问题与排查坐标漂移、事件丢失和重绘残留4.1 页面滚动后连线整体偏移现象页面没有滚动时一切正常一滚动再拖线线的起点落在选项图标上方一大截整个连线像被平移了。原因代码里用了 e.offsetX / e.offsetY 计算位置。offsetX 相对的是事件 target 的 padding 边滚动后 canvas 的实际显示位置变化但 offset 值没跟着变坐标自然就飘了。解决所有坐标统一改用 e.clientX - canvas.getBoundingClientRect().left。getBoundingClientRect 返回的是相对视口的实时位置滚动、缩放都跟着走。排查时可以直接在 onPointerMove 里打印 getBoundingClientRect().left 对比滚动前后的差值差距出现基本就是坐标基准不一致。4.2 鼠标在画布外松开连线没触发现象按下选项后拖得动作太大鼠标出了 canvas 再松开线消失了也没完成连接。原因pointermove 和 pointerup 都绑在 canvas 元素上。鼠标一旦移出 canvas浏览器就不再向 canvas 派发这两个事件松手信息从此丢失。解决把 move 和 up 绑定到 window按下时建立跟踪松手时统一收口。改完后还要注意一个连带问题window 上的 up 事件会把“点击空白处取消”也实现掉——只要 pendingLine 存在且落点不在任何选项上就清理 pendingLine相当于给用户一个后悔药。4.3 Retina 屏上线模糊粗细不均匀现象同一套代码在自己电脑上线条清晰换到 Retina 屏发现线发虚、宽度明显不一致文字轮廓也像被磨掉一层。原因canvas.width 设置的是逻辑像素没乘以 devicePixelRatio。Retina 屏的实际物理像素是逻辑像素的两倍画布被拉伸放大边缘锯齿和模糊就出来了。解决初始化画布时 canvas.width cssWidth * devicePixelRatio然后立即 ctx.setTransform(devicePixelRatio, 0, 0, devicePixelRatio, 0, 0)后续绘制的坐标、线宽都按逻辑值写。窗口 resize、浏览器缩放、用户切换系统缩放比例后 devicePixelRatio 会变要重新跑初始化函数只改 canvas.width 不重置 transform 的话坐标系会叠加放大线宽越画越粗。4.4 触屏上拖线页面跟着滚动现象手机上答题按住选项往右拖画面没出现线整页倒是滚下去了。原因触屏的 touchmove 默认用于页面滚动拖线手势和滚动手势被浏览器优先判给了滚动页面开始滚pointermove 就被打断。解决给 canvas 加一行 CSStouch-action: none。这个属性告诉浏览器这个区域的手势交给页面自身处理不要触发默认滚动。然后按 3.2 节的 PointerEvent 逻辑touch 手势会自动走 pointerdown/move/up代码不用单独为触屏写一套。注意这个方案的前提是答题区本身不参与页面滚动如果整个题目就在一个可滚动长页面里参考 5.2 节的折中方案。4.5 撤销后残留半条线重绘不干净现象点掉某条已连接的线后画布上还留着一段弧线或颜色残影越撤销越花。原因用的是局部清除策略只 clearRect 了线的 bounding box而二次贝塞尔曲线的实际范围会超出简单外接矩形或者 clearRect 时传的宽高是旧画布尺寸新尺寸下清除范围不够。解决每次 render 无条件 clearRect(0, 0, canvas.clientWidth, canvas.clientHeight) 全画布清空然后完整重绘 drawnLines。连线条数少全量重绘没有任何性能压力换来的是永远一致的重绘结果。撤销操作要把 pendingLine 一起置空防止取消后残留的临时线又出现在下一次重绘里。需要更精细的撤销命中时可以在松手事件里加一个“是否点到线”的判断把鼠标位置与每条线上中下三个采样点的距离都算一遍小于 6px 视为命中。5. 连线题进阶动画反馈、触屏优化和数据驱动批量出题5.1 正确连线时的“生长”动画连对一条线时让线条从起点向终点“生长”出来反馈感比静态变色强很多。实现方式是记录动画开始时间在 requestAnimationFrame 循环里按进度 t 截取曲线的一部分function animateLine(line, duration 300) { const startTime performance.now() const from centerOf(optionById(line.fromId).el) const to centerOf(optionById(line.toId).el) function step(now) { const t Math.min((now - startTime) / duration, 1) // 按 t 插值出当前终点控制点跟着比例走过去 const cur { x: from.x (to.x - from.x) * t, y: from.y (to.y - from.y) * t } ctx.clearRect(0, 0, canvas.clientWidth, canvas.clientHeight) drawnLines.forEach(l { const f centerOf(optionById(l.fromId).el) const t2 centerOf(optionById(l.toId).el) drawCurve(f, t2, l.correct ? #22c55e : #ef4444) }) drawCurve(from, cur, #22c55e, 2) if (t 1) requestAnimationFrame(step) } requestAnimationFrame(step) }动画只在画布层做drawnLines 数组不动动画结束后下一次正常 render 会接管画面不会出现状态不同步。duration 到 300 毫秒比较合适再短没有反馈感再长用户会觉得拖沓。5.2 触屏设备上让“滚动”和“拖线”共存当答题区在一个可滚动的长页面里时不能无脑 touch-action: none否则整块区域滚不动。常见做法是区分区域只有选项附近的一段带状区域允许拖线其余地方恢复滚动。用几何判定按下时如果坐标落在左列选项上下各 40px 的范围内就动态给 canvas 加上 touch-action: none否则不加。手指在选项上按住拖线能走手指从选项之间空白处按下页面照常滚两个手势互不抢断。5.3 数据驱动的批量出题一份配置换一套测验连线题做成组件后出题不应该靠手写 DOM而是统一走数据。维护一个题库数组每道题复用前面 QUIZ 的结构渲染函数和判定函数完全通用。右侧选项顺序在初始化时打乱answer 数组始终指向原始 id判分时循环比对映射关系即可。整个流程整合下来新增一道题只需要配 six 个 id 和一组 answer页面结构和逻辑代码一行都不用改。连线题做到这个程度我自己最深的体会是越像“画条线而已”的功能越要在坐标和事件上较真。很多翻车现场都不是逻辑复杂而是坐标系没统一、事件绑错了对象。希望你做完这套之后能把时间花在题目本身的交互设计上而不是救火坐标偏移。希望帮到你。本文还有配套的精品资源点击获取