端畫板批注PDF預(yù)覽:坐標(biāo)映射與性能優(yōu)化實(shí)踐)
簡介面向移動(dòng)端H5開發(fā)者的Canvas畫板批注PDF預(yù)覽資源包專注解決在手機(jī)端實(shí)現(xiàn)PDF文檔渲染、手寫批注與標(biāo)記保存等交互需求。資源以PDF.js解析流程為技術(shù)主線覆蓋從PDF文檔加載、頁面渲染到Canvas畫板觸控繪制的完整鏈路并給出批注導(dǎo)出與持久化的實(shí)現(xiàn)參考。包內(nèi)共150個(gè)文件其中JS腳本承擔(dān)核心解析與繪制邏輯HTML與CSS搭建頁面結(jié)構(gòu)GIF動(dòng)圖直觀演示操作過程PNG/JPG圖片提供界面預(yù)覽另含字體、Map及PDF樣例等輔助文件壓縮后僅2.4MB小巧輕量。已有1750人學(xué)習(xí)下載適合需要快速落地移動(dòng)端PDF批注功能的前端工程師與H5開發(fā)者。參考該資源可避開跨域配置、坐標(biāo)適配、移動(dòng)端事件兼容等常見坑點(diǎn)并結(jié)合layui等前端組件快速集成對(duì)分頁渲染與批注存儲(chǔ)等性能優(yōu)化也給出方向提示能顯著縮短從零開發(fā)周期。1. canvas 移動(dòng)端畫板批注 PDF 預(yù)覽為什么圖片方案在這里不可行做移動(dòng)端 H5 PDF 預(yù)覽時(shí)我最早偷懶把 PDF 轉(zhuǎn)成圖片再鋪到頁面上直到接了一個(gè) canvas 移動(dòng)端畫板批注需求被打臉用戶要能在 PDF 上直接畫圈、寫意見畫完還要能導(dǎo)出和回放。圖片方案表面簡單可一旦涉及多頁預(yù)覽、放大后批注對(duì)位、換設(shè)備之后筆跡偏移就處處踩坑。這份資源給的是另一條路用 canvas 承載批注層和 PDF 渲染層分離再用一套坐標(biāo)映射把屏幕上的筆跡還原到 PDF 原始坐標(biāo)系。適合正在做 H5 文檔預(yù)覽、審批流、簽字確認(rèn)這類場(chǎng)景的前端也適合想搞清 canvas 分層繪制的讀者。2. 工程拆解PDF 渲染層與畫板批注層先分開再談功能拿到資源后別急著跑起來先把它拆成三個(gè)模塊PDF 渲染、畫板繪制、坐標(biāo)換算。很多半成品項(xiàng)目的問題就是這三塊糊在一個(gè)組件里滾動(dòng)、縮放、畫條線互相打架。下面先看源碼包的核心結(jié)構(gòu)再逐層說為什么選這種方案。2.1 源碼包里的核心模塊長什么樣這份資源解開后工程結(jié)構(gòu)大致是這樣src/ ├── pages/ # 頁面入口預(yù)覽頁、批注頁 │ ├── preview.js │ └── annotate.js ├── core/ │ ├── pdfRender.js # PDF 加載、翻頁、渲染 │ ├── board.js # 畫板層、筆跡繪制與事件綁定 │ ├── coordTransform.js # 屏幕坐標(biāo)與 PDF 坐標(biāo)互轉(zhuǎn) │ └── history.js # 批注記錄、撤銷棧 ├── utils/ │ ├── dpr.js # 設(shè)備像素比處理 │ └── storage.js # 批注數(shù)據(jù)本地保存 └── styles/ └── board.css # 圖層定位、觸摸行為樣式這個(gè)結(jié)構(gòu)對(duì)應(yīng)的是分層思路pdfRender.js只負(fù)責(zé)把 PDF 畫到頁面board.js只負(fù)責(zé)在透明 canvas 上畫線coordTransform.js是兩者之間唯一的溝通橋梁。這樣做的好處是后續(xù)換 PDF 渲染庫、改畫板樣式都不會(huì)牽連到坐標(biāo)換算邏輯。跑通資源里的 demo 后建議先找到coordTransform.js把里面兩個(gè)核心函數(shù)讀一遍。這個(gè)文件是整個(gè)項(xiàng)目的命脈不管你只是想要“能畫”還是想要“畫了能存、存了能還原”最終都要回到這里。2.2 PDF 渲染為什么選 pdf.js 而不是后端轉(zhuǎn)圖移動(dòng)端 PDF 預(yù)覽有三種常見做法后端把 PDF 轉(zhuǎn)成圖片一次性返回、前端用原生控件加載、前端用 PDF 渲染庫逐頁繪制。這里的資源選的是第三種并且用的渲染方案是 pdf.js。原因是批注場(chǎng)景對(duì)“坐標(biāo)一致性”要求太高。如果你把 PDF 轉(zhuǎn)成圖片前端拿到的是一張位圖就算知道圖片寬高也很難定位“這個(gè) PDF 的第 3 頁里某個(gè)表格區(qū)域”在哪個(gè)像素點(diǎn)因?yàn)?PDF 頁面里的文字和矢量圖形已經(jīng)被拍扁成圖片了。而直接用 PDF 渲染庫可以得到每一頁的 viewport 數(shù)據(jù)知道當(dāng)前頁的原始尺寸、縮放比例這樣畫板上的一個(gè)點(diǎn)可以精確映射回 PDF 的坐標(biāo)點(diǎn)。// 以頁面原始寬度為基準(zhǔn)構(gòu)造當(dāng)前頁 viewport const baseViewport page.getViewport({ scale: 1 }); const availableWidth container.clientWidth; const scale availableWidth / baseViewport.width; const viewport page.getViewport({ scale }); const renderTask page.render({ canvasContext: pdfCanvasContext, viewport }); renderTask.promise.then(() { // 渲染完成后再啟用畫板避免用戶畫在空白頁上 board.enable(); });邏輯說明先拿 scale1 的原始 viewport 計(jì)算網(wǎng)頁容器寬度下的縮放比再用這個(gè)縮放比生成正式 viewport最后調(diào)用render。renderTask.promise是渲染完成的回調(diào)入口畫板必須等 PDF 頁真正畫出來后再允許落筆否則第一筆會(huì)落在白底上。參數(shù)說明baseViewport.width是 PDF 頁在縮放比例為 1 時(shí)的寬度單位是 PDF 點(diǎn)。availableWidth是頁面容器去掉左右 padding 后的實(shí)際寬度。這里用“寬度自適應(yīng)”而不是固定縮放是為了讓 PDF 鋪滿不同寬度的手機(jī)屏防止窄屏上出現(xiàn)橫向滾動(dòng)條。2.3 畫板層一個(gè)透明 canvas 如何蓋在 PDF 上畫板層不是新造一個(gè)頁面而是用 CSS 把一個(gè)透明 canvas 絕對(duì)定位在 PDF canvas 上方。兩層結(jié)構(gòu)如下div idpdfContainer classpdf-container canvas classpdf-layer idpdfCan/canvas canvas classboard-layer idboardCan/canvas /div.pdf-container { position: relative; width: 100%; overflow: hidden; } .pdf-layer, .board-layer { position: absolute; inset: 0; width: 100%; height: 100%; } .board-layer { touch-action: none; /* 禁止瀏覽器處理這套手勢(shì) */ z-index: 2; }邏輯說明pdf-layer負(fù)責(zé)顯示 PDF 頁面board-layer覆蓋在它上面透明背景只顯示用戶新畫的筆跡。touch-action: none很關(guān)鍵它告訴瀏覽器這塊區(qū)域的手勢(shì)不要滾動(dòng)頁面、不要縮放頁面統(tǒng)統(tǒng)交給 JS 處理否則在手機(jī)上畫橫線會(huì)被系統(tǒng)識(shí)別成滾動(dòng)操作。參數(shù)說明inset: 0是讓畫板層和 PDF 層嚴(yán)格貼合任何一層的 border、padding 都會(huì)導(dǎo)致坐標(biāo)計(jì)算起步就錯(cuò)。z-index不是越高越好畫板層只需要比 PDF 層高一級(jí)太高了反而遮擋其他彈層組件。資源里把兩個(gè) canvas 的寬高都用 CSS 設(shè)置為 100%但 canvas 內(nèi)部的像素寬高并沒有寫死這是給 dpr 適配預(yù)留的空間后面第三章會(huì)細(xì)講。3. 移動(dòng)端適配觸摸事件、dpr 縮放與滾動(dòng)沖突畫板在 PC 端 mouse 事件能跑通一到手機(jī)就露餡。這章講三個(gè)最常見又最容易忽略的移動(dòng)端適配點(diǎn)。3.1 pointer events 替代 mouse 事件資源里的畫板用的是 Pointer Events而不是分開監(jiān)聽 touch 和 mouse。原因很簡單現(xiàn)在的觸控筆、手指、鼠標(biāo)都能統(tǒng)一觸發(fā) pointer 事件不用再寫一套“如果支持 touch 就怎樣否則就怎樣”的分支代碼。const board document.getElementById(boardCan); const ctx board.getContext(2d); let isDrawing false; let lastPoint null; board.addEventListener(pointerdown, (e) { isDrawing true; lastPoint getPos(e); board.setPointerCapture(e.pointerId); }); board.addEventListener(pointermove, (e) { if (!isDrawing) return; const cur getPos(e); ctx.beginPath(); ctx.moveTo(lastPoint.x, lastPoint.y); ctx.lineTo(cur.x, cur.y); ctx.stroke(); lastPoint cur; }); board.addEventListener(pointerup, (e) { isDrawing false; board.releasePointerCapture(e.pointerId); });邏輯說明pointerdown觸發(fā)時(shí)用setPointerCapture鎖定指針防止手指畫出畫板范圍后事件丟失pointermove里不斷從上一個(gè)點(diǎn)連線到當(dāng)前點(diǎn)pointerup結(jié)束本次筆畫。這里沒有用mousedown/mousemove/mouseup因?yàn)橛|摸屏上 mouse 事件是模擬出來的會(huì)帶來幾十毫秒延遲畫出來的線不跟手。參數(shù)說明e.pointerId表示當(dāng)前觸點(diǎn)編號(hào)。在單指批注場(chǎng)景我們只取第一個(gè)觸點(diǎn)的坐標(biāo)如果后續(xù)要支持雙指同時(shí)書寫就需要用 pointerId 區(qū)分筆畫本資源默認(rèn)不做多筆并發(fā)。getPos的實(shí)現(xiàn)在后面 3.2 節(jié)會(huì)一起說這里先記住它返回的是相對(duì)畫板左上角的 CSS 坐標(biāo)值不含 dpr 放大。3.2 dpr 不等于 1 時(shí)畫布尺寸怎么取舍iPhone 的物理像素寬度經(jīng)常是 CSS 寬度的兩倍或三倍如果 canvas 的寬高直接用 CSS 尺寸出來的線條會(huì)糊。原因是 canvas 的繪圖緩沖區(qū)比屏幕物理像素小瀏覽器把它拉伸上去就虛了。解決辦法是讓 canvas 內(nèi)部像素尺寸乘上 dpr。function setupCanvas(canvas) { const dpr window.devicePixelRatio || 1; const rect canvas.getBoundingClientRect(); canvas.width rect.width * dpr; canvas.height rect.height * dpr; const ctx canvas.getContext(2d); ctx.setTransform(dpr, 0, 0, dpr, 0, 0); return ctx; }邏輯說明canvas.width rect.width * dpr設(shè)置的是繪圖緩沖區(qū)大小getBoundingClientRect拿到的是實(shí)際排版后的 CSS 尺寸。ctx.setTransform(dpr, 0, 0, dpr, 0, 0)讓后續(xù)所有繪圖坐標(biāo)都按 CSS 坐標(biāo)計(jì)算繪制系統(tǒng)自動(dòng)映射到物理像素。參數(shù)說明這里千萬不能用window.innerWidth計(jì)算畫布寬度因?yàn)楫嫲迦萜骺赡懿皇侨量赡艿撞坑泄ぞ邫?、頂部有?biāo)題欄。改成getBoundingClientRect后任何布局變化都能即時(shí)反映。另一個(gè)容易忽略的點(diǎn)是在dpr為 3 的高端機(jī)型上整頁 canvas 的像素總量可能達(dá)到 3000x6000這對(duì) WebGL 或 canvas 合成都有壓力后面第四章會(huì)專門講內(nèi)存問題。3.3 畫板與頁面滾動(dòng)的互斥處理移動(dòng)端 PDF 往往超過一屏頁面需要上下滾動(dòng)。當(dāng)手指落在畫板層上時(shí)畫板要求“這一筆完全由 JS 接管不要滾動(dòng)”當(dāng)手指落在 PDF 頁面的空白區(qū)域或工具欄時(shí)頁面該滾還得滾。實(shí)現(xiàn)上用 CSStouch-action控制畫板區(qū)域并在事件里判斷是否繼續(xù)傳播。const isOnBoard (e) { const boardRect board.getBoundingClientRect(); return e.clientX boardRect.left e.clientX boardRect.right e.clientY boardRect.top e.clientY boardRect.bottom; }; board.addEventListener(pointerdown, (e) { if (!isOnBoard(e)) return; e.preventDefault(); isDrawing true; // 阻止頁面滾動(dòng)邏輯由 touch-action 控制這里不用手動(dòng)調(diào) stopPropagation });這里preventDefault()與touch-action: none是雙保險(xiǎn)。但注意preventDefault放在pointerdown里只是避免后續(xù)行為真正避免滾動(dòng)還是要靠touch-action。如果你用pointermove里preventDefault部分 iOS 版本會(huì)報(bào) Illegal invocation正確做法是在 CSS 層就聲明這個(gè)區(qū)域不可滾動(dòng)。還有一點(diǎn)是長按選擇。移動(dòng)端瀏覽器長按 canvas 會(huì)觸發(fā)圖片保存菜單或文本選擇干擾批注體驗(yàn)。資源里給.board-layer加了全局樣式-webkit-user-select: none; user-select: none; -webkit-touch-callout: none;這些樣式在 Android 和 iOS 上都能有效減少誤觸但 Android WebView 的某些版本仍可能彈菜單必要時(shí)要在contextmenu事件里preventDefault()。4. 避坑批注偏移、白屏、內(nèi)存暴漲的排查筆記這章寫的是我反復(fù)翻車后整理出來的排查筆記每個(gè)現(xiàn)象都按“現(xiàn)象 → 原因 → 解決”展開。如果你在復(fù)現(xiàn)資源時(shí)遇到問題優(yōu)先對(duì)照這里。4.1 現(xiàn)象寫出來的筆跡和手指差一大截現(xiàn)象在手機(jī)上用手指畫線筆跡明顯偏到右下角位置還隨滾動(dòng)變化。原因多半是 canvas 的 CSS 尺寸與內(nèi)部像素尺寸不一致時(shí)坐標(biāo)計(jì)算直接用了canvas.width做比例換算。比如畫布內(nèi)部寬度是 750CSS 寬度只有 375代碼里卻把offsetX/clientX直接當(dāng) canvas 坐標(biāo)用畫出來的位置自然翻倍。解決在繪制前先做一次坐標(biāo)換算把鼠標(biāo)或指針的屏幕坐標(biāo)轉(zhuǎn)換成畫板坐標(biāo)系中的 CSS 坐標(biāo)再交給繪圖 API。function getPos(e) { const rect board.getBoundingClientRect(); return { x: e.clientX - rect.left, y: e.clientY - rect.top }; }這里返回的是未乘 dpr 的 CSS 坐標(biāo)配合 3.2 節(jié)的ctx.setTransform(dpr,0,0,dpr,0,0)使用。只要這兩處統(tǒng)一批注層就不會(huì)偏移。另一個(gè)常見錯(cuò)誤是忽略按鈕和導(dǎo)航欄占位導(dǎo)致rect.top算錯(cuò)所以監(jiān)聽事件時(shí)要綁定在畫板自身而非整個(gè) document然后用getBoundingClientRect實(shí)時(shí)取值別緩存一個(gè)靜態(tài) rect 用到翻頁后。4.2 現(xiàn)象PDF 頁面白屏只出邊框不出內(nèi)容現(xiàn)象PDF 容器有陰影邊框但里面空白的控制臺(tái)沒有報(bào)錯(cuò)。原因通常是 PDF Worker 加載失敗。pdf.js 的解析運(yùn)行在 Worker 線程里如果資源的 worker 路徑配置不對(duì)主線程會(huì)靜默失敗渲染層拿不到頁面數(shù)據(jù)。解決檢查 worker 路徑是否指向正確的本地文件并在初始化后打印加載狀態(tài)。pdfjsLib.GlobalWorkerOptions.workerSrc ./static/pdf.worker.min.js; pdfjsLib.getDocument(pdfUrl).promise.then((doc) { console.log(document loaded, pages:, doc.numPages); }).catch((err) { console.error(load failed, err); });注意getDocument的參數(shù)如果是跨域地址服務(wù)端必須配置跨域響應(yīng)頭Access-Control-Allow-Origin否則 Worker 拿不到數(shù)據(jù)。另一個(gè)白屏來源是未設(shè)置pdfRender.js里的canvasContext類型部分版本需要{ canvasContext: ctx }而非{ context: ctx }寫錯(cuò)不會(huì)立刻報(bào)錯(cuò)渲染結(jié)果卻是空白排查時(shí)優(yōu)先看網(wǎng)絡(luò)請(qǐng)求里.worker文件是否 200。4.3 現(xiàn)象翻頁幾十次后頁面卡頓明顯現(xiàn)象PDF 一共 100 頁來回翻頁 30 次后滑動(dòng)開始掉幀內(nèi)存占用翻倍。原因是每一頁渲染都新建了renderTask翻頁時(shí)沒有銷毀上一次任務(wù)甚至每次翻頁都在同一個(gè) canvas 上反復(fù)繪制但未清除畫布導(dǎo)致 GPU 緩沖和 JS 對(duì)象越積越多。解決渲染下一頁前先取消正在進(jìn)行的渲染任務(wù)并清理畫布。if (currentRenderTask) { currentRenderTask.cancel(); currentRenderTask null; } pdfCtx.clearRect(0, 0, pdfCanvasEl.width, pdfCanvasEl.height); currentRenderTask page.render({ canvasContext: pdfCtx, viewport });renderTask.cancel()會(huì)拋出一個(gè) AbortException所以調(diào)用處最好用.catch判斷一下避免異常被當(dāng)成錯(cuò)誤上報(bào)。另外不要為了省事把整本 PDF 的所有頁都渲染成 canvas 再切換顯示移動(dòng)端 canvas 數(shù)量一多WebView 的位圖緩存會(huì)先被擊穿。資源里的方案是只渲染當(dāng)前頁翻頁時(shí)銷毀上一頁最多保留上一頁做過渡動(dòng)畫。4.4 現(xiàn)象viewport 縮放后出現(xiàn)模糊或毛邊現(xiàn)象PDF 放大 200% 后批注線條看起來比 PDF 頁面文字更模糊有的線段邊緣出現(xiàn)明顯鋸齒。原因是放大后 PDF 渲染庫會(huì)重新生成高分辨率 viewport但畫板層內(nèi)部像素尺寸沒有跟著更新導(dǎo)致批注層仍然用低分辨率緩沖區(qū)繪制。解決在縮放操作結(jié)束后重設(shè)畫板的寬高和 transform。function resizeBoardToPdf(pdfCanvas) { const rect pdfCanvas.getBoundingClientRect(); board.style.width rect.width px; board.style.height rect.height px; board.width rect.width * dpr; board.height rect.height * dpr; boardCtx.setTransform(dpr, 0, 0, dpr, 0, 0); }有一個(gè)細(xì)節(jié)canvas 重新設(shè)置width或height會(huì)清空內(nèi)容所以這步必須在畫板無筆跡時(shí)執(zhí)行或者在重設(shè)前先用getImageData保存一份離屏位圖重設(shè)后再putImageData復(fù)原。資源里用的是離屏 canvas 緩沖后面第六章會(huì)演示。5. 實(shí)戰(zhàn)批注坐標(biāo)映射與 JSON 導(dǎo)出讓畫的東西可回放移動(dòng)端畫板批注的真正門檻不是畫線而是“在不同頁面尺寸、不同縮放比例、不同設(shè)備上畫出來都能對(duì)得上原始 PDF”。這里的關(guān)鍵是永遠(yuǎn)不要保存屏幕坐標(biāo)保存 PDF 坐標(biāo)。5.1 坐標(biāo)轉(zhuǎn)換公式與代碼實(shí)現(xiàn)屏幕上的點(diǎn)(x, y)先換算成畫板 CSS 坐標(biāo)再除以當(dāng)前縮放比例得到 PDF 頁面坐標(biāo)。反過來回放時(shí)把 PDF 坐標(biāo)乘上當(dāng)前 viewport 縮放比例即可。// 屏幕坐標(biāo) - PDF 坐標(biāo) function screenToPdf(screenX, screenY, rect, viewport) { const cssX screenX - rect.left; const cssY screenY - rect.top; return { x: cssX / viewport.scale, y: cssY / viewport.scale }; } // PDF 坐標(biāo) - 屏幕坐標(biāo) function pdfToScreen(pdfX, pdfY, rect, viewport) { return { x: pdfX * viewport.scale rect.left, y: pdfY * viewport.scale rect.top }; }邏輯說明viewport對(duì)象來自page.getViewport({ scale })viewport.scale就是頁面渲染時(shí)的縮放倍數(shù)。之所以用除法而不是pageWidth / rect.width是因?yàn)榭s放后 PDF 的坐標(biāo)和畫布 CSS 坐標(biāo)是線性關(guān)系除法最簡單也最不依賴頁面布局。參數(shù)說明如果 PDF 頁面在容器內(nèi)做了水平居中還需要減去容器的scrollLeft和偏移量。資源里默認(rèn)容器寬度等于容器本身所以不用額外減。但一旦你改成左右留白模式就必須把rect.left換成“PDF 實(shí)際顯示區(qū)域”的 left否則批注會(huì)整體向右偏。建議把這段函數(shù)寫在coordTransform.js里并加上單測(cè)因?yàn)楹罄m(xù)所有功能都建立在它之上。5.2 批注數(shù)據(jù)結(jié)構(gòu)設(shè)計(jì)一次畫線動(dòng)作可以抽象成一條 stroke數(shù)據(jù)結(jié)構(gòu)如下const stroke { id: stroke_20240320_1645, page: 3, type: pen, color: #ff5722, width: 3, points: [ { x: 128.34, y: 56.02 }, { x: 129.1, y: 58.5 }, { x: 130.2, y: 61.0 } ] };這里points存的是 PDF 坐標(biāo)不是屏幕坐標(biāo)。顏色和線寬也存原始值因?yàn)?PDF 頁面重新渲染時(shí)如果設(shè)備像素比不同線寬的視覺表現(xiàn)會(huì)變存儲(chǔ)原始值可以保證回放時(shí)一致。數(shù)據(jù)結(jié)構(gòu)定好后導(dǎo)出就是一個(gè)JSON.stringify(strokes)。資源里提供了history.js內(nèi)部維護(hù)strokes數(shù)組和undoStack數(shù)組。每完成一筆就 push 一次每撤銷一次就把當(dāng)前 stroke push 到undoStack并重新繪制剩余筆畫。注意撤銷不是簡單地strokes.pop()因?yàn)楹罄m(xù)還有“重做”操作所以要保留被撤銷的數(shù)據(jù)。function addStroke(stroke) { strokes.push(stroke); undoStack.length 0; // 新筆畫會(huì)清空重做棧 redrawAll(); } function undo() { if (strokes.length 0) return; const removed strokes.pop(); undoStack.push(removed); redrawAll(); }5.3 重新打開文檔時(shí)恢復(fù)批注恢復(fù)批注的流程是先渲染 PDF 當(dāng)前頁拿到 viewport再遍歷該頁的 stroke 列表把 PDF 坐標(biāo)轉(zhuǎn)成屏幕坐標(biāo)逐段繪制。這里要特別小心跨頁數(shù)據(jù)如果用戶在第 3 頁畫到一半手指拖到了第 2 頁現(xiàn)實(shí)中很少發(fā)生但非禁用畫板時(shí)可能會(huì)導(dǎo)致一條筆畫的前半段在第 3 頁、后半段在第 2 頁。解決方法是每次pointerup時(shí)按當(dāng)前頁號(hào)拆開保存而不是存一個(gè)全局 stroke。async function restorePageAnnotations(pageNum, strokes, viewport) { const pageStrokes strokes.filter((s) s.page pageNum); boardCtx.clearRect(0, 0, board.width, board.height); for (const stroke of pageStrokes) { boardCtx.beginPath(); boardCtx.strokeStyle stroke.color; boardCtx.lineWidth stroke.width; stroke.points.forEach((pt, i) { const sp pdfToScreen(pt.x, pt.y, boardRect, viewport); if (i 0) { boardCtx.moveTo(sp.x, sp.y); } else { boardCtx.lineTo(sp.x, sp.y); } }); boardCtx.stroke(); } }這段代碼在恢復(fù)多頁批注時(shí)有一個(gè)隱藏問題clearRect用的是board.width這是 dpr 放大后的像素值而clearRect的坐標(biāo)單位受setTransform影響。如果用 CSS 坐標(biāo)寫就應(yīng)該清rect.width * dpr。建議統(tǒng)一封裝一個(gè)clearBoard()函數(shù)內(nèi)部用ctx.save()、ctx.setTransform(1,0,0,1,0,0)、clearRect(0,0,canvas.width,canvas.height)、ctx.restore()避免不同縮放狀態(tài)下清屏不干凈的 bug。5.4 導(dǎo)出圖片把 PDF 和批注合成為一張海報(bào)圖有些業(yè)務(wù)場(chǎng)景要求把“PDF 頁面 用戶批注”導(dǎo)出成一張圖片用于審批留檔或轉(zhuǎn)發(fā)。最常見做法是把 PDF 渲染到一個(gè)大 canvas再把批注層數(shù)據(jù)按同樣的 viewport 畫上去最后用canvas.toDataURL()導(dǎo)出。const mergeCanvas document.createElement(canvas); mergeCanvas.width pdfCanvas.width; mergeCanvas.height pdfCanvas.height; const mergeCtx mergeCanvas.getContext(2d); mergeCtx.drawImage(pdfCanvas, 0, 0); restorePageAnnotationsToContext(mergeCtx, pageStrokes, viewport); const imageUrl mergeCanvas.toDataURL(image/png);這里要注意drawImage(pdfCanvas, 0, 0)會(huì)把當(dāng)前 PDF canvas 完整復(fù)制過去所以先畫 PDF 再畫批注順序不能反。導(dǎo)出的圖片像素大小等于 pdfCanvas 的內(nèi)部像素大小也就是 dpr 放大后的尺寸在手機(jī)上看起來清晰直接放到電腦上也不糊。如果遇到圖片導(dǎo)出后黑屏十有八九是restorePageAnnotationsToContext里沒有傳 viewport導(dǎo)致批注坐標(biāo)和 PDF 圖片坐標(biāo)不一致。6. 進(jìn)階用離屏 canvas 與歷史棧優(yōu)化批注的撤銷和重繪畫板在筆畫少時(shí)性能沒問題但批注多到一頁 50 條以上時(shí)每次撤銷都要從頭重畫所有筆畫肉眼可見地卡頓。我在這份資源上做了兩處優(yōu)化效果都很直接。第一處是離屏 canvas 緩沖。新建一個(gè)不掛在文檔上的 canvas每次pointerup時(shí)把當(dāng)前畫板內(nèi)容完整復(fù)制到離屏 canvas下一次繪制新筆畫前先drawImage恢復(fù)離屏內(nèi)容再畫新筆跡。這樣重繪畫板時(shí)不需要遍歷所有歷史 stroke只要一次位圖畫布拷貝。const offscreen document.createElement(canvas); offscreen.width board.width; offscreen.height board.height; function snapshot() { offscreenCtx.clearRect(0, 0, offscreen.width, offscreen.height); offscreenCtx.drawImage(board, 0, 0); } function restore() { boardCtx.clearRect(0, 0, board.width, board.height); boardCtx.drawImage(offscreen, 0, 0); }第二處是歷史棧的“全量重繪改增量恢復(fù)”。撤銷一條筆畫時(shí)不必重新執(zhí)行整個(gè)strokes數(shù)組的繪制而是先從離屏快照里去掉當(dāng)前最后一條筆畫。但 canvas 位圖不方便“擦除某條線”所以最穩(wěn)的方案是維護(hù)兩個(gè)離屏 canvas一個(gè)存筆畫前快照一個(gè)存筆畫后快照。撤銷時(shí)直接切回前一張快照重做時(shí)切回后一張。代價(jià)是每畫一條筆跡會(huì)多占一份 canvas 內(nèi)存但移動(dòng)端一次批注一般不會(huì)超過百條內(nèi)存換流暢度值得。從那以后我每次接移動(dòng)端畫板需求都會(huì)先問清楚三個(gè)問題批注數(shù)據(jù)要不要保存到后端、PDF 放大后批注要不要跟著放大、撤銷最多支持多少層。這三個(gè)答案決定我要不要做增量快照、要不要存 PDF 坐標(biāo)、要不要限制歷史棧長度。資源里默認(rèn)做的是完整快照棧你在實(shí)際復(fù)現(xiàn)時(shí)如果發(fā)現(xiàn)內(nèi)存吃緊可以把快照壓成webp的 dataURL 再存雖然編解碼有開銷但內(nèi)存占用能降低不少。希望這份拆解和踩坑記錄幫到你。本文還有配套的精品資源點(diǎn)擊獲取