免费获取学习方案
ARTICLE DETAIL

资讯详情

深耕编程基础知识与建站技术分享的一线实战洞察。

浏览器标注功能优化实战:坐标系统、Canvas渲染与性能调优

浏览器标注功能优化实战:坐标系统、Canvas渲染与性能调优 Hermes Studio 的浏览器标注功能是用来在页面元素、设计稿或者截图区域上快速画矩形、箭头和文字注释的模块。最初它只承担评审阶段的临时标记工作功能简单效果也勉强够用。但随着使用人数增加标注位置漂移、高分屏模糊、触控设备无法拖动以及批量标注后卡顿这些反馈开始集中出现于是对 Hermes Studio 的浏览器标注功能做了一轮系统优化。这篇文章会围绕浏览器标注功能从基线实现到优化落地的完整过程展开重点说明坐标系统怎么设计、Canvas 渲染层怎么处理高分屏和滚动、指针事件怎么兼容鼠标与触控以及标注数量增加后怎么保持流畅。适合正在做页面批注、图片标记、元素高亮类功能的开发者阅读。读完以后可以把文章里的坐标换算、渲染裁剪和生命周期管理思路直接用到自己的项目里。1. 先选对标注形态否则后续优化都是在补窟窿浏览器端标注功能有很多种实现思路。如果不先想清楚使用场景后续所有优化都会为了弥补基础架构的缺陷而反复返工。所以优化 Hermes Studio 标注功能之前先明确页面里可能出现的标注形态。1.1 DOM 覆盖层实现简单适合元素级注释DOM 覆盖层方案是在目标节点上插入高亮 div、气泡、遮罩层。缺点也很直接标注图形会参与页面布局和样式继承一旦页面结构变化覆盖节点可能找不到宿主元素定位也随之失效。DOM 覆盖层适合做“元素级提示”比如某个按钮旁边挂一个说明气泡或者用 outline 高亮表单字段。它不适合绘制自由形状因为每个矩形、箭头都要创建节点标注数量一多DOM 节点数就会膨胀重排成本很高。1.2 Canvas 或 SVG 覆盖层适合任意图形但坐标要同步Canvas 覆盖层是创建一个透明画布铺在页面上层所有标注通过 Canvas 2D API 绘制。Canvas 是位图渲染标注数量多时性能更稳定也比较容易导出图片。SVG 覆盖层和 Canvas 类似但每个图形都是独立 DOM 元素交互和拾取更灵活。缺点是节点数量同样会占用内存并且复杂 SVG 图形的渲染成本不高但也不低。Canvas 和 SVG 的共同难题是坐标同步。标注图形要跟随页面滚动和缩放覆盖层尺寸要随时和视口保持一致否则绘制的位置和回显的位置会出现偏差。1.3 截图后标注实现容易但无法跟随页面变化另一种思路是对当前页面截图再在图片上覆盖标注层。这种方式不依赖页面 DOM代码最简单可以用第三方库快速实现。但问题也很明显截图是静态画面页面滚动、展开折叠、窗口 resize 之后标注和真实元素的位置就完全对不上了。截图标注适合“对最终效果图做批注”的场景不适合“在真实页面上标注后还要交给人继续操作”的场景。1.4 Hermes Studio 为什么选择 Canvas 加 DOM 混合方案Hermes Studio 的标注功能需要同时满足两个需求一是在真实页面上标注矩形和箭头二是对设计稿图片追加文字批注。单一方案无法同时覆盖。最终采用的思路是使用一个全屏 Canvas 覆盖层负责图形标注同时保留轻量 DOM 气泡用于文字说明。Canvas 负责矩形、箭头、高亮区域DOM 气泡只在用户查看或编辑文字时出现避免大量文字节点影响性能。这里的关键判断是Canvas 覆盖层只管“画”不管“页面元素是谁”。页面结构和标注数据通过坐标关联不通过 DOM 引用关联这样即使页面 DOM 变化标注数据也不会丢失。2. 基线实现先跑通最简标注流程优化不是从零重写而是先让一个最简标注流程跑通再逐项优化。Hermes Studio 的基线版本包含创建 Canvas 覆盖层、绘制矩形、保存标注数据、回显标注。2.1 技术栈与目录结构基线版本使用 TypeScript 编写构建工具为 Vite运行环境是 Chromium 内核浏览器。目录结构设计成模块化方便后续扩展渲染器和历史记录。src/ annotations/ Annotation.ts AnnotationCanvas.ts createOverlay.ts renderer.ts history.ts每个模块只负责一件事Annotation.ts 定义标注数据结构。createOverlay.ts 负责创建和销毁 Canvas 覆盖层。AnnotationCanvas.ts 负责事件绑定和标注生命周期。renderer.ts 负责把标注数据绘制到 Canvas。history.ts 实现撤销重做的数据栈。这样拆分以后后续优化高性能渲染时只需要改动 renderer.ts不需要动事件模块。2.2 先定义标注数据结构标注数据设计是否合理直接影响后续坐标换算、序列化和协同同步。基线版本使用下面这套结构。export type AnnotationKind rect | arrow | text; export interface Annotation { id: string; kind: AnnotationKind; x: number; y: number; width: number; height: number; color: string; text?: string; createdAt: number; updatedAt: number; } export type AnnotationDraft OmitAnnotation, id | createdAt | updatedAt;这里最需要留意的是 x 和 y 的语义。基线版本中x 和 y 存的是“文档坐标”也就是相对整个文档左上角的位置而不是相对当前浏览器视口左上角的位置。后面会专门解释为什么要这样存。2.3 创建覆盖层并统一坐标换算覆盖层需要铺满当前视口并且使用固定定位不参与页面布局。export function createOverlay(): HTMLCanvasElement { const canvas document.createElement(canvas); canvas.style.cssText [ position: fixed, left: 0, top: 0, z-index: 9999, pointer-events: auto ].join(;); document.body.appendChild(canvas); return canvas; }坐标换算是浏览器标注功能最核心的部分。交互事件拿到的 clientX 和 clientY 是相对视口的坐标而标注数据要保存成相对文档的坐标这样滚动页面后标注不会偏移。export function toDocumentPoint(clientX: number, clientY: number) { return { x: clientX window.scrollX, y: clientY window.scrollY }; } export function toScreenPoint(annotation: PickAnnotation, x | y) { return { x: annotation.x - window.scrollX, y: annotation.y - window.scrollY }; }保存时用 toDocumentPoint渲染时用 toScreenPoint。只要这两个函数保持一致滚动带来的坐标漂移问题就能从根上解决。2.4 用指针事件实现矩形绘制基线版本直接使用 Pointer Events而不是分别监听鼠标和触摸事件。Pointer Events 可以统一处理鼠标、触控笔和手指输入后续优化触控体验时省去大量兼容代码。export class AnnotationCanvas { private overlay: HTMLCanvasElement; private ctx: CanvasRenderingContext2D; private annotations: Annotation[] []; private draft: AnnotationDraft | null null; constructor() { this.overlay createOverlay(); this.ctx this.overlay.getContext(2d); if (!this.ctx) { throw new Error(Canvas 2D context is not supported); } this.resize(); this.bindEvents(); } private bindEvents() { this.overlay.addEventListener(pointerdown, this.handlePointerDown); this.overlay.addEventListener(pointermove, this.handlePointerMove); this.overlay.addEventListener(pointerup, this.handlePointerUp); } }pointerup 需要绑定在 document 上否则鼠标移出画布后松开事件可能接收不到。更可靠的做法是在 pointerdown 时使用 setPointerCapture。2.5 数据序列化与回显标注数据需要保存到服务端或者本地存储因此需要序列化。Hermes Studio 基线版本直接把标注数组序列化成 JSON 保存。{ version: 1, annotations: [ { id: a1, kind: rect, x: 320, y: 180, width: 200, height: 120, color: #ff4d4f, createdAt: 1710000000000, updatedAt: 1710000000000 } ] }回显逻辑就是遍历标注数组把文档坐标转成屏幕坐标后绘制。基线版本到这里已经能完成“画一个矩形、保存、刷新后重新显示”的最小闭环。下一步优化才是真正处理生产环境里遇到的问题。3. 坐标漂移问题滚动、缩放、容器变化坐标漂移是浏览器标注功能最容易被反馈的问题。表现形式是标注完成时位置正确页面一滚动标注就跑到了另外的位置。这个问题在基线版本里也可能出现因为如果只处理了 clientX 到 document 坐标的转换却没有处理渲染层的同步漂移就依然存在。3.1 问题现象描述最常见现场是页面存在长滚动区域。用户在当前视口顶部画了一个红色矩形标注的是页面顶部搜索按钮。向下滚动 1000 像素之后红色矩形跑到当前视口中间对应的搜索按钮已经在视口上方看不到的位置。还有一种情况是窗口 resize 之后Canvas 覆盖层尺寸没有更新导致绘制区域比视口小标注图形在视口边缘时无法完整显示。3.2 根因存储坐标和渲染坐标语义不一致如果存储的是 clientX / clientY那么数据本身就依赖“画标注那一刻”的滚动位置。一旦滚动值变化旧标注的坐标点就失去意义。如果渲染层始终用 document 坐标直接绘制到 canvas 上没有减去 window.scrollX 和 window.scrollY那么标注也会随着滚动失效。排查坐标漂移问题时先检查两件事保存坐标时是否调用 toDocumentPoint。渲染坐标时是否调用 toScreenPoint。只要任一处缺少转换滚动后就会错位。3.3 统一使用文档坐标存储、屏幕坐标渲染修复后的核心逻辑是存储永远用文档坐标渲染永远用屏幕坐标。public render() { const width this.overlay.clientWidth; const height this.overlay.clientHeight; this.ctx.clearRect(0, 0, width, height); for (const annotation of this.annotations) { const screen toScreenPoint(annotation); this.drawAnnotation(annotation, screen); } }drawAnnotation 内部只使用 screen.x 和 screen.y 作为 Canvas 绘制坐标不再依赖任何全局状态。这里要强调一点toScreenPoint 每次渲染时都会读取 window.scrollX 和 window.scrollY。如果页面的滚动容器不是 window而是某个嵌套 div就需要把滚动元素暴露出来在 toScreenPoint 里改成基于目标滚动元素的 scrollLeft 和 scrollTop。3.4 用 ResizeObserver 监听容器尺寸变化Canvas 覆盖层是 fixed 定位窗口 resize 时需要重新设置 canvas.width 和 canvas.height。只用 window resize 事件虽然能满足大多数场景但页面里有 iframe 或者 UI 框架动态调整侧边栏宽度时viewport 可能不变覆盖层尺寸也会受影响。更稳妥的做法是使用 ResizeObserver 观察 overlay 的父节点或 body。private handleResize () { this.resize(); this.render(); }; private observeResize() { this.resizeObserver new ResizeObserver(this.handleResize); this.resizeObserver.observe(document.body); }注意 ResizeObserver 回调触发后不能再次修改被观察元素的 size否则可能造成循环触发。这里只修改 overlay 的 canvas 宽高observer 观察的是 body所以不会形成死循环。3.5 验证坐标是否一致的实用方法写一个临时调试函数在滚动前后输出同一个标注的数据可以快速确认坐标转换是否正确。function debugAnnotation(annotation: Annotation) { const screen toScreenPoint(annotation); const doc toDocumentPoint(screen.x, screen.y); console.log({ stored: { x: annotation.x, y: annotation.y }, screen, restored: doc, scrollX: window.scrollX, scrollY: window.scrollY }); }如果 stored 和 restored 一致说明坐标转换是可逆的。之后再做滚动、缩放、窗口尺寸变化测试标注位置都能保持稳定。4. 绘制质量、触控交互和撤销重做坐标问题解决后标注功能已经基本可用。接下来要处理的是体验层面的问题Canvas 在高分屏上模糊、触控设备无法拖动、鼠标松手后矩形意外丢失以及撤销重做不完善。4.1 高分屏 Canvas 模糊必须处理 devicePixelRatio在高分屏上Canvas 的 CSS 尺寸和物理像素尺寸不一定一致。如果不做处理画出来的线条边缘会发虚看起来像整张画布被拉伸过。修复方法是在设置 canvas 尺寸时乘上 devicePixelRatio并在绘制前调用 ctx.scale。private resize() { const dpr window.devicePixelRatio || 1; const width window.innerWidth; const height window.innerHeight; this.overlay.style.width ${width}px; this.overlay.style.height ${height}px; this.overlay.width Math.round(width * dpr); this.overlay.height Math.round(height * dpr); this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0); }每次 resize 都要重新 setTransform因为设置 canvas.width 或 canvas.height 会重置 Canvas 的变换状态。如果遗漏这一步后续绘制坐标会被 dpr 放大标注位置又会偏移。4.2 鼠标事件改造成 Pointer Events兼容触摸设备基线版本已经使用 Pointer Events但还需要处理细节pointermove 和 pointerup 的绑定范围以及 setPointerCapture 的使用。正式实现时pointerdown 后立刻把指针捕获到 overlay 上保证后续 move 和 up 事件都派发给 overlay即使指针移动到浏览器窗口边缘也不会丢事件。private handlePointerDown (event: PointerEvent) { if (event.button ! 0 event.pointerType mouse) { return; } const point toDocumentPoint(event.clientX, event.clientY); this.draft { kind: rect, x: point.x, y: point.y, width: 0, height: 0, color: this.currentColor }; try { this.overlay.setPointerCapture(event.pointerId); } catch (error) { // 个别浏览器在异常状态下调用 setPointerCapture 会抛错不影响主流程 } this.render(); };这里还要处理一个常见问题用户从右下角往左上角拖拽时draft.width 和 draft.height 可能是负数。绘制时会得到反向矩形视觉上非常别扭。处理方式是在绘制前对宽高做归一化确保 x 始终是矩形左上角。private handlePointerMove (event: PointerEvent) { if (!this.draft) return; const point toDocumentPoint(event.clientX, event.clientY); const x Math.min(this.draft.x, point.x); const y Math.min(this.draft.y, point.y); const width Math.abs(point.x - this.draft.x); const height Math.abs(point.y - this.draft.y); this.draft.x x; this.draft.y y; this.draft.width width; this.draft.height height; this.render(); };4.3 处理 touch-action 和用户选中冲突触控设备上手指在 Canvas 上滑动时浏览器默认会把滑动手势解释为页面滚动或双击缩放。这会导致用户画矩形时页面跟着滚动。在覆盖层的 CSS 中加入 touch-action: none可以禁止浏览器对该区域的手势处理。canvas.style.touchAction none;另外还需要阻止文本选中。用户连续快速绘制时如果页面中有文本节点可能会触发文本选择干扰交互。推荐在绘制状态下临时给 body 添加 user-select 样式或者统一在覆盖层上设置 user-select: none。.annotation-overlay { touch-action: none; user-select: none; -webkit-user-select: none; }4.4 撤销重做用快照还是用命令模式撤销重做的核心是保存历史状态。最直接的实现是每次完成一个标注后把 annotations 数组的深拷贝压入 undo 栈。export class AnnotationHistory { private undoStack: Annotation[][] []; private redoStack: Annotation[][] []; private limit 50; push(snapshot: Annotation[]) { const copy snapshot.map((item) ({ ...item })); this.undoStack.push(copy); if (this.undoStack.length this.limit) { this.undoStack.shift(); } this.redoStack []; } undo(): Annotation[] | null { if (this.undoStack.length 0) return null; const snapshot this.undoStack.pop(); if (snapshot) { this.redoStack.push(snapshot); } return snapshot ?? null; } redo(): Annotation[] | null { if (this.redoStack.length 0) return null; const snapshot this.redoStack.pop(); if (snapshot) { this.undoStack.push(snapshot); } return snapshot ?? null; } }快照方案在标注数量少于几百条时足够用。如果标注数量很大或者每次操作后数组都要深拷贝就需要改成命令模式记录“新增”“删除”“修改”的操作对象用操作反推历史。5. 大量标注场景下的性能优化当页面里出现几十、上百个标注时如果每次滚动和 resize 都全量重绘所有标注浏览器会明显掉帧。性能优化不是一上来就引入虚拟列表而是先确认瓶颈在哪里。5.1 使用 requestAnimationFrame 合并高频事件scroll 和 resize 事件触发频率很高不能每次事件都同步执行 render。推荐的做法是保存一个标记位在下一帧执行时统一渲染。private renderScheduled false; private scheduleRender() { if (this.renderScheduled) return; this.renderScheduled true; requestAnimationFrame(() { this.render(); this.renderScheduled false; }); }事件回调里只调用 scheduleRenderrender 函数本身保持同步绘制。这样即使滚动事件连续触发几十次真正执行渲染的只有每帧一次。5.2 按视口裁剪减少无效绘制当页面很长标注分散在不同区域时Canvas 绘制所有标注是浪费的。可以把坐标判断放在绘制前只绘制与当前视口相交的标注。private getVisibleAnnotations(): Annotation[] { const left window.scrollX; const top window.scrollY; const right left window.innerWidth; const bottom top window.innerHeight; return this.annotations.filter((annotation) { return ( annotation.x annotation.width left annotation.x right annotation.y annotation.height top annotation.y bottom ); }); }这里的核心思路叫作“视口裁剪”。对于长度几万像素的页面视口裁剪能明显减少绘制调用次数。这个优化对文本标注和箭头标注同样有效因为箭头和文本都有 bounding box可以先用外接矩形做粗过滤。5.3 把静态标注和正在绘制的标注分层正在绘制的 draft 每帧都会变化而已完成的标注在滚动前不会变化。如果每次都把已完成标注全部重新绘制浪费很大。优化思路是使用两个 Canvas底层 Canvas 只绘制已完成的 static 标注。上层 Canvas 只绘制当前正在绘制的 draft。滚动或 resize 时重绘底层指针移动时只重绘上层。这样交互响应会更快。代价是增加一个 Canvas 节点和对应的事件处理。实现拆分前要先确认浏览器对多个 Canvas 重叠渲染的内存开销是否在接受范围内。一般来说两个全屏 Canvas 对现代浏览器压力不大。5.4 销毁时清理监听器和 Canvas避免内存泄漏标注模块在单页应用里可能被反复创建和销毁。如果不清理全局事件上一次实例的监听器还在运行会造成多次渲染和内存泄漏。销毁逻辑必须包含destroy() { this.resizeObserver?.disconnect(); this.overlay.removeEventListener(pointerdown, this.handlePointerDown); this.overlay.removeEventListener(pointermove, this.handlePointerMove); document.removeEventListener(pointerup, this.handlePointerUp); window.removeEventListener(resize, this.handleResize); window.removeEventListener(scroll, this.scheduleRender); this.overlay.remove(); }注意事件处理函数必须是类字段确保引用稳定这样才能用 removeEventListener 精确移除。6. 常见问题定位与浏览器兼容性浏览器标注功能出现问题大多数时候不是框架问题而是坐标、事件、样式或生命周期问题。下面整理一份排查清单遇到问题可以先按顺序检查。6.1 常见问题现象与处理方案问题现象常见原因检查方式处理建议滚动后标注位置偏移存储坐标用了 clientX/clientY没有转文档坐标在 render 中打印 annotation.x 和 window.scrollX统一使用 toDocumentPoint 保存toScreenPoint 渲染标注文字模糊Canvas 物理尺寸没有乘 devicePixelRatio对比 canvas.width 和 clientWidthresize 时乘 dpr并调用 ctx.setTransform触摸设备无法绘制touch-action 默认为 auto手势被浏览器接管在设备模拟器里查看 touch 事件是否触发覆盖层设置 touch-action: none鼠标拖出窗口后矩形丢失pointermove 和 pointerup 只绑定在 overlay 上查看 pointerup 是否触发在 pointerdown 时调用 setPointerCapture快速滚动时页面卡顿scroll 事件直接触发全量 render在 render 中打点统计次数使用 requestAnimationFrame 合并渲染覆盖层挡住了页面按钮pointer-events: auto点击页面元素没反应标注空闲时设置 pointer-events: none绘制时再开启打开页面后没有标注Canvas 初始化时尺寸为 0打印 canvas.width 和 canvas.height在窗口尺寸计算后再创建 overlay6.2 按这条链路排查浏览器标注问题标注功能出问题排查顺序比排查单个变量更重要。建议按照下面流程来检查是否有多个 Canvas 覆盖层实例叠加。单页应用路由切换后旧实例没有销毁新实例叠加在上方。检查坐标转换函数。保存和渲染必须使用互逆的转换否则滚动一定出错。检查 Canvas 尺寸。如果 canvas.width 小于视口宽度画布右侧会出现空白区域。检查 pointer 事件是否被页面其他元素拦截。很多 UI 库会调用 stopPropagation导致 pointerdown 无法到达 overlay。检查 destroy 是否执行。重复进入页面会导致事件监听叠加表现就是绘制一次生成多个标记。6.3 浏览器兼容性注意事项Pointer Events 在现代 Chrome、Edge、Firefox、Safari 中都能使用但需要兼容旧版移动浏览器时仍然要提供 MouseEvent 和 TouchEvent 的回退逻辑。Canvas 的 getContext(2d) 在所有主流浏览器中都支持但 toDataURL 导出图片时要注意如果 Canvas 中绘制了来自其他域的图片资源canvas 会被污染toDataURL 会抛出 SecurityError。Hermes Studio 中标注图层只绘制几何图形和文字不读取页面像素所以不受污染问题影响。建议在浏览器兼容测试中至少覆盖桌面端 Chrome、Edge、Firefox、Safari。移动端 WebView 的 touch 事件。页面使用 CSS transform 缩放后标注坐标是否仍然正确。CSS transform 缩放是比较容易忽略的边界情况。如果页面本身没有缩放只需要处理滚动如果页面有 scale 变换坐标换算还要除以 scale 系数。7. 生产环境落地和扩展方向基线功能跑通、优化完成之后浏览器标注功能要真正放到生产环境还需要补齐不少工程能力。7.1 从 Demo 到生产还需要补齐的能力第一是数据持久化。标注数组要保存到服务端至少要附带 version 字段方便以后调整数据结构。前端拉取后通过 version 做迁移。第二是权限控制。标注功能要区分“只读模式”和“编辑模式”。只读模式下 overlay 不绑定 pointer 事件Canvas 只负责回显。第三是记录操作日志。用户在评审场景中对同一页面反复标注编辑历史需要留存。每次新增、删除、修改都提交一条日志方便追溯。第四是多人协作。多用户同时标注同一个页面时需要 WebSocket 同步标注数据。推荐为每条标注生成 ID并通过 updatedAt 决定冲突时的合并策略。7.2 发布前检查清单以下是 Hermes Studio 标注功能发布前使用的检查清单也可以直接用于自己的项目。Canvas 覆盖层创建后是否只存在一个实例。坐标转换是否统一使用文档坐标存储、屏幕坐标渲染。devicePixelRatio 变化时比如拖拽窗口到高分屏是否重新 resize。滚动、resize、缩放后标注位置是否与页面元素对齐。touch-action 和 user-select 是否配置。pointermove 和 pointerup 是否在 destroy 时解绑。大量标注场景下是否启用视口裁剪。Canvas 导出图片时是否被跨域资源污染。只读模式下 overlay 的 pointer-events 是否设置为 none。数据持久化字段是否包含 version便于后续迁移。7.3 从标注图形到完整批注系统浏览器标注功能优化完成后可以继续向完整批注系统扩展。比较有价值的三个方向是矢量导出把 Canvas 标注转换为 SVG 或 JSON 描述服务端可以生成可编辑的批注图层。AI 辅助标注根据元素文本和位置自动生成建议标签降低人工标注成本。多页面标注同一测试任务包含多个 URL将标注数据和页面 URL 关联形成完整评审记录。如果浏览器标注功能是项目里的核心交互之一建议把坐标转换和渲染器拆成独立模块并用单元测试覆盖滚动、缩放和窗口变化场景。这样后续增加图形类型、接入协同协议时底层的稳定性不会成为瓶颈。
返回列表