简介面向移动端 H5 开发者的 Canvas 交互示例包解决在手机上预览 PDF 并叠加手写批注的核心需求整合 PDF.js 解析渲染、Canvas 矢量绘图、touch 事件监听与批注持久化等关键环节并补充桌面端 mouse 事件兼容写法适合正在做移动画板、电子签章或文档审阅功能的中级前端开发者参考。压缩包共 150 个文件体积 2.4MB主体为 34 个 js 逻辑脚本、10 个 css 样式、4 个 html 入口页面以及 11 个 png、6 个 jpg、75 个 gif 等演示素材附有一份 pdf 示例文件与地图、字体等配套资源目录结构能快速对应到 PDF 加载、画板绘制、批注落盘等模块目前已有 1750 人学习下载。代码片段覆盖从网络请求读取 PDF、按页渲染到 Canvas、监听触摸轨迹绘制线条到导出 Base64 图片的完整链路可据此直接搭建最小可用原型。在此基础上可继续扩展高亮、文本、橡皮擦等批注操作并借助分页渲染与图片导出策略优化移动端性能。1. canvas 移动端画板批注 pdf 预览一块能写字的 pdf 阅读器在移动端 H5 里做 pdf 预览不稀奇稀奇的是你需要在同一块屏幕上既能看到 pdf 原文又能在上面圈圈画画——而且画完之后批注不丢、能导出。我拆过的这个 canvas 移动端画板批注 pdf 预览项目就是这样一块能写字的 pdf 阅读器。它用 canvas 2D 画板叠加在 pdf.js 渲染出来的 pdf 页面上把双图层叠加、触控笔迹平滑、多页面批注数据关联这些最常踩坑的环节都落在了一套可运行的代码里。适合两类人一类是正在接移动端文档批注需求的开发另一类是打算在 H5 里做电子签名、作业批改、图纸审阅的同学。2. 技术选型与整体架构pdf.js 渲染层与画板层为什么要拆开2.1 技术选型为什么是 pdf.js 加原生 canvas 2D我拆这个项目时最关心的一个问题是它为什么不用 DOM 来做批注层。做移动端 H5 批注很多人第一反应是用 div 或 svg 覆盖在页面上去画线。这个思路在固定的背景图场景下确实可用但放到 pdf 预览里就会出问题pdf 页面可以翻页、可以缩放、可以改变布局DOM 元素的坐标和 z-index 管理会越来越费劲。这个项目选择的方式是双层 canvas底层渲染 pdf 页顶层专门画批注两层 canvas 严格对齐坐标统一走同一套 canvas 逻辑坐标换算。这样的拆分带来三个直接好处。首先是渲染性能。批注笔迹需要高频刷新canvas 2D 的绘制开销远小于 DOM 节点增删触控跟手程度明显好。其次是擦除能力橡皮擦在 canvas 里用 globalCompositeOperation 的 destination-out 就能按像素擦除而 DOM 方案做橡皮擦要切 clip 路径或重建节点麻烦得多。第三个好处是导出。批注所在的 canvas 可以直接和 pdf 渲染层合成一张图导出的效果就是用户最后看到的样子不需要额外做快照处理。这三点叠加起来canvas 方案在移动端画板这个场景下几乎是唯一合理的选择。2.2 项目目录结构一个文件一个职责整个项目没有引入重型 UI 框架代码组织是典型的前端模块化写法。核心目录长这样src/ ├── main.js # 入口文件初始化 pdf 加载和画板绑定 ├── pdf/ │ ├── renderer.js # pdf.js 封装负责页面渲染和 viewport 计算 │ └── pdf-worker-loader.js # 动态加载 pdf.js worker 的兼容处理 ├── board/ │ ├── canvas-board.js # 画板类管理图层、画笔/橡皮擦切换 │ ├── gestures.js # touch 事件处理与笔迹点采集 │ ├── brush.js # 画笔绘制内置二次贝塞尔平滑 │ └── eraser.js # 橡皮擦基于 destination-out ├── annotate/ │ ├── annotation-model.js # 批注数据模型统一一条笔迹的数据结构 │ ├── annotation-store.js # 批注存储按 pageIndex 索引 │ └── exporter.js # 批注导出合成图片或输出 JSON └── utils/ ├── dpr.js # 设备像素比适配工具 └── point.js # 坐标转换与中点计算对照这个结构二次开发的入口基本是清晰的底层渲染只管 pdf 画出来画板层只管捕捉手势和画线批注模型只管把轨迹存成可回放的数据。三层之间通过一个简单的观察者模式通信画板在每次笔迹结束时向存储层发送通知存储层按当前页码把笔迹追加到对应数组里。对于一个演示级项目来说这个分层足够支撑大多数改动你要做的不是重构它而是顺着边界往里加功能。2.3 数据流设计一处笔迹从产生到落盘的完整路径理清这个项目的关键是搞懂一条笔迹的数据流。手指按下时gestures.js 采集的是 canvas 坐标系里的像素坐标为了让批注在不同 dpr 和不同缩放比例下都能还原annotation-model.js 在保存前会把坐标归一化成相对值也就是除以画布宽高得到 0 到 1 之间的小数。这个设计是这里最值得学的地方它让批注数据彻底和渲染尺寸解耦换一台分辨率完全不同的手机同样的 JSON 数据也能原样画在同一位置。数据流大概是这样的流程touchstart 触发创建笔迹对象touchmove 不断往点的数组里填充坐标和可选的压力值touchend 时把完整的笔迹对象交给 annotation-storestore 根据当前页面的 pageIndex 存入内存数组。这时如果用户切页再切回来store 会从数组里取出该页所有笔迹重新调用画笔接口原样播放一遍。整个流程没有数据库也不依赖服务端批注数据完全在前端内存中管理想要持久化exporter.js 提供 toJSON 方法把全部笔迹序列化成字符串开发时可以存到 localStorage也可以交给服务端做归档。与之相比如果是一条带完整后端的批注系统数据流会多出一层网络同步但这个项目的取舍是刻意保持在纯前端入手门槛低演示效果又足够完整。3. 核心代码拆解一条笔迹从按下到导出的完整链路3.1 pdf 渲染层pdf.js 的 viewport 与 dpr 适配先说底层渲染。renderer.js 里最关键的是渲染单个页面时如何处理移动端的精度问题import * as pdfjsLib from pdfjs-dist; export async function renderPage(pdfDoc, pageNum, containerWidth, dpr) { const page await pdfDoc.getPage(pageNum); const baseViewport page.getViewport({ scale: 1 }); // 以容器宽度为基准算出初始缩放再乘 dpr 保证清晰度 const cssScale containerWidth / baseViewport.width; const viewport page.getViewport({ scale: cssScale }); const canvas document.getElementById(pdf-canvas); canvas.style.width ${viewport.width}px; canvas.style.height ${viewport.height}px; canvas.width Math.floor(viewport.width * dpr); canvas.height Math.floor(viewport.height * dpr); const ctx canvas.getContext(2d); ctx.setTransform(dpr, 0, 0, dpr, 0, 0); const renderContext { canvasContext: ctx, viewport, }; await page.render(renderContext).promise; return { viewport, page }; }这段代码是移动端 canvas 渲染 pdf 的标准写法。如果不做 dpr 适配canvas.width 就等于 css 宽度在大多数手机上看 pdf 字都是虚的。逻辑拆开看先拿 scale 为 1 的 viewport 读出基准宽再用容器宽度除以它得到让页面刚好铺满容器宽度的缩放比例viewport.width 是 css 像素下的页面宽canvas.width 是物理像素宽二者一乘清晰度就有了。setTransform 让后续绘制在同一套 css 像素坐标系里进行pdf.js 内部渲染也会按这个上下文画不会出现字体被拉伸的问题。回传参数里viewport 在画板层要用。main.js 拿到这一步返回的 viewport 后会把同样的宽高设置给画板 canvas保证两层画布像素级对齐。这里有个容易被忽略的点viewport.width 和 canvas.width 是两个维度的数值css 样式里的宽高必须用前者canvas 属性里的像素值用后者搞反了就会出现画板能画但位置对不上的怪问题。3.2 画板层touch 事件采集与笔迹平滑画板层是整个项目里最值得逐行看的模块。gestures.js 负责把原生 touch 事件翻译成笔迹点brush.js 负责把这些点画成平滑的线条。核心实现集中在 canvas-board.js 里export class CanvasBoard { constructor(canvas, { strokeColor #ff0000, strokeWidth 3 } {}) { this.canvas canvas; this.ctx canvas.getContext(2d); this.strokeColor strokeColor; this.strokeWidth strokeWidth; this.isDrawing false; this.points []; this.lastPoint null; this.attachEvents(); } attachEvents() { this.canvas.addEventListener(touchstart, this.onTouchStart.bind(this)); this.canvas.addEventListener(touchmove, this.onTouchMove.bind(this)); this.canvas.addEventListener(touchend, this.onTouchEnd.bind(this)); } onTouchStart(e) { e.preventDefault(); const touch e.touches[0]; const pos this.toCanvasPos(touch); this.isDrawing true; this.points [pos]; this.lastPoint pos; } onTouchMove(e) { e.preventDefault(); if (!this.isDrawing) return; const touch e.touches[0]; const pos this.toCanvasPos(touch); this.points.push(pos); // 取上一点和当前点的中点用二次贝塞尔连接线条不会出现折角 const mid { x: (this.lastPoint.x pos.x) / 2, y: (this.lastPoint.y pos.y) / 2 }; this.ctx.beginPath(); this.ctx.moveTo(this.lastPoint.x, this.lastPoint.y); this.ctx.quadraticCurveTo(pos.x, pos.y, mid.x, mid.y); this.ctx.strokeStyle this.strokeColor; this.ctx.lineWidth this.strokeWidth; this.ctx.lineCap round; this.ctx.lineJoin round; this.ctx.stroke(); this.lastPoint pos; } onTouchEnd() { if (!this.isDrawing) return; this.isDrawing false; this.emit(stroke-end, this.buildStrokeData()); this.points []; } }每一步都有讲究。touchstart 里必须 preventDefault否则安卓上手指滑动会联动页面滚动的默认行为。toCanvasPos 是把页面坐标转换成 canvas 坐标需要减掉 canvas 元素的 getBoundingClientRect 的 left 和 top。二次贝塞尔的做法本质是拿当前触摸点和上一个点的中点作为曲线的终点这样点与点之间就有曲率过渡而不用等 touchmove 给到足够密的点。移动端触控事件的采样密度通常只有每秒 40 到 60 次不做平滑画出来的线会有肉眼可见的折线。onTouchEnd 里值得注意的是一句 emit(stroke-end)这个事件携带 buildStrokeData 的结果内部把页码、工具、颜色、宽度和原始点数组组装好在下一小节的模型层统一做归一化。这样画板层不关心数据存到哪只管采集和绘制存储逻辑被干净地剥离开。3.3 批注数据模型用相对坐标消除设备差异annotation-model.js 定义了一条笔迹的归一化数据结构。核心逻辑很直接把所有坐标除以画布宽高让它变成 0 到 1 的相对值这样渲染在什么分辨率的设备上都能还原到正确位置。export function normalizeStroke(stroke, canvasWidth, canvasHeight) { const points stroke.points.map((p) ({ x: p.x / canvasWidth, y: p.y / canvasHeight, })); return { pageIndex: stroke.pageIndex, tool: stroke.tool, // pen | eraser color: stroke.color, width: stroke.width, points, }; } export function restoreStroke(stroke, canvasWidth, canvasHeight) { // 假定 getCanvasContext 是按页码取画板 context 的辅助函数 const ctx getCanvasContext(stroke.pageIndex); ctx.beginPath(); stroke.points.forEach((p, i) { const drawX p.x * canvasWidth; const drawY p.y * canvasHeight; if (i 0) ctx.moveTo(drawX, drawY); else ctx.lineTo(drawX, drawY); }); ctx.strokeStyle stroke.color; ctx.lineWidth stroke.width; ctx.lineCap round; ctx.stroke(); }normalizeStroke 里做的是坐标归一化width 虽然直接存了数值但严格讲应该也参与缩放。这个项目做得比较简化只存了 css 像素值二次开发时如果要做多端等比还原可以把 width 也除以画布宽度保存。restoreStroke 的还原逻辑是把相对坐标乘回当前画布的宽高再用标准 lineTo 画路径。这里没有二次贝塞尔是因为保存下来的 points 密度已经足够还原时直接逐点连线效果和绘制时一致计算量反而更小。批注和页面的绑定关系由 pageIndex 字段完成。在 annotation-store.js 里所有笔迹按 pageIndex 分组存放切换页面时遍历对应页面的数组重放一遍即可。这种结构给撤销带来了便利数组里最后一个笔迹在撤销时直接 pop 再重绘整页视觉和数据的真一致比单纯清空 canvas 再画的方案可靠得多。4. 避坑指南移动端画板最常见的五个翻车现场4.1 橡皮擦把 pdf 底图也擦没了现象在批注图层上用橡皮擦擦完发现 pdf 文字也跟着消失了一块。原因这是典型的没有分图层问题。很多人把批注和 pdf 画在同一个 canvas 上橡皮擦一发 destination-out底图自然被一并擦掉。即便分图层如果画板层没有自动重绘机制擦除操作会直接破坏画板内容恢复只能靠重绘而重绘又没写现象就是越擦越花。解决给画板单独一个 canvas 叠在 pdf canvas 上方橡皮擦只在画板 canvas 上操作。每次擦除后把这段路径用 destination-out 画进画板 contextpdf 层完全不碰。如果后续要支持撤销擦除从批注数组里重绘整个画板层不要依赖 canvas 的像素历史。4.2 手指画线断断续续像一串省略号现象慢速画线是平滑的快速一划就断线或是一条线由很多小点组成。原因touchmove 的触发频率赶不上快速滑动的速度两个采样点之间间距过大逐点 moveTo/lineTo 会形成明显的折线间隙。另外如果在 touchmove 里做了比较重的逻辑操作比如每次都遍历整条路径重绘事件循环被拖慢采集点会更稀疏。解决项目里用中点贝塞尔平滑每次只画相邻两段而不是把所有点重绘一遍。同时把坐标采集、绘制、存储分开避免在 touchmove 里做序列化这类重活。如果还是觉得断线可以简化采样逻辑只保留 point 数组和 lastPoint 两个引用不要每次都读 points 数组的末尾元素这也是一种微优化。4.3 画板手势带动页面滚动现象在手机上手指在 pdf 上写批注时页面跟着手指一起滚画出来的线是斜的。原因canvas 上监听的是原生 touch 事件浏览器对 touchmove 的默认行为是滚动页面必须主动禁止。部分浏览器对 addEventListener 的 passive 参数有兼容处理直接 e.preventDefault() 在某些版本里不管用。解决这有两个手段。一是给画板 canvas 的 css 加上 touch-action: none二是监听 touchmove 时传入 { passive: false } 再调用 preventDefault。注意 touch-action 在 iOS Safari 的兼容性细节比较多最好两个手段都保留。这个问题不处理后面的一切操作都是白搭。4.4 canvas 里的字发虚圆角边缘像狗啃现象pdf 渲染出来整体模糊画线时笔迹边缘有明显锯齿。原因上面讲的 dpr 适配没做。canvas 的物理像素尺寸小于 css 尺寸浏览器被迫用插值算法把低分辨率画布拉伸显示。画板 canvas 如果也是同样问题再加设置 lineWidth 时用的是 css 像素值画出的线条在物理像素上只有一半宽度边缘自然毛刺。解决两层 canvas 都按 dpr 缩放同时 context.setTransform(dpr, 0, 0, dpr, 0, 0) 把逻辑坐标系对齐。画板层的 lineWidth 用逻辑像素值实际物理像素宽度是它的 dpr 倍在大多数手机上效果正好合适。做这一步之前最好先打印一下 window.devicePixelRatio不同机型的取值差异很大。4.5 切页切回来批注不见了现象翻到第二页再回到第一页刚才画的那几笔不见了。原因画板层只保存了当前页面的 canvas 像素切换页面时画板 canvas 被清空或重新初始化批注数据没有持久化到 store。常见误用是在页面切换时直接调用 canvas.clearRect却忘了 store 里的数组还没有把这一页的笔迹收集进去。解决在 onTouchEnd 时就把 stroke-end 事件里的笔迹数据推入 annotation-store 的对应 pageIndex 数组。切换页面时先清空画板 canvas再从 store 中读取目标页的所有笔迹调用 restoreStroke 重播。如果还要支持撤销就再维护一份未撤销笔迹堆栈撤销时不仅清空 canvas还要从堆栈里剔除最后一笔后整体重绘。5. 把项目跑起来从单页 demo 改成多页批注5.1 环境准备与启动项目用 Vite 做开发服务器主要原因是对移动端调试很友好。项目下载后在根目录执行npm install npm run devnpm install 会装上 pdfjs-dist 这个核心依赖它是 pdf.js 的 npm 包名。值得提醒的是pdf.js 的 worker 文件是单独打包的项目里的 pdf-worker-loader.js 负责在运行时动态拼接 worker 的 url。如果你直接 import pdfjsLib 然后用默认 worker 模式本地开发会看到 Setting up fake worker 的告警严重时同时渲染多个页面会把主线程卡死。这个项目的处理方式是在构建时把 worker 作为静态资源单独打包开发阶段则指向 node_modules 里的 pdf.worker.js 文件。启动后把一份测试 pdf 放到 public 目录下命名为 demo.pdf打开浏览器就能看到第一页渲染出来。注意必须用 http/https 访问file:// 协议下 pdf.js 无法读取 worker 文件。真机测试时用 npm run dev -- --host 让手机通过局域网 ip 访问。如果手机加载不到 worker多半是开发服务器的 https 配置没做好Vite 的 server.https 选项开一下或者用 devtools 远程调试查看具体的网络请求报错。5.2 从单页 demo 扩展成多页批注项目初始代码只渲染了第一页但真实场景几乎都是多页文档批注。改成多页是性价比最高的二次开发路径。思路很简单主界面用一个滚动的容器每个页码对应一个容器区块而不是一张整体 canvas渲染层按需渲染每一页。具体来说把 renderPage 改成接受一个 canvas 元素和一个 pageIndex并维护一个页面状态数组记录已经渲染过的页码。在滚动容器上监听 scroll用 IntersectionObserver 判断哪些页进入视口再真正渲染和重放批注。这样首屏只渲染第一页滚动到哪页再画哪页性能开销小很多。批注存储那边把 annotation-store 的 pageIndex 按真实页码填充翻页时不走清空重绘而是直接切换 store 的当前页索引。const observer new IntersectionObserver((entries) { entries.forEach((entry) { if (entry.isIntersecting) { const pageNum Number(entry.target.dataset.pageNum); ensurePageRendered(pageNum).then(() { annotationStore.get(pageNum).forEach((stroke) { restoreStroke(stroke, pageNum); }); }); observer.unobserve(entry.target); } }); }, { root: scrollContainer, threshold: 0.1 });这段代码的要点是每个 entry.target 是预先铺好的容器节点它的>export function exportPageAsImage(pageIndex, pdfViewport) { const boardCanvas document.querySelector(#board-${pageIndex}); const pdfCanvas document.querySelector(#pdf-${pageIndex}); const merged document.createElement(canvas); merged.width pdfCanvas.width; merged.height pdfCanvas.height; const ctx merged.getContext(2d); ctx.drawImage(pdfCanvas, 0, 0); ctx.drawImage(boardCanvas, 0, 0); return merged.toDataURL(image/png); }这里第一个 createElement 用的是离屏 canvas不挂在 DOM 上专门用来做像素合并。drawImage 两次合成的顺序是先 pdf 底图再批注层出来的效果和用户在屏幕上看到的完全一致。需要注意批注层如果用了 destination-out 做橡皮擦这个操作会把底下画板层的像素抠掉但 pdf 层不受影响所以合成结果里被橡皮擦掉的部分会露出 pdf 空白底色这是符合直觉的。JSON 导出的粒度更细。annotation-store 提供一个 dump 方法把全部页面的笔迹拍平成一个数组每个元素是 normalizeStroke 后的对象。导入时先校验版本号和坐标边界再逐条 restoreStroke。我在这类项目里通常建议加一个版本号字段因为后续一旦把宽度的相对归一化改了老数据回放会错位有版本号就能做迁移这是提前给自己留的一张后悔药。6. 二次开发还能怎么往下走撤销、压感与协同编辑的三个方向对已经能跑的项目我建议下一个改动从撤销重做开始。前面批注数据是按数组存笔迹的撤销本质就是 pop 掉当前页最后一笔再整页重绘。关键点是别在 canvas 上做像素级 undo因为橡皮擦和画笔混合后的像素历史不可靠必须以数据驱动重绘。五百笔以内的重绘只需几十毫秒用户几乎无感。// 撤销当前页最后一笔 function undoLastStroke(pageIndex) { const strokes annotationStore.get(pageIndex); if (strokes.length 0) return; strokes.pop(); clearBoardCanvas(pageIndex); strokes.forEach((stroke) restoreStroke(stroke, pageIndex)); }第二个方向是压感模拟。大多数移动端浏览器不提供笔压数据但可以用两个指尖的距离模拟笔迹宽度touchmove 里取 touches[0] 和 touches[1] 的距离距离越大 lineWidth 越粗这样画出来的字有自然的粗细变化。实现时只需要在 brush.js 里把 strokeWidth 改成距离映射值。第三个方向是协同批注的落点。批注已经是可序列化的数据下一步就是把 toJSON 的数据推到 WebSocket 或白板服务别人拿到同样的 JSON 就能重放出同一份批注。关键不是通信而是操作序列的合并每个人维护自己的局部序列把新增笔迹按全序同步给其他人。这个项目拆到这里剩下的空间其实已经从怎么画转移到了怎么同步。我说说自己的习惯。每次拿到类似的画板项目我都强制先做一遍 dpr 适配和 touch-action 排查因为这两个问题会在真机上反复出现而且都是那种看起来不明显、又很致命的玄学问题。再朴素一点说做完这些改动后把每一条改动都放到真机或者手机模拟器上跑一遍不能只在桌面浏览器里点。希望这篇拆解帮到你项目跑起来之后你会发现在移动端做批注并没有想象中那么难。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?