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 交互题都省了一半调试时间。希望帮到你。本文还有配套的精品资源点击获取

相关新闻

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

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

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

2026/10/11 7:32:19 阅读更多 →
HTML静态旅游网站源码实战:从骨架搭建到部署避坑

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

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

2026/10/14 21:08:39 阅读更多 →
RK3588加Jetson AI智能盒子:本地推理部署实战与性能优化

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

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

2026/10/13 16:37:05 阅读更多 →

最新新闻

RHEL 8存储管理实战:Stratis与VDO的池化、去重与组合使用

RHEL 8存储管理实战:Stratis与VDO的池化、去重与组合使用

1. 为什么要在RHEL 8上折腾Stratis和VDORHEL 8把存储管理拆成了两条腿走路:一条是Stratis,面向本地磁盘池的"傻瓜式"卷管理;另一条是VDO,面向块设备的"压缩去重"层。很多人第一次听到这两个词的反应是"L…

2026/10/15 11:45:26 阅读更多 →
Kindle Fire Utility刷机实战:驱动、adb与fastboot救砖指南

Kindle Fire Utility刷机实战:驱动、adb与fastboot救砖指南

简介:Kindle Fire Utility v0.9.9刷机工具包是面向Amazon Kindle Fire HD用户的定制与修复工具,主要用于获取root权限、刷入自定义恢复系统以及应对设备无法正常开机等场景。压缩包共43个文件,约18.33MB,包括12个exe主程序和11个d…

2026/10/15 11:45:26 阅读更多 →
context-mode:日志排障与代码阅读的上下文利器

context-mode:日志排障与代码阅读的上下文利器

有一次线上报警,服务内存持续飙升。我翻日志文件,看到上百条孤零零的 OutOfMemoryError ,每一条都长得几乎一样,既看不出是哪条调用链触发的,也看不出当时的线程状态和参数。那会儿我只有一个想法:如果这…

2026/10/15 11:45:26 阅读更多 →
AI时代品牌流量基建:传播易GEO生成式引擎优化服务采购攻略

AI时代品牌流量基建:传播易GEO生成式引擎优化服务采购攻略

随着生成式AI大模型成为用户核心信息获取载体,品牌在AI问答场景中的可见度与可信度,已经成为决定B2B企业采购决策、B2C用户消费选择的核心因素。区别于传统搜索引擎营销,GEO(Generative Engine Optimization,生成式引擎优化)是适配…

2026/10/15 11:45:26 阅读更多 →
MCP协议中context-mode的本质与SQLite FTS5上下文索引实践

MCP协议中context-mode的本质与SQLite FTS5上下文索引实践

1. “context-mode”不是功能开关,而是MCP协议中上下文协商的运行态标识 最近在多个开发场景里反复看到“context-mode”这个词——它既不像传统软件里的“debug mode”或“production mode”那样直白,也不像HTTP头里的 X-Mode 那样有明确语义。我最初…

2026/10/15 11:45:26 阅读更多 →
14B视频世界模型如何实现零样本实时闭环机器人策略

14B视频世界模型如何实现零样本实时闭环机器人策略

1. 从一段标题说起:为什么“视频世界模型”突然成了机器人圈的热词第一次看到“把 14B 视频世界模型变成可实时闭环执行的零样本机器人策略”这个说法时,我脑子里冒出来的第一个念头是:这不就是把“看视频学做事”这件事,从离线训…

2026/10/15 11:44:23 阅读更多 →

日新闻

Docker Compose 容器化部署 ChirpStack LoRaWAN 网络服务器实战

Docker Compose 容器化部署 ChirpStack LoRaWAN 网络服务器实战

1. 为什么选择容器化方式部署 LoRa 网络服务LoRa 物联网项目落地时,网络服务器是整个链路的核心枢纽。它负责接收网关上报的数据包、完成去重与解密、再把有效载荷分发给上层应用。早期大家习惯把网络服务器直接装在物理机上,依赖一堆系统库和特定版本的…

2026/10/15 0:00:46 阅读更多 →
共享LLM服务下的跨模型自动扩缩容:Kubernetes实战与避坑指南

共享LLM服务下的跨模型自动扩缩容:Kubernetes实战与避坑指南

1. 共享推理服务里,扩缩容到底难在哪做过大模型推理服务部署的人,大概都经历过这样的场景:一个团队共用一套推理集群,A 业务跑的是 7B 的小模型,B 业务跑的是 72B 的大模型,C 业务白天流量高、晚上几乎归零…

2026/10/15 0:00:46 阅读更多 →
Systemd 启动阶段根文件系统只读之谜:fstab 与 remount-fs 服务深度解析

Systemd 启动阶段根文件系统只读之谜:fstab 与 remount-fs 服务深度解析

1. 引子:一次内核恐慌引发的追查先从一个真实场景说起。某次我给一台老旧的嵌入式设备折腾系统裁减,为了把启动时间压进两秒以内,我手贱地把一串自认为“没用”的 systemd 服务给 disable 了,其中就包括systemd-remount-fs.servic…

2026/10/15 0:00:46 阅读更多 →

周新闻

流感时间序列预测实战:ARIMA/LSTM全流程拆解与避坑指南

流感时间序列预测实战:ARIMA/LSTM全流程拆解与避坑指南

简介:基于 ARIMA、LSTM、Transformer 等模型的流感时间序列预测 Python 源码,面向计算机相关专业课程设计与期末大作业学生,以及项目实战学习者。内容覆盖预处理、平稳性检验、定阶、残差分析、多模型对比预测的完整时序建模流程,…

2026/10/14 3:30:28 阅读更多 →
影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别

影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别

影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别 做影刀RPA自动化,十个新手有八个栽在"往输入框里填东西"这件事上:要么填不进去,要么填了一半,要么直接把原来内容追加在后面。这背后的根因&…

2026/10/14 16:30:29 阅读更多 →
影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容

影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容

影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容 1. 认识影刀:什么场景该用RPA采小说数据 起点中文网的页面结构相对稳定——分类榜单、书籍详情、章节内容三块独立页面,跳转链路清晰。这种场景非常适合影刀自动化&#x…

2026/10/14 3:29:58 阅读更多 →

月新闻

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

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

2026/10/15 0:14:36 阅读更多 →
Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

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

2026/10/14 22:45:47 阅读更多 →
黑夜航拍船只数据集训练YOLOV5模型全流程解析

黑夜航拍船只数据集训练YOLOV5模型全流程解析

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

2026/10/15 9:49:14 阅读更多 →