
简介H5canvasJS实现连线题的完整方案面向需要开发在线测验、教学练习或互动小游戏的前端开发者。方案利用Canvas绘制连线背景、选项与路径配合JavaScript/jQuery完成拖拽、释放、实时连线及结果判断并支持取值回调用于答案验证与反馈连线正确性判断涉及坐标系统与两点距离计算代码中对拖动事件和连线状态更新均有清晰处理。压缩包共4个文件包括1个HTML入口、2个CSS样式表和1个JS逻辑文件整体仅9KB代码结构简洁便于直接运行与二次修改。项目同时提供纵向与横向两种连线模板可快速适配不同题型布局兼顾鼠标与触屏事件、提示与错误反馈并考虑了减少重绘等基础性能优化。已有640人学习参考适合用于理解Canvas绘图、事件交互与简单算法设计的综合实践。1. H5canvasjs 实现连线题从画一条线到一套可判题的题型一个在线测评系统需要把选择题改成连线题第一版用 DOM 加 SVG 拼选项用 div 摆两列线用 svg 的 line 元素来画拖动时每条线都要跨元素改坐标选项一多、线一多页面明显掉帧移动端更是跟手都费劲。最后决定用 H5canvasjs 从零写连线题反而把问题一次说清了canvas 只负责画端点命中、线条吸附、已连校验全交给 JS 的数据结构管理。这套方案的核心是你得接受一个反直觉的事实——canvas 上根本没有按钮和点击事件用户到底是点中了哪个选项、要不要开始拉线、松手时线落没落到目标上全都要自己算。本文就沿着「画布初始化 → 交互链路 → 常见坑 → 判题数据 → 手感进阶」这条路把一套可以直接抄的连线题实现讲透适合做 H5 答题页、教育互动课件和各类测评小程序的开发者参考。2. canvas 画布初始化坐标换算与高分屏适配2.1 为什么连线题选 canvas 而不是 DOM 拼接连线题本质上是一个点对点的连接交互左侧一组选项、右侧一组选项用户从一端按下、拖到另一端松开系统记录这条线并在后续做判题。DOM 方案里选项用 div 排成两列线要么用 SVG 的 line 元素、要么用绝对定位的细长 div 旋转角度来假装直线拖动过程中需要不断更新线的坐标属性和长度属性每一条线都是独立的 DOM 节点。当题目数量小、固定不动的场景下DOM 拼一个连线题确实快三五条线看不出问题。但一旦到了移动端、题目数量到 8 到 10 组、用户快速连续拖拽的时候每次 mouse 移动都要改多个 DOM 属性浏览器布局和样式计算的开销会直接变成肉眼可见的掉帧。canvas 方案不同所有选项和线条都画在同一张画布上拖动时只需要清空画布、重绘一帧渲染开销集中且可控命中检测也不用管“哪个元素被点到”而是遍历保存好的选项矩形数组做数学判断。选型上还有一个容易被忽略的点DOM 的线跨元素放置在移动端 WebView 里容易出现 1px 的偏移错位canvas 是整块渲染坐标只要自己换算准确就不存在元素间偏移。下面这张表是我在两种方案反复横跳后留下的结论。维度DOM SVG 方案canvas 方案事件模型每个 div 天然可点击事件全在画布上命中靠自算拖动渲染每条线更新多次 DOM整帧重绘一次移动端适配元素偏移、滚动问题多集中处理坐标换算即可代码量前期少、后期补丁多前期多一些、结构稳定2.2 初始化画布高分屏三件套与坐标换算canvas 初始化最常见的翻车点是不管 devicePixelRatio。手机屏幕物理像素密度通常是逻辑像素的 2 到 3 倍如果你直接把 canvas 的 width 和 height 设置成 CSS 里看到的宽度画出来的线和文字在移动端就是糊的。常见的正确做法是把 CSS 尺寸和物理尺寸分开CSS 负责布局占位canvas 的 width 和 height 写物理像素再用 ctx.scale 把坐标系恢复成逻辑像素。canvas idlinkCanvas/canvas#linkCanvas { width: 100%; height: 360px; touch-action: none; display: block; }const canvas document.getElementById(linkCanvas) const ctx canvas.getContext(2d) const dpr window.devicePixelRatio || 1 const rect canvas.getBoundingClientRect() canvas.width rect.width * dpr canvas.height rect.height * dpr ctx.scale(dpr, dpr) const canvasWidth rect.width const canvasHeight rect.height function getPos(e) { const r canvas.getBoundingClientRect() const source e.touches ? e.touches[0] : e return { x: source.clientX - r.left, y: source.clientY - r.top } }逻辑说明canvas 的 width 属性是画布物理像素CSS 里的 width 决定它在页面里占据多大空间。如果不乘 dpr画布物理像素只有逻辑像素那么多浏览器会把这张小图拉伸到大尺寸结果就是发虚。ctx.scale(dpr, dpr) 之后后续所有绘图坐标依旧用 CSS 像素写不用在每一条 drawLine 里都除以 dpr。getPos 负责把鼠标或手指的屏幕坐标换算成画布坐标用的是 clientX 减 getBoundingClientRect 的 left而不是 pageX因为画布在视口里的位置不一定从 0 开始。参数说明dpr 在主流手机里常见值是 2 或 3部分折叠屏和旧安卓机是 1.5。这段初始化代码如果遇到画布尺寸变化例如屏幕旋转、容器宽度改变需要在 resize 事件里重新执行一遍否则画布内容会变形或模糊。2.3 选项布局用数据生成左右两列坐标canvas 里没有 DOM 节点可以绑定事件所以我从一开始就维护一个 hitAreas 数组每个选项的矩形区域都存进这个数组画布渲染和命中检测都读同一份数据。这样就不会出现“画出来的位置”和“能点中的位置”不一致的问题。const optionWidth 80 const optionHeight 44 const startY 28 const gap 18 const leftX 20 const hitAreas [] function buildOptions(leftList, rightList) { hitAreas.length 0 const rightX canvasWidth - optionWidth - 20 leftList.forEach((text, i) { hitAreas.push({ side: left, index: i, text, x: leftX, y: startY i * (optionHeight gap), w: optionWidth, h: optionHeight }) }) rightList.forEach((text, i) { hitAreas.push({ side: right, index: i, text, x: rightX, y: startY i * (optionHeight gap), w: optionWidth, h: optionHeight }) }) } function drawOptions() { hitAreas.forEach(area { ctx.fillStyle #ffffff ctx.strokeStyle #4a7dff ctx.lineWidth 1.5 ctx.fillRect(area.x, area.y, area.w, area.h) ctx.strokeRect(area.x, area.y, area.w, area.h) ctx.fillStyle #333333 ctx.textAlign center ctx.textBaseline middle ctx.fillText(area.text, area.x area.w / 2, area.y area.h / 2) }) }逻辑说明左侧选项的 x 固定右侧选项的 x 根据画布宽度反推保证都在可视范围内y 坐标按 index 累加optionHeight 加 gap 就是每个选项的垂直步长。hitAreas 里的 x、y、w、h 是后面命中检测的唯一依据绘制选项和判断点击都从这一个数组里取数据从源头上避免坐标维护两套的混乱。drawOptions 每次重绘时遍历 hitAreas画矩形、画边框、居中写文字。参数说明optionWidth 和 optionHeight 建议按实际文案长度调整文案超过 6 个中文字时宽度 80 可能不够可以放宽到 100 或 110。gap 在题目数量多时建议加大到 20 以上不然用户手指点选时容易误触相邻选项。startY 是顶部留白如果画布上方还要放题干这个值要相应加大。3. 连线交互链路命中检测、端点吸附与状态管理3.1 事件绑定把鼠标和触摸合成一套逻辑连线题的事件交互比普通按钮复杂在一个地方——拖动过程中鼠标可能移出画布再松开如果你把 mouseup 也绑定在 canvas 上就会遇到“线画一半拖拽状态卡死”的现象。常见做法是 mousedown 绑在 canvas 上mouseup 绑在 window 上这样无论用户在哪儿松开都能正常结束一次拖拽。let state idle // idle: 待命 | dragging: 拖拽中 let dragLine null let lines [] // 已完成的连线 { leftIndex, rightIndex } let dragStart null // 拖拽起点信息 canvas.addEventListener(mousedown, onDown) canvas.addEventListener(mousemove, onMove) window.addEventListener(mouseup, onUp) canvas.addEventListener(touchstart, onDown, { passive: false }) canvas.addEventListener(touchmove, onMove, { passive: false }) canvas.addEventListener(touchend, onUp) function onDown(e) { e.preventDefault() const pos getPos(e) const area hitTest(pos) if (!area) return // 起点已连接时先移除原连线实现“重连”而不是叠线 if (area.side left isLeftConnected(area.index)) { lines lines.filter(l l.leftIndex ! area.index) } if (area.side right isRightConnected(area.index)) { lines lines.filter(l l.rightIndex ! area.index) } dragStart { side: area.side, index: area.index, x: pos.x, y: pos.y } state dragging draw() }逻辑说明onDown 是整条交互链路的入口。先调用 getPos 做坐标换算再用 hitTest 判断是否点中了某个选项区域点中后判断该端点是否已经连线如果已经连了先把旧线从 lines 里移除再开始新的拖拽这就是“重连”而不是“叠加”的产品逻辑。触摸事件里指定 passive: false 是为了能在事件回调里调用 preventDefault阻止页面跟随手指滚动。参数说明{ passive: false }是触摸事件的一个易漏参数不写的话浏览器会默认 passive 为 truepreventDefault 调用无效。另外如果不需要同时支持手指和鼠标也可以直接用 pointer 事件合并但部分安卓 WebView 对 pointer 事件的支持有兼容问题我习惯用鼠标和触摸两套事件指向同一个处理函数兼容性更稳。3.2 命中检测与拖拽状态机命中检测这个单词听起来玄学实际就是一个遍历比较的数学问题遍历 hitAreas判断当前点的 x、y 是否落在某个矩形的范围内。唯一需要注意的是命中检测既要用在 mousedown 找起点也要用在 mouseup 找终点所以它必须和绘制选项共用同一份 hitAreas 数据坐标有任何偏差都会导致“画出来的框点不中”的问题。function hitTest(pos) { for (const area of hitAreas) { if ( pos.x area.x pos.x area.x area.w pos.y area.y pos.y area.y area.h ) { return area } } return null } function isLeftConnected(index) { return lines.some(l l.leftIndex index) } function isRightConnected(index) { return lines.some(l l.rightIndex index) }逻辑说明hitTest 遍历 hitAreas 数组返回第一个命中的区域用矩形范围做判断。isLeftConnected 和 isRightConnected 用来检查某个端点是否已经连过线它在 onDown 里已经用到除此之外在后续判题时也可以复用。这里返回的是区域对象里面带了 side 和 index所以调用方不需要再根据坐标反查它属于左侧还是右侧。用比较数字索引避免字符串和数字混用的隐式转换。状态机是这套交互的心智模型idle 表示等待用户按下dragging 表示已经按下并正在拖拽。只有 dragging 状态下 mousemove 才需要更新临时线只有 dragging 状态下 mouseup 才需要尝试落线。没有状态机就会出现鼠标在画布上晃动也在画线、松手后线残留的情况。这个状态机不需要写成一个类三个变量加 if 判断已经足够。3.3 动态画线、端点吸附与全量重绘画线本身没有秘密mousemove 的时候把鼠标当前位置记下来draw 函数里画出从起点到鼠标位置的线mouseup 的时候判断终点是否落在有效的右侧或左侧选项上落上了就写进 lines没落上就丢弃这次拖拽。这里说的端点吸附并不是真的要做一个复杂的捕捉算法因为 mouseup 时 hitTest 已经能判断终点是否落在选项矩形内本身就是一种吸附。function onMove(e) { if (state ! dragging) return e.preventDefault() const pos getPos(e) dragLine { startSide: dragStart.side, startIndex: dragStart.index, endX: pos.x, endY: pos.y } draw() } function onUp(e) { if (state ! dragging) return e.preventDefault() const pos getPos(e) const area hitTest(pos) if (area area.side ! dragStart.side) { if (area.side right) { lines.push({ leftIndex: dragStart.index, rightIndex: area.index }) } else { lines.push({ leftIndex: area.index, rightIndex: dragStart.index }) } } dragLine null dragStart null state idle draw() } function draw() { ctx.clearRect(0, 0, canvasWidth, canvasHeight) drawOptions() lines.forEach(item { const from getAreaBySideIndex(left, item.leftIndex) const to getAreaBySideIndex(right, item.rightIndex) drawLine(from, to) }) if (dragLine) { const from getAreaBySideIndex(dragLine.startSide, dragLine.startIndex) drawLineTo(from, dragLine.endX, dragLine.endY) } }逻辑说明onUp 是终点判定最关键的一步——它判断 area 存在并且 area.side 与拖拽起点 dragStart.side 不同也就是左侧只能连右侧、右侧只能连左侧。判断通过后把 leftIndex 和 rightIndex 存入 lines 数组顺序统一。onUp 里没有做额外的距离吸附判断因为 hitTest 找到的就是用户松手时所在的选项矩形如果希望用户松手时不需要精确落在矩形里只需要在 onUp 时把所有右侧或左侧端点遍历一遍找出距离最近且小于某个阈值的端点来落线这个阈值通常取 22 像素。draw 函数每次都是全量重绘clearRect 清空整张画布drawOptions 重画所有选项再画所有已连线最后画拖拽中的临时线。这里最反直觉的点在于canvas 没有“更新某一根线”的方法每次重绘整个画布是最简单且最不容易出错的方式。一帧里画 20 个矩形加 10 条线的性能完全没问题不需要做增量绘制优化。4. 连线题避坑指南五个真实踩坑记录4.1 坑一移动端线条偏移线画不到选项上做出来第一版在桌面浏览器里一切正常一放到手机上测试线条起点和手指位置差了一截而且越往下滑动画面偏移越明显。查了一圈发现原因是坐标换算混用了 pageY页面发生滚动后pageY 是相对文档顶端的坐标而 canvas 在视口内的位置是变化的直接用 pageY 减 canvas 的 top 自然对不上。解决方法是把 getPos 统一改成用 clientX 和 clientY再减 getBoundingClientRect 的 left 和 top。function getPos(e) { const r canvas.getBoundingClientRect() const source e.touches ? e.touches[0] : e return { x: source.clientX - r.left, y: source.clientY - r.top } }不要用 offsetX 和 offsetY这两个属性在 canvas 上会被 CSS 缩放影响还得自己再算 dpr属于给自己挖坑。触摸事件里一定要取 e.touches[0]因为 touches 是一个列表直接 e.touches.x 是 undefined。4.2 坑二画布内容模糊线条锯齿明显一开始没有处理 devicePixelRatiocanvas.width 直接写成 CSS 像素值在 dpr 为 3 的手机上画面边缘全是锯齿、文字起毛。原因是物理像素不够CSS 里画布占 360 像素宽物理像素也给了 360但屏幕实际有 1080 个物理像素浏览器只能拉伸放大自然模糊。解决方法是按第 2 章的初始化三件套做物理宽高乘 dpr再 ctx.scale(dpr, dpr)。有一点要留意如果画布尺寸在运行中变化比如横竖屏切换导致容器宽度变化需要在 resize 里重新初始化一次否则画布内容会再次变虚。4.3 坑三同一端点连出多条线判题数据错乱测试反馈说一个右侧选项可以连三四个左侧选项数据完全乱了。原因是 onDown 里只判断了是否命中选项没有检查这个端点是不是已经连过线用户从已连的端点再次按下拖拽系统没有先移除旧线而是继续往 lines 里 push。解决方法是加 isLeftConnected 和 isRightConnected 的判断已连端点重新按下时先移除旧线再启动新的拖拽。这样既实现了“重连”的交互也从数据结构上堵住了多对一的情况。4.4 坑四触摸拖动时页面跟着滚动移动端上手指在画布里拖动页面跟着滚动而且 touchmove 拿到的坐标还会因为滚动而跳动线画得七扭八歪。根因是浏览器默认手势把横向和纵向滑动都当成页面滚动。需要在画布上禁用默认手势CSS 加touch-action: nonetouchstart 和 touchmove 里调用 preventDefault。注意 preventDefault 只加在画布事件上不要给 document 全局加上副作用太大了。动画反馈和后续交互都要在 preventDefault 之后再做。4.5 坑五直线穿过选项视觉上误认为已连接左右端点中心直接拉直线线条必然穿过中间区域如果选项排得密线会压住选项文字。有两条路一是把选项行距加大给直线留出绕行空间适合简约风格二是改用贝塞尔曲线控制点偏移让线拱起来适合中等数量题目。直线方案实现简单但视觉上限明显曲线方案好看但要控制好控制点参数我在第 6 章专门给出了一组能直接用的控制点算法。5. 题目数据与自动判题让连线题真正可交付5.1 用索引对做答案而不是用坐标存储答案连线题如果只在画布上画线不给判题模块数据结构等于白做。判题的第一步是确定答案怎么存。千万不能把答案存成坐标因为不同手机屏幕宽度下同一题目的画布坐标完全不同存坐标就是存死数据正确的做法是把答案存成“左侧索引 右侧索引”的索引对和布局无关、和设备无关。const question { left: [春分, 夏至, 秋分, 冬至], right: [3月, 6月, 9月, 12月], answer: [[0, 0], [1, 1], [2, 2], [3, 3]] } // 渲染前打乱右侧顺序让每次刷题的选项位置不同 const rightOrder shuffle([0, 1, 2, 3]) const shuffledRight rightOrder.map(i question.right[i]) // 渲染时根据打乱后的顺序重新映射答案 const answerMap question.answer.map(([li, ri]) [li, rightOrder.indexOf(ri)]) buildOptions(question.left, shuffledRight) // 判题时使用 answerMap 而不是 question.answer逻辑说明answer 数组里每一项是两个索引[0, 0] 表示左侧第 0 个选项连右侧第 0 个选项。因为产品要求换题时选项顺序要打乱所以渲染时先用 shuffle 生成一个乱序再用 rightOrder.indexOf(ri) 把标准答案映射到当前显示顺序上。这一步漏掉的话画布上正确答案的位置和 answer 对应不上判题全错。shuffle 函数可以用经典的 Fisher-Yates这里不复述重点在于渲染顺序和答案映射必须走同一套 rightOrder。参数说明answer 的索引全部从 0 开始和 hitAreas 里的 index 保持一致避免出现“答案说的是第 1 个hitAreas 里对应第 0 个”的差一错误。打乱右侧顺序是常见的防作弊手段但如果产品要求固定顺序跳过 shuffle 直接按原顺序渲染即可。5.2 自动判题处理顺序无关与方向无关的两种误判连线题判题有一个隐藏业务陷阱用户把右侧第 0 个连到左侧第 3 个也可能从右侧往左侧拖拽导致 lines 数组里 leftIndex 和 rightIndex 的赋值顺序不稳定。如果判题时逐项比较 [leftIndex, rightIndex]用户只是反着拉一根线就会被误判为错。所以判题前必须做归一化再排序最后逐对比较。function judge(userLines, answerPairs) { const norm (pair) pair[0] pair[1] ? [pair[0], pair[1]] : [pair[1], pair[0]] const sortFn (a, b) a[0] - b[0] || a[1] - b[1] const user userLines.map(norm).sort(sortFn) const ans answerPairs.map(norm).sort(sortFn) let hit 0 ans.forEach(pair { if (user.some(u u[0] pair[0] u[1] pair[1])) { hit } }) return { hit, total: ans.length, pass: hit ans.length } }逻辑说明norm 函数把 [左, 右] 归一化保证无论用户从哪一侧开始拖拽存的索引对都是“小索引在前、大索引在后”sort 再对索引对排序避免用户连线的先后顺序影响比较结果。归一化之后用户怎么连、从哪个方向连都不影响判题准确率。pass 表示是否全部连对hit 可以拿来做部分得分比如每题对应 25 分hit 乘以 25 就是本次得分。5.3 只读回显与错题重做上线前还要做一个容易忽略的闭环结果页回显和错题重做。结果页只需要把用户的 lines 传入画布并画出来不响应任何鼠标触摸事件错题集则要允许用户清空旧线重做一遍。这两个需求由一个 readonly 参数就能打通。function init(question, { readonly false, initialLines [] } {}) { lines initialLines.slice() buildOptions(question.left, question.right) draw() if (readonly) return // 正常模式才绑定事件 canvas.addEventListener(mousedown, onDown) canvas.addEventListener(mousemove, onMove) window.addEventListener(mouseup, onUp) canvas.addEventListener(touchstart, onDown, { passive: false }) canvas.addEventListener(touchmove, onMove, { passive: false }) canvas.addEventListener(touchend, onUp) }逻辑说明readonly 为 true 时直接跳过事件绑定只调用 draw 渲染初始线条适合结果页和答案分析页。initialLines 是外部传入的用户作答记录配合第 5.2 节的 judge 函数就能在结果页画出用户自己的连线与正确答案的对比。错题重做则在按钮的 click 回调里清空 lines、重置 state、重新绑定事件即可不需要重建整个画布。6. 进阶技巧贝塞尔曲线、动画反馈与一套顺手的手感6.1 贝塞尔曲线让线绕行不压选项文字直线被选项压住的问题我最后的解法是给所有已连线和临时线都换成二次贝塞尔曲线控制点放在起终点之间的水平中线偏上位置这样线条会自然拱起来绕开中间区域的选项文字。控制点的 y 取值一般用起终点的 y 均值减去一个固定偏移12 到 16 像素的效果最自然偏移太小线条仍然是直的偏移太大曲线弧度又太夸张。function drawLineFromTo(from, to) { const midX (from.x to.x) / 2 const controlY Math.min(from.y, to.y) - 14 ctx.beginPath() ctx.moveTo(from.x, from.y) ctx.quadraticCurveTo(midX, controlY, to.x, to.y) ctx.strokeStyle #4a7dff ctx.lineWidth 2 ctx.stroke() }控制点取 midX 和 min(from.y, to.y) - 14保证曲线向左上拱起而不是向下方或中间乱拐视觉上更稳定。两个端点等高的场景下曲线依然成立属于闭着眼能用的参数组合。6.2 动画反馈与撤销习惯一根线连成功之后我习惯给这条线做一个透明度从 0.4 到 1 的短暂过渡让用户明确感受到“落线了”。实现方式并不复杂在 drawLine 里维护一个线创建时间时间戳画线时根据当前时间与创建时间的差值计算透明度200 毫秒内从浅到深渐变。这个细节对答题手感的提升比想象中大用户不会产生“到底连上没有”的疑惑。至于单条撤销我最后采用的是最简单的一种点击已连线的端点时移除该端点对应的线视觉上立即消失允许重新连接。前面第 3 章的 onDown 里已经实现了这个逻辑实际上不需要额外做撤销按钮端点点击即撤销种交互反而比按钮更符合直觉。另一种做法是在画布角落放“清除全部连线”按钮适合错题重做场景一行代码清空 lines 再 draw 即可。这套宏线题方案我先后改过几轮最后沉淀下来的交付检查清单是第一初始化代码必须包含 dpr 三件套否则拒绝发布第二getPos 一律用 clientX 减 getBoundingClientRect第三mouseup 一定挂在 window 上第四判题前先归一化再排序第五交付前至少在一台 dpr 为 3 的手机上完整做一遍连线、撤销、重连、判题的操作。这几个习惯帮我挡掉了很多在测试阶段才暴露的问题也省掉了后面反复找原因的后悔药。希望这份思路能帮到你把连线题稳稳落地成一套可交付的题型。本文还有配套的精品资源点击获取