H5+Canvas连线题实战:坐标、命中检测与重绘全解析

发布时间:2026/10/9 19:28:43

H5+Canvas连线题实战:坐标、命中检测与重绘全解析 简介这份资源面向前端初学者与需要实现互动题型的开发者提供一套基于HTML5 Canvas与JavaScript的连线题完整实现方案可用于在线教育、认知测试或轻量游戏场景。压缩包共4个文件包含1个HTML页面、1个JavaScript脚本和2个CSS样式表整体仅9KB结构精简便于快速阅读与二次改造。代码覆盖Canvas绘制题目与连线路径、jQuery处理选中与拖动事件、坐标计算与连线判定、取值回调验证答案以及纵向与横向两种题型模板的布局适配同时兼顾鼠标与触屏交互和基础性能优化。已有640人学习适合想掌握Canvas绘图、事件处理与交互算法并希望直接获得可运行示例的读者参考借鉴。1. 连线题在 H5 里到底难在哪一次把 canvas 坐标、命中与重绘讲透很多人第一次接到「H5canvasjs 实现连线题」这个需求时第一反应是「画两条线而已能有多难」。真上手才发现难点根本不在画线而在三件事一是 canvas 是位图没有 DOM 节点你没法给一条线绑 click二是移动端手指和鼠标事件模型不一样touch 坐标还要减去 canvas 的 boundingRect三是每次重绘都要清空整块画布稍不注意连线就闪、就丢、就连到别的题上。我见过一个模拟项目题干在左、选项在右用户拖到一半松手线停在半空判定直接算错——这就是没做「落点吸附」的典型翻车。这篇笔记就围绕这个标题把一套能直接抄进项目的连线题方案拆开坐标系怎么统一、命中检测怎么算、状态怎么存、重绘怎么不闪、判定怎么给分。适合正在做答题类 H5、互动课件、营销小游戏的初中级前端也适合想搞清楚 canvas 事件模型的熟手对照边界。读完你能自己搭出一个支持多题、多连线、可判分、能适配移动端的连线题组件。2. 先把坐标系和数据结构定死连线题的地基2.1 为什么必须先统一坐标系而不是直接画canvas 的绘制坐标是相对画布左上角的 CSS 像素但事件对象给的 clientX/clientY 是相对视口的。如果 canvas 有 padding、有缩放、被 flex 居中两者就对不上。常见做法是封装一个getPos(e)用getBoundingClientRect()把视口坐标换算成画布坐标再乘上canvas.width / rect.width处理高分屏缩放。这一步不做后面所有命中检测都是玄学。// 统一坐标换算视口坐标 - 画布逻辑坐标 function getPos(canvas, e) { const rect canvas.getBoundingClientRect(); // 兼容 touch 与 mouse const point e.touches ? e.touches[0] : e; const scaleX canvas.width / rect.width; // 处理 devicePixelRatio 缩放 const scaleY canvas.height / rect.height; return { x: (point.clientX - rect.left) * scaleX, y: (point.clientY - rect.top) * scaleY }; }逻辑说明rect拿到画布在页面中的真实位置和显示尺寸scaleX/scaleY解决画布内部分辨率比如 750和 CSS 显示宽度比如 375不一致的问题。参数上canvas.width建议设为CSS宽度 * devicePixelRatio这样高清屏不糊。注意 touch 事件要用e.touches[0]touchend时touches为空得改用changedTouches[0]这是新手最容易漏的一处。2.2 用一份数据描述整道题而不是散着画连线题的本质是「左列节点 右列节点 若干条已连的线」。我一般把题目抽象成一个对象节点带 id、文本、坐标连线只存 id 对。这样重绘时只依赖数据不依赖上一次画布内容天然支持撤销、判分、序列化。// 一道连线题的数据结构 const question { left: [ { id: l1, text: 光合作用, x: 60, y: 80 }, { id: l2, text: 呼吸作用, x: 60, y: 160 } ], right: [ { id: r1, text: 制造有机物, x: 340, y: 80 }, { id: r2, text: 分解有机物, x: 340, y: 160 } ], // 已建立的连线只存 id 对不存坐标 links: [{ from: l1, to: r1 }], // 正确答案用于判分 answer: { l1: r1, l2: r2 } };逻辑说明节点坐标写死在数据里是为了让命中检测和绘制共用同一份来源避免「画在这、判在那」。links只存 id好处是节点位置调整后线自动跟着走。参数上x/y是节点圆心命中检测按半径算。注意answer用对象映射而不是数组判分时 O(1) 查表比遍历数组干净。2.3 命中检测点到圆的距离判定用户按下手指时要判断落在哪个节点上。节点是圆判定就是「点到圆心距离 ≤ 半径」。这里有个细节手指比鼠标粗命中半径要比视觉半径大一圈否则移动端点不中。const HIT_RADIUS 28; // 视觉半径 20命中半径放大到 28 function hitTest(nodes, pos) { for (const node of nodes) { const dx pos.x - node.x; const dy pos.y - node.y; if (dx * dx dy * dy HIT_RADIUS * HIT_RADIUS) { return node; // 返回命中的节点 } } return null; }逻辑说明用平方比较避免开根号性能更好。参数HIT_RADIUS是血泪经验值移动端建议 2432太小用户骂太大容易误触相邻节点。注意遍历顺序如果节点可能重叠后画的应该优先命中可以把数组倒序再遍历。3. 从按下到松手一条连线的完整生命周期3.1 三个事件阶段start、move、end连线交互拆开就是按下时记录起点节点移动时画一条跟随手指的临时线松手时判断落点是否命中目标节点命中就固化成正式连线否则丢弃。这套状态机用三个变量就能描述dragging是否在拖、startNode起点、currentPos当前手指位置。let dragging false; let startNode null; let currentPos { x: 0, y: 0 }; function onStart(e) { const pos getPos(canvas, e); const node hitTest([...question.left, ...question.right], pos); if (node) { dragging true; startNode node; currentPos pos; } } function onMove(e) { if (!dragging) return; currentPos getPos(canvas, e); render(); // 重绘包含临时线 } function onEnd(e) { if (!dragging) return; const pos getPos(canvas, e); const target hitTest([...question.left, ...question.right], pos); // 必须一左一右且不是同一个节点 if (target target.id ! startNode.id isOpposite(startNode, target)) { addLink(startNode.id, target.id); } dragging false; startNode null; render(); }逻辑说明onStart只在命中节点时才进入拖拽态避免空白处乱画。onEnd里isOpposite保证只能左连右防止用户把两个左节点连起来。参数上addLink要做去重和「一个节点只能连一条」的约束具体见下一节。注意移动端要e.preventDefault()阻止页面滚动否则拖到一半页面跟着滑。3.2 连线约束一对一、去重、可覆盖真实答题场景里一个左节点通常只能连一个右节点。用户重新连时应该覆盖旧连线而不是叠加。我一般把links当数组维护addLink时先删掉涉及这两个节点的旧线再 push 新线。function addLink(fromId, toId) { // 移除起点或终点已存在的连线覆盖逻辑 question.links question.links.filter( l l.from ! fromId l.to ! toId ); question.links.push({ from: fromId, to: toId }); } function isOpposite(a, b) { const aIsLeft question.left.some(n n.id a.id); const bIsLeft question.left.some(n n.id b.id); return aIsLeft ! bIsLeft; // 一左一右才允许 }逻辑说明filter同时清掉「起点重复」和「终点重复」两种情况实现覆盖。参数上如果业务允许一对多把filter条件改成只删from fromId即可。注意isOpposite每次遍历数组节点多时可以先建一个 id→side 的映射缓存几十个节点内无所谓。3.3 重绘为什么必须整屏清空以及怎么不闪canvas 没有「删除一条线」的 API只能清空重画。clearRect清整块画布然后按顺序画背景 → 节点 → 已固化连线 → 临时拖拽线。顺序错了线会盖住文字。闪的问题通常不是重绘本身而是重绘频率过高或没做requestAnimationFrame节流。function render() { ctx.clearRect(0, 0, canvas.width, canvas.height); drawNodes(question.left); drawNodes(question.right); // 已固化的连线 question.links.forEach(l { const a findNode(l.from), b findNode(l.to); drawLine(a, b, #2b7de9, 3); }); // 拖拽中的临时线用虚线区分 if (dragging startNode) { drawLine(startNode, currentPos, #999, 2, [6, 4]); } } function drawLine(a, b, color, width, dash) { ctx.save(); ctx.beginPath(); ctx.setLineDash(dash || []); ctx.strokeStyle color; ctx.lineWidth width; ctx.moveTo(a.x, a.y); ctx.lineTo(b.x, b.y); ctx.stroke(); ctx.restore(); }逻辑说明save/restore保证每次画线的样式不污染后续绘制这是 canvas 里最常见的样式串味坑。setLineDash让临时线是虚线用户一眼能区分「还没连上」。参数上线宽 23 在移动端清晰又不糊虚线间隔[6,4]是手感比较舒服的值。注意onMove里如果每帧都render低端机可能掉帧可以用requestAnimationFrame包一层保证一帧只画一次。4. 判分、撤销与移动端适配让它真能用4.1 判分逻辑按 id 对逐条比对判分不复杂遍历正确答案检查每条是否在links里存在。全对给满分部分对给部分分。关键是判分要基于数据而不是画布这样刷新、重做都不影响。function grade(question) { const answer question.answer; const total Object.keys(answer).length; let correct 0; for (const fromId in answer) { const toId answer[fromId]; const hit question.links.some( l l.from fromId l.to toId ); if (hit) correct; } return { correct, total, score: Math.round((correct / total) * 100) }; }逻辑说明answer是{左id: 右id}映射遍历它逐条查links。参数上score按百分比算业务要 5 分制就再换算。注意如果允许一对多判分逻辑要改成集合包含判断别用some直接套。4.2 撤销与重置给用户后悔药答题类交互一定要有撤销。撤销就是把links最后一条 pop 掉再重绘重置就是清空links。这两个操作都只动数据不动画布实现极简。function undo() { question.links.pop(); render(); } function reset() { question.links []; render(); }逻辑说明因为所有视觉都从links派生撤销天然正确。参数上如果要支持多步撤销把links每次变更前的快照压进一个栈即可。注意撤销按钮要禁用态处理links为空时点了没反应会让用户以为卡了。4.3 移动端适配devicePixelRatio 与事件绑定移动端两个坑一是高清屏模糊二是 touch 和 mouse 事件重复触发。解决前者靠按 dpr 放大画布内部分辨率解决后者靠统一用 pointer 事件或做事件去重。function setupCanvas(canvas) { const dpr window.devicePixelRatio || 1; const rect canvas.getBoundingClientRect(); canvas.width rect.width * dpr; canvas.height rect.height * dpr; const ctx canvas.getContext(2d); ctx.scale(dpr, dpr); // 之后按 CSS 像素坐标绘制即可 return ctx; } // 统一用 pointer 事件避免 touch/mouse 双触发 canvas.addEventListener(pointerdown, onStart); canvas.addEventListener(pointermove, onMove); canvas.addEventListener(pointerup, onEnd); canvas.style.touchAction none; // 阻止拖拽时页面滚动逻辑说明ctx.scale(dpr, dpr)之后所有绘制坐标按 CSS 像素写不用手动乘 dpr代码干净。touchAction: none是移动端连线的关键不加的话手指一拖页面就滚。参数上pointer 事件现代浏览器都支持如果要兼容老设备再退回 touch mouse 并做去重。注意setupCanvas要在布局稳定后调用否则getBoundingClientRect拿到 0 宽。5. 避坑与排查连线题最容易翻车的 5 个点5.1 线画出来了但点不中节点现象视觉上节点就在那手指按上去没反应。原因命中检测用的坐标没做 dpr 换算或者getBoundingClientRect在画布被 CSS 缩放后没同步。解决统一走getPos并在窗口 resize 时重新setupCanvas把缩放比例更新掉。5.2 拖拽时页面跟着滚动现象手指在画布上移动整个页面上下滑。原因touchmove 默认行为没阻止。解决给 canvas 设touch-action: none或在onMove里e.preventDefault()。前者更干净后者要注意 passive 监听器不能 preventDefault。5.3 连线闪烁或残影现象拖动时线一闪一闪或者松手后旧线还在。原因重绘没清干净或者clearRect用的尺寸和画布实际尺寸不一致。解决clearRect(0, 0, canvas.width, canvas.height)用画布内部分辨率别用 CSS 尺寸重绘用requestAnimationFrame节流。5.4 一个节点连出多条线现象同一个左节点连了两个右节点线叠在一起。原因addLink没做去重。解决push 前先 filter 掉涉及该节点的旧线实现覆盖。如果业务允许多连那要在 UI 上明确提示别让用户猜。5.5 判分结果和视觉不一致现象用户看着连对了判分说错。原因判分读的是links但视觉可能因为重绘顺序或临时线没清导致「看起来连了实际没存」。解决所有连线必须先落数据再重绘onEnd里先addLink再render顺序不能反。6. 进阶把连线题做成可配置组件的一个技巧做到这里单题已经能跑。但真实项目往往要支持多题切换、题目从接口来、样式可配。我的习惯是把整块逻辑收进一个工厂函数外部只传容器和题目数据内部自己管状态和事件。这样一页能挂多个连线题互不干扰。function createLinkGame(container, question) { const canvas document.createElement(canvas); canvas.style.width 100%; canvas.style.height 300px; container.appendChild(canvas); const ctx setupCanvas(canvas); // 内部状态闭包隔离多个实例互不影响 let dragging false, startNode null, currentPos { x: 0, y: 0 }; // ... 绑定事件、render、grade 等 return { grade: () grade(question), undo, reset, destroy() { // 解绑事件防止内存泄漏 canvas.removeEventListener(pointerdown, onStart); canvas.removeEventListener(pointermove, onMove); canvas.removeEventListener(pointerup, onEnd); container.removeChild(canvas); } }; }逻辑说明闭包把每个实例的状态封起来question是实例私有的多个题不会串数据。返回的destroy很关键单页应用里切路由不解绑事件canvas 和监听器会一直堆在内存里。参数上container建议给固定高度canvas 高度依赖容器容器没高度画布就是 0。再补一个验证方法写完别急着接业务先手动构造几组边界数据——节点重叠、答案全错、答案部分对、连线数为 0——跑一遍判分和重绘确认没有 NaN、没有越界、没有残留。我一般还会在render里临时打一行console.log(question.links.length)确认每次操作后数据条数符合预期比盯着画布猜靠谱得多。最后说个我踩过的坑早期我把节点坐标写死在绘制函数里后来题目要动态换行、要适配不同屏宽改一处漏一处血泪教训。现在一律坐标进数据、绘制只读数据改布局只动数据生成那一段。这个习惯让我后面接任何 canvas 交互题都省了一半调试时间。希望帮到你。本文还有配套的精品资源点击获取
延伸阅读

更多相关文章

2026/10/9 19:28:43

PLC温度控制实战:干燥箱PID参数整定与调试全解析

做过几年自动化项目的人应该都有同感:温度控制看着简单,真正调起来却能让人的头发少一半。PID指令是解决这类问题的核心手段,今天我就拿一个实际做过的干燥箱温控项目,完整拆一遍从方案设计、参数整定到上电调试的思路和踩坑过程。…

2026/10/9 19:28:43

HTML静态旅游网站源码实战:从骨架搭建到部署避坑

简介:一份基于HTML、CSS与JavaScript的静态旅游网站源码,适合前端初学者、网页设计爱好者及需要快速搭建旅游展示页的开发者参考使用。压缩包共173个文件,主要包含24个htm页面、18个css样式、20张jpg图片、85个gif动图及少量swf/js文件&#…

2026/10/9 19:28:43

RK3588加Jetson AI智能盒子:本地推理部署实战与性能优化

1. 从一块开发板到一台"盒子":我为什么盯上了RK3588加Jetson这套组合去年年底帮朋友的工作室做一套本地化的视觉识别方案,需求说起来不复杂:几路摄像头实时跑目标检测,结果要能本地展示、本地存储,最好还能接…

2026/10/9 20:34:04

MySQL Workbench 使用教程:连接、SQL 编辑、建模与导入导出实战指南

简介:这是一份面向MySQL数据库管理员、开发人员及初学者的实操教程,以docx文档形式系统讲解MySQL Workbench的日常用法。教程从主界面SCHEMAS面板入手,逐步演示数据库的创建、字符集修改、删除与默认库设置,并完整覆盖数据表的创建…

2026/10/9 20:34:04

潜供电流与电容电路:单相重合闸背后的工程解析

潜供电流这个词,很多干运维和继电保护的人都不陌生,但要真把它讲清楚,尤其是把它和电容电路的关系掰扯明白,不少人会含糊其辞。我在变电站做故障录波分析时经常碰到这种情况:线路单相接地,保护动作跳开故障…

2026/10/9 20:34:04

数学函数从入门到应用:概念、性质与实战场景全解析

数学函数这个东西,我在带新人和做项目复盘的时候,发现一个特别有意思的现象:几乎所有人都说“我学过”,但真到用的时候,能把它讲清楚、用明白的人不到三成。问题出在哪?不是大家记性差,而是当初…

2026/10/9 20:34:04

基于YOLO的PCBA高密度板电子元件缺失与错装检测实战

就在去年下半年,我被一桩事反复折磨:产线上那块高密度PCBA板,0402规格的电阻电容密密麻麻排了大半张板子,AOI设备天天误报,人工目检那边又跟不上节奏。每天盯屏的质检员换了三轮,漏检率还是压不下去。那段时…

2026/10/9 20:34:04

自动化测试详解:从接口到UI的落地经验与避坑指南

先聊点实在的。我在这个行业摸爬滚打这些年,经手过的项目大大小小也有几十个了,几乎每个团队立项时都会拍着胸脯说要上自动化测试,但真正能把自动化测试做出价值、让它在版本迭代里起到“守门员”作用的团队,屈指可数。大部分人要…

2026/10/9 20:29:04

SWE-Bench 卷到 73.4% 之后,国产编程模型还能卷什么

SWE-Bench 卷到 73.4% 之后,国产编程模型还能卷什么 【免费下载链接】KAT-Coder-V2.5-Dev 项目地址: https://ai.gitcode.com/hf_mirrors/Kwaipilot/KAT-Coder-V2.5-Dev 当快手 Kwaipilot 团队把闭源旗舰 KAT-Coder 在 SWE-Bench Verified 上的解决率推到 7…

2026/10/8 10:03:18

Jev+Agent接管浏览器:browser-use实战与jev-ultrafast性能优化

1. 从“Jev”说起:为什么我要把Agent接进浏览器“Jev”这个词最近在圈子里出现的频率越来越高,很多人第一次听到会以为是某个新模型的名字,其实它更像是一种思路——把Jev模型的能力当作底座,通过Agent的方式去接管浏览器&#xf…

2026/10/9 20:15:56

多智能体集群实战:DeepAgents编排、MCP与A2A协议及Skills体系

1. 从"单兵作战"到"集群协同":多智能体编排到底在解决什么问题如果你最近在折腾 Agent 相关的东西,大概率会有一种感觉:单个 Agent 能做的事情,其实很快就摸到天花板了。你给它一个提示词,挂几个工…

2026/10/8 6:05:44

无源低通滤波器设计实战:从RC到LC,手把手教你避开那些坑

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/9 0:04:27

毕业论文初稿完成后首次进行AIGC疑似度自查的摸底与分流策略

毕业论文初稿完成后首次进行AIGC疑似度自查的摸底与分流策略当数万字的学位论文初稿经历开题、实验、问卷与多轮文献梳理最终成形时,绝大多数研究生都会面临一道全新的形式审查关卡:AIGC 疑似度排查。在高校毕业审核流程中,盲审前的文本检测通…

2026/10/9 0:04:27

食堂节能改造源头工厂,商用厨房设备焕新方案广受好评

商用厨房作为餐饮经营、单位供餐的核心后勤阵地,其设备配置、动线规划与运维体系直接决定后厨作业效率、运营成本与合规性。从基础的灶具、制冷存储设备,到油烟净化、水处理等配套系统,每一个环节的合理性都与食品安全、能耗管控、消防安全挂…

还想了解更多?直接咨询顾问

免费诊断 + 免费方案 + 透明报价。

全国咨询热线400-8866-253
免费获取方案
☎咨询二维码 ☎ ↑