原生JS Canvas DOM截图与图片下载实战 简介面向希望脱离第三方库、用原生 JavaScript 与 Canvas 完成网页截图并导出图片的前端开发者与学习者这份单页示例围绕离屏 Canvas 创建、可视区域尺寸获取、页面绘制、toDataURL 生成 Base64 图像、带 download 属性的 a 标签触发保存等关键环节展开并提示跨域图片污染画布、复杂 CSS 还原受限、需将链接挂载到 DOM 等实际边界适合作为截图导出功能的最小原型、课堂演示与二次开发起点。压缩包约 4KB仅含 1 个 html 文件代码集中、依赖为零便于直接阅读和改造也方便对照理解 data URL、图片格式与浏览器下载行为的配合。已有 257 人学习读者可从中获得可复用的实现骨架、文件命名与参数设置思路以及向 Blob 下载、异常处理和多格式导出扩展的参考。1. 需求逼出来的原生 js canvas 截图老项目里加一个「保存为图片」按钮包体积已经卡到不能再引第三方截图库构建链还是几年前的版本。能用的只剩原生 js 加 canvas把目标 DOM 或已有 canvas 转成位图再挂到a标签上用download属性保存下来。它解决的是「不引依赖、不上传服务端、纯前端出图」这一件事适合活动海报、成绩单、二维码卡片、报表快照这类结构相对固定的场景。它不适合整页任意截图也不适合内容里全是跨域图片又拿不到 CORS 的页面。下面把我拆过的这套流程按「选路线 → 序列化 → 下载 → 排错 → 验收」讲透。2. 三条路线怎么选foreignObject 保真、canvas 重绘可控、直接快照最省事先别急着写代码先确认要截的对象是什么。对象不同路线完全不同选错了后面全是白干。2.1 三类目标对应三条路线第一类是「目标本身就是 canvas」——图表、签名板、视频帧、另一个 canvas 生成的图。这类最省事直接toBlob就完事不需要任何 DOM 转换。第二类是「结构固定的 DOM」——证书、名片、优惠券。这类节点数量可控、排版不变手工用fillRect/fillText重绘反而最稳字体、圆角、渐变都能精确控制也不会踩跨域和序列化的坑。第三类是「结构会变的任意 DOM」——用户填了内容的表单卡片、动态列表。这类只能走 SVG 的foreignObject把 DOM 序列化成 XHTML 字符串塞进 SVG浏览器把它当图片渲染一遍再drawImage到 canvas。维度canvas 直接快照foreignObject 序列化canvas 手工重绘保真度100%70%~90%取决于样式内联程度看你画得多细代码量10 行150~300 行200 行起随模板增长跨域图片不涉及必须 CORS 或转 base64必须转 base64伪元素 / 外部字体不涉及基本丢失完全可控适用场景图表、签名、视频帧任意 DOM 快照模板固定的海报、证书我的经验是能直接快照就别序列化模板固定就别序列化只有「用户内容 结构不定」才值得上foreignObject。因为那条路的坑最多下一章大半篇幅都在填它。2.2 直接快照目标本身就是 canvas这种场景没有玄学唯一要注意的是别用同步的toDataURL去导大图。2000×2000 的 canvas 走toDataURL(image/png)主线程能卡住一百多毫秒页面直接掉帧。// 目标本身是 canvas 时这是最短路径 const board document.getElementById(board); // toBlob 是异步的不阻塞主线程内存占用也比 dataURL 低 board.toBlob((blob) { if (!blob) return; const url URL.createObjectURL(blob); const a document.createElement(a); a.href url; a.download snapshot.png; a.click(); URL.revokeObjectURL(url); }, image/png); // 第二个参数是 MIME第三个 quality 只对 jpeg/webp 生效toBlob的第二个参数决定编码格式第三个参数quality在image/png下会被忽略——PNG 是无损的别指望传 0.6 能压小体积想要体积就换image/jpeg或image/webp但要注意透明背景会变黑得先在 canvas 上铺一层白底。2.3 foreignObject任意 DOM 的通用解这条路分四个阶段缺一个环节就出空白图深拷贝目标节点把里面的canvas换成静态imgcanvas 克隆出来是空白的遍历源节点和克隆节点把getComputedStyle的结果写进style属性外部 CSS 在 SVG 里不生效把图片的相对路径转绝对路径、跨域图转 base64再拼成 SVG 字符串用BlobURL 载入这个 SVG等img.decode()成功再drawImage到目标 canvas。骨架大概长这样细节在下一章展开// 四步流水线的骨架真正的实现后面逐段补 async function domToCanvas(node, scale 2) { const clone cloneForShot(node); // 1. 克隆 canvas 转 img inlineStyles(node, clone); // 2. 把计算样式写死到行内 await inlineImages(clone); // 3. 图片全部转 base64 const svgUrl buildSvgUrl(clone); // 4. 拼 SVG 转 Blob URL const img new Image(); img.src svgUrl; await img.decode(); // ...drawImage 到 canvas }顺序不能乱。样式必须先于删节点内联图片必须先于序列化转码decode必须先于drawImage。这三条我都在真实项目里违反过代价分别是样式全丢、图裂、白屏。2.4 手工重绘模板固定时最稳如果模板是设计稿定死的比如一张 750×1334 的分享海报重绘反而是最省心的不用管 DOM 结构、不用管用户浏览器字体坐标自己定出图一致性最好。// 固定模板重绘坐标全部写死输出结果和浏览器环境无关 function drawPoster(ctx, { title, avatar, qr }) { ctx.fillStyle #ffffff; ctx.fillRect(0, 0, 750, 1334); // 头像先铺个圆角底避免透明 PNG 边缘发灰 ctx.save(); ctx.beginPath(); ctx.arc(375, 320, 120, 0, Math.PI * 2); ctx.clip(); ctx.drawImage(avatar, 255, 200, 240, 240); ctx.restore(); ctx.fillStyle #1a1a1a; ctx.font bold 44px system-ui, sans-serif; // 字体栈要写全别只写一个 ctx.textAlign center; ctx.fillText(title, 375, 560); ctx.drawImage(qr, 295, 1000, 160, 160); // 二维码建议整数坐标 整数尺寸 }两个细节字体栈一定要写系统字体兜底system-ui在某些安卓机上解析不到补一个sans-serif二维码、条形码这类需要精确识别的元素坐标和尺寸都用整数缩放会引入插值模糊扫码识别率会掉。3. 把 DOM 变成一张图克隆、内联样式、图片转码、drawImage这一章是整条路线的核心。凡是「截图空白」「样式全丢」「图片裂了」八成出在这四步里的某一步。3.1 克隆节点的四件事cloneNode(true)只复制结构不复制状态。最典型的坑是 canvas克隆出来的 canvas 尺寸对但像素全空因为像素数据不在 DOM 里。所以必须先toDataURL转成 img 再替换。// 深拷贝目标节点并把内部的 canvas 换成静态图片 function cloneForShot(node) { const clone node.cloneNode(true); const srcCanvas node.querySelectorAll(canvas); const dstCanvas clone.querySelectorAll(canvas); dstCanvas.forEach((c, i) { // canvas 克隆出来是空白必须把像素转成 img 再替换 const img document.createElement(img); img.src srcCanvas[i].toDataURL(image/png); img.style.width c.clientWidth px; img.style.height c.clientHeight px; c.parentNode.replaceChild(img, c); }); // 滚动容器在截图里要展开否则只截到一屏 clone.querySelectorAll([data-shot-scroll]).forEach(el { el.style.height auto; el.style.overflow visible; }); return clone; }srcCanvas[i]和dstCanvas[i]的索引必须一一对应所以这个函数里不能增删任何节点——>// 只保留影响渲染的属性全量写会让 SVG 字符串膨胀好几倍 const KEEP [ display, position, top, left, right, bottom, z-index, width, height, margin, padding, box-sizing, border, border-radius, background, background-color, background-image, background-size, background-position, box-shadow, opacity, color, font-family, font-size, font-weight, line-height, letter-spacing, text-align, text-decoration, white-space, flex, flex-direction, flex-wrap, justify-content, align-items, gap, transform, overflow, visibility ]; // 源节点与克隆节点必须索引对齐调用前不能增删元素 function inlineStyles(srcRoot, cloneRoot) { const src [srcRoot, ...srcRoot.querySelectorAll(*)]; const dst [cloneRoot, ...cloneRoot.querySelectorAll(*)]; src.forEach((el, i) { const target dst[i]; if (!target || target.nodeType ! 1) return; const cs getComputedStyle(el); let css ; for (const p of KEEP) { const v cs.getPropertyValue(p); if (!v || v none || v normal || v auto) continue; css ${p}:${v};; } target.setAttribute(style, css); }); }性能上要有心理准备节点上千时每个节点做一次getComputedStyle外加 40 次属性读取很容易触发强制重排耗时能干到几百毫秒。常见做法是给截图区域限定节点数超过 800 个就考虑简化模板或者按requestAnimationFrame分批内联。background-image里的url(...)也是相对路径SVG 里同样解析不了这一条下面接着处理。3.3 图片资源相对路径、跨域、懒加载三件套克隆里的img有三个隐患src是相对路径、图片是跨域的、图片还没加载完。相对路径在 SVG 文档里没有基准地址直接裂。跨域图片会污染 canvastoDataURL/toBlob直接抛SecurityError。未加载完的图片drawImage会画一片空白。统一解法是全部转成 base64。// 把 img 统一转成 dataURL解决相对路径、跨域污染、未加载三个问题 async function inlineImages(root) { const imgs [...root.querySelectorAll(img)]; await Promise.all(imgs.map(async (img) { const src img.getAttribute(src) || ; // 已经是 data: 或 blob: 的不用动 if (/^(data:|blob:)/.test(src)) return; try { // 走 fetch 而不是 canvas 中转不改分辨率、不二次压缩 const res await fetch(new URL(src, location.href).href, { mode: cors, credentials: omit // CDN 不需要 cookie 时关掉少一类 CORS 报错 }); if (!res.ok) throw new Error(res.status); const blob await res.blob(); const dataUrl await new Promise((resolve, reject) { const fr new FileReader(); fr.onload () resolve(fr.result); fr.onerror reject; fr.readAsDataURL(blob); }); img.setAttribute(src, dataUrl); } catch (e) { // 拿不到的图不要留空 src换成一个 1x1 占位避免整张图渲染失败 img.setAttribute(src, data:image/gif;base64,R0lGODlhAQABAAAAACw); } })); }为什么不用canvas.toDataURL()中转因为跨域图在 canvas 上画完再导出一样会被污染而且还多了一次重采样头像会糊。fetchFileReader保留原始字节是更干净的做法。注意credentials: omit是默认值如果你的图床需要带 cookie 才能取要改成include同时服务端必须返回Access-Control-Allow-Credentials: true且不能用*通配。懒加载图片是另一个坑。img>// 拼 SVG 字符串两个命名空间都不能省 function buildSvgUrl(clone, width, height) { // xhtml 命名空间缺失部分内核会直接不渲染 clone.setAttribute(xmlns, http://www.w3.org/1999/xhtml); clone.style.width width px; clone.style.height height px; clone.style.margin 0; const html new XMLSerializer().serializeToString(clone); const svg svg xmlnshttp://www.w3.org/2000/svg width${width} height${height} foreignObject x0 y0 width100% height100%${html}/foreignObject /svg; // 用 Blob URL不受 dataURL 长度上限与字符转义影响 return URL.createObjectURL(new Blob([svg], { type: image/svgxml;charsetutf-8 })); }XMLSerializer输出的字符串里可能带#、、中文走 dataURL 时如果不做encodeURIComponent#之后的内容会被当成锚点截断图就只剩一半。Blob URL 没这个问题而且长度不受限内容长了也不会被某些内核拒掉。载入阶段用img.decode()而不是onload。decode返回 Promise解码失败会 reject能拿到真实错误onload在 SVG 渲染失败时也可能被触发拿到一张空白图却不知道错在哪。// 载入 绘制注意 canvas 内部尺寸要乘像素比 async function svgUrlToCanvas(url, width, height, scale 2) { const img new Image(); img.src url; await img.decode(); // 解码失败会 reject比 onload 好排查 const canvas document.createElement(canvas); canvas.width Math.round(width * scale); // 内部像素 canvas.height Math.round(height * scale); canvas.style.width width px; // CSS 尺寸 canvas.style.height height px; const ctx canvas.getContext(2d); ctx.scale(scale, scale); // 之后按逻辑坐标画 ctx.drawImage(img, 0, 0, width, height); return canvas; }scale就是devicePixelRatio不乘这一下导出图在手机上就是糊的——canvas 的 CSS 尺寸和内部像素尺寸是两个东西很多人只设了后者忘了前者或者反过来。4. a 标签下载闭环Blob、文件名和三个时序坑图出来了还得落盘。a标签的download属性看着简单但用错了就是「点了没反应」或者「下下来 0 字节」。4.1 dataURL 还是 Blob先算一笔账canvas.toDataURL()返回 base64 字符串体积比原始二进制大约膨胀三分之一而且是一次性常驻内存的字符串。2000×3000 的 PNG 原始可能 3MBbase64 之后字符串 4MB 多加上 canvas 本身的位图内存低端机上很容易直接崩标签页。方式体积内存是否阻塞适合toDataURL33%字符串常驻同步大图卡顿小于 500KB 的小图、要内联进img预览toBlob原始大小可被 GC 回收异步不阻塞超过 1MB 的图一律走这个结论很直接导出下载用toBlob。只有一种情况例外——你需要把结果直接塞进一个img做即时预览dataURL省一次createObjectURL。4.2 标准写法toBlob createObjectURL a.download// 通用的 canvas 下载返回 Promise 方便串行调用 function downloadCanvas(canvas, filename snapshot.png, type image/png, quality 0.92) { return new Promise((resolve, reject) { canvas.toBlob((blob) { if (!blob) return reject(new Error(toBlob 返回空检查 canvas 是否被污染)); const url URL.createObjectURL(blob); const a document.createElement(a); a.href url; a.download filename; // 只有同源或 blob:/data: 地址才认这个属性 a.rel noopener; a.style.display none; document.body.appendChild(a); // 部分内核要求节点在文档里才触发点击 a.click(); a.remove(); // 立刻 revoke 会让下载拿到 0 字节给个延迟 setTimeout(() URL.revokeObjectURL(url), 4000); resolve(blob); }, type, quality); }); }几个参数要说清楚。filename的后缀必须和type对得上.png配image/jpeg会让系统用错程序打开。quality只在image/jpeg/image/webp下有意义PNG 忽略它。a.download只有在href是同源地址或者blob:/data:时生效——如果你直接把a.href设成一张外域图片链接浏览器会忽略download变成一次页面跳转。这也是「点了下载却打开了图片」的最常见原因。至于URL.revokeObjectURL的延迟4000ms 是个经验值。立刻释放会让部分内核在下载线程还没读完 blob 时就拿到空数据最终文件 0 字节。4.3 时序什么才算「真的可以截了」截图动作触发得太早是空白图的高频原因。至少三件事要等字体加载完、图片complete、字体渲染完成一帧。// 截图前必须走一遍的等待字体 图片 一帧渲染 async function waitForReady(root) { // 字体没加载完文字会用 fallback 字体渲染宽度全变 if (document.fonts document.fonts.ready) { await document.fonts.ready; } const pending [...root.querySelectorAll(img)].filter(i !i.complete); await Promise.all(pending.map(img new Promise((resolve) { img.onload img.onerror resolve; // 加载失败也要放行否则永远卡住 }))); // 等一帧让刚改过的样式真正落到渲染树上 await new Promise(r requestAnimationFrame(() requestAnimationFrame(r))); }img.onerror也要 resolve不然一张图 404 就把整个下载流程挂死。这是踩过的坑CDN 抽风一张头像用户点下载按钮毫无反应控制台还没报错。如果截图的目标本身就是用户刚输入的内容还得等输入事件那一轮 DOM 更新完requestAnimationFrame两次是比较稳的兜底。4.4 移动端download 属性不生效时的降级a.download在桌面端很稳在移动端浏览器上支持参差不齐尤其是 iOS 上的内联浏览器点了经常没有任何反应。上线前先探测一下// 特性探测不支持 download 就走长按保存的降级路径 const canDownload download in document.createElement(a); async function saveImage(canvas) { if (canDownload) { await downloadCanvas(canvas, poster-${Date.now()}.png); return; } // 降级把图渲染到页面上提示用户长按保存 const url canvas.toDataURL(image/png); const box document.getElementById(preview); box.innerHTML img src${url} alt长按保存到相册; box.hidden false; }降级方案就是放弃自动保存把图贴到页面上让用户长按。体验差一点但比点了没反应强。有些环境支持系统分享能力可以把 blob 转成File塞进去这一层要看具体宿主环境提供什么接口不稳定别当主路径。5. 排查清单canvas 污染、白屏、字体乱码、长图截断的五次翻车这一章全是我在真实项目里翻过的车。每条按「现象 → 原因 → 解决」写遇到了直接对号入座。5.1 污染与白屏跨域图片的两个变种现象一控制台报Uncaught SecurityError: Failed to execute toDataURL on HTMLCanvasElement: Tainted canvases may not be exported.原因canvas 里画过跨域图片且该图片的响应没带Access-Control-Allow-Origin或者img上没设crossOrigin。一旦被标记为污染整张 canvas 就再也导不出来了。解决两头都要做。前端img crossOriginanonymous必须在设置src之前设顺序反了不生效服务端返回Access-Control-Allow-Origin且不能是*配credentials。最稳的做法是根本不用跨域图走 3.3 的fetchFileReader全转 base64。顺带一句污染是「一次污染永久污染」同一个 canvas 上后面再画同源内容也救不回来。现象二图导出来了尺寸也对但图片位置是一块空白其他地方都正常。原因图片在drawImage那一刻还没解码完成或者 base64 字符串过长被截断。解决先await waitForReady(root)逐张确认img.completefetch转 base64 后校验字符串长度异常长度直接走占位图。5.2 尺寸超限与精度丢失现象三桌面端完美iOS 上导出是纯白img.decode()也不报错。原因SVG 字符串太长或者 canvas 面积太大超了移动端内核的上限。SVG 走 dataURL 时字符数超限会被静默截断canvas 面积超过大约 1670 万像素不同机型不一样会直接变成空白画布。解决三步走。第一SVG 改用 Blob URL别用 dataURL绕开长度限制第二给 canvas 面积设上限超过就按比例降scale第三内容特别长的比如整页文章分块渲染成多张图再拼或者干脆放弃一次成型改成截当前视口。现象四导出的图在手机上看着糊边缘发虚文字像加了一层模糊。原因只设了canvas.width/height没乘devicePixelRatio或者只设了 CSS 尺寸没设内部像素。解决内部像素 逻辑尺寸 × dprCSS 尺寸 逻辑尺寸然后ctx.scale(dpr, dpr)。dpr 取Math.min(window.devicePixelRatio || 1, 3)超过 3 的机型部分安卓是 3.5、4没必要跟面积翻倍涨收益看不见。5.3 样式与字体现象五截图里字体全变成了默认衬线体和页面上完全不一样中文有时候直接变方块。原因foreignObject是个独立文档页面上的font-face自定义字体不会跟过去。而且 SVG 里可用的字体名必须是系统已安装的font-family: PingFang SC在安卓上就是空。解决优先用系统字体栈-apple-system, BlinkMacSystemFont, Segoe UI, PingFang SC, Microsoft YaHei, sans-serif这种组合能覆盖绝大多数设备。必须用自定义字体时把字体文件转成 base64 内联成font-face塞进 SVG代价是字符串长度暴涨一个中文字体子集动辄几百 KB只适合标题几个字。我一般直接在截图场景换系统字体视觉差异在一个像素级别内用户根本看不出来。注意font-weight也会翻车。页面用了 500 这种中间字重系统字体不一定有对应字库会 fallback 成 400 或 700。截图前把所有字重收敛到 400 / 700 两档能省一大类排查。6. 一次成功的验收像素比、裁剪与可复用的封装做到能出图只是及格要上线还得控住像素、控住内存、控住失败路径。我把前面几步收口成一个函数参数就三个。// 统一入口像素比、裁剪区域、面积上限一次配齐 function renderToCanvas(source, { scale 2, clip null, maxPixels 16e6 } {}) { const w clip ? clip.width : source.width; const h clip ? clip.height : source.height; // 面积兜底超限就按 0.5 递减 scale避免大图直接空白 let s Math.min(scale, Math.min(window.devicePixelRatio || 1, 3)); while (w * s * h * s maxPixels s 1) s - 0.5; const canvas document.createElement(canvas); canvas.width Math.round(w * s); canvas.height Math.round(h * s); const ctx canvas.getContext(2d); ctx.imageSmoothingQuality high; // 缩小渲染时明显更清晰 if (clip) { // 九参数版本从源图裁一块再铺满目标画布 ctx.drawImage(source, clip.x, clip.y, w, h, 0, 0, canvas.width, canvas.height); } else { ctx.drawImage(source, 0, 0, canvas.width, canvas.height); } return { canvas, scale: s }; }三个参数的含义和取值参数默认说明scale2渲染倍率最终取min(scale, dpr, 3)超过 3 收益极低clipnull{x, y, width, height}逻辑坐标用于只截可视区或指定区域maxPixels16e6面积上限超过自动降 scale防止移动端白屏imageSmoothingQuality high这行在小图上没感觉但在「把 3 倍图缩到 1 倍」的场景里差别很明显文字边缘会干净很多。裁剪场景里注意clip用的是逻辑坐标不是画布像素坐标乘 dpr 的事让scale统一处理否则两个地方同时缩放图会缩两次。验收我会按这几条走一遍桌面端导出后比对像素尺寸是否等于逻辑尺寸 × scale移动端导出后看文件大小是否合理纯白图通常只有几 KB一眼能看出白屏检查 PNG 打开后有没有透明区域变成黑色连续点击下载三次看第二次之后文件名是否冲突、URL 是否释放干净最后在飞行模式下点一次确认失败路径有提示而不是静默。从那以后我每次接截图需求都强制先跑一遍这套验收——尤其是「连续点三次」和「飞行模式点一次」这两条前者暴露 URL 释放问题后者暴露 Promise 悬空问题。这两个 bug 上线后极难复现用户只会说「有时候点不动」。希望帮到你。本文还有配套的精品资源点击获取