☰
canvas实现H5连线题:高清适配、贝塞尔曲线与拖拽状态机实战
2026/10/14 17:38:48 网站建设 项目流程

简介:基于H5画布与JavaScript、jQuery实现连线题网页应用,面向前端初学者、教育游戏开发者,也适合在教学、测试或互动场景中需要在线题型的用户。整个资源压缩后约9KB,解压后共4个文件:两个CSS样式表负责视觉与布局,一个HTML文件承载页面结构,一个JavaScript文件处理交互逻辑,结构小巧且职责分离,便于阅读、修改与复用。项目内置纵向题连线与横向题连线两种模板,覆盖画布绘图、鼠标拖动与释放事件、坐标距离判断、实时更新连线路径,以及连线完成后的取值回调验证机制,逻辑完整,可直接运行体验并嵌入到教育类项目中。当前已有640人学习下载,对于想通过一个短小而完整的项目串联画布交互绘图、页面事件处理与简单算法设计的开发者来说,是一份不错的实践蓝本。

1. 连线题不只是一条线:canvas 方案为什么比 DOM 更稳

连线题在 H5 交互题里看着最简单——左一列选项,右一列选项,用户按住一端拖到另一端,松手画出一条线。但真正把它做成不发虚、不卡状态、能上判题的版本,难点根本不在“画线”,而在坐标系换算和交互状态管理:坐标偏一个像素,用户就点不中选项;mouseup 少绑一层,拖拽状态就永远卡死。用纯 DOM 去画这种动态连线,线一多就会出现布局抖动和样式走样,canvas + js 才是匹配题场景里更稳的方案。这套实现适合在线题库、课堂互动页和笔试系统,凡是需要在网页里做连线匹配的开发者,直接照这套结构改就能落地。

2. canvas 初始化与坐标体系:DPR 高清适配与三层布局

在写任何绘制代码之前,先把页面结构定住。连线题最常见的错误是上来就画线,画着画着发现选项移动了一格,线上所有锚点都歪了。下面这套三层结构把选项和画线层彻底解耦,后面所有逻辑都建立在这上面。

2.1 三层布局:选项层、连线层与事件层

页面结构只有左列、右列、canvas 三块,canvas 绝对定位盖在容器最上层。为什么不用一堆 div 去模拟连线?因为连线组合是任意的,跨元素坐标换算、旋转、层级遮挡,实现起来比 canvas 重得多;canvas 的 2D 上下文天生适合这种高频重绘型交互。

<style> .stage { position: relative; width: 720px; height: 360px; margin: 0 auto; } .stage canvas { position: absolute; left: 0; top: 0; width: 100%; height: 100%; z-index: 2; } .left-col, .right-col { position: absolute; top: 20px; display: flex; flex-direction: column; gap: 30px; z-index: 1; } .left-col { left: 30px; } .right-col { right: 30px; } .option-item { width: 180px; height: 48px; line-height: 48px; text-align: center; border: 1px solid #ccc; border-radius: 8px; background: #fff; cursor: pointer; } .option-item.active { border-color: #4a90d9; box-shadow: 0 0 6px rgba(74,144,217,.4); } </style> <div class="stage" id="stage"> <div class="left-col"> <div class="option-item">function resizeCanvas() { var stage = $('#stage'); var canvas = document.getElementById('lineCanvas'); var dpr = window.devicePixelRatio || 1; var rect = stage[0].getBoundingClientRect(); canvas.style.width = rect.width + 'px'; canvas.style.height = rect.height + 'px'; canvas.width = Math.round(rect.width * dpr); canvas.height = Math.round(rect.height * dpr); var ctx = canvas.getContext('2d'); ctx.setTransform(dpr, 0, 0, dpr, 0, 0); }

setTransform(dpr, 0, 0, dpr, 0, 0) 的作用是把坐标系原点留在左上角,同时把 1 个逻辑单位拉长成 dpr 个物理像素。做完这一步,ctx 里所有坐标、半径、线宽都可以按 CSS 像素直接写,不用再操心物理像素。如果跳过这步,后面每个绘制函数里都要手动乘 dpr,代码到处是魔法数字,维护成本很高。

resizeCanvas 在页面加载时调用一次,window resize 时再调用一次。注意一个细节:canvas.width 只要被赋值,画布内容和上下文状态都会被清空,所以 resize 之后必须把所有已画好的线重新绘制一遍。我一般会在 resizeCanvas 末尾调一次 redraw(),让状态保持一致,这个习惯后面能省不少事。

3. 绘制那根线:贝塞尔曲线参数、端点锚点与全量重绘

结构定了,坐标清了,开始画线。连线用什么线形是有讲究的——直线最直接但视觉很生硬,而且左右列间距大时直线会横穿中间区域,干扰阅读。三阶贝塞尔曲线是交互题里比较耐看的默认方案。

3.1 三阶贝塞尔曲线:控制点偏移与最小弧度兜底

最简单的连线是 moveTo + lineTo 一条直线,上线后视觉很“愣”。改成三阶贝塞尔曲线后,线从起点水平甩出去,再水平收回到终点,观感柔和很多,这个方案在多数 H5 匹配题里都是默认做法。

function getOptionCenter($el) { var elOff = $el.offset(); var stageOff = $('#stage').offset(); return { x: elOff.left - stageOff.left + $el.outerWidth() / 2, y: elOff.top - stageOff.top + $el.outerHeight() / 2 }; } function getLinePoints($start, $end) { var p0 = getOptionCenter($start); var p1 = getOptionCenter($end); var dx = p1.x - p0.x; var offset = Math.abs(dx) * 0.5; if (offset < 40) offset = 40; // 弧线最小弯曲兜底 var c1 = { x: p0.x + offset, y: p0.y }; var c2 = { x: p1.x - offset, y: p1.y }; return { p0: p0, c1: c1, c2: c2, p1: p1 }; } function drawLine(ctx, line) { var pts = getLinePoints(line.startItem, line.endItem); ctx.beginPath(); ctx.moveTo(pts.p0.x, pts.p0.y); ctx.bezierCurveTo(pts.c1.x, pts.c1.y, pts.c2.x, pts.c2.y, pts.p1.x, pts.p1.y); ctx.lineWidth = 2.5; ctx.strokeStyle = line.color || '#4a90d9'; ctx.stroke(); drawAnchor(ctx, pts.p0.x, pts.p0.y, 6, '#4a90d9'); drawAnchor(ctx, pts.p1.x, pts.p1.y, 6, '#4a90d9'); }

控制点的选取规则是:c1 在起点右侧、c2 在终点左侧,水平偏移量取两点水平距离的一半。之所以用 |dx| * 0.5 而不是固定值,是因为左右列距离会随容器宽度变化,写死的话小屏上弧线可能挤成一团、大屏上又不够弯。下限 40px 是经验值:当左右选项几乎处于同一竖线时,dx 很小,如果控制点不往外推,曲线会退化到接近直线,看不出连接关系。

getOptionCenter 里用的是 jQuery 的 offset(),返回的是相对 document 的坐标,必须减去 stage 的 offset 才是 canvas 局部坐标。这里是最容易踩坑的位置,后面第 5 章会展开说。

3.2 锚点圆与全量重绘:为什么不在 mousemove 里做局部擦除

canvas 是即时模式绘制,每画一笔都会直接写进位图,不清除就会叠加。拖拽过程中如果不做清理,新线会叠着旧线越来越花。所以每次 mousemove 触发重绘时,第一件事是 clearRect 清空整个绘制区域,再把所有已确认的连线重新画一遍,最后画当前拖拽中的临时线。

function drawAnchor(ctx, x, y, r, color) { ctx.beginPath(); ctx.arc(x, y, r, 0, Math.PI * 2); ctx.fillStyle = color; ctx.fill(); ctx.lineWidth = 1.5; ctx.strokeStyle = '#fff'; ctx.stroke(); } function redraw() { var ctx = canvas.getContext('2d'); ctx.clearRect(0, 0, stageWidth, stageHeight); lineArr.forEach(function(line) { drawLine(ctx, line); }); if (state.status === 'dragging') { drawTempLine(ctx); } }

这里 stageWidth 和 stageHeight 建议在 resizeCanvas 里缓存成模块变量,不要在 redraw 里每次再用选择器查询。mousemove 高频触发时,每帧多一次 jQuery 选择器解析也是不必要的开销。

为什么选全量重绘而不是局部擦除?局部擦除要先算贝塞尔曲线的包围盒,还要处理弧形凸出部分,边界差一个像素就会残留描边残影,排错成本很高。720×360 的画布,全量重绘一次也就几毫秒,连线上限几十条的场景完全无所谓。还有一点:ctx.save() 和 ctx.restore() 尽量少包在绘制循环里,save 本身不重,但容易忘记 restore,状态一乱后面所有线条样式都会跟着崩。

4. 拖拽交互与状态机:命中检测、状态流转和线数据模型

画布能画了,接着补交互。连线拖拽看着只有“按下、拖动、松开”三步,但状态没管好就会出现“没按左键也能画线”“拖出画布再松开状态卡死”这类问题。用一个显式状态对象管理,比散落一堆布尔变量可靠得多。

4.1 拖拽状态机:从 idle 到 dragging 的流转与重置

整个拖拽过程只有 idle 和 dragging 两个主状态,但状态之间的“出口”必须堵死。下面这张表是完整的状态流转逻辑。

状态进入条件退出条件
idle初始状态,或 mouseup 重置后mousedown 命中选项起点后进入 dragging
dragging按下左键且已记录 startItemmouseup 后回到 idle;若命中有效终点则写入一条线
var state = { status: 'idle', // idle | dragging startItem: null, // 起点 DOM 元素 startPos: null, // 起点坐标 currentPos: null // 临时线的终点坐标 }; var lineArr = []; $('#lineCanvas').on('mousedown', function(e) { var pos = getCanvasPos(e); if (state.status !== 'idle') return; var $el = hitOption(pos); if (!$el) return; removeLinesFrom($el); // 起点去重:先删旧线再画新线 state.status = 'dragging'; state.startItem = $el; state.startPos = getOptionCenter($el); state.currentPos = pos; redraw(); }); $(document).on('mousemove', function(e) { if (state.status !== 'dragging') return; state.currentPos = getCanvasPos(e); redraw(); }); $(document).on('mouseup', function(e) { if (state.status !== 'dragging') return; var pos = getCanvasPos(e); var $end = hitOption(pos); if ($end && $end.data('id') !== state.startItem.data('id')) { lineArr.push({ startId: state.startItem.data('id'), endId: $end.data('id'), startItem: state.startItem, endItem: $end }); } state.status = 'idle'; state.startItem = null; state.startPos = null; state.currentPos = null; redraw(); });

关键细节在事件绑定位置:mousedown 绑在 canvas 上,mousemove 和 mouseup 绑在 document 上。原因是用户拖拽过程中鼠标很容易移出 canvas 边界,如果 mouseup 只绑 canvas,在画布外松开鼠标时事件丢失,状态永远停在 dragging,下一次点击就会画出一条“无头线”。

mouseup 里必须把 startItem、startPos、currentPos 全部重置,否则下一次 mousedown 会读到上一次的脏数据。我见过不少线上版本就是少重置了 startPos,导致第二根线的起点比实际位置偏了一截。

4.2 命中检测与删除:矩形容错与点到线段距离

选项命中不用 isPointInRect,因为浏览器内置的矩形判断太严格。用户点选项边框、甚至边框外两三个像素,都倾向认为自己是在点这个选项。常见做法是用中心点加宽高容错值,做成一个宽松的矩形命中区。

function hitOption(pos) { var hit = null; $('.option-item').each(function() { var $el = $(this); var center = getOptionCenter($el); if (Math.abs(pos.x - center.x) < 70 && Math.abs(pos.y - center.y) < 24) { hit = $el; return false; } }); return hit; }

半宽 70、半高 24 对应的是 180×48 的选项,容错余量约三分之二区域。实际可以按视觉密度调:选项之间间距大就放宽,间距小就要收紧,否则会误命中相邻选项。也有用圆形判定(半径 20 左右)的写法,但矩形判定对长方形选项更贴合手型,我一般用矩形。

用户连线连错了怎么办?产品上必须有删除机制,否则只能刷新页面重来。判断鼠标是否点中线,需要“点到线段的最短距离”:

function distToSegment(point, p0, p1) { var dx = p1.x - p0.x; var dy = p1.y - p0.y; var lenSq = dx * dx + dy * dy; if (lenSq === 0) return Math.hypot(point.x - p0.x, point.y - p0.y); var t = ((point.x - p0.x) * dx + (point.y - p0.y) * dy) / lenSq; t = Math.max(0, Math.min(1, t)); var projX = p0.x + t * dx; var projY = p0.y + t * dy; return Math.hypot(point.x - projX, point.y - projY); }

标准做法是把鼠标点投影到直线上,再把投影点限制在线段范围内,算两点距离。这里的距离阈值设 10px 左右比较合适,太近用户点不中,太远会误删旁边的线。贝塞尔曲线实际上不是直线,但我们的曲线弧度不大,用直线近似完全够用。严格做法是把曲线采样成几十个点再逐段判断,性能开销大,收益几乎为零。

删除交互的完整逻辑是:mousedown 时先遍历 lineArr,用 distToSegment 判断鼠标是否命中某条线,命中就删除并重绘,没有命中才进入正常的起笔流程。这样用户单击线就可以移除,不需要额外的“删除模式”按钮。

5. 连线题避坑记录:坐标偏移、事件穿透与触摸屏失灵

这章的坑不是猜的,是实践里逐条排出来的。每一条都具备“现象奇怪、原因隐蔽、解决简单”三个特点,踩过一次就印象深刻。

5.1 高清屏画线发糊:DPR 没乘进画布宽高

现象:开发机上线条清晰,推到高分屏笔记本或 Retina 屏,线条边缘全是锯齿,锚点圆点发虚,整体感觉像马赛克。

原因:canvas.width 只设了 CSS 像素值。浏览器把 720 像素的位图拉伸到 1440 物理像素显示,每个逻辑像素被放大两倍,边缘抗锯齿算法全部失效。

解决:用第 2 章的 resizeCanvas 思路,canvas.width / canvas.height 乘上 window.devicePixelRatio,再用 setTransform 把坐标系缩回去。有个细节:canvas.width 被赋值后画布内容会被清空,所以 resize 回调里必须触发一次 redraw()。从那以后我每次做 canvas 交互,第一步一定是先写 resizeCanvas,再写绘制函数。

5.2 选项点不准:坐标少了 stage 的偏移量

现象:鼠标点在选项正中央,命中检测却选中了旁边的项;或者线从选项下方 20px 的地方飞出去,起点和选项完全对不上。

原因:getOptionCenter 里用 $el.offset() 拿到的是相对 document 的坐标,而 canvas 坐标系原点在 canvas 左上角。如果 stage 不在页面顶部,或者页面有滚动条,两者之间存在一个固定偏移,不减去这个偏移,所有命中检测和锚点定位都会整体平移。

解决:统一减掉 stage 的 offset,就是 getOptionCenter 里 stageOff 那段逻辑。注意不要图省事用 position(),position() 返回的是相对最近定位父级的坐标,不是全局坐标,层级一变就错。还有一个小点:如果页面开启了缩放(比如浏览器 80% 显示比例),getBoundingClientRect 返回的尺寸也会按比例变化,这种情况下以 getBoundingClientRect 为准,不要用手写死数值。

5.3 鼠标在画布外松开:状态卡死

现象:按住起点拖到页面空白处松手,再回到 canvas 里,光标还是拖拽状态,点哪都能拉出一条没头没尾的线。

原因:mouseup 监听绑在了 canvas 上,鼠标在 canvas 外松开时事件没有触发,state.status 停在 dragging,startItem 也残留着上一次的值。

解决:mousedown 绑 canvas,mousemove / mouseup 绑 document。按下之后即使鼠标飞出 canvas 区域,document 上的 mouseup 也会捕捉到松开动作,保证状态一定重置。同时 mouseup 回调整体要包在 status 判断里,避免在没有拖拽时收到 mouseup 导致额外逻辑。

5.4 手机端没反应:touch 事件与 pointer 事件

现象:Pad 或手机上用手指触摸选项,画布上什么都不出现,PC 端一切正常。

原因:整套交互只绑定了 mouse 事件。移动端浏览器会派发 touch 事件,但有些环境不会把 touch 自动转成对应的 mouse 事件,两者之间存在时间差和位置偏差。

解决:用 PointerEvent 统一处理,pointerdown / pointermove / pointerup 同时兼容鼠标和触屏,代码改动最小。如果项目要兼容老版本浏览器,就单独补 touchstart / touchmove / touchend,并在 touchmove 里调用 e.preventDefault() 防止页面滚动。注意触摸事件的坐标要从 e.touches[0] 取,不是 e.clientX,这个细节很容易漏。我在分屏答题这类场景里会优先选 PointerEvent,新浏览器覆盖率足够高,代码也干净。

5.5 连线越画越多:起点去重与终点冲突处理

现象:同一道题里选项 A 可以先连到 B,再连到 C,A 同时伸出去两条线,判题逻辑直接乱掉,视觉上也分不清哪条是先画的、哪条是后画的。

原因:没有做线重。有的产品需求不允许起点连多条线,即便允许,也应该有明确的交互反馈,而不是无限制地堆线。

解决:进入拖拽前,先检查起点是否已有连线,有就从 lineArr 里删掉再创建新线。这样每次按下都是最新状态,用户不需要手动删线,体验也顺。

function removeLinesFrom($start) { var id = $start.data('id'); lineArr = lineArr.filter(function(line) { return line.startId !== id; }); }

同理,终点也可以做去重:如果终点已经被另一条线占用,一并清除那条线,避免一个终点被两三个起点瓜分。具体取舍看产品需求——有些练习题要求“每个选项只能用一次”,这种场景下终点去重就是必须的。

6. 进阶:线生长动画与自动判题反馈

基础功能跑通后,再加两个提升体验的小功能:连线生长动画和自动判题。前者让交互反馈更精致,后者让答案验证立刻可见,都是 H5 连线题里出现频率极高的需求。

6.1 线生长动画:贝塞尔采样与 requestAnimationFrame

整条线瞬间画出来没有反馈感,加一点生长动画会让页面精致很多。做法是不画完整曲线,而是用贝塞尔公式在 t=0 到 t=progress 之间取点连线。三阶贝塞尔公式直接写出来:

function getBezierPoint(t, p0, c1, c2, p1) { var u = 1 - t; return { x: u * u * u * p0.x + 3 * u * u * t * c1.x + 3 * u * t * t * c2.x + t * t * t * p1.x, y: u * u * u * p0.y + 3 * u * u * t * c1.y + 3 * u * t * t * c2.y + t * t * t * p1.y }; } function drawPartialLine(ctx, line, progress) { var pts = getLinePoints(line.startItem, line.endItem); var step = 30; ctx.beginPath(); for (var i = 0; i <= step; i++) { var t = (i / step) * progress; var point = getBezierPoint(t, pts.p0, pts.c1, pts.c2, pts.p1); if (i === 0) ctx.moveTo(point.x, point.y); else ctx.lineTo(point.x, point.y); } ctx.stroke(); }

step 是采样段数。30 段对 720px 级别的画布足够顺滑,再多就是浪费。progress=1 时画出来就是完整曲线,progress=0.5 时只画到曲线中点。动画时用 requestAnimationFrame 驱动进度从 0 走到 1,每帧先 redraw 全部旧线,再画当前生长中的线,避免互相干扰。

6.2 自动判题:data-pair 配对与红绿着色

判题最简单的方式是显示文本比对,但文本比对意味着题库不能乱序。用>function judgeAnswer() { var score = 0; var total = $('.left-col .option-item').length; lineArr.forEach(function(line) { var pairStart = line.startItem.data('pair'); var pairEnd = line.endItem.data('pair'); if (pairStart === pairEnd) { line.color = '#2ecc71'; line.result = 'right'; score++; } else { line.color = '#e74c3c'; line.result = 'wrong'; } }); redraw(); return { score: score, total: total }; }

正确线描绿色,错误线描红色,用户一眼就能看到问题出在哪对选项。做判断题的统计时,以 left 列选项数为准,因为每道题左右数量一致,但有些题型会故意加入干扰项,这时就要单独维护一个 total 字段,不能从 DOM 数量推断。完整示例页我已经把上述的去重、判题、动画全部整理好,放进了资源包里,拿回去直接改选项文本就能投入使用。

那次上线后,测试同学在 Retina 屏上截图反馈线条发糊,我排查了半天最后发现就是 canvas.width 没乘 dpr。从那以后我每次做连线题,第一步永远先把 resizeCanvas 和坐标换算这两块基础函数写好、调试通过,再开始画线——希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询