简介:这是一份基于 canvas 实现的时间轴插件源码,面向需要在前端项目中实现视频回放时间轴、录像片段管理等交互功能的开发者,尤其适合具备一定 JavaScript 与 jQuery 基础的中级前端人员。插件支持拖动时间轴、点击定位返回时间点、动态增加录像块以及放大缩小视图等操作,可直接嵌入视频回放类业务场景。压缩包共 6 个文件,包含 3 个 js 脚本、2 个 html 示例页面和 1 个 md 说明文档,整体约 90KB,体积轻量,便于快速集成与二次开发。其中 js 文件承载时间轴核心逻辑与交互控制,html 页面提供可直接运行的演示入口,md 文档则补充使用说明,方便读者理解调用方式。目前已有 4077 人学习下载,说明该方案在实际项目中具备一定参考价值。读者可借此掌握 canvas 绘制时间轴、事件绑定与视图缩放等实现思路,并对照示例快速改造为自己的回放时间轴组件。
1. 时间轴插件为什么值得用 Canvas 重写一遍
做过录像回放、监控检索、音视频剪辑这类界面的人,大概率都碰过同一个问题:时间轴用 DOM 堆出来,一开始挺顺,录像块一多、缩放一拉、拖动一频繁,页面就开始卡,滚动条抖,鼠标事件对不上位置。我最早也是拿 div 拼的,几十个录像块还行,上千个直接掉帧,后来换成 Canvas 重画,才算把这块心病去掉。
这个标题说的东西很具体:一个用 Canvas 写的时间轴插件,支持拖动、点击返回时间点、增加录像块、放大缩小。它解决的是「时间轴可视化 + 交互」这一整类需求,适合做安防回放、录屏剪辑、日志时间定位的开发者。核心思路是把时间轴当成一块画布,所有刻度、录像块、游标都画上去,交互靠坐标换算反推时间。下面我按自己落地的顺序,把选型、实现、参数和坑一条条讲清楚。
2. 先把时间轴的数据模型和坐标换算立住
Canvas 时间轴翻车最多的地方,不是画不出来,而是「画出来的东西和鼠标点的地方对不上」。根子在于没有把时间、像素、缩放三者的关系定义清楚。这一章先把模型立住,后面所有交互都从这里推。
2.1 时间轴的三层结构:总时长、可视窗口、缩放比
我一般把时间轴拆成三个量:totalDuration(总时长,单位毫秒)、viewStart(可视窗口左边界对应的时间)、pxPerMs(每毫秒占多少像素,也就是缩放比的倒数)。可视窗口宽度canvasWidth和缩放比的关系是:
pxPerMs = canvasWidth / visibleDuration visibleDuration = canvasWidth / pxPerMs拖动改变的是viewStart,缩放改变的是pxPerMs。这两个量一变,重画一遍即可。关键约束是viewStart不能小于 0,也不能让右边界超过totalDuration,否则会出现「拖到空白区」的玄学现象。
时间转像素、像素转时间的公式必须成对出现,且只写一份:
// 时间(ms) -> 画布x坐标 function timeToX(t, viewStart, pxPerMs) { return (t - viewStart) * pxPerMs; } // 画布x坐标 -> 时间(ms) function xToTime(x, viewStart, pxPerMs) { return viewStart + x / pxPerMs; }逻辑说明:timeToX用于绘制,xToTime用于点击和拖动时反推时间。参数viewStart是当前窗口左边界时间,pxPerMs是缩放比。这两个函数是全篇的地基,任何一处自己手写换算,迟早对不上。
2.2 录像块的数据结构怎么定
录像块本质是一段时间区间,我用的结构是:
const record = { id: 'r_001', start: 1700000000000, // 起始时间戳(ms) end: 1700000060000, // 结束时间戳(ms) color: '#3b82f6', // 可选,按类型着色 type: 'normal' // 可选,用于区分报警/普通 };逻辑说明:start/end用绝对时间戳而不是相对偏移,好处是新增录像块时不用重算全量,直接 push 再排序即可。参数color和type是给业务层留的口子,报警录像标红、普通录像标蓝,这类需求几乎每个项目都会提。
提示:录像块数组建议按
start升序维护,绘制时可以用二分找到可视窗口内的第一块,避免每帧遍历上千条数据。
2.3 用 requestAnimationFrame 还是按需重绘
Canvas 时间轴不需要一直刷。我的做法是「按需重绘」:拖动、缩放、数据变更时置一个dirty标记,在requestAnimationFrame里统一重画。这样拖动时最多 60fps,静止时零开销。
let dirty = false; function markDirty() { if (dirty) return; dirty = true; requestAnimationFrame(() => { dirty = false; draw(); }); }逻辑说明:markDirty做了节流,同一帧内多次调用只重画一次。参数上没有额外配置,requestAnimationFrame由浏览器调度。相比setInterval定时重绘,这种方式在静止时完全不占 CPU,笔记本风扇不会一直转。
3. 拖动、点击返回时间点、缩放三件套怎么落地
模型立住之后,交互就是坐标换算的应用题。这一章把三个核心交互逐个拆开,每个都给可抄的代码和参数说明。
3.1 拖动平移:mousedown/mousemove/mouseup 的完整闭环
拖动的本质是:按下时记录起始 x 和当时的viewStart,移动时算出位移对应的时长,反推新的viewStart。
let dragging = false; let dragStartX = 0; let dragStartViewStart = 0; canvas.addEventListener('mousedown', (e) => { dragging = true; dragStartX = e.offsetX; dragStartViewStart = viewStart; }); window.addEventListener('mousemove', (e) => { if (!dragging) return; const dx = e.offsetX - dragStartX; // 位移像素换算成时间偏移,注意方向:向右拖,窗口左边界时间减小 let next = dragStartViewStart - dx / pxPerMs; // 边界钳制 const maxStart = Math.max(0, totalDuration - canvasWidth / pxPerMs); viewStart = Math.min(Math.max(0, next), maxStart); markDirty(); }); window.addEventListener('mouseup', () => { dragging = false; });逻辑说明:mousemove绑在window而不是canvas上,是为了防止鼠标拖出画布后事件丢失,这是血泪经验。参数maxStart是右边界钳制值,保证窗口不会拖出总时长。方向别搞反:向右拖动,看到的是更早的时间,所以viewStart要减小。
3.2 点击返回时间点:区分单击和拖动
点击返回时间点,就是xToTime(e.offsetX)。但拖动结束时也会触发 click,所以要判断位移阈值。
let downX = 0, downY = 0; canvas.addEventListener('mousedown', (e) => { downX = e.offsetX; downY = e.offsetY; }); canvas.addEventListener('click', (e) => { const moved = Math.abs(e.offsetX - downX) + Math.abs(e.offsetY - downY); if (moved > 4) return; // 超过4px视为拖动,不触发点击 const t = xToTime(e.offsetX, viewStart, pxPerMs); onTimePick(t); // 业务回调 });逻辑说明:moved阈值取 4px,是手抖和真实拖动的经验分界。参数onTimePick是业务层回调,拿到时间戳后去 seek 播放器。注意xToTime的结果要钳制在[0, totalDuration]内,防止点到画布边缘外。
3.3 缩放:以鼠标位置为锚点,别以左边界为锚点
缩放最容易翻车的地方是锚点。如果以左边界为锚点,鼠标指着某个录像块放大,那个块会跑掉,体验很差。正确做法是以鼠标位置对应的时间为锚点,缩放后让这个时间仍在鼠标下方。
canvas.addEventListener('wheel', (e) => { e.preventDefault(); const anchorTime = xToTime(e.offsetX, viewStart, pxPerMs); const factor = e.deltaY < 0 ? 1.1 : 1 / 1.1; // 滚轮上滚放大 let nextPxPerMs = pxPerMs * factor; // 缩放比钳制 nextPxPerMs = Math.min(Math.max(nextPxPerMs, minPxPerMs), maxPxPerMs); // 反推新的 viewStart,使 anchorTime 仍在 e.offsetX 处 viewStart = anchorTime - e.offsetX / nextPxPerMs; pxPerMs = nextPxPerMs; // 边界钳制 const maxStart = Math.max(0, totalDuration - canvasWidth / pxPerMs); viewStart = Math.min(Math.max(0, viewStart), maxStart); markDirty(); });逻辑说明:factor取 1.1 是每格滚轮的缩放步长,太大跳变、太小没手感。参数minPxPerMs/maxPxPerMs是缩放上下限,一般按「最小能看到 1 秒、最大能看到全部时长」来定。锚点公式viewStart = anchorTime - offsetX / nextPxPerMs是核心,记住它,缩放就不会飘。
3.4 增加录像块:插入后排序并触发重绘
新增录像块就是往数组里塞一条,然后排序、标记重绘。如果新块和已有块重叠,业务上要么合并要么拒绝,我一般交给业务层决定。
function addRecord(record) { records.push(record); records.sort((a, b) => a.start - b.start); markDirty(); }逻辑说明:排序保证绘制时二分查找可用。参数record需满足 2.2 的结构。如果录像块数量上万,sort每次全量排会有开销,可以改成插入到正确位置,但一般千级数据全量排足够快。
4. 绘制层:刻度、录像块、游标怎么画才不糊
交互对了,画得糊一样没人用。这一章讲绘制细节,重点是高清屏适配和分层绘制。
4.1 高清屏适配:devicePixelRatio 必须处理
Canvas 在 Retina 屏上默认会模糊,因为 CSS 像素和物理像素是 1:2 或 1:3。必须按devicePixelRatio放大画布再缩放上下文。
function resizeCanvas() { const dpr = window.devicePixelRatio || 1; const rect = canvas.getBoundingClientRect(); canvas.width = rect.width * dpr; canvas.height = rect.height * dpr; canvas.style.width = rect.width + 'px'; canvas.style.height = rect.height + 'px'; ctx.setTransform(dpr, 0, 0, dpr, 0, 0); // 之后按CSS像素绘制 canvasWidth = rect.width; markDirty(); } window.addEventListener('resize', resizeCanvas);逻辑说明:setTransform把绘制坐标系映射回 CSS 像素,之后所有绘制代码按 CSS 像素写即可。参数dpr由浏览器提供。注意resize后要重算canvasWidth,否则坐标换算会错。
4.2 刻度自适应:根据 pxPerMs 决定刻度粒度
刻度不能固定间隔,否则放大后全是重复标签,缩小后挤成一团。我按「目标刻度间距约 80px」反推时间粒度。
const STEPS = [1, 5, 10, 50, 100, 500, 1000, 5000, 10000, 30000, 60000, 300000, 600000]; function pickStep(pxPerMs) { const targetPx = 80; const targetMs = targetPx / pxPerMs; for (const s of STEPS) { if (s >= targetMs) return s; } return STEPS[STEPS.length - 1]; }逻辑说明:STEPS是候选刻度粒度(毫秒),从 1ms 到 10 分钟。参数targetPx是期望的刻度像素间距,80px 是标签不重叠的经验值。pickStep返回第一个不小于目标粒度的档位,保证刻度不会太密。
4.3 录像块绘制:可视区裁剪 + 圆角矩形
录像块多的时候,只画可视窗口内的,能省大量绘制开销。
function drawRecords() { const viewEnd = viewStart + canvasWidth / pxPerMs; for (const r of records) { if (r.end < viewStart) continue; if (r.start > viewEnd) break; // 已排序,可提前退出 const x1 = timeToX(r.start, viewStart, pxPerMs); const x2 = timeToX(r.end, viewStart, pxPerMs); ctx.fillStyle = r.color || '#3b82f6'; roundRect(ctx, x1, 40, Math.max(x2 - x1, 2), 24, 4); ctx.fill(); } }逻辑说明:break依赖数组已排序,这是 2.2 排序的回报。参数Math.max(x2 - x1, 2)保证极短的录像块也有 2px 可见宽度,否则会「消失」。roundRect是自实现的圆角矩形,Canvas 原生roundRect兼容性看目标浏览器。
4.4 游标和当前时间指示线
游标就是一条竖线,位置由当前播放时间决定。
function drawCursor(currentTime) { const x = timeToX(currentTime, viewStart, pxPerMs); if (x < 0 || x > canvasWidth) return; ctx.strokeStyle = '#ef4444'; ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, canvas.height); ctx.stroke(); }逻辑说明:先判断游标是否在可视区内,不在就不画。参数currentTime由播放器提供。lineWidth取 2 是为了在高清屏上也清晰。
5. 避坑与排查:时间轴插件最常见的 5 个翻车点
这一章是我踩过的坑,按「现象 → 原因 → 解决」写,能帮你省下不少后悔药。
5.1 拖动时鼠标移出画布,事件丢失
现象:拖到画布外松手,时间轴卡在拖动状态,再移动鼠标还在拖。 原因:mousemove/mouseup绑在了canvas上,鼠标移出后不再触发。 解决:把mousemove和mouseup绑到window,如 3.1 所示。这是最经典的一个坑。
5.2 缩放后点击位置偏移
现象:放大后点击某个录像块,返回的时间点偏了几秒。 原因:xToTime用的pxPerMs是缩放前的旧值,或者viewStart没同步更新。 解决:确保pxPerMs和viewStart在缩放时原子更新,且xToTime只读当前值。别在事件里缓存旧值。
5.3 录像块数量上千后拖动掉帧
现象:录像块超过 1000 条,拖动明显卡顿。 原因:每帧遍历全部录像块,且没有可视区裁剪。 解决:数组按start排序,绘制时用二分找到可视区起点,遇到r.start > viewEnd直接break。如 4.3。
5.4 高清屏上线条发虚
现象:刻度线和游标在笔记本上模糊,像蒙了一层。 原因:没处理devicePixelRatio,画布被拉伸。 解决:按 4.1 设置canvas.width = rect.width * dpr并setTransform。注意resize后要重设。
5.5 滚轮缩放导致页面滚动
现象:在时间轴上滚轮,页面跟着上下滚。 原因:wheel事件默认行为没阻止。 解决:在wheel回调里e.preventDefault()。注意如果用了passive: true监听,preventDefault无效,要显式设{ passive: false }。
6. 进阶:把时间轴插件做成可复用组件的几个技巧
写到能跑只是及格,做成能复用的组件才算落地。这一章讲几个我常用的进阶技巧,收在具体做法上。
第一个技巧是「配置对象化」。把所有可调参数收进一个options,外部只传关心的项,其余走默认值。这样同一个插件能适配监控回放和剪辑两种场景。
const defaultOptions = { pxPerMs: 0.01, minPxPerMs: 0.001, maxPxPerMs: 1, trackHeight: 24, trackTop: 40, cursorColor: '#ef4444', onTimePick: () => {} }; function createTimeline(canvas, records, userOptions) { const opts = Object.assign({}, defaultOptions, userOptions); // ... 初始化逻辑 }逻辑说明:Object.assign做浅合并,够用。参数pxPerMs默认 0.01 表示 1 秒占 10px,适合监控场景;剪辑场景可以传更大的值。onTimePick是点击回调,业务层注入。
第二个技巧是「事件解耦」。插件内部只负责把时间点抛出去,不直接操作播放器。这样插件不依赖具体播放器,换一个播放器只改回调。
第三个技巧是「销毁方法」。组件用完要能干净卸载,否则window上的mousemove监听会泄漏。
function destroy() { window.removeEventListener('mousemove', onMouseMove); window.removeEventListener('mouseup', onMouseUp); window.removeEventListener('resize', resizeCanvas); canvas.removeEventListener('wheel', onWheel); // 清空画布 ctx.clearRect(0, 0, canvas.width, canvas.height); }逻辑说明:把所有绑在window和canvas上的监听逐个移除,顺序无所谓,但一个都不能漏。参数上没有,纯清理逻辑。单页应用里不写destroy,路由切换几次后监听会堆叠,表现为「点一下触发好几次」,这个坑我踩过。
最后一个技巧是「用离屏 Canvas 缓存静态层」。刻度和背景网格变化频率低,可以画到离屏 Canvas,拖动时只重画录像块和游标,性能能再提一档。做法是建一个document.createElement('canvas'),在viewStart/pxPerMs变化时重画静态层,主循环里drawImage贴上去。这个优化在录像块上千、拖动要求 60fps 的场景下值得做,数据量小的时候反而增加复杂度,按需上。
我自己的习惯是:先把交互和坐标换算写对,再谈性能优化,顺序反了会一直在调玄学 bug。时间轴这东西,坐标换算对了,剩下都是体力活。希望帮到你。
本文还有配套的精品资源,点击获取