简介:面向前端开发者的原生JS+Canvas网页截图下载实现资源,无需第三方依赖即能截取当前可视区域并保存为图片,适合需要轻量截图功能或希望避开大型库的项目场景。压缩包内仅1个HTML文件,体积4KB,但代码附有步骤注释,可直接运行参考,能从中了解离屏Canvas、toDataURL、a标签download属性等核心API的配合方式。已有255人学习/下载,示例完整覆盖获取窗口尺寸与滚动位置、创建离屏Canvas、生成PNG数据、创建下载链接并触发click的流程,同时指出跨域图片与复杂CSS效果的兼容性局限,帮助读者在必要时改用html2canvas等方案。文件虽小,但逻辑清晰,对遇到canvas画布空白、跨域污染等问题的初学者有直接参考价值,也便于二次修改或集成到现有工具中。
1. 原生JS+Canvas截图:不引库也能做浏览器端出图方案
在浏览器里把页面某个区块存成一张PNG,很多人第一反应是“找个截图库”。但实际项目里,这类需求往往集中在两处:数据大屏要导出一张当前画面的存档,后台管理系统要把某张图表或某个卡片存成图片交给用户。这两类需求用原生JS配合Canvas完全做得下来,代码量可控、没有额外依赖、构建体积不变。我把这个方案拆成三层来讲:怎么从已有Canvas里拿像素,怎么把DOM节点转成Canvas能画的内容,以及最后怎么用a标签让浏览器把手里的图真正落到磁盘。适合想少引一个依赖的开发者,也适合被第三方截图库折磨过、想搞清楚底层原理的人。这篇文章只讲一套能跑通的原生链路。
2. Canvas截图的两条技术路线:画已有Canvas,还是画DOM节点
动手前先想清楚一个前提:你要截的内容本身是Canvas,还是普通HTML节点?这两条路的实现机制完全不一样。前者用drawImage把Canvas当图像源再画一遍;后者要把DOM序列化成图片,本质上是绕道SVG。把它俩混为一谈是后面翻车的第一来源。
2.1 从已有Canvas取图:drawImage与toDataURL的组合
最常见的场景:页面上已经有一张Canvas图表,比如自绘的折线图、雷达图,要把它导出成图片。这时不需要逐像素拷贝,直接使用toDataURL就行:
const sourceCanvas = document.getElementById('chart'); const dataURL = sourceCanvas.toDataURL('image/png', 1);这段代码的前提是:sourceCanvas的2D上下文没有被跨域图片污染过。只要之前往这个画布里画过一张没带CORS的跨域图片,这一行会直接抛SecurityError。所以更稳妥的做法,是先把源画布绘制到一张新Canvas上,再做导出:
function exportCanvas(sourceCanvas, mimeType = 'image/png', quality = 1) { const output = document.createElement('canvas'); output.width = sourceCanvas.width; output.height = sourceCanvas.height; const ctx = output.getContext('2d'); ctx.drawImage(sourceCanvas, 0, 0); return output.toDataURL(mimeType, quality); }这里的关键点是output.width和output.height直接取了源画布的width/height属性,而不是offsetWidth。Canvas元素有两组尺寸:width/height属性表示实际像素缓冲大小,CSS样式上的offsetWidth/offsetHeight是它显示在页面里的大小。截图导出必须取前者,否则导出来就是一张被CSS缩放过的模糊图。toDataURL的第二个参数quality取值0到1,只在导出JPEG或WebP时生效,导出PNG时传了也不起作用。
如果要把多张Canvas拼成一张图,思路也一样,按坐标位置多次drawImage即可。这里有个容易忽略的细节:第二次drawImage时,如果目标Canvas像素不够,图片会被裁剪而不会报错。所以合成前先算好最终Canvas的宽高,再依次画,别画到最后发现右下角的图表没进画面。
2.2 把DOM节点转成Canvas:SVG foreignObject的启动路径
当目标是HTML节点,比如一张工单卡片、一个统计表格区域,Canvas本身没法直接画HTML。常见做法是借道SVG:把节点序列化后放进SVG的<foreignObject>标签里,SVG可以被Image对象加载,加载完成再用drawImage画到Canvas上。最小可运行版本如下:
function serializeNodeToSVG(node) { const clone = node.cloneNode(true); const width = node.offsetWidth; const height = node.offsetHeight; const svg = ` <svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}"> <foreignObject width="100%" height="100%"> <div xmlns="http://www.w3.org/1999/xhtml">${new XMLSerializer().serializeToString(clone)}</div> </foreignObject> </svg>`; return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`; } const img = new Image(); img.onload = () => { const canvas = document.createElement('canvas'); canvas.width = node.offsetWidth; canvas.height = node.offsetHeight; canvas.getContext('2d').drawImage(img, 0, 0); }; img.src = serializeNodeToSVG(node);这里有两个细节直接决定成败:一是cloneNode(true)必须深拷贝,只拷贝标签不拷贝子节点的话,序列化出来是空壳;二是foreignObject内部的div必须带xmlns="http://www.w3.org/1999/xhtml"声明,否则部分浏览器把它当XML解析,画出来是空白。
序列化只带走节点结构和内联内容,页面上通过<style>或link[rel="stylesheet"]加载的样式是带不过去的。要让截图不白屏,常见做法是把页面里已有的<style>标签内容一并注入SVG。我在前面那段代码上补一个增强版:
function buildSVGWithStyles(node) { const clone = node.cloneNode(true); const width = node.offsetWidth; const height = node.offsetHeight; const styleTags = Array.from(document.querySelectorAll('style')) .map(el => el.outerHTML) .join(''); const svg = ` <svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}"> <foreignObject width="100%" height="100%"> <div xmlns="http://www.w3.org/1999/xhtml"> ${styleTags} ${new XMLSerializer().serializeToString(clone)} </div> </foreignObject> </svg>`; return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`; }注意link[rel="stylesheet"]引用的外链样式,在SVG里跨域加载经常受限,最可靠的做法还是想办法内联。这也是为什么这个方案对“样式依赖重的组件”不友好:如果节点外观全靠外部CSS类撑起来,截图结果大概率缺样式。
另外,foreignObject内部的img标签还有个隐患。HTML序列化时<img src="...">没有闭合斜杠,但SVG上下文里是XML解析器,严格的XML要求标签自闭合。多数现代浏览器对foreignObject内部按HTML解析规则处理,但为了保险,遇到SVG加载失败时,可以先检查节点里有没有img、input这类void元素,把它们规范化成<img src="..." />这种自闭合写法再序列化。
2.3 为什么不直接引第三方截图库:体积、控错与跨域成本的取舍
第三方截图库的成熟度确实高,兼容性处理也全面,但引入前值得算三笔账。
第一笔是体积与构建:这类库普遍包含序列化、样式计算、CSS解析、渲染多套逻辑,压缩后几十KB起步。如果项目只是“导出一张图表”,这个占比并不划算。第二笔是可控性:库把细节封装后,一旦出现截图位置偏移、样式缺失、字体未加载这类问题,排查时面对的是它内部的序列化和渲染逻辑,黑匣子属性很强,你很难在半小时内定位到“是某条样式规则没被带进去”。第三笔是跨域:第三方库同样受浏览器CORS约束,跨域图片一样会污染画布。它解决不了你后端不配合的问题。
我一般的选择标准是:目标内容是自身可控的Canvas,或样式简单的DOM卡片,用原生方案;目标内容是布局复杂、样式嵌套深、还有大量跨域资源的整页或大区块,才考虑上库。这件事的本质不是“谁更高级”,而是“你的截图内容在哪个边界内可控”。后面所有代码都按原生方案来,遇到跨域时也要清楚这是浏览器安全模型的红线,不是换个库就能绕掉的。
3. 实现a标签下载:同源与跨域的下载语义差异
图片在Canvas里生成好了,最后一步是让用户拿到文件。a标签下载看起来是小事,但这里恰恰是“点下去没反应”“直接新标签页打开图片”“文件名不生效”三类问题的集中爆发区。
3.1 最小下载代码:为什么用Blob URL而不是DataURL
先给出一段可以放进生产环境的最小下载函数:
function triggerDownload(blob, filename) { const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = filename; document.body.appendChild(a); a.click(); document.body.removeChild(a); setTimeout(() => URL.revokeObjectURL(url), 1000); }逻辑说明:先把Blob对象转成object URL,挂到一个临时创建的a标签上,再把a临时塞进body触发click,最后收尾。三步都别省:不塞进body的话,部分浏览器对a.click()无响应;不revokeObjectURL的话,大图下载后浏览器内存里的URL引用迟迟不释放,连点几次下载内存会明显上涨,在长驻后台页面里尤其明显。
参数说明:filename要带扩展名。浏览器是根据a.download的字符串来决定保存文件名的,不带扩展名会让用户拿到一个无后缀的文件,系统不知道用什么程序打开。a.download在同源和Blob/DataURL场景下有效,但如果href是一个跨域HTTP地址,浏览器会无视download属性直接导航过去,这个在下一节详细展开。
至于为什么用Blob URL而不是DataURL:Blob URL是临时内存引用,字符串极短;DataURL把图片base64后整个塞进href,同尺寸图片字符串体积约增大33%,而且超长字符串在某些浏览器里会被截断。DataURL的优势是同步,拿到的字符串可以直接发给后端、可以直接在新标签页预览。我的习惯是:导出单张图用Blob URL给a标签下载;需要把图片数据再交给服务端时,才保留DataURL。
3.2 download属性失效的三种情况
第一种是跨域href。Canvas导出的DataURL和Blob URL都属于“本地来源”,download有效;但如果你把href直接指向一个跨域服务器的图片地址,download会被浏览器忽略,行为变成新标签页打开图片。第二种是iOS上的Safari,对download属性的支持长期不完整,即便在新版本上也只是部分可用,老版本上点击后会进入图片预览,用户需要长按保存,这是平台限制,不是代码问题。第三种是文件名里带特殊字符,比如反斜杠、换行符,部分浏览器会自行改名或拒绝下载。
遇到第一种情况,如果业务上必须下载跨域资源,常见做法是后端代理:你的服务端去拉取那张图,再以二进制流返回。浏览器拿到同源响应,download才生效。前端这里没有后悔药,绕不过安全策略。
3.3 从Canvas导出到下载的完整链路:Blob优先还是DataURL优先
实际操作中,我常用一个统一出口函数,同时支持Blob和DataURL两种来源,避免调下载的地方重复造轮子:
function saveCanvas(canvas, filename = 'screenshot.png', mimeType = 'image/png', quality = 1) { if (canvas.toBlob) { canvas.toBlob((blob) => { if (blob) { triggerDownload(blob, filename); } else { const dataURL = canvas.toDataURL(mimeType, quality); triggerDownload(dataURL, filename); } }, mimeType, quality); } else { const dataURL = canvas.toDataURL(mimeType, quality); triggerDownload(dataURL, filename); } }这里canvas.toBlob是异步回调,回调里的blob可能为null,比如低内存时导出失败。所以代码里加了一次回退:拿不到Blob就直接退到同步的toDataURL。toDataURL在JPG格式下支持质量参数,但在PNG下会忽略,这个差异要心里有数。
对应的triggerDownload也要能分辨传入值是Blob还是字符串:
function triggerDownload(fileOrDataUrl, filename) { const url = fileOrDataUrl instanceof Blob ? URL.createObjectURL(fileOrDataUrl) : fileOrDataUrl; const a = document.createElement('a'); a.href = url; a.download = filename; document.body.appendChild(a); a.click(); document.body.removeChild(a); if (fileOrDataUrl instanceof Blob) { setTimeout(() => URL.revokeObjectURL(url), 1000); } }到这一步,一张来自Canvas的图已经能稳定下载了。如果截图过程本身还要处理DOM节点、高清屏、字体加载等问题,就进入下一章,把它们串成完整流程。
4. 把完整截图流程串起来:从点击按钮到图片落盘
前面分开讲了Canvas取图、DOM转SVG、a标签下载,现在把它们组装成一个能直接用的流程。以截取一个DOM卡片为例,最终产物是一张物理分辨率的PNG,并且自动触发下载。
4.1 主流程:一张图的五个步骤
完整流程先看代码骨架:
async function captureAndDownload(node, filename = 'capture.png') { await document.fonts.ready; await waitForImages(node); const dpr = Math.max(window.devicePixelRatio || 1, 1); const width = node.offsetWidth; const height = node.offsetHeight; if (width === 0 || height === 0) { throw new Error('节点不可见,宽或高为0,无法截图'); } const svgDataUrl = buildSVG(node, width * dpr, height * dpr); const canvas = await drawSVGToCanvas(svgDataUrl, width * dpr, height * dpr); saveCanvas(canvas, filename, 'image/png'); }五个步骤分别是:等字体就绪、等节点内图片加载完、计算目标宽高并放大DPR、把节点序列化成SVG并绘制到Canvas、最后调上一章的下载出口。每个环节都有必要性:字体未就绪时,截出的文字可能用的fallback字体,渲染结果和屏幕看到的不一致;图片未加载完时,截图里会出现占位符或空白区;DPR不处理时,高分屏下导出的图会明显发虚。
waitForImages的实现用img.decode()更干净,兼容性注意点写在注释里:
function waitForImages(rootNode) { const images = Array.from(rootNode.querySelectorAll('img')) .filter(img => !img.complete || img.naturalWidth === 0); return Promise.all(images.map(img => { if (img.decode) { return img.decode().catch(() => Promise.resolve()); } return new Promise(resolve => { img.addEventListener('load', resolve, { once: true }); img.addEventListener('error', resolve, { once: true }); }); })); }逻辑说明:img.decode()是浏览器提供的与解码完成绑定的API,比监听load更可靠,因为load事件可能在图片已被缓存时错过,而decode()的状态与当前解码任务直接关联。不支持时回退到事件监听。注意decode()失败要吞掉异常,否则一张图片损坏会导致整次截图中断。
注意:
document.fonts.ready只在支持Font Loading API的浏览器里生效,老浏览器里它是undefined。使用前做一次能力判断,不存在就直接跳过等字体这一步。
4.2 高清屏适配:devicePixelRatio的三个处理点
DPR处理是这个方案里最容易做错的一步。很多人只把canvas.width乘以DPR,结果导出图的布局全变了。正确做法是三个处理点联动:
function buildSVG(node, svgWidth, svgHeight) { const clone = node.cloneNode(true); resetInlineStyles(clone, node); const svg = ` <svg xmlns="http://www.w3.org/2000/svg" width="${svgWidth}" height="${svgHeight}" viewBox="0 0 ${svgWidth} ${svgHeight}"> <foreignObject width="100%" height="100%"> <div xmlns="http://www.w3.org/1999/xhtml" style="width:${svgWidth}px;height:${svgHeight}px;zoom:${window.devicePixelRatio || 1}"> ${new XMLSerializer().serializeToString(clone)} </div> </foreignObject> </svg> `; return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`; }三个处理点分别是:SVG的width/height用物理像素;viewBox的宽高与物理像素一致,让内部坐标系从头到尾就落在物理像素空间;内部div的zoom设为DPR值,把原节点的CSS尺寸整体放大到物理比例,连带字体、内边距、边框一起放大。如果只放SVG宽高、忘记放大内部div的内容,会出现图片尺寸变大、内容却只占左上角一小块的尴尬。
另一种常见思路是不放大SVG,绘制到Canvas后再ctx.scale(dpr, dpr)整体放大。效果等价,但放大发生在位图层面,内容是被拉伸上来的,不如从源头就按物理像素生成SVG干净。resetInlineStyles的作用是把节点通过class样式计算出来的最终外观固化进inline style,否则截图里会出现“结构在、样式全丢”的裸体版:
function resetInlineStyles(root, sourceRoot) { const cloneNodes = root.querySelectorAll('*'); const sourceNodes = sourceRoot.querySelectorAll('*'); cloneNodes.forEach((cloneEl, index) => { const computed = window.getComputedStyle(sourceNodes[index]); if (computed.position === 'fixed' || computed.position === 'sticky') { cloneEl.style.position = 'relative'; } cloneEl.style.cssText += ';' + [ 'margin', 'padding', 'color', 'background', 'border', 'font-size', 'line-height', 'text-align', 'display', 'width', 'height', 'box-sizing', 'border-radius', 'box-shadow', 'transform' ].map(prop => `${prop}:${computed[prop]}`).join(';'); }); }这里依赖一个前提:cloneNode深拷贝出来的树结构和源节点树结构完全一致,所以querySelectorAll('*')的遍历顺序能一一对应,取到的getComputedStyle结果也能原样套回克隆节点上。position为fixed或sticky的元素在截图里容易跑飞,强制转成relative是经验换来的默认动作。
这段代码只抽取了一批高频样式属性,实际项目里要根据被截节点的外观名单扩充。比如节点里用到了flex布局上的gap属性,就需要在数组里补上gap,否则截图里元素间距会变。
4.3 用drawSVGToCanvas把SVG安全画成Canvas
function drawSVGToCanvas(svgDataUrl, width, height) { return new Promise((resolve, reject) => { const img = new Image(); img.onload = () => { const canvas = document.createElement('canvas'); canvas.width = width; canvas.height = height; const ctx = canvas.getContext('2d'); ctx.drawImage(img, 0, 0, width, height); resolve(canvas); }; img.onerror = () => reject(new Error('SVG加载失败,检查是否存在非法标签或不支持的样式')); img.src = svgDataUrl; }); }把SVG当Image画到Canvas上,本质上是让浏览器完整走一遍SVG的图像解码流程。SVG内部任何不被支持的解析结果,都表现为img的error,而不是个别元素缺失。所以这个Promise的错误信息写得泛一点没关系,关键是让调用方能感知失败并进入loading态。
流程串完了,下面进入踩坑重灾区。如果你把这段代码放进真实项目后遇到“弹了个SecurityError”“图上全是空白”“文字发虚”之类的问题,下一章的排查路径大概率能覆盖。
5. 截图下载避坑指南:跨域污染、样式丢失、模糊的排查路径
做截图功能,十次有七次时间花在排错上。我把实际遇到过的四类问题整理成“现象→原因→解决”的格式。每一条背后都是一个真实的翻车现场。
5.1 canvas.toDataURL抛SecurityError:谁污染了我的画布
现象:调用toDataURL时控制台抛Uncaught DOMException: Failed to execute 'toDataURL' on 'HTMLCanvasElement': Tainted canvases may not be exported.,整个下载流程中断。
原因:画布曾经绘制过跨域来源且未带CORS标记的图片,浏览器给这张画布打上“被污染”标记,禁止导出。污染是全流程传递的:源Canvas被污染后,再drawImage到新Canvas,新Canvas同样被污染。这个殃及面比预想的大,最常见的来源是CDN上的配图、第三方头像、用户上传的图片。
解决:三层排查。第一层,确认图片请求是否带crossOrigin="anonymous",没加就加到<img>或new Image()上。第二层,去后端确认响应头有没有Access-Control-Allow-Origin,且值匹配当前站点域名。CORS头不是前端能自证的事,必须后端配合。第三层,检查缓存:同一URL如果先前以不带crossOrigin的方式加载过,浏览器缓存里那份就是非CORS版,即使后来加了crossOrigin也可能复用旧缓存。解决方式是给URL加时间戳参数,强制重新请求。
如果被截节点里的图片来自一个让你无法控制的第三方域名,那就等于宣告这个方案对“整图导出”失效。放行的唯一路径是后端代理转发,让图片走你的域名进出。
5.2 截图出来一整片空白:序列化的SVG里没有样式
现象:DOM结构和文字都在,但截图出来的PNG是白底黑字的“原始页面”,所有背景色、边框、圆角、布局全部丢失。
原因:serializeToString只序列化节点本身,class类名引用的样式规则不会跟随节点一起走。凡是外观靠外部CSS类决定的节点,序列化后就是没穿衣服的裸结构。
解决:用resetInlineStyles把计算样式内联回节点。这里有一个取舍:把getComputedStyle的每个属性都写回style会生成超大SVG,截图慢且内存吃紧,建议只挑选影响外观的几十个高频属性。另外,如果目标节点里有iframe、video这类特殊元素,foreignObject解析经常不给面子,SVG直接加载失败。目前没有完全通用的兜底,只能从需求上排除这类节点,或者单独处理它们的内容。
5.3 导出图片发虚:CSS尺寸和Canvas像素尺寸被混淆
现象:导出的图片在屏幕上预览时还算清晰,保存后1:1查看,边缘发虚,文字有明显的锯齿。
原因:canvas.width = node.offsetWidth取的是CSS像素逻辑尺寸。在高DPR设备上,它对应的物理像素是逻辑值的2倍或3倍。直接按逻辑值画,物理像素被砍了三倍的分辨率,导出自然发虚。
解决:导出时统一按“宽 = offsetWidth × devicePixelRatio”放大画布,同时让SVG的尺寸也保持同比例,生成出的位图本身就是物理分辨率,不存在后续拉伸。注意别只放大Canvas不放大SVG,否则内容只占画布的一小块。
5.4 下载变成新标签页打开图片,或文件名不生效
现象:点击下载按钮,没有弹出保存框,反而新标签页展示图片;在手机上则是进入图片预览页。
原因:a.download只对同源地址和本地源(DataURL/Blob URL)生效。如果下载逻辑里的href是一个HTTP地址,比如直接指向跨域图片URL,浏览器下载语义失效,退化为导航行为。iOS Safari对download属性的支持一直存在兼容缺口。
解决:图片生成类下载坚持用canvas.toBlob。拿到的Blob本身没有源问题,触发下载时用URL.createObjectURL(blob)生成引用,download一定生效。跨域HTTP图片做下载时,不要在前端硬绕,让后端代理转一次成同源流最靠谱。iOS上如果确认download失效,可选的兜底方案是打开图片新窗口让用户长按保存,这是体验降级,不是功能失败。
6. 进阶:区域截图与导出前加附加元素的技巧
到这个阶段,整条链路的下载和排错都通了。想让功能在真实体验上更进一步,最后补两个实用点。
6.1 区域截图:drawImage的sourceRect参数别写反
如果需求不是整块节点,而是“截取卡片右上角200×150的一块”,用带九个参数的drawImage从源图里抠一块再画:
const targetCanvas = document.createElement('canvas'); targetCanvas.width = 200; targetCanvas.height = 150; const ctx = targetCanvas.getContext('2d'); ctx.drawImage(sourceCanvas, sx, sy, sWidth, sHeight, 0, 0, 200, 150);参数顺序是drawImage(图像源, 源x, 源y, 源宽, 源高, 目标x, 目标y, 目标宽, 目标高)。我见过不少把源坐标和目标坐标写反导致截图内容偏移的情况,写这一段时按参数名对齐填数值最稳。做裁剪预览时,sx和sy通常来自鼠标相对源图的位置计算。
6.2 导出前临时加水印:绘制在合成Canvas上
导出前给截图加一行水印,在原生方案里就是在目标Canvas上再画一次:
function addTextWatermark(canvas, text, position = 'bottom-right') { const ctx = canvas.getContext('2d'); ctx.save(); const size = canvas.width * 0.04; ctx.font = `${size}px sans-serif`; ctx.fillStyle = 'rgba(255,255,255,0.7)'; ctx.shadowColor = 'rgba(0,0,0,0.5)'; ctx.shadowBlur = 4; const metrics = ctx.measureText(text); let x = canvas.width - metrics.width - 20; let y = canvas.height - 20; if (position === 'bottom-left') { x = 20; } ctx.fillText(text, x, y); ctx.restore(); }这段代码在下载前调用,水印会跟随导出画布一起输出到PNG。注意用ctx.save()和ctx.restore()包住绘制状态,不然字体、阴影、填充色会污染后续绘制。水印字号用画布物理宽度的百分比而不是CSS像素,保证不同DPR的屏幕上导出后视觉占比一致。
我早期的习惯是把水印直接画在被截的源图上,结果每次导出图表,源图也跟着多一行字,越攒越脏。后来的做法是导出流程里始终用一张干净的合成Canvas,源图保持不变,只在导出前临时绘制水印、标题、日期这类附加元素。这个习惯后来成了我所有导出功能的默认姿势。
截图功能写在页面上只有几行,藏在背后的却是安全模型、渲染时序、设备像素比的一堆博弈。把这个原生方案跑通之后,再遇到“截图库也不行”的问题,至少能分清是跨域、样式还是DPR的锅,不用一头扎进别人的黑匣子里瞎调。希望帮到你。
本文还有配套的精品资源,点击获取