资讯详情

资讯详情

Canvas 重写监控回放时间轴:jQuery 插件与性能优化

简介面向视频回放与媒体剪辑场景的 Canvas 时间轴插件适合需要在前端实现可交互时间轴的中初级开发者参考。它围绕拖动浏览、点击定位、录像块增删和缩放查看等交互展开可用于视频回放进度控制、监控片段标注等业务帮助解决原生时间轴组件定制成本高、交互细节难处理的问题。压缩包共 6 个文件约 90KB包含 3 个 JavaScript 文件、2 个 HTML 演示页和 1 个 Markdown 说明含 Canvas 绘制逻辑、jQuery 示例调用与使用说明便于对照运行和移植。已有 4080 人学习说明其在同类需求中具有一定参考价值。读者可从中了解 Canvas 坐标换算、拖拽与点击事件处理、时间刻度绘制、录像块数据映射和缩放状态维护等实现思路并借助演示页快速验证效果后接入自身项目适合作为视频回放模块的轻量参考实现。1. 监控回放的进度条拖不动我改用 canvas 重写了一个时间轴插件一块 24 小时录像回放面板需求砸下来就四条拖着看、滚轮放大看细节、点一下跳到某个时刻、在轴上打标记块。第一版用 DOM 拼——每个录像块一个 absolute 定位的 div几百个块叠上去拖动时left一路改浏览器掉帧掉到没法看鼠标一松还得等半秒才跟上。后来我把整条轴换成一张 canvas重写成 canvas 时间轴插件外壳还是 jQuery 的$.fn扩展调用方一句$(#wrap).timeline({...})挂上去内部自己管坐标系、自己管重绘。这套东西适合两类人一类是做视频回放、录制剪辑、日志切片这类带时间刻度的面板需要拖和缩的另一类是手里已经有 jQuery 老项目不想为了一个控件引入整套重型图表库的。下面按我拆解的顺序讲从架构、绘制、事件到踩坑和改造代码都能直接抄。2. 插件分层与目录jQuery 外壳裹住 canvas 内核五个文件各管一件事这个插件最先要解决的不是画得漂不漂亮而是「怎么在老项目里低摩擦地装进去又不把绘制逻辑焊死在 jQuery 上」。所以拆成两层外壳层只负责 jQuery 插件约定、参数合并、实例缓存、链式调用内核层是一个纯 JS 的Timeline构造器只管状态、坐标、绘制、事件。两层之间只通过this.$el、this.$canvas和几个回调通信。这么拆的好处很直接——哪天要迁到别处把内核整个搬走外壳二十行代码重写即可。2.1 外壳与内核的边界在哪外壳只做四件事把用户传的 options 和默认配置合并、把实例挂到$el.data(timeline)上做单例复用、把字符串形式的调用路由到实例方法比如$(#x).timeline(addSegment, {...})、把若干实例方法暴露成链式 API。内核不importjQuery也不碰 DOM 结构以外的东西它只认一个 canvas 元素和一个配置对象。这个边界一定要在动手前画清楚我见过太多插件写着写着绘制函数里开始$(this).closest(.panel).find(.toolbar).hide()最后想抽都抽不出来。下面这段是外壳骨架$.extend做参数合并是 jQuery 老项目里最省事的做法比手写 deepMerge 少踩坑。(function ($) { use strict; // 默认配置调用方不传就用这一套注意每一项都在注释里写清楚单位 var DEFAULTS { width: 0, // 0 表示跟随容器宽度自适应布局用 height: 76, // 画布高度CSS 像素不含 DPR 放大 pxPerMs: 0.02, // 初始缩放1 毫秒对应多少像素 duration: 24 * 3600 * 1000, // 总时长毫秒 minScale: 0.0005, // 缩放下限再小就没意义了 maxScale: 4, // 缩放上限防止放大到块边缘发毛 onTimeClick: null // 点击返回时间点的回调 }; function Timeline($el, options) { this.$el $el; this.opts $.extend({}, DEFAULTS, options || {}); this.segments []; // 录像块数组始终按 start 升序 this.viewStart 0; // 视口左边界对应的时间毫秒 this.scale this.opts.pxPerMs; this.currentTime 0; // 游标时间 this._raf null; // 合帧用的句柄 } Timeline.prototype.init function () { // 只创建一层容器 一张 canvas别在轴上堆任何 DOM 节点 this.$wrap $(div classtl-wrap/div); this.canvas document.createElement(canvas); this.ctx this.canvas.getContext(2d); this.$wrap.append(this.canvas); this.$el.append(this.$wrap); this._resize(); this._bindEvents(); this.draw(); return this; }; $.fn.timeline function (options) { var args Array.prototype.slice.call(arguments, 1); return this.each(function () { var $this $(this); var inst $this.data(timeline); if (!inst) { inst new Timeline($this, options); $this.data(timeline, inst); inst.init(); } else if (typeof options string $.isFunction(inst[options])) { // 支持 $(#x).timeline(addSegment, seg) 这种命令式调用 inst[options].apply(inst, args); } }); }; })(jQuery);这段的关键在于$this.data(timeline)这个实例缓存。没有它页面上任何一次误调用都会重复init一张 DOM 里塞两张 canvas第二张盖在第一张上面画上去的东西永远只显示一半——这种问题查起来是纯玄学因为日志里一切正常。参数合并用$.extend({}, DEFAULTS, options)第一层空对象不能省省了就把 DEFAULTS 给改了第二个实例会继承第一个实例改过的配置。2.2 目录结构与文件职责实际工程里我会拆成下面这几个文件行数不多但职责要单一方便后面按需替换某一块。timeline-plugin/ ├── src/ │ ├── jquery.timeline.js # 外壳$.fn 注册、参数合并、实例缓存约 60 行 │ ├── timeline.core.js # 内核状态机、坐标换算、公有 API约 200 行 │ ├── timeline.draw.js # 绘制刻度、录像块、游标、框选约 260 行 │ ├── timeline.event.js # 交互鼠标、滚轮、键盘约 180 行 │ └── timeline.util.js # 工具时间格式化、节流、二分查找 ├── dist/ │ └── jquery.timeline.min.js └── demo/ └── index.htmltimeline.core.js里放的是不涉及绘制的纯状态逻辑比如timeToX、xToTime、clampView、setCurrentTime、addSegment、removeSegment。timeline.draw.js里所有方法都接收ctx作为第一个参数不直接读this.ctx这样单元测试里可以塞一个 mock context 进去断言调用次数。timeline.event.js单独拆出来的理由是事件绑定和解绑必须成对拆开了才好写destroy()混在核心文件里很容易出现「实例销毁了document 上的 mousemove 还挂着」这种内存泄漏。用script直接引的话注意顺序util → core → draw → event → 外壳。这几个文件通过挂到同一个命名空间对象上来共享方法比如Timeline.prototype.draw function () {...}谁先加载谁先挂外壳最后注册$.fn。2.3 时间轴的三套坐标系与两行换算整个插件最容易翻车的地方是把「画布上的像素」和「时间轴上的毫秒」搅在一起用。我在代码里强制分三套CSS 像素坐标cssWidth/cssHeight跟布局一致、画布物理像素canvas.width等于 CSS 像素乘 DPR、时间坐标毫秒。绘制时一律按 CSS 像素写DPR 缩放通过ctx.setTransform一次性做掉换算只在两个函数里发生。// 毫秒 - 画布 xCSS 像素 Timeline.prototype.timeToX function (t) { return (t - this.viewStart) * this.scale; }; // 画布 xCSS 像素 - 毫秒 Timeline.prototype.xToTime function (x) { return this.viewStart x / this.scale; }; // 把视口锁死在 [0, duration] 区间内防止拖到空白区 Timeline.prototype.clampView function () { var viewDuration this.cssWidth / this.scale; // 当前视口覆盖的时间跨度 var maxStart Math.max(0, this.opts.duration - viewDuration); if (this.viewStart 0) this.viewStart 0; if (this.viewStart maxStart) this.viewStart maxStart; };timeToX和xToTime必须严格互逆任何一次改动都要两个一起改。中间加过一版「为了性能把 scale 换成整数」结果timeToX用新 scale、xToTime忘了改点击落点整体偏差位置误差随距离线性放大越往右点偏得越多——那会儿排查了半天最后发现是两行函数不同步。clampView里viewDuration是「视口宽度除以缩放比」这一项决定了能不能拖到末尾。当缩放级别小到整个时间轴都塞得下时viewDuration durationmaxStart就是负的被Math.max(0, ...)钳成 0此时轴上不该允许拖动——所以拖动分支里还得补一个判断if (this.cssWidth / this.scale this.opts.duration) return;。这一步不做用户能把内容拖出左边界松手之后画面里空一大片。3. 渲染链路拆解一帧 draw() 里刻度、录像块、游标、框选的绘制顺序canvas 没有图层所有东西画在同一块位图上先画的会被后画的覆盖。所以绘制顺序本身就是设计的一部分背景 → 刻度尺 → 录像块 → 选中态 → 游标 → 框选 → 文字标注。我把这个顺序固定在一个draw()里任何需要重绘的动作都只调这一个入口不散在各个事件回调里。这么做的代价是每次都要全量重画好处是永远不可能出现「某个部分忘了更新」这种状态不一致。3.1 draw() 的固定执行序列Timeline.prototype.draw function () { var ctx this.ctx; var W this.cssWidth, H this.cssHeight; // 1. 先清整块用 clearRect 而不是 fillRect // 因为容器背景是 CSS 给的画布本身要透出去 ctx.clearRect(0, 0, W, H); this._drawRuler(ctx, W, H); // 2. 顶部刻度尺 网格线 this._drawSegments(ctx, W, H); // 3. 录像块 this._drawSelection(ctx, W, H); // 4. 选中块的高亮描边 this._drawCursor(ctx, W, H); // 5. 当前时间游标 if (this._marquee) this._drawMarquee(ctx); // 6. 框选矩形最上层 };clearRect和fillRect的区别这里必须说清画布默认是透明的如果容器有渐变背景或者圆角用fillRect填一个纯色会把背景整个盖掉圆角也会被切掉。用clearRect让画布透明背景交给 CSS后面改主题只改样式表不碰 JS。另外注意_drawRuler画网格线时是画满整个高度的而录像块又画在网格线之上。如果你希望网格线穿透块有些设计稿是这样顺序要改成「块 → 网格线半透明→ 游标」这属于视觉决策不是技术问题定下来之后写进注释免得后面的人乱改。3.2 刻度自适应一秒和一小时都得画得下刻度是时间轴的脸面。缩放比大的时候每 100ms 一根线缩到全局视角每 30 分钟一根线。写死步长的结果只有两种要么放大了刻度密得像扫描线要么缩小了只剩一根竖线。做法是准备一组「好看的」候选步长从里面挑第一个满足最小像素间距的。Traject.prototype._drawRuler function (ctx, W, H) { var rulerH 24; // 候选步长从细到粗全是人类读起来舒服的值 var STEPS [100, 200, 500, 1000, 2000, 5000, 10000, 30000, 60000, 300000, 900000, 1800000, 3600000]; var minPx 64; // 两条主刻度之间至少留 64px否则标签会叠字 var step STEPS[STEPS.length - 1]; for (var i 0; i STEPS.length; i) { if (STEPS[i] * this.scale minPx) { step STEPS[i]; break; } } ctx.fillStyle #1b1f26; ctx.fillRect(0, 0, W, rulerH); ctx.font 11px ui-monospace, monospace; ctx.textBaseline middle; // 从视口左边界往前取整到 step 的整数倍避免刻度随拖动抖动 var startTime Math.floor(this.viewStart / step) * step; var endTime this.viewStart W / this.scale; for (var t startTime; t endTime; t step) { var x this.timeToX(t); if (x -2 || x W 2) continue; // 0.5 让 1px 宽的线落在像素中心不然会糊成 2px 灰线 ctx.strokeStyle rgba(255,255,255,0.10); ctx.beginPath(); ctx.moveTo(x 0.5, 0); ctx.lineTo(x 0.5, H); ctx.stroke(); ctx.fillStyle #8b95a5; ctx.fillText(this._fmt(t), x 5, rulerH / 2); } };startTime那行取整是关键。如果直接从viewStart开始画拖动的时候刻度会连着标签一起平移看上去整条轴在「抖」。取整到步长倍数之后刻度位置只跟时间有关跟视口无关拖起来就像真的在移动一张纸。minPx 64这个值不是拍脑袋的是按 11px 字号下最长的标签「00:00:00」大约 54px 宽加上左右各 5px 留白算出来的。你把字号改大这个值要跟着改否则标签会撞。_fmt我按「有小时才显示小时」处理24 小时的回放轴上写00:00:00太占地方Timeline.prototype._fmt function (ms) { var s Math.floor(ms / 1000); var h Math.floor(s / 3600); var m Math.floor((s % 3600) / 60); var ss s % 60; var pad function (n) { return n 10 ? 0 n : n; }; return h 0 ? pad(h) : pad(m) : pad(ss) : pad(m) : pad(ss); };3.3 录像块绘制与视口裁剪录像块是轴上的主体内容每个块有start、end、color、label。24 小时的回放里几千个块很正常如果每个draw()都从头遍历拖动时帧率会掉到十几帧。办法很简单遍历时先判一下这个块跟当前视口有没有交集没有就continue一句话省掉 90% 的绘制开销。Timeline.prototype._drawSegments function (ctx, W, H) { var top 28, bottom H - 12; var viewEnd this.viewStart W / this.scale; for (var i 0; i this.segments.length; i) { var seg this.segments[i]; // 视口裁剪完全在视野外的块直接跳过 if (seg.end this.viewStart || seg.start viewEnd) continue; var x1 this.timeToX(seg.start); var x2 this.timeToX(seg.end); // 缩到极小时块宽度会小于 1px保底 2px否则整块凭空消失 var w Math.max(2, x2 - x1); ctx.fillStyle seg.color || #3b82f6; ctx.fillRect(x1, top, w, bottom - top); // 宽度够 48px 才画文字省掉大量无意义的 fillText 调用 if (w 48) { ctx.save(); ctx.beginPath(); ctx.rect(x1, top, w, bottom - top); ctx.clip(); // 裁剪防止长标签溢出到隔壁块上 ctx.fillStyle rgba(255,255,255,0.92); ctx.font 12px sans-serif; ctx.textBaseline top; ctx.fillText(seg.label || , x1 6, top 6); ctx.restore(); // save/restore 必须成对否则 clip 会一直生效 } } };ctx.save()/ctx.restore()这里不能省。clip()是持久状态一旦设了不还原后面画的游标、框选全部会被裁掉画面看起来像「右边一半没了」——现象非常吓人原因就是一次忘了 restore。3.4 高分屏适配DPR 处理只做一次在 Retina 屏上没做 DPR 处理画布会被浏览器从 1 倍位图拉伸到 2 倍显示刻度线发虚、文字边缘发毛看起来像整个插件质量不行。正确做法是把 canvas 的width/height属性设成 CSS 尺寸乘 DPR然后setTransform一次性把手写坐标缩放到 CSS 尺度。Timeline.prototype._resize function () { var dpr window.devicePixelRatio || 1; var cssW this.opts.width || this.$el.width(); var cssH this.opts.height; this.cssWidth cssW; this.cssHeight cssH; // 物理像素 CSS 像素 × DPR this.canvas.width Math.round(cssW * dpr); this.canvas.height Math.round(cssH * dpr); // 样式尺寸必须写回 CSS 像素否则画布会被撑大 this.canvas.style.width cssW px; this.canvas.style.height cssH px; // 关键把变换矩阵设成 DPR 缩放 // 之后所有绘制代码都按 CSS 像素写不用再乘 dpr this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0); };setTransform而不是scale是因为窗口大小变化时_resize会被重复调用用scale会不断累乘越缩越离谱。setTransform是绝对设置重复调用无副作用。改完 DPR 之后this.cssWidth也更新了clampView里用到的就是这个字段所以 resize 之后必须再调一次clampView()再draw()。4. 交互编排mousedown 到 mouseup 之间拖动、点击、缩放怎么互不打架时间轴的交互有个天然的冲突拖动和点击用的是同一套鼠标动作。用户的意图靠「按下到松开之间移动了多少像素」来区分。另外拖动过程中如果鼠标移出 canvas 进入工具栏事件要还能收到否则松手那一刻dragging状态卡在 true后面整页鼠标移动都在拖轴。这几个问题是这个插件里最容易写错的部分。4.1 事件绑定与坐标取值鼠标坐标要相对 canvas 左上角不能用offsetX子元素一嵌套就错也不能用 jQuery 事件的pageX要考虑滚动。标准做法是clientX减去getBoundingClientRect().left。Timeline.prototype._localX function (e) { // e 是 jQuery 事件对象clientX 已被代理过来 var rect this.canvas.getBoundingClientRect(); return e.clientX - rect.left; };getBoundingClientRect()每次调用都会触发一次布局计算在mousemove里高频调用有性能开销。我的做法是在mousedown时缓存一次结果拖动过程中复用只有resize、scroll或者窗口尺寸变化才重新算。这个优化在小页面上感知不到但页面很长、下面还有一堆表格的时候省下来的时间很可观。4.2 拖动viewStart 的增量更新拖动改的是viewStart方向要反过来鼠标往右移内容跟着往右走说明视口在往左移viewStart要减小。Timeline.prototype._bindDrag function () { var self this; var dragging false, moved false; var anchorX 0, anchorView 0; $(this.canvas).on(mousedown.tl, function (e) { if (e.which ! 1) return; // 只响应左键 dragging true; moved false; anchorX self._localX(e); anchorView self.viewStart; self._rectCache self.canvas.getBoundingClientRect(); self.canvas.style.cursor grabbing; }); // 挂在 document 上保证鼠标移出 canvas 也能收到 $(document).on(mousemove.tl, function (e) { if (!dragging) return; var dx (e.clientX - self._rectCache.left) - anchorX; if (Math.abs(dx) 3) moved true; // 3px 阈值区分点击与拖动 self.viewStart anchorView - dx / self.scale; self.clampView(); self._scheduleDraw(); // 合帧不直接 draw }); $(document).on(mouseup.tl, function () { if (!dragging) return; dragging false; self.canvas.style.cursor grab; self.$el.trigger(timeline:dragend, { viewStart: self.viewStart, viewEnd: self.viewStart self.cssWidth / self.scale }); }); // 实例销毁时一定要解绑 document 上的监听 this._unbindDrag function () { $(document).off(.tl); }; };dx / this.scale这一步就是「像素换成时间」。拖动 100px在scale 0.02的时候等于移动了 5000ms在scale 0.2的时候只移动 500ms——所以放大之后拖得更精细符合直觉。_scheduleDraw用requestAnimationFrame合并同一帧内的多次重绘请求mousemove在一帧里可能触发好几回每次都draw()会让主线程直接卡死Timeline.prototype._scheduleDraw function () { if (this._raf) return; var self this; this._raf requestAnimationFrame(function () { self._raf null; self.draw(); }); };4.3 以鼠标位置为锚点的缩放缩放最容易写错的地方是锚点。如果只是改scale不管viewStart缩放时内容会往左边界跑用户想看的那个点瞬间飞出屏幕。正确的做法是先算出鼠标位置对应的时间anchorTime缩放完成后反推viewStart让这个时间仍然落在同一个像素位置。Timeline.prototype.zoomAt function (anchorX, factor) { var anchorTime this.xToTime(anchorX); // 缩放前鼠标下的时间 var next this.scale * factor; // 钳制到上下限到边界直接返回省一次重绘 next Math.min(this.opts.maxScale, Math.max(this.opts.minScale, next)); if (next this.scale) return; this.scale next; // 反推让 anchorTime 仍然出现在 anchorX 这个像素位置 this.viewStart anchorTime - anchorX / this.scale; this.clampView(); this._scheduleDraw(); this.$el.trigger(timeline:zoom, { scale: this.scale, pxPerMs: this.scale, viewStart: this.viewStart }); };滚轮事件的绑定要注意归一化。deltaY在 Chrome 上是 100 左右的整数在 Firefox 上可能是 3 或者 -3直接乘系数会导致两个浏览器缩放速度差十倍。用deltaY 0判断方向再乘一个固定系数最稳。$(this.canvas).on(wheel.tl, function (e) { e.preventDefault(); // 阻止页面跟着滚动 var oe e.originalEvent; // 1.15 是每次缩放的步进调大更灵敏调小更细腻 var factor oe.deltaY 0 ? 1 / 1.15 : 1.15; self.zoomAt(self._localX(e), factor); });提示wheel的监听必须用passive: false才能preventDefault()jQuery 的.on(wheel)默认不是 passive所以能直接阻断。如果改用原生addEventListener第三个参数要写成{ passive: false }。4.4 点击返回时间点与命中检测「点一下跳到某个时刻」是这个插件的核心诉求之一。实现上要区分两件事点是落在空白处还是落在录像块上。落在空白处返回该点对应的时间落在块上还可以额外把块的 id 一起回调出去方便上层打开详情。Timeline.prototype.hitTest function (x) { var t this.xToTime(x); // segments 已按 start 升序排列这里用二分先定位再线性微扫 var lo 0, hi this.segments.length - 1, idx -1; while (lo hi) { var mid (lo hi) 1; if (this.segments[mid].start t) { idx mid; lo mid 1; } else { hi mid - 1; } } // 从候选位置往前最多回扫 8 个块可能有重叠 for (var i idx; i 0 i idx - 8; i--) { var s this.segments[i]; if (t s.start t s.end) return s; } return null; }; Timeline.prototype.setCurrentTime function (t) { t Math.max(0, Math.min(this.opts.duration, t)); this.currentTime t; this._scheduleDraw(); };点击事件和拖动共用mousedown所以在click回调里必须先判断moved。这个变量在mouseup之后才有效所以顺序是mousedown置 false →mousemove置 true →click判断。漏了这一步用户拖完轴松手顺带触发一次点击游标跳到松手位置看起来像「拖一下就自己乱跳」这是反馈最多的一个 bug。$(this.canvas).on(click.tl, function (e) { if (self._moved) return; // 拖动结束不算点击 var t self.xToTime(self._localX(e)); var seg self.hitTest(self._localX(e)); self.setCurrentTime(t); self.$el.trigger(timeline:timechange, { time: t, segment: seg }); if ($.isFunction(self.opts.onTimeClick)) { self.opts.onTimeClick.call(self, t, seg); } });onTimeClick的签名是(time, segment)segment为 null 表示点在空白。上层拿这个回调去调播放器 seek比让插件自己管播放逻辑干净得多。4.5 框选放大marquee的状态机再补一个实际项目里很想要的功能按住某个键比如 Alt拖动变成框选松开后把选中区间放大到铺满整屏。这个交互的状态机比普通拖动多一个mode字段mousedown时决定进drag还是marqueemousemove时按模式分流。Timeline.prototype._onDown function (e) { this._mode e.altKey ? marquee : drag; // Alt 切换框选模式 this._startX this._localX(e); this._marquee (this._mode marquee) ? { x1: this._startX, x2: this._startX } : null; }; Timeline.prototype._onMove function (e) { if (!this._mode) return; if (this._mode marquee) { this._marquee.x2 this._localX(e); this._scheduleDraw(); // 框选矩形要实时跟着鼠标 } }; Timeline.prototype._onUp function () { if (this._mode marquee this._marquee) { var a Math.min(this._marquee.x1, this._marquee.x2); var b Math.max(this._marquee.x1, this._marquee.x2); if (b - a 12) { // 小于 12px 当误操作忽略 var t1 this.xToTime(a); var t2 this.xToTime(b); this.zoomToRange(t1, t2); // 把区间铺满视口 } this._marquee null; } this._mode null; this._scheduleDraw(); };zoomToRange就是反解scalescale cssWidth / (t2 - t1)然后viewStart t1。加上clampView和上下限钳制就完事。这功能写下来不到 30 行但用户用起来体感差异极大——从「拖到某个大概位置再滚轮放大」变成「框一下直接到位」。5. 避坑与排查canvas 时间轴上线后踩到的五个坑下面这五条都是实打实跑出来才发现的按「现象 → 原因 → 解决」记一下下次写类似组件能少熬两个晚上。5.1 现象拖动时画面拖影、块边缘出现重影一开始偶发后来发现跟拖拽速度正相关快速拖动时右边会拖出一条残影慢拖就没有。原因是draw()里用了ctx.fillRect(0, 0, W, H)填背景填的是半透明色rgba(27,31,38,0.9)每次重绘只是叠加一层薄色上一次的块没被完全覆盖叠几次就成了重影。解决背景清除改用ctx.clearRect(0, 0, W, H)容器背景交给 CSS 的background。如果确定要画布自带底色就用完全不透明的#1b1f26别用带 alpha 的值。// 错误半透明底色会在每次重绘时叠加残留 // ctx.fillStyle rgba(27,31,38,0.9); // ctx.fillRect(0, 0, W, H); // 正确彻底清空背景由 CSS 负责 ctx.clearRect(0, 0, W, H);5.2 现象缩放之后录像块整体向右偏移一截缩放级别越大偏得越多1 倍时几乎看不出8 倍时能偏出半个屏幕。原因是宽高变化包括窗口 resize 和容器布局变化之后viewStart没重新钳制viewDuration变了但viewStart还停在旧值上导致超出右边界的部分被「挤」进来视觉上就是整条轴右移。解决_resize末尾追加一次clampView()并且把resize事件做 100ms 防抖避免拖窗口时每帧都重算。var resizeTimer null; $(window).on(resize.tl, function () { clearTimeout(resizeTimer); resizeTimer setTimeout(function () { self._resize(); self.clampView(); // 关键宽度变了必须重新钳制视口 self.draw(); }, 100); });5.3 现象点击位置和实际跳转的时间差几百毫秒越靠右差得越多拿到的clientX是对的xToTime也是对的问题出在getBoundingClientRect().left。容器外层做了transform: translateX(-8px)做入场动画getBoundingClientRect返回的是变换后的坐标而clientX是视口坐标两者能对上但缓存下来之后动画结束没更新于是偏了。另一种更常见的情况是页面滚动后缓存失效。解决不要在mousedown里缓存跨帧使用除非同时在scroll和transitionend上做失效。求稳的做法是每次click和wheel都实时取一次 rect只在mousemove的拖动分支里用缓存。Timeline.prototype._localX function (e, useCache) { var left (useCache this._rectCache) ? this._rectCache.left : this.canvas.getBoundingClientRect().left; return e.clientX - left; };5.4 现象录像块超过两千个之后拖动明显掉帧用 Performance 面板看draw一次 18ms 左右大头在_drawSegments的 for 循环和频繁的fillStyle赋值。虽然加了视口裁剪但裁剪本身还是全量遍历两千个块每个都要判一次。解决把遍历换成二分定位第一个可见块然后从那个位置往后扫到越界为止顺便把相同颜色的块合并成一次路径绘制。// 二分找到第一个右边界越过视口左边的块 Timeline.prototype._firstVisible function () { var lo 0, hi this.segments.length - 1, ans this.segments.length; while (lo hi) { var mid (lo hi) 1; if (this.segments[mid].end this.viewStart) { ans mid; hi mid - 1; } else { lo mid 1; } } return ans; }; // 用法从 _firstVisible 开始往后画遇到 start viewEnd 就 break for (var i this._firstVisible(); i this.segments.length; i) { if (this.segments[i].start viewEnd) break; // ... 绘制逻辑 }改完之后同样两千个块draw降到 2ms 以内。前提是segments必须保持按start升序所以addSegment里那次sort不能省。5.5 现象Retina 屏上刻度线发虚文字边缘像素感很重外接显示器上一切正常笔记本上一看就糊。原因是 canvas 的width/height属性按 CSS 像素设了没乘 DPR浏览器把 1 倍位图拉到 2 倍显示。日志里什么都看不出来只能靠肉眼对比。解决_resize里乘 DPR 并setTransform同时检查所有手写的坐标是否都是 CSS 像素尺度。有几个地方容易漏ctx.lineWidth 1在 DPR2 时物理上只有 0.5px会渲染成浅灰ctx.font 11px在高分屏上偏小。我的处理是把lineWidth设成1 / dpr之下的最近偶数字体统一走cssWidth比例计算别写死。注意setTransform之后clearRect(0, 0, W, H)里的 W/H 要用 CSS 像素不是canvas.width。用物理像素清会清多了看起来像画面被裁掉一块。6. 二次开发把单轨时间轴改成多轨编辑器需要动哪几处单轨改成多轨是这类插件最常见的二次开发需求——比如把「录像段」拆成「视频轨 / 音频轨 / 事件轨」每轨独立高度、独立数据源。我改过两版第一版把segments改成二维数组结果所有绘制、命中、拖动的索引全乱白折腾一天。第二版换了思路数据仍然是一维的每个 segment 多一个track字段绘制时按 track 分桶改起来只动了三处。改动点涉及文件改动量说明数据模型加trackcore约 10 行addSegment里校验 track 是否在允许范围内轨道高度与 y 坐标计算draw约 40 行trackY(trackIndex)返回该轨的 top/bottom命中检测带 yevent约 20 行hitTest(x, y)先由 y 反推 track再在桶里二分拖动改viewStart不动0多轨共用同一个时间视口天然兼容缩放锚点不动0同上核心改动就是给绘制函数传一个 y 范围别的地方不动// 每轨的最小高度轨道多了整体高度要跟着长 Timeline.prototype.trackY function (trackIndex) { var pad 4; var h this.opts.trackHeight; // 单轨高度配置项 var top this.rulerHeight trackIndex * (h pad); return { top: top, bottom: top h }; }; // 命中检测先由 y 定位轨道再在该轨道内找时间区间 Timeline.prototype.hitTest function (x, y) { var trackIndex Math.floor( (y - this.rulerHeight) / (this.opts.trackHeight 4) ); if (trackIndex 0 || trackIndex this.opts.trackCount) return null; var t this.xToTime(x); var bucket this._trackBuckets[trackIndex] || []; // 按 track 预分好的数组 // 后面还是二分 回扫跟单轨版本一模一样 return this._searchIn(bucket, t); };_trackBuckets在addSegment里顺手维护插入时按 track push 再按 start 排序。这样绘制和命中的复杂度都只跟单轨的块数有关不会被总块数拖累。改完之后我拿 5000 个块跑了一遍拖动稳定在 60fps点击落点误差在 ±1px 内。验证方法我一般走三步先在 demo 里塞三条轨、每条 2000 个随机块拖动滚轮压一遍看帧率再用console.assert断言xToTime(timeToX(t))在浮点误差内等于t缩放锚点前后鼠标位置的时间不变最后在真机上用手指触屏版要另接touchstart系列事件注意e.preventDefault()阻止页面滚动。踩过最狠的一次坑是给轨道加了删除功能之后_trackBuckets没同步清理删掉的块还在桶里被画出来——画面上是「删了但没完全删」刷新就好不刷新一直在。从那以后我每次加集合类字段都强制写一个_rebuildIndex()所有增删改之后统一调它宁可多花 0.2ms 也不留这种黑匣子。希望这套拆法帮到你少走两趟我走过的弯路。本文还有配套的精品资源点击获取
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →