简介:一套基于 Canvas 编写的时间轴交互插件源码,面向需要实现视频回放时间轴、录像片段管理和时间点定位的前端开发者,适合已接触 jQuery、希望补齐 Canvas 拖拽与缩放能力的中初级人员。插件围绕时间轴拖动、点击返回时间点、增加录像块及视图放大缩小展开,可参考其事件绑定、坐标换算与重绘逻辑,用于监控回放、课程录像等界面。
压缩包共 6 个文件、约 90KB,含 3 个 JavaScript 脚本、2 个 HTML 示例页和 1 个 Markdown 说明文档;脚本负责核心绘制与交互,示例页便于运行对照,文档用于梳理用法。已有 4080 人学习,说明该实现对视频回放时间轴场景具备参考价值。读者可获得可运行的 Canvas 时间轴范例、录像块渲染思路、拖拽与缩放事件处理方式,以及接入现有页面的组织方法,适合按示例拆解后移植到自身项目。
1. 从一根游标说起:canvas 时间轴插件到底解决什么问题
录像回放系统里最让人抓狂的,往往不是视频解码,而是拖进度条那一下——浏览器自带的 range 控件没法同时承载几十段录像块、缩放刻度和回放游标。这套基于 canvas 的时间轴插件,用最朴素的 html + jquery 把时间刻度、录像块、游标画在同一块画布上,再通过 mousedown、mousemove、wheel 把用户动作翻译成时间区间变化,拖动、放大缩小、点击返回时间点、增加录像块这些操作都能在一根轴上完成。它适合监控回放、录播剪辑、日志时间关联这类需要同时看到“全貌 + 局部”的场景,没有框架包袱,脚本挂进老页面就能跑起来。不过想把它改到顺手,先得搞清楚时间和像素是怎么互相换算的,否则后面每调一个交互都是在赌运气。
2. 时间与像素的换算:把一条时间轴拆成三层来画
拿到一个时间轴需求,我最先做的事不是写交互,是把“画什么”和“怎么定位”定死。时间轴本质上是两种坐标系的重叠:一边是单调递增的毫秒时间戳,一边是从 0 到 canvas 宽度的像素横轴。所有功能——拖动、缩放、点击定位、录像块落位——最后都收敛成同一件事:在两个坐标系之间来回换算。这一层算错了,上面的拖拽手感再好也是白搭。
2.1 三层结构:刻度层、内容层、交互层
新手最容易上手的写法是把整个绘制塞进一个 draw 函数里,先画线、再画块、最后画游标。能跑,但改两次就乱:想加一个 hover 高亮,得在几十行代码中间找插入点;想优化重绘,又分不清哪部分该跟着拖动变。我一般会按“稳定性”分层,从下到上依次是刻度层、内容层、交互层,画的时候按顺序画,后画的盖住先画的。
// 单 canvas 多阶段绘制:顺序即层级 function render() { const { width, height } = view; ctx.clearRect(0, 0, width, height); drawGrid(ctx, view); // 第一层:时间刻度线 + 标签 drawBlocks(ctx, view, blocks); // 第二层:录像块 + 通道条 drawCursor(ctx, view, cursorTime); // 第三层:回放游标 / 选区 if (hoverBlock) drawHover(ctx, hoverBlock, view); // 高亮永远压在最上层 }这里view是可视窗口对象,至少带startTime、endTime、width、height四个字段;blocks是录像块数组;cursorTime是当前游标时间。分层的好处是“重绘粒度”可控:拖动时只有块和游标的位置在变,刻度可以缓存;缩放时刻度需要按新的可视跨度重新采样,而块的位置只是换算结果变了。
不少人会问,为什么不用三块 canvas 叠 DOM?多 canvas 在层级调试上确实直观,但每块 canvas 都要单独做 devicePixelRatio 适配、单独绑事件、单独维护坐标换算,等于把一份工作拆成三份。单 canvas 多阶段绘制只需要绑一次事件,坐标换算也只有一套,代价是重绘时全量清除——后面讲性能优化会补上这一点。
// 刻度采样:可视跨度越大,主刻度越粗,避免标签糊成一片 function pickTickStep(span) { const steps = [1e3, 5e3, 1e4, 3e4, 6e4, 3e5, 6e5, 1.8e6, 3.6e6]; // 1s 到 1h const target = span / 10; // 目标是画 10 根左右主刻度 for (const s of steps) { if (s >= target) return s; } return steps[steps.length - 1]; }pickTickStep的思路是:先假设这一屏画 10 根主刻度比较好看,把可视跨度除以 10 得到理想步长,再从预设的“人眼舒服”的步长序列里挑第一个大于等于理想值的。这样缩放到任何跨度,刻度间距都不会出现 13 秒、47 分钟这种别扭数字。
2.2 时间戳到像素位置的双向映射
整个插件真正的核心不过两个函数。把它们写对,后面所有功能都是四则运算。
// 时间戳 -> 像素横坐标 function timeToX(t, view) { const span = view.endTime - view.startTime; if (span <= 0) return 0; return ((t - view.startTime) / span) * view.width; } // 像素横坐标 -> 时间戳 function xToTime(x, view) { const span = view.endTime - view.startTime; return view.startTime + (x / view.width) * span; }参数只有两个:view提供当前窗口的起止时间和像素宽度,t/x是待换算的值。这里我强烈建议把这两个函数当成唯一入口——绘制块用它做x1、x2,命中检测用它做反查,拖拽位移也用它做换算。实际项目里最常见的偏位事故,就是某个模块“顺手”手写了一份比例换算,结果和主流程差几像素,累加几百次之后直接偏出几秒。
另一个容易被忽略的点是大数精度。JavaScript 的 Number 是双精度浮点,能精确表达的安全整数上限是 2^53,毫秒级 Unix 时间戳完全在范围内,不会有问题。但如果你的数据带着微秒,或者需要做极端的加减运算,我一般会先把时间转成相对于数据起点的“相对毫秒”,界面层只用相对值,仅在回调和外部接口处转回绝对时间。
// 相对时间:界面层只用相对毫秒,避开了大数运算和时区显示问题 const baseTime = records[0].start; const toRelative = ts => ts - baseTime; const toAbsolute = rt => rt + baseTime;这样做还有个附带好处:刻度标签可以按“距起点 0、1、2 小时”来渲染,不用每次先算时区再算格式化,跨时区场景下的边界问题会少很多。
2.3 高 DPI 与 devicePixelRatio 的校正
在普通屏上画得很清楚的线,到了 2 倍屏笔记本上会变得毛糙。原因不复杂:canvas 的绘制缓冲区尺寸和它的 CSS 显示尺寸不是一回事。浏览器默认把 300x150 的缓冲区拉伸到 CSS 尺寸,屏幕像素密度一高,边缘就糊了。
// 把缓冲区放大到物理像素,再整体缩放上下文,绘制仍按 CSS 像素写 function resizeCanvas(canvas, ctx) { const dpr = window.devicePixelRatio || 1; const rect = canvas.getBoundingClientRect(); canvas.width = Math.round(rect.width * dpr); canvas.height = Math.round(rect.height * dpr); canvas.style.width = rect.width + 'px'; canvas.style.height = rect.height + 'px'; ctx.setTransform(dpr, 0, 0, dpr, 0, 0); // 之后按 CSS 像素绘制 }dpr是设备像素比,rect是 canvas 在页面上的实际占位。设置canvas.width/height时必须乘dpr,而 CSS 宽高保持原值,视觉尺寸才不变。setTransform(dpr, 0, 0, dpr, 0, 0)相当于给整个上下文加了一层缩放,于是timeToX返回的 CSS 像素坐标可以直接用,不用每处都乘除。
这里有一个坑:每次给canvas.width赋值,上下文状态都会被重置,之前设的setTransform也一并清零。所以resizeCanvas必须在窗口尺寸变化时重新调用,并且在里面重设 transform,不能只在初始化时设一次。
// 事件坐标统一用 getBoundingClientRect 换算,别用 offsetX function getLocalX(e, canvas) { const rect = canvas.getBoundingClientRect(); return e.clientX - rect.left; }offsetX看着更省事,但它受最近定位祖先和 CSS transform 影响,容器一旦被缩放或旋转就会飘。getBoundingClientRect + clientX是稳的,代价只是多一次布局读取,量级可以忽略。所有换算函数统一走这个入口,后面排查坐标问题时心里才有底。
3. 拖动与缩放:手感由这几个参数决定
同样的功能,有人做出来的时间轴拖起来像抹了黄油,有人做出来像拖着一块砖。差别不在代码量,而在几个具体参数:拖动阈值取多大、缩放锚点跟不跟鼠标、重绘是不是每帧合并、边界怎么钳制。这一章把这几个参数摊开讲,代码都能直接抄,但参数值要按你的数据密度微调。
3.1 拖动平移的增量计算与边界钳制
拖动最容易写错的是方向。用户把鼠标往右移,本意是“看更早的时间”,所以可视窗口应该往左走。换算的时候记住一条:先算位移,再反着挪窗口。
let drag = null; canvas.addEventListener('mousedown', e => { if (e.button !== 0) return; // 只响应左键 drag = { startX: getLocalX(e, canvas), startView: { ...view }, // 快照,避免本帧中途被别的交互改掉 moved: false }; }); window.addEventListener('mousemove', e => { if (!drag) return; const dx = getLocalX(e, canvas) - drag.startX; if (Math.abs(dx) > 3) drag.moved = true; // 3px 内不算拖动,后面 click 会用到 const span = drag.startView.endTime - drag.startView.startTime; const dt = (dx / view.width) * span; // 像素位移换算成时间位移 let newStart = drag.startView.startTime - dt; let newEnd = drag.startView.endTime - dt; // 先压头,再压尾,两次都调整,跨度不会被意外压缩 if (newStart < totalStart) { newStart = totalStart; newEnd = totalStart + span; } if (newEnd > totalEnd) { newEnd = totalEnd; newStart = totalEnd - span; } view.startTime = newStart; view.endTime = newEnd; requestRender(); });drag.startView用快照而不是实时view,是因为拖动过程中view自己在变;如果拿变化后的view当基准,位移会被重复累加,最后拖一下飞出去半屏,这是很典型的一类玄学 bug。dt的符号是正的(鼠标右移)而窗口减dt,方向就对了。
边界钳制这两段看着对称,其实有讲究:两次判断都做了“把另一端跟着拉”的动作,而不是单纯clamp(newStart)。假设跨度是 30 分钟,数据总共只有 10 分钟,单纯钳制newStart会把窗口缩成 0 宽;把newEnd跟着平移,跨度保持不变,最多是到头之后拖不动。
鼠标离开画布还在拖的情况也要处理,所以mousemove和mouseup绑在window上而不是canvas上。否则用户拖快了移出边界,松手事件丢失,状态机就卡在拖动态,后续点击全失效。
3.2 缩放锚点为什么必须跟着鼠标走
缩放有两种做法:以可视窗口中心为锚点,或者以鼠标位置为锚点。前者实现简单,但用起来别扭——用户把鼠标指到某个录像块上滚轮放大,结果那个块跑到了屏幕外面。以鼠标位置为锚点的做法是把鼠标下方那个时间点钉死,缩放前后它还在原来的横向比例位置,这才是符合直觉的手感。
canvas.addEventListener('wheel', e => { e.preventDefault(); // 阻止页面滚动,滚轮交给时间轴 const x = getLocalX(e, canvas); const anchorTime = xToTime(x, view); // 鼠标下方对应的时间点 const scale = e.deltaY > 0 ? 1.25 : 0.8; // 下滚缩小,上滚放大 let newSpan = (view.endTime - view.startTime) * scale; newSpan = Math.min(Math.max(newSpan, minSpan), maxSpan); // 跨度上下限 const ratio = x / view.width; // 锚点在视口中的相对位置 0~1 view.startTime = anchorTime - newSpan * ratio; view.endTime = view.startTime + newSpan; clampView(view); // 统一做一次总范围钳制 requestRender(); });minSpan和maxSpan是两个必须给的参数。minSpan防止缩到几毫秒看不见刻度,maxSpan防止缩到比总数据还宽、两侧全是空白。常见取值是一个秒级的下限加一个略大于总数据的上限,具体按录像块的最小粒度来定。
锚点数学其实就一行:startTime = anchorTime - newSpan * ratio。ratio是鼠标在画布上的横向百分比,缩放后锚点时间仍落在同一个百分比位置,视觉上就是“这块内容一直粘在鼠标下面”。clampView单独抽出来,是因为缩放和拖动都需要做总范围约束,散在两处容易写漏。
// 统一的视口钳制:先保跨度合法,再保起止在总范围内 function clampView(v) { const totalSpan = totalEnd - totalStart; let span = Math.min(Math.max(v.endTime - v.startTime, minSpan), maxSpan); span = Math.min(span, totalSpan); // 跨度不能超过总数据量 if (v.startTime < totalStart) { v.startTime = totalStart; v.endTime = totalStart + span; } if (v.endTime > totalEnd) { v.endTime = totalEnd; v.startTime = totalEnd - span; } }这段函数看着平淡,但它把“最小跨度、最大跨度、总范围”三个约束收在一处,是后期改需求时最不容易出错的一块。有些实现把钳制逻辑散在 wheel、mousemove、代码初始化三处,加一条“左右各留 5% 空白”的需求就得改三个地方,改漏一处就会出现“拖到头再缩就白屏”的现象。
3.3 requestAnimationFrame 与重绘合并
mousemove和wheel的触发频率跟着输入设备走,高刷屏上一帧能触发好几次。如果每次事件里都直接调render(),绘制次数远超屏幕刷新次数,掉帧就来了。我一般用一个 rAF 合并函数挡在中间,事件只负责改状态,绘制统一在下一帧做。
let rafId = 0; function requestRender() { if (rafId) return; // 本帧已经排过队,直接复用 rafId = requestAnimationFrame(() => { rafId = 0; render(); }); }rafId非零表示“已经排了”,再调只是改状态,不重复排队。这个模式对拖动、滚轮、播放游标推进都通用。想再进一步,可以在render内部按脏标记分层重绘:拖动只重画内容层和交互层,刻度层缓存到离屏 canvas,缩放或尺寸变化才重画刻度。
// 脏标记:把刻度层画到离屏 canvas,只在视口结构变化时更新 const gridCache = document.createElement('canvas'); function render() { if (dirty.grid) { gridCache.width = canvas.width; gridCache.height = canvas.height; const gctx = gridCache.getContext('2d'); drawGrid(gctx, view); dirty.grid = false; } ctx.clearRect(0, 0, view.width, view.height); ctx.drawImage(gridCache, 0, 0, view.width, view.height); drawBlocks(ctx, view, blocks); drawCursor(ctx, view, cursorTime); }dirty.grid在缩放、尺寸变化、可视跨度变化时置 true,拖动时不置。离屏 canvas 也要按devicePixelRatio处理,否则贴回来还是模糊的。这套缓存做不做,取决于你的时间轴是不是全屏大画布;小尺寸时间轴直接全量重绘反而省心。
还有一点,wheel事件必须preventDefault才能阻止页面跟着滚,但在部分浏览器里默认是被动监听,不一定能拦住。稳妥做法是绑定时显式声明非被动:
canvas.addEventListener('wheel', onWheel, { passive: false });不写{ passive: false },preventDefault可能无效果,用户滚时间轴整个页面也跟着跳,这种体验问题排查起来很费时间。
4. 点击回放与录像块:命中检测和数据建模
拖和缩解决的是“看哪里”,点击和录像块解决的是“看什么”。这两块的技术难点不在绘制,而在数据模型和命中检测的边界处理——块会重叠、块会窄到只有几像素、点击和拖动会互相干扰。数据模型建对了,很多边界问题会自动消失。
4.1 点击位置反查时间点
点击时间轴,最常见的行为是把回放游标定位到点击处,同时把时间抛给业务层去拉流。逻辑本身不复杂,麻烦的是“拖动结束时也会触发 click”这件事,得先按住它。
let movedSinceDown = false; canvas.addEventListener('mousedown', () => { movedSinceDown = false; }); canvas.addEventListener('mousemove', e => { if (Math.abs(e.movementX) + Math.abs(e.movementY) > 2) { movedSinceDown = true; // 只要动了,就标记为拖动 } }); canvas.addEventListener('click', e => { if (movedSinceDown) { movedSinceDown = false; return; // 拖动收尾的 click 直接吞掉 } const x = getLocalX(e, canvas); const t = xToTime(x, view); cursorTime = Math.min(Math.max(t, totalStart), totalEnd); emit('seek', cursorTime); // 把时间抛给播放器 requestRender(); });movedSinceDown用一个布尔量就够了,阈值 2px 是留给手抖的余量,和前面拖动的 3px 阈值不冲突,一个是判定“算不算拖动”,一个是判定“算不算点击”,两条线分工明确。emit是内部简单的事件分发,业务方监听seek拿到时间再决定是跳本地缓存还是走接口。
还有一种交互形态是“点击录像块本身”和“点击空白处”行为不同:点块是选中块,点空白是定位游标。这需要在 click 里先做一次命中检测,命中块就不发seek。
canvas.addEventListener('click', e => { if (movedSinceDown) { movedSinceDown = false; return; } const x = getLocalX(e, canvas); const hit = hitTest(x, view, blocks); if (hit) { selectedBlockId = hit.id; emit('blockClick', hit); // 选中块,不定位游标 } else { cursorTime = xToTime(x, view); emit('seek', cursorTime); } requestRender(); });hitTest的返回值是块对象或 null,selectedBlockId供绘制时高亮使用。这种“先命中、后定位”的判断顺序如果反了,用户永远选不中块,因为点击总是先被定位逻辑吃掉。
4.2 录像块的数据结构与绘制顺序
录像块看着只是一条带颜色的横条,但字段取舍决定了后面好不好扩展。我的习惯是最小字段起步,按需求再加,不要一开始就堆一堆用不到的字段。
// 录像块:这几个字段够撑起拖动、命中、绘制、序列化 const block = { id: 'rec_001', // 唯一标识,命中检测和选中态都靠它 start: 1710000000000, // 录像开始时间戳(ms) end: 1710001200000, // 录像结束时间戳(ms) color: '#3b82f6', // 块主色,按通道或状态区分 label: '通道 1', // 宽度足够时才绘制的文字 channel: 0 // 多通道场景下决定纵向位置 };绘制时有两个参数值得单独调:纵向位置和最小可见宽度。前者跟通道数有关,后者决定了极短录像块还看不看得见。
function drawBlocks(ctx, view, blocks) { const baseY = 40; // 第一个通道的纵向起点 const barH = 36; // 块高度 const gap = 8; // 通道间间距 const MIN_W = 2; // 再窄也保留 2px,否则看不见 blocks.forEach(b => { const x1 = timeToX(b.start, view); const x2 = timeToX(b.end, view); // 视口裁剪:完全在视口外的块直接跳过,长跨度时能救帧率 if (x2 < 0 || x1 > view.width) return; const w = Math.max(MIN_W, x2 - x1); const y = baseY + b.channel * (barH + gap); ctx.fillStyle = b.color; ctx.fillRect(x1, y, w, barH); // 只有宽度足够时才画文字,否则会糊成一团黑 if (w > 48) { ctx.fillStyle = '#fff'; ctx.font = '12px sans-serif'; ctx.textBaseline = 'middle'; ctx.fillText(b.label, x1 + 6, y + barH / 2); } }); }channel字段把块的纵向位置从绘制函数里解耦出来,加一路通道就是数据里多一个值,不用改绘制逻辑。MIN_W是经验值,2px 在高 DPI 屏上肉眼刚好能分辨,低于这个宽度用户根本点不中,不如不画。
块的数组顺序也有讲究。绘制顺序决定了视觉层级,命中顺序决定了重叠时选哪个。我一般要求业务层维护一个按start升序的数组,绘制和命中都用同一份顺序,从后往前遍历,保证“后画的在上面、命中时优先选中”。
4.3 新增录像块的交互闭环
“增加录像块”是这套插件里唯一需要同时处理“拖出选区”和“提交数据”的交互。完整闭环是:按下进入选区模式,移动时实时预览,松开校验并生成块,最后交给业务层持久化。
let select = null; // 框选模式由外部开关控制,避免和拖动平移打架 canvas.addEventListener('mousedown', e => { if (!selectMode) return; select = { startX: getLocalX(e, canvas), endX: null }; }); window.addEventListener('mousemove', e => { if (!select) return; select.endX = getLocalX(e, canvas); requestRender(); // 选区预览跟着鼠标走 }); window.addEventListener('mouseup', () => { if (!select || select.endX == null) { select = null; return; } const x1 = Math.min(select.startX, select.endX); const x2 = Math.max(select.startX, select.endX); if (x2 - x1 < 4) { select = null; return; } // 4px 内算误触,丢弃 const newBlock = { id: 'rec_' + Date.now(), start: xToTime(x1, view), end: xToTime(x2, view), color: '#3b82f6', label: '新录像', channel: 0 }; blocks.push(newBlock); blocks.sort((a, b) => a.start - b.start); // 保持有序,命中时才稳定 select = null; emit('blockAdd', newBlock); // 交给业务层决定落不落库 requestRender(); });selectMode这个开关是关键。拖动平移和框选都是 mousedown 起手,如果两种模式同时开,用户永远不知道自己按下去会发生什么。常见做法是用一个工具栏按钮切换,或者按住 Shift 进入框选、默认是平移。4px阈值挡的是“点一下没拖”这种误操作,不然单击一下就凭空多出一个几毫秒的假块。
blocks.sort在每次新增后调一次,看起来浪费,其实块数量通常在几百级别,排序开销可以忽略。但如果你的场景会一次导入上万条录像,排序要挪到导入之后统一做,不要放进逐个 push 的循环里。
选择模式下的预览绘制也需要注意,选区矩形要画在块之后、游标之前,颜色用半透明,否则会盖住下面块的标签。
function drawSelection(ctx, select, view) { if (!select || select.endX == null) return; const x1 = Math.min(select.startX, select.endX); const w = Math.abs(select.endX - select.startX); ctx.fillStyle = 'rgba(59, 130, 246, 0.25)'; ctx.fillRect(x1, 0, w, view.height); // 贯穿整列,视觉上更像时间选区 ctx.strokeStyle = '#3b82f6'; ctx.strokeRect(x1, 0, w, view.height); }rgba的透明度是手感参数,0.2~0.3 之间比较舒服,太高会盖住底色,太低看不见。边框和填充用同一个色值,视觉上才是一个整体,这种细节在评审时往往最容易被挑。
5. 避坑与排查:canvas 时间轴最常翻车的四个地方
时间轴这类交互密集的组件,bug 从来不是“写错了”,而是“两套逻辑撞在一起”。下面四条是同类项目里复现率最高的,每条都按现象、原因、处理写清楚,照着对照基本能定位掉大部分线上问题。
5.1 拖完松手触发 click
现象:用户按住时间轴横向拖了一段,松手后回放游标反而跳到了松手位置,录像是没跟着动的。
原因:浏览器的默认行为是 mousedown 和 mouseup 在同一个元素上时补发一次 click,它不关心中间移动了多少像素。拖动逻辑和点击定位逻辑如果各写各的,就会同时收到这次 click,导致“拖完又跳一下”。
处理:在 mousedown 里清标记,在 mousemove 里按位移阈值置标记,在 click 里先看标记再决定要不要定位。阈值取 2~5px 之间,太小会把轻微手抖当成拖动,太大则拖了明显一段还判定为点击。同时注意mousemove绑在window上而不是canvas上,否则拖出边界后标记置不上,click 判断就失效了。更彻底的做法是不用原生 click,自己在mouseup里判断moved之后手动触发定位,可控性更高。
5.2 缩放后画面发虚、坐标偏移
现象一:在 2 倍屏上,竖线和文字边缘发虚,像蒙了一层灰。现象二:放大后点击某个块,实际落点和视觉位置差十几像素。
原因一:canvas.width/height没有按devicePixelRatio放大,或者放大了但setTransform只设了一次,后续 resize 时被浏览器重置。原因二:事件坐标用了offsetX,而容器上有 CSS transform 或缩放,导致该值和 canvas 内部坐标系对不上。
处理:在统一的resizeCanvas里设置缓冲区尺寸和setTransform,并在 resize、方向变化、容器尺寸变化时都重新调用;事件坐标统一走getBoundingClientRect + clientX。另外检查timeToX返回的是 CSS 像素还是物理像素——如果绘制时用的是 CSS 像素,命中检测也必须用 CSS 像素,两边混用就是差一个dpr的比例。这个坑我见过不止一次,最后都是靠把换算函数收口到一处才彻底解决。
5.3 长跨度时间轴卡顿
现象:时间轴拉到几小时以上跨度,拖动时帧率明显掉,鼠标跟手但画面一顿一顿。
原因:每帧对全部块做一次fillRect,加上刻度标签的全量重绘,块数量到几千时单帧绘制耗时超过 16ms。如果块没有视口裁剪,绝大多数绘制都落在画布外面,纯属浪费。
处理:第一步加视口裁剪,x2 < 0 || x1 > view.width的块直接return,这一条通常能砍掉 80% 以上的绘制量。第二步给刻度做分层采样,跨度越大主刻度步长越粗,标签数量控制在 10 个左右。第三步把刻度层缓存到离屏 canvas,只有缩放或尺寸变化时才重绘。三步做完,几千个块在长跨度下也能稳定在 60 帧附近。如果还卡,就把块按通道分组,只重绘发生变化的通道。
5.4 录像块重叠后点击选错
现象:同一个通道里两段录像时间有重叠,用户点击重叠区域,选中的始终是下面那个块,上面那个怎么点都点不中。
原因:命中检测从数组前往后遍历,或者从绘制顺序的形状看是从下往上找,第一个匹配就返回,结果永远返回下面那个。
处理:命中检测从数组末尾往前遍历,后加入、后绘制的块优先命中,和视觉层级保持一致。
function hitTest(x, view, blocks) { const t = xToTime(x, view); for (let i = blocks.length - 1; i >= 0; i--) { // 倒序 = 从最上层开始 const b = blocks[i]; if (t >= b.start && t <= b.end) return b; } return null; }如果产品要求“重叠时按开始时间最近的优先”,那就不能简单倒序,得在命中后比较候选块的start与点击时间的距离,取最近的。另外点击块和点击边缘的行为要分开:块内左右各留 6px 作为“拖拽改长度”的热区,中间才是选中,这个热区宽度要跟着dpr和小尺寸块做下限保护,否则窄块永远进不了编辑态。
6. 可验证的封装:用一张对照表把插件调到顺手
功能都能跑之后,真正决定这套东西好不好维护的,是它有没有被封装成一个可配置、可自检的组件。用 jQuery 的场景下,我一般用$.fn挂一个实例,把配置项在初始化时就定死,避免参数散落在业务代码里。
// jQuery 插件形态:实例挂在元素上,重复初始化不会叠加 $.fn.timeline = function (options) { return this.each(function () { const $el = $(this); if ($el.data('timeline')) return; // 已初始化则跳过 const instance = new Timeline(this, $.extend({ minSpan: 5 * 60 * 1000, // 最小可视跨度 5 分钟 maxSpan: 24 * 3600 * 1000, // 最大可视跨度 24 小时 tickTarget: 10, // 目标主刻度数量 blockMinWidth: 2, // 块最小可见宽度(CSS px) dragThreshold: 3, // 判定拖动的最小位移 selectMode: false // 是否开启框选新增 }, options)); $el.data('timeline', instance); }); };参数集中在一个对象里,好处是调手感的时候不用满文件搜数字。下面这张对照表是我每次接新需求时会先填一遍的,填完基本就知道这套时间轴的边界在哪。
| 参数 | 控制的行为 | 常见取值 | 调大 / 调小的影响 |
|---|---|---|---|
minSpan | 最多能放大到什么粒度 | 1~10 分钟 | 调小能看清秒级录像,但刻度标签会挤 |
maxSpan | 最多能缩小到什么粒度 | 略大于总时长 | 调大两侧出现空白,调小拖不到头 |
tickTarget | 主刻度密度 | 8~12 | 调大标签重叠,调小刻度显得空 |
blockMinWidth | 极短录像块的可见宽度 | 2~4 px | 调大窄块失真,调小点不中 |
dragThreshold | 拖动和点击的分界 | 2~5 px | 调大误判为点击,调小误判为拖动 |
selectMode | 按下去是平移还是框选 | 布尔开关 | 两个都开会互相抢事件 |
填完表还不算完,交付前我会按下面这份清单手动走一遍,比写单元测试快,覆盖的又都是真实交互:
- 拖到最左、最右各一次,确认不会拖出空白,松手后游标不动。
- 在块上滚轮放大到
minSpan,确认锚点下方的块还在鼠标下面。 - 缩到
maxSpan,确认两侧不出空白、刻度标签不重叠。 - 在有重叠的通道里点重叠区,确认选中的是最上层块。
- 开框选模式拖一个 3px 的小段,确认不生成新块。
- 窗口从 1 倍屏拖到 2 倍屏,确认线条不发虚、点击不偏移。
这套清单跑下来,八成的问题在交付前就暴露了。从那以后我每次接时间轴类需求,都会先把这张表和清单过一遍,再动业务代码——不是流程洁癖,是被“拖完跳一下”“缩放后点不准”这类问题坑过太多次,宁可前期多花二十分钟。插件源码和示例页面解压后直接打开就能改参数试手感,建议先拿自己的数据喂一遍,再决定要不要接进主流程。希望帮到你。
本文还有配套的精品资源,点击获取