简介这是一份面向前端初学者与互动教学开发者的实战示例用HTML5 Canvas配合JavaScript与jQuery实现可交互的在线连线题适用于教育测评、课堂练习或轻量游戏场景。压缩包共4个文件包含1个html页面、1个js逻辑脚本与2个css样式文件整体仅9KB结构精简便于直接运行与二次改造。代码覆盖Canvas绘制题目与连线路径、鼠标或触屏拖动事件处理、连线正确性判断与取值回调并区分纵向与横向两种题型模板同时兼顾提示反馈与重绘性能等交互细节。已有640人学习适合想掌握Canvas绘图、事件响应与连线算法并希望快速搭建可复用答题组件的开发者参考借鉴。1. 从一道“连不上”的题说起H5canvasjs 连线题到底怎么落地去年帮一个做在线教育的朋友看他们的练习模块有个需求卡了三天要在手机浏览器里做一道“把左边概念和右边解释连起来”的题。他们一开始用 DOM 加绝对定位画线结果手指一滑页面就跟着滚线还错位安卓上尤其玄学。后来换成 H5canvasjs 这套组合问题才收敛。所谓连线题本质就是在一个 canvas 画布上维护两组可点击的锚点用户从左侧某点按下、拖到右侧某点抬起中间实时画一条贝塞尔曲线最后校验配对是否正确。它适合谁做 K12 练习、语言配对、知识图谱小游戏的前端尤其是需要兼容移动端触摸、又不想引入重型图形库的场景。jquery js 在这里不是必须但如果你维护的是老项目用 jquery 处理事件委托和 DOM 初始化反而顺手。这篇就把我拆过的实现路径、参数和坑一次讲清。2. 画布与坐标系先把 canvas 的“像素账”算明白2.1 为什么不用 SVG 或纯 DOM 画线先讲选型。SVG 画线在桌面端很舒服每个line都是独立节点事件绑定直观。但连线题有个特点线是高频重绘的用户拖动时每帧都要更新路径。SVG 节点一多移动端重排开销就上来了尤其是低端安卓机。纯 DOM 加 border 或 transform 画斜线更麻烦角度计算和端点对齐容易翻车。canvas 的优势是“一块画布全量重绘”没有节点树负担配合requestAnimationFrame做拖拽反馈很稳。代价是事件命中要自己算不能靠 DOM 冒泡。常见做法是锚点用 DOM 元素渲染方便点击和样式连线用 canvas 覆盖层绘制。这样既保留 DOM 的可访问性又拿到 canvas 的绘制性能。2.2 处理 devicePixelRatio别让线发虚canvas 在高分屏上默认按 CSS 像素渲染会模糊。必须按设备像素比放大画布再用 CSS 缩回去。这是第一个必踩的坑。// 初始化 canvas处理高清屏 function initCanvas(canvas, cssWidth, cssHeight) { const dpr window.devicePixelRatio || 1; // 实际像素尺寸 CSS 尺寸 × dpr canvas.width cssWidth * dpr; canvas.height cssHeight * dpr; // CSS 尺寸保持不变视觉上不放大 canvas.style.width cssWidth px; canvas.style.height cssHeight px; const ctx canvas.getContext(2d); // 缩放坐标系之后所有绘制按 CSS 像素写即可 ctx.scale(dpr, dpr); return ctx; }逻辑说明canvas.width是画布真实像素style.width是显示尺寸。两者不一致时浏览器会拉伸导致模糊。ctx.scale(dpr, dpr)之后你写ctx.moveTo(100, 100)就代表 CSS 像素的 100不用到处乘 dpr。参数上cssWidth建议取容器clientWidth但要注意在 flex 布局里初始化时容器可能还没撑开最好在window.onload或ResizeObserver里做。如果页面会旋转屏幕记得重新初始化并重绘否则线会错位。2.3 锚点坐标怎么拿getBoundingClientRect 的坑锚点用 DOM 渲染时坐标要通过getBoundingClientRect()拿再减去 canvas 的偏移。注意getBoundingClientRect返回的是视口坐标如果 canvas 本身有滚动或 transform要一并换算。// 获取锚点相对 canvas 的中心坐标 function getAnchorPoint(el, canvas) { const rect el.getBoundingClientRect(); const canvasRect canvas.getBoundingClientRect(); return { x: rect.left - canvasRect.left rect.width / 2, y: rect.top - canvasRect.top rect.height / 2 }; }这里rect.width / 2是取元素中心连线题一般从锚点中心出发。如果锚点是圆形中心就是圆心。注意如果 canvas 或祖先元素有transform: scale()getBoundingClientRect返回的是缩放后的尺寸而 canvas 内部坐标系是缩放前的两者会打架。稳妥做法是避免在 canvas 祖先上做 scale或者用offsetLeft/offsetTop逐级累加但后者在复杂布局里更易错。我一般优先保证 canvas 不被缩放。3. 交互与绘制拖拽、贝塞尔曲线和命中判定3.1 触摸与鼠标事件统一pointer events 是首选移动端要同时处理 touch 和 mouse早期写法是两套监听代码翻倍还容易重复触发。现在直接用 Pointer Events一套搞定。jquery js 项目里可以用$(document).on(pointerdown, ...)但注意 jquery 对 pointer 事件的支持只是透传没有额外封装所以参数还是原生的。// 绑定拖拽开始 canvas.addEventListener(pointerdown, (e) { const point getCanvasPoint(e, canvas); const anchor hitTest(point, anchors); // 命中检测 if (anchor !anchor.locked) { dragging { from: anchor, to: point }; canvas.setPointerCapture(e.pointerId); // 捕获指针移出画布也能收到事件 } }); // 拖拽中 canvas.addEventListener(pointermove, (e) { if (!dragging) return; dragging.to getCanvasPoint(e, canvas); redraw(); // 重绘所有线和临时线 }); // 拖拽结束 canvas.addEventListener(pointerup, (e) { if (!dragging) return; const point getCanvasPoint(e, canvas); const target hitTest(point, anchors); if (target target.side ! dragging.from.side) { // 记录配对锁定锚点 pairs.push([dragging.from.id, target.id]); dragging.from.locked true; target.locked true; } dragging null; redraw(); });逻辑说明setPointerCapture是关键它保证手指滑出 canvas 后pointermove仍然派发到 canvas否则拖到边缘线就断了。getCanvasPoint要把 client 坐标转成 canvas 内部坐标同样要减 canvasRect 并考虑 dpr 缩放如果 ctx 已 scale则直接用 CSS 像素即可。命中检测hitTest用距离判断锚点半径建议不小于 44px这是移动端可点击区域的经验值。3.2 贝塞尔曲线怎么画才自然直线连接太生硬常见做法是用三次贝塞尔曲线控制点水平偏移。偏移量取两点水平距离的一半曲线会呈现自然的“S”形。// 绘制一条从 p1 到 p2 的连线 function drawLink(ctx, p1, p2, color) { const dx Math.abs(p2.x - p1.x) * 0.5; // 控制点水平偏移 ctx.beginPath(); ctx.moveTo(p1.x, p1.y); ctx.bezierCurveTo( p1.x dx, p1.y, // 控制点1 p2.x - dx, p2.y, // 控制点2 p2.x, p2.y ); ctx.strokeStyle color; ctx.lineWidth 3; ctx.lineCap round; ctx.stroke(); }参数说明dx取水平距离一半是通用做法如果两点水平距离很近曲线会接近直线也合理。lineWidth在移动端建议 3 到 4太细看不清。lineCap设 round 让线头圆润。如果要做“已连对”和“连错”的颜色区分在redraw里根据配对状态传不同 color。注意每次重绘要ctx.clearRect清空整个画布但清空范围要按 CSS 像素算因为坐标系已经 scale 过。3.3 重绘策略全量重绘 分层连线题的重绘频率高但内容不复杂。我一般用单 canvas 全量重绘清空、画所有已确认的线、画临时拖拽线、画锚点高亮。如果锚点也是 canvas 画的那 DOM 层就只剩容器。全量重绘在几十条线以内毫无压力。若线特别多比如上百条可以分两层 canvas底层画静态线顶层画临时线减少重绘量。但多数练习场景没必要。requestAnimationFrame节流 pointermove避免每像素都重绘。let rafId null; function requestRedraw() { if (rafId) return; rafId requestAnimationFrame(() { redraw(); rafId null; }); }这样在 pointermove 里调requestRedraw一帧最多重绘一次。4. 避坑与排查连线题最容易翻车的五个地方4.1 现象安卓上拖拽时页面跟着滚动原因pointermove 默认会触发页面滚动尤其是 canvas 没有阻止默认行为。解决在 canvas 的touchstart或pointerdown里调用e.preventDefault()同时 CSS 加touch-action: none;。注意touch-action要加在 canvas 上不是 body。如果只加 preventDefault 不加 touch-action部分浏览器仍会滚动。4.2 现象线画出来了但位置整体偏移原因canvas 的 CSS 尺寸和实际像素尺寸不一致或者getBoundingClientRect没减 canvas 偏移。解决统一用getCanvasPoint换算确保 canvas 没有 margin 导致的额外偏移。如果 canvas 在滚动容器里getBoundingClientRect会随滚动变化每次事件里重新取不要缓存。4.3 现象快速拖拽时线断掉或跳变原因pointermove 事件频率高于重绘频率或者没有用setPointerCapture。解决用setPointerCapture捕获指针并用requestAnimationFrame节流重绘。另外pointermove里不要做复杂计算只更新坐标和请求重绘。4.4 现象连对后锚点还能再次被拖动原因没有锁定已配对的锚点。解决在配对成功后给锚点加locked标记hitTest时跳过 locked 的锚点。如果允许取消连线就加一个点击已连线区域删除配对的逻辑但要注意和拖拽开始的事件区分。4.5 现象iOS Safari 上 canvas 模糊或线有锯齿原因devicePixelRatio 处理不当或者ctx.scale后没有重新设置 lineWidth。解决按 2.2 的方式初始化并且每次重绘前ctx.setTransform(dpr, 0, 0, dpr, 0, 0)重置变换避免 scale 累加。如果线仍有锯齿可以开启ctx.imageSmoothingEnabled true但贝塞尔曲线本身是矢量绘制锯齿主要来自像素对齐确保坐标取整到 0.5 像素可缓解。5. 进阶技巧把连线题做成可配置、可校验的组件5.1 数据结构设计锚点、配对、状态分离一个可复用的连线题组件数据层应该和渲染层分开。我一般用这样的结构const config { left: [ { id: l1, text: 概念A }, { id: l2, text: 概念B } ], right: [ { id: r1, text: 解释A }, { id: r2, text: 解释B } ], answer: { l1: r1, l2: r2 } // 正确答案映射 };渲染时根据 left/right 生成 DOM 锚点canvas 只负责画线。校验时拿用户配对和 answer 比对。这样换题目只改 config不动逻辑。jquery js 项目里可以用$.each遍历生成但建议生成后用原生事件绑定避免 jquery 事件在 canvas 上的兼容问题。5.2 校验与反馈即时反馈 vs 提交后反馈即时反馈是连对一条就变绿连错变红并回弹。提交后反馈是全部连完再统一判。即时反馈体验好但实现上要在 pointerup 里判断对错错的线要能撤销。我一般用即时反馈连对锁定并画绿线连错画红线并 300ms 后清除锚点解锁。这样用户能立刻知道对错不用等提交。注意红线清除要用setTimeout配合重绘别直接改数据不重绘。5.3 响应式与重绘窗口变化时重新计算锚点移动端旋转屏幕或窗口 resize 时锚点位置会变canvas 尺寸也要重设。用ResizeObserver监听容器重新初始化 canvas 并重绘所有线。注意重绘时要用最新的锚点坐标所以redraw函数里每次都重新调getAnchorPoint不要缓存坐标。const ro new ResizeObserver(() { const { clientWidth, clientHeight } container; ctx initCanvas(canvas, clientWidth, clientHeight); redraw(); }); ro.observe(container);参数说明clientWidth不含边框适合做 canvas 尺寸。如果容器高度由内容撑开要确保锚点渲染后容器高度稳定再初始化否则 canvas 高度为 0。可以在requestAnimationFrame里延迟一帧初始化。5.4 一个具体技巧用离屏 canvas 缓存静态线如果题目锚点很多已确认的线在每次重绘时都重画一遍有点浪费。可以建一个离屏 canvas把已确认的线画上去主 canvas 每次只清空临时层再 drawImage 离屏内容。这样拖拽时只重绘临时线性能更好。但多数场景线不超过 20 条直接全量重绘足够。这个技巧留给线特别多的知识图谱类连线题。从那以后我每次做 canvas 交互都强制先写initCanvas和getCanvasPoint两个函数把 dpr 和坐标换算一次性做对后面就很少再为模糊和偏移返工。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?