简介:这是一款基于Canvas实现的时间轴插件,面向需要在页面中展示和交互时间轴的中高级前端开发者,可应用于视频回放、录像检索、监控记录管理等场景。插件支持拖动时间轴、点击时间点触发回调、动态增加录像块以及整体放大缩小视图,核心逻辑由Canvas绘制完成,基于jQuery封装,接入门槛低,适合快速集成到已有项目中。压缩包共6个文件,包含3个JavaScript源文件(含jQuery依赖库与插件主体)、2个HTML演示页面和1个README说明文档,整体大小约90KB。演示页可直观看到插件效果与基本调用方式,便于对照学习参数配置;插件源自真实项目中视频回放时间轴部分,经实际需求打磨,已验证可用,能省去从零编写时间轴交互的大量排错时间。目前已有4079人学习下载,对正在开发视频回放、录像管理或需要自定义时间轴组件的前端开发者具有直接参考价值。
1. 时间轴插件:用 Canvas 把录像回放的时间轴做成可拖可点的交互组件
在监控回放、云端录像检索这类系统里,时间轴是最容易被低估的一块。用 DOM 拼出来的时间轴,一旦数据量上来——比如几千个录像片段、按秒级密度渲染——DOM 节点多到浏览器直接卡顿,拖动跟手性也差。这个基于 Canvas 的时间轴插件就是来解决这个问题的:所有刻度、网格线、录像块都画在画布上,不依赖 DOM 节点数量,通过 requestAnimationFrame 驱动重绘,拖动、点击返回时间点、增加录像块、滚轮放大缩小都是毫秒级响应。适合做视频监控平台、行车记录仪回放、医疗影像回放这类需要精确到秒的时间轴需求的开发者。核心依赖只有 jQuery 和原生 Canvas API,不引重型可视化库,集成成本低,拿来改一改就能嵌进自己的项目。
2. 设计思路与技术选型:为什么用 Canvas 而不是 DOM,数据层怎么组织
2.1 Canvas 与 DOM 的取舍,以及绘制机制的建立
先说结论:如果你的时间轴只需要展示几十个节点,用 DOM 绝对够,代码还更简单。但当录像块数量达到几千、并且需要在拖动和缩放时频繁重绘,DOM 的节点创建和样式计算会成为性能瓶颈。Canvas 的优势在于绘制次数固定——无论有多少录像块,每帧只执行一次重绘,绘制开销由渲染面积决定,而不是由数据量决定。代价是命中检测和事件处理都得自己算坐标,没有 DOM 的天然事件委托可用。
这个插件的事件机制我采用的是「单事件监听 + 坐标换算」的方式。canvas 外层容器监听 mousedown、mousemove、mouseup、wheel,事件回调里拿到的是屏幕坐标,需要先减去 canvas 的 boundingClientRect 偏移,再换算成时间轴坐标。换算公式是:
// 将像素坐标换算为时间戳 function pixelToTime(pixelX, viewport) { // viewport.startTick: 视口起始刻度(毫秒) // viewport.pxPerMs: 每毫秒对应的像素数,缩放时变化 // pixelX: 相对 canvas 左边界的像素坐标 return viewport.startTick + (pixelX - timelineArea.offset) / viewport.pxPerMs; }这里 timelineArea.offset 是时间轴绘图区相对 canvas 左边缘的偏移,通常左侧会预留一段操作按钮区域。pxPerMs 是缩放的核心参数,默认值一般设为 0.02,也就是 1 秒占 20 像素,用户滚动滚轮时按倍率调整它。
2.2 目录结构与数据模型
插件源码采用单文件功能拆分的方式,每个文件负责一类职责,方便后期维护:
timeline-plugin/ ├── index.html // 演示页面,包含容器节点和初始化代码 ├── css/ │ └── timeline.css // 容器样式、游标样式、提示层样式 ├── js/ │ ├── timeline.js // 主类,对外暴露 API,管理视图状态 │ ├── timeline.draw.js // 绘制模块:网格、刻度、录像块、游标 │ ├── timeline.event.js // 事件模块:鼠标与滚轮事件绑定、坐标转换 │ ├── timeline.data.js // 数据模块:录像块数组、增删改查、排序合并 │ └── jquery.min.js // 可能需要引入的 jquery 依赖 └── README.md // 配置说明与 API 文档数据模型的核心是录像块 record block 的定义。每个录像块不是简单的矩形,而是包含时间范围和状态属性:
// 录像块数据结构 { id: 'REC-20241118-001', // 唯一标识,删除或更新时使用 startTick: 1734508800000, // 开始时间戳,毫秒 endTick: 1734509100000, // 结束时间戳,毫秒 type: 'normal', // 录像类型:normal | alarm | manual label: '主入口-北向' // 展示文字,鼠标悬停时使用 }type 字段在绘制时会映射成不同颜色,normal 用蓝色系,alarm 用红色系,manual 用绿色系,这样用户扫一眼就能分辨哪些时段有报警录像。数据模块还负责相邻碎片的合并——如果两个录像块时间相接且 type 相同,绘制时可以合成一个矩形,减少绘制指令数量。
3. 核心功能代码逐段拆解:拖动、点击、录像块、缩放全流程
3.1 拖动时间轴的实现:偏移量累积与重绘时机
拖动功能看起来简单,但细节在于「按下时记录参考点,移动时计算差值,用差值更新视口起始刻度」。核心代码在 timeline.event.js 中:
// 时间段选择与拖动 this.events.on('mousedown', function (e) { if (e.target !== canvas) return; // 只响应画布本体 var rect = canvas.getBoundingClientRect(); plugin.dragging = true; plugin.lastMouseX = e.clientX - rect.left; // 记录按下点 plugin.dragStartTick = plugin.viewport.startTick; // 记录起始刻度 }); this.events.on('mousemove', function (e) { if (!plugin.dragging) return; var rect = canvas.getBoundingClientRect(); var currentX = e.clientX - rect.left; var dx = currentX - plugin.lastMouseX; // 本次移动的像素差 // 将像素差换算为时间差,更新视口 var dt = -dx / plugin.viewport.pxPerMs; plugin.viewport.startTick = plugin.dragStartTick + dt; // 先更新游标位置,再统一重绘 plugin.updateCursorByMouse(e); plugin.draw(); });逻辑说明:mousedown 时不立即重绘,只是记录状态;mousemove 中才计算位移。dx 为正表示鼠标向右移,时间轴内容应该跟着向右即向更早的时间方向移动,所以 startTick 变化量取负号。还有一个细节:如果每次 mousemove 都触发全量重绘,帧率不够时会出现撕裂感,常见的做法是配合 requestAnimationFrame 做合并,同一帧内多次 mousemove 只执行一次 draw。
3.2 点击返回时间点:命中检测与回调触发
点击返回时间点需要判断点击位置是否落在某个录像块内部,以及是否落在空白区域。Canvas 没有 DOM 的 elementFromPoint 可用,必须自己做「反向命中」——遍历录像块数组,逐个判断点击坐标对应的时间戳是否落在块范围内。这里有一个优化点:录像块按 startTick 排序后,可以用二分查找先定位候选块,而不是全量遍历。
// 在 timeline.draw.js 中通过绘制时维护的块矩形数组实现命中检测 this.hitTestToBlocks = function (pixelX, pixelY) { // 从上到下遍历绘制区域,时间轴高度通常在 30~60 像素 var time = plugin.pixelToTime(pixelX); var blocks = plugin.data.blocks; var len = blocks.length; for (var i = 0; i < len; i++) { var b = blocks[i]; // 先做粗筛:时间戳必须在块范围内 if (time >= b.startTick && time <= b.endTick) { // 再判断纵坐标是否落在块矩形内部 var y0 = b.level * BLOCK_HEIGHT + AREA_TOP; // level 是多行时的行号 var y1 = y0 + BLOCK_HEIGHT; if (pixelY >= y0 && pixelY <= y1) { return b; } } } return null; };时间戳命中的判断标准:公式里 time 是像素坐标经 pixelToTime 换算出来的毫秒时间戳。点击事件触发时,主逻辑是这样串联的:
// timeline.event.js 中的 click 响应 this.events.on('click', function (e) { var pos = plugin.getLocalPos(e); var block = plugin.hitTestToBlocks(pos.x, pos.y); if (block) { // 点击到录像块:触发 seek 回调,同时把游标定位到块开始位置 plugin.emitCallback('onBlockClick', [block, plugin]); plugin.seek(block.startTick); } else { // 点击到空白区域:直接执行 seek 到该时间点 var t = plugin.pixelToTime(pos.x); plugin.seek(t); plugin.emitCallback('onTimelineClick', [t, plugin]); } });参数说明:pos.x 和 pos.y 是相对于 canvas 左上角的坐标;seek(tick) 方法用于把游标移到 tick 处并触发 onSeek 回调,外部播放器拿到时间戳后自动定位到对应帧画面。这里要注意 click 事件和拖动事件的区分——鼠标按下后如果移动距离超过 3 像素再松开,应当视为拖动而不是点击,否则会误触。常见的做法是记录 mousedown 位置,mouseup 时计算两点距离,小于阈值才走 click 逻辑。
3.3 增加录像块与删除录像块:数据变更后的增量重绘
录像块增加提供两种方式:程序化添加和用户框选添加。程序化接口很简单:
// 增加一个录像块,支持单个和批量,自动排序去重 plugin.addBlocks = function (newBlocks) { var list = $.isArray(newBlocks) ? newBlocks : [newBlocks]; for (var i = 0; i < list.length; i++) { var item = list[i]; // 合法性校验:startTick 必须小于 endTick,且不能与已有块完全重叠 if (item.startTick >= item.endTick) continue; plugin.data.blocks.push(item); } plugin.data.blocks.sort(function (a, b) { return a.startTick - b.startTick; }); plugin.data.mergeAdjacent(); // 合并同类型相邻块 plugin.draw(); // 全量重绘,确保新增块立即显示 return plugin; // 返回 this 支持链式调用 };mergeAdjacent 是录像块模块一个值得借鉴的方法。监控场景中经常出现因为网络抖动导致录像断断续续,产生大量 1~2 秒的小碎片,绘制时每个碎片都要走一次 path 指令,性能很差。合并规则:同 type、时间上连续(EndTick 与下一块的 startTick 差值小于 500ms 即视为连续)的块合并为一个大的绘制矩形。
用户框选添加则在 canvas 上监听 mousedown 后拖动,画出一个半透明选区,mouseup 时把选区起止时间封装成一个录像块对象,再走 addBlocks 接口。绘制选区时单独画一次,不污染正式数据。
3.4 放大与缩小:滚轮事件与倍率变化的边界控制
缩放是本插件交互体验的重中之重。基本思路:以鼠标所在位置为锚点,滚轮向上放大(pxPerMs 变大),向下缩小(pxPerMs 变小),并保证锚点对应的时间戳在缩放后仍然停留在鼠标位置下方。
// wheel 事件的缩放处理 this.events.on('wheel', function (e) { e.preventDefault(); var rect = canvas.getBoundingClientRect(); var mouseX = e.clientX - rect.left; var anchorTime = plugin.pixelToTime(mouseX); // 缩放前鼠标位置对应的时间 // 定义缩放因子:向上滚动放大,向下缩小 var factor = e.originalEvent.deltaY < 0 ? 1.24 : 1 / 1.24; var newPxPerMs = plugin.viewport.pxPerMs * factor; // 边界控制:最小每像素代表 0.005 毫秒(约 200 像素/秒),最大 1 像素/毫秒 if (newPxPerMs < 0.005 / plugin.devicePixelRatio) newPxPerMs = 0.005 / plugin.devicePixelRatio; if (newPxPerMs > 1) newPxPerMs = 1; plugin.viewport.pxPerMs = newPxPerMs; // 关键:重新计算 startTick,保证锚点时间不移动 plugin.viewport.startTick = anchorTime - mouseX / newPxPerMs; plugin.draw(); });参数说明:factor 为什么取 1.24 而不是 2?1.24 是交互设计里常见的「每档缩放约 25%」经验值,缩放曲线更细腻,用户不容易一下拉过头。边界值也做了保护:pxPerMs 太小时时间轴一眼望到几个月前,太小则秒级刻度挤满屏幕,这两个边界就是产品的可读性底线。同时要考虑 devicePixelRatio,高清屏上物理像素与 CSS 像素不一致,缩放边界要按实际像素密度换算。
3.5 绘制层面的刻度自适应:从年视图到秒视图
时间轴的刻度粒度不能写死。当视口跨度不同,刻度应该自动切换:跨度大到几个月时显示「月/日」刻度,小到几分钟时显示「分/秒」刻度。我采用的策略是「阶梯式匹配」——预设几个刻度档位,每档对应一个最小像素间隔,绘制时按当前 pxPerMs 选档:
// 刻度粒度选择逻辑 function getTickLevel(pxPerMs) { var viewportWidth = plugin.viewport.width; var spanMs = viewportWidth / pxPerMs; // 当前完整视口的毫秒跨度 // 核心规则:让相邻刻度的像素距离保持在 50~120 之间,避免过密或过疏 if (spanMs > 90 * 24 * 3600 * 1000) return 'quarter'; // 超过 90 天,显示季度 else if (spanMs > 14 * 24 * 3600 * 1000) return 'day'; // 超过 14 天,显示日 else if (spanMs > 36 * 3600 * 1000) return 'hour'; // 超过 36 小时,显示小时 else if (spanMs > 90 * 60 * 1000) return 'min5'; // 超过 90 分钟,显示 5 分钟间隔 else if (spanMs > 18 * 60 * 1000) return 'min1'; // 超过 18 分钟,显示 1 分钟间隔 else return 'sec'; // 更小范围,显示秒 }这个函数的阈值保证在不同缩放级别下刻度密度始终处于舒适区间。刻度绘制时字符串格式也要随之变化,秒级视图显示完整时分秒,天级视图只显示月日。这块做不好,时间轴放大后看起来就是一团乱麻,几乎是这个插件里最需要调试的地方。
4. 把插件跑起来:初始化、配置参数与对外 API
4.1 最小可用示例:三分钟跑通
拿到源码后,直接打开 index.html 是无法看到效果的前提是本地需要起一个 HTTP 服务。因为模块拆分使用了大文件加载方式,直接双击打开会因跨域问题加载失败。推荐用 VS Code 的 Live Server 或者跑 python -m http.server 8000,然后浏览器访问。初始化代码写在 index.html 底部:
<div id="timelineContainer" style="width: 100%; height: 80px;"></div> <script src="js/jquery.min.js"></script> <script src="js/timeline.js"></script> <script src="js/timeline.data.js"></script> <script src="js/timeline.draw.js"></script> <script src="js/timeline.event.js"></script> <script> // 插件实例化:传入容器选择器和配置对象 var timeline = $.fn.timeline({ container: '#timelineContainer', startTick: 1734508800000, // 视口起始时间,对应 2024-12-18 08:00 endTick: 1734512400000, // 视口结束时间,跨度 1 小时 pxPerMs: 0.02, // 初始缩放:1 秒 = 20 像素 cursor: { // 游标初始位置和状态 tick: 1734508800000, visible: true }, onSeek: function (tick) { console.log('seek to:', new Date(tick).toLocaleTimeString()); // 这里可以放播放器跳转逻辑 }, onBlockClick: function (block) { console.log('block clicked:', block.label, block.startTick, block.endTick); } }); // 添加几个示例录像块 timeline.addBlocks([ { id: 'rec1', startTick: 1734508800000, endTick: 1734509100000, type: 'normal', label: '走廊-东' }, { id: 'rec2', startTick: 1734509400000, endTick: 1734510000000, type: 'alarm', label: '大厅-南' }, { id: 'rec3', startTick: 1734510600000, endTick: 1734511200000, type: 'normal', label: '走廊-西' } ]); </script>4.2 配置参数表:每个选项怎么调
插件的配置项集中在初始化对象的 options 里,专项说明如下:
| 参数 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| container | string | 必填 | 容器选择器,必须有已声明宽高的父节点 |
| startTick | number | Date.now() | 视口起始时间戳(毫秒) |
| endTick | number | startTick + 3600000 | 视口结束时间戳,决定初始跨度 |
| pxPerMs | number | 0.02 | 每毫秒像素数,0.02 表示 1 秒占 20 像素 |
| showScale | boolean | true | 是否显示刻度尺背景网格线 |
| showCursor | boolean | true | 是否显示当前游标竖线 |
| cursorColor | string | '#ff5722' | 游标颜色,建议与录像块警示色区分 |
| blockHeight | number | 22 | 录像块矩形的高度,多行块会自动下移 |
| offsetLeft | number | 50 | 绘图区左侧留白,用于容纳操作图标 |
注意 endTick 的默认逻辑:如果只传 startTick 不传 endTick,插件会按 1 小时跨度计算 pxPerMs 的对齐值,保证刻度落在整点上。实际项目中建议显式传这两个值,避免默认值产生的跨度和刻度不在预期位置的问题。
4.3 对外 API 速查与链式调用
插件对外暴露的主要方法覆盖日常开发 80% 的场景:
// 时间跳转:把游标移动到指定时间点,触发 onSeek 回调 timeline.seek(tick, { triggerCallback: true }); // 动态增删录像块:支持传数组或单个对象,可链式调用 timeline .addBlocks([{ id: 'rec4', startTick: ..., endTick: ..., type: 'manual' }]) .removeBlocks(['rec1']) .clearBlocks(); // 清空所有录像块 // 缩放与视口控制 timeline.setZoom(0.05); // 直接指定 pxPerMs 值 timeline.moveTo(tick); // 把视口平移到指定时间点为起点 timeline.refresh(); // 外部数据变更后强制重绘还有两个内部状态读取方法,在联动外部 UI 时非常关键:getCursor() 返回当前游标时间戳;getViewport() 返回当前视口信息。我在接入视频播放器时,就是在播放器的 timeupdate 事件里调 timeline.seek(currentTime),让游标跟随视频播放进度走。
5. 避坑与常见问题:Canvas 时间轴开发中的几处血泪经验
5.1 高清屏下模糊重影,canvas 显示像隔了一层雾
现象:时间轴刻度文字发虚,线条宽度不一致,截图后尤其明显。原因:高分屏(如 Retina 屏、2K 屏)的物理像素是 CSS 像素的 2 倍,canvas 的绘图缓冲区默认只有 CSS 像素尺寸,画出来被浏览器放大后产生了模糊。解决:获取 devicePixelRatio 并让 canvas 的实际尺寸乘以它:
// 初始化画布时适配高清屏 function setupCanvas(canvas, containerWidth, containerHeight) { var dpr = window.devicePixelRatio || 1; // 物理像素尺寸 = CSS 尺寸 * DPR canvas.width = containerWidth * dpr; canvas.height = containerHeight * dpr; canvas.style.width = containerWidth + 'px'; canvas.style.height = containerHeight + 'px'; // 绘图时全局缩放 DPR 倍,之后按 CSS 像素操作 var ctx = canvas.getContext('2d'); ctx.setTransform(dpr, 0, 0, dpr, 0, 0); return ctx; }注意:setTransform 后所有坐标仍按 CSS 像素写,但实际落点是物理像素,清晰度立刻改善。窗口 resize 时要重新调用 setupCanvas,并且读取容器宽高要在 resize 事件里做防抖处理,否则拖动缩放时会频繁重建画布导致闪烁。
5.2 缩放后点击命中偏移,点到的录像块不是鼠标下面那个
现象:滚轮缩放几次后点击 A 录像块,回调给出的却是 B 块,或者回调的时间戳和鼠标位置差了十几秒。原因:缩放时更新了 pxPerMs 和 startTick,如果不更新数据模块里缓存的块矩形坐标,hitTest 用的还是旧坐标。解决:绘制模块里维护一个块布矩形数组,每次 draw 时同步更新,并且保证 zoom 和 drag 结束时强制重算一次:
// 绘制结束后更新块矩形索引 plugin.blockRects = []; blocks.forEach(function(b) { var x0 = plugin.timeToPixel(b.startTick); // 时间戳 → 像素 var x1 = plugin.timeToPixel(b.endTick); var y0 = b.level * BLOCK_HEIGHT + AREA_TOP; plugin.blockRects.push({ block: b, x0: x0, x1: x1, y0: y0, y1: y0 + BLOCK_HEIGHT }); });这类问题最大的陷阱是:代码逻辑上完全走得通,但就是点击位置不对,属于典型的坐标映射没有跟随缩放更新。以后凡是改了 pxPerMs 或 startTick,强制重绘并重建 blockRects,不要依赖事件回调里临时计算。
5.3 拖动过程中触发 click 事件,造成误跳转
现象:用户想通过拖动来查看不同时间段的录像,松开鼠标后却触发了时间点跳转回调,播放器跟着跳到了点击位置。原因:浏览器在 mouseup 时会触发 click 事件,而拖动的起止位置都在同一个 canvas 上,导致无法区分「点击」和「拖动」。解决:引入最小移动阈值,记录 mousedown 的坐标,mouseup 时判断位移量,超过 3 像素视为拖动,仅执行拖动结束逻辑,不触发 click:
this.events.on('mouseup', function (e) { var pos = plugin.getLocalPos(e); var distance = Math.abs(pos.x - plugin.lastMouseX) + Math.abs(pos.y - plugin.lastMouseY); if (distance > 3) { plugin.isDragging = false; return; // 结束拖动,不触发点击 } plugin.isDragging = false; plugin.emitClickCallback(e); // 仅在位移小于阈值时走点击逻辑 });这个 bug 在鼠标事件里经常翻车,特别是横竖滚动混在一起时更容易忽略偏移量阈值判断。
5.4 大量录像块绘制导致掉帧,滚动时卡顿
现象:录像块达到几千个时拖动时间轴明显卡顿,帧率掉到 20 fps 以下。原因:每帧全量遍历所有块并调用 fillRect 绘制,操作指令数过多。解决:视口裁剪(只绘制可见区域)加同类型合并。先判断块的像素区间是否与 canvas 宽度的可见范围相交,不相交直接跳过;然后用上文的 mergeAdjacent 把连续同类型块合并为一次 fillRect:
// 只绘制可见区域的录像块 blocks.forEach(function(b) { var x0 = plugin.timeToPixel(b.startTick); var x1 = plugin.timeToPixel(b.endTick); if (x1 < 0 || x0 > plugin.viewport.width) return; // 不可见,跳过 ctx.fillStyle = colorMap[b.type]; ctx.fillRect(Math.max(x0, 0), y0, Math.min(x1, plugin.viewport.width) - Math.max(x0, 0), blockHeight); });从万级数据降几千指令,性能提升巨大。另外如果缩放跨度极大导致所有块重叠在一起,可以加一个简化模式:当每像素对应时间跨度超过某个阈值时,只画轮廓不画填充,交互流畅度优先。
6. 二次开发与改造思路:把时间轴接进你的播放器业务
拿到这个插件,最值得做的改造是把它和实际播放器做时间轴联动。我这里以一个典型监控平台场景为例,梳理改造步骤:
第一步,把时间轴作为一个独立的「时间轴控制层」,不承载业务数据渲染。播放器当前播放时间通过 timeupdate 事件驱动游标移动,播放器暴露 seek 接口供时间轴点击时调用。常见做法是封装一个 bridge 对象,专门管理时间轴与播放器之间的双向同步,避免时间轴直接依赖播放器实例。
第二步,扩展录像块类型。目前 type 只支持 normal、alarm、manual 三种,实际项目里可能还要区分 PTZ 巡航录像、动检录像、全天定时录像。可以在 timeline.data.js 的 block 对象上增加 isPtz、channel 字段,并在 timeline.draw.js 的颜色映射表里扩展对应关系。注意在绘制层做双层级展示:外层是通道概览条,内层是每个通道的独立录像块轨道,类似安防行业标准的「通道维度时间轴」。
第三步,批量数据集的虚拟加载。如果录像块数据来自后端接口,一次可能返回上万条,处理策略是「按视口范围分批请求」——拖动结束或缩放结束后,根据当前视口时间范围调用后端接口,只拉取当前视野内的录像块数据,拉回来后调用 addBlocks 替换旧数据。这个方法能有效避免一次性加载大数据集导致的白屏和卡顿。我实际开发时遇到一个需求,需要展示 30 天 64 通道的持续录像,按秒生成数据块接近 16 万条,用这套方案后请求延时从秒级降到百毫秒级,绘制帧率稳定在 60fps。
第四步,验证改造效果的思路要可量化。用 performance.now() 记录拖动帧率,目标 50fps 以上;用二分查找验证 hitTest 延迟在 1ms 内;持续拖动 10 分钟后检查内存是否增长。
有一点我感触最深:坐标换算的尺寸单位一定要统一,像素坐标、时间戳毫秒、缩放倍率,这三者混用是时间轴开发里最痛苦的排查点。后来我在代码里强规定——所有方法参数要么是毫秒时间戳,要么是 CSS 像素值,绝不允许在函数内部转换单位后再对外传参。一旦出现「看起来是像素其实是秒」的半路出家式参数,排查玄学就开始了。从那以后我每次升级版本都强制走一遍坐标换算的单测用例,每个缩放级别下点击位置必须与命中结果一致才放行。希望帮到你。
本文还有配套的精品资源,点击获取