简介:一份围绕原生JavaScript与Canvas实现网页截图及浏览器下载的前端示例,面向希望减少第三方依赖、理解截图下载底层流程的初中级开发者,重点解决可视区域捕获、画布位图导出与本地保存等基础问题。压缩包内共1个HTML文件,约4KB,以单页示例集中呈现离屏画布创建、数据URL转换、下载链接动态生成、点击触发及节点清理等核心代码,结构精简,便于直接查阅和移植。已有257人学习。读者可从中获得一条从画布绘制到文件下载的最小闭环参考,并了解跨域图片污染、复杂样式还原偏差等常见限制,为判断是否需要引入成熟截图库提供依据。适合前端入门练习、课堂演示与轻量项目原型中的截图下载需求参考,也可作为对照成熟库差异的简易样本。
1. 报表页要一键出图,我最后没用第三方库
上个月有个后台系统改版,运营每天要把“日报”页面截下来发群里,手动截图分辨率不统一,还经常把浏览器地址栏也带进去,一张张裁剪。需求绕了一圈到我这里:页面上加一个“导出图片”按钮,点一下出 PNG,直接落盘。这个项目就是干这件事的:用原生 js 操作 canvas,把要导出的内容重绘一遍,再用 a 标签的 download 属性触发下载。整套实现不引任何构建工具,一个 script 标签引入就能跑,压缩前不到三百行。适合谁?做后台管理、做活动分享卡片、做低代码组件预览的前端,只要导出范围是你自己能控制的 DOM 或数据就行。不适合拿去截任意第三方页面——跨域那一关会让你怀疑人生,后面会专门讲。
2. 架构拆解:截图导出功能的模块划分与三段式数据流
这个功能看着只有“画图”和“下载”两步,真写起来会散成一团:取尺寸、算缩放、加载图片、等字体、画文字、转 Blob、触发 a 标签。散着写第一版能跑,第二版加个圆角头像就全乱。我在动手之前先按职责切了三段,后面的代码都是照这个骨架填的。
2.1 目录结构与模块职责
目录不复杂,关键是把“渲染”和“下载”拆开,中间用 Blob 作为唯一交接物:
shot/ ├── index.html # 演示页,放一个 #card 节点和导出按钮 ├── src/ │ ├── main.js # 入口:绑按钮、组装配置、串流程 │ ├── assets.js # 资源预加载:图片、字体就绪判断 │ ├── renderer.js # 渲染:建画布、画背景、画图片、画文字 │ ├── exporter.js # 导出:canvas -> Blob -> a 标签下载 │ └── utils.js # 工具:圆角裁剪、文字换行、文件名清洗 └── assets/ ├── logo.png └── avatar.jpg各模块的职责边界我用一张表定死,写的时候就不容易越界:
| 模块 | 输入 | 输出 | 不该干的事 |
|---|---|---|---|
assets.js | 图片 URL 数组 | Promise<Image[]> | 不碰 canvas |
renderer.js | 配置对象 + 已加载资源 | 一个HTMLCanvasElement | 不触发下载 |
exporter.js | canvas + 文件名 | 无(副作用是下载) | 不改画布内容 |
utils.js | 纯参数 | 纯结果 | 不访问 DOM 以外的全局状态 |
这样切的直接好处是测试方便:渲染层可以单独在页面上挂一个 canvas 看效果,导出层可以拿任意 canvas 单独验证,排查问题时能快速定位是“画错了”还是“存错了”。
2.2 三段式数据流:采集、渲染、导出
整条链路我按三段走,入口只有一个main.js里的函数:
// main.js —— 入口只做编排,不做具体绘制 import { preloadImages } from './assets.js'; import { renderCard } from './renderer.js'; import { downloadCanvas } from './exporter.js'; async function onExportClick() { const config = { width: 750, // 逻辑宽度,按设计稿走 scale: 2, // 导出倍率,跟屏幕 DPR 解耦 background: '#ffffff', avatar: './assets/avatar.jpg', logo: './assets/logo.png', title: '今日数据日报', lines: ['活跃用户 12,480', '新增订单 3,206', '转化率 8.7%'] }; try { // 第一段:资源到位再开画,避免画布上出现空白图 const assets = await preloadImages([config.avatar, config.logo]); // 第二段:纯函数式渲染,返回画布 const canvas = renderCard(config, assets); // 第三段:转 Blob 并触发下载 await downloadCanvas(canvas, '日报-2024-06-01.png'); } catch (err) { console.error('[export] 失败:', err.message); // 这里换成自己的 toast 提示 } } document.querySelector('#export-btn').addEventListener('click', onExportClick);这段代码里有两个决策值得说清楚。一是scale和devicePixelRatio解耦:页面上显示多清晰是屏幕的事,导出多清晰是业务的事,运营要发群的图必须固定为 2 倍,不能因为某台机器 DPR 是 3 就导出个巨大的文件。二是把await放在资源加载和导出两个异步点上,中间的renderCard保持同步,这样渲染逻辑里不需要处理“图还没到”的分支,代码干净很多。
后续要加内容,比如加个二维码,只需要在preloadImages的数组里加一项、在renderCard里加一段绘制,入口函数一个字都不用改。
2.3 为什么没直接上 html2canvas 这类现成方案
选型阶段我对比过两条路,结论写在这里,避免后面接手的人重复纠结:
| 方案 | 优点 | 代价 | 适用场景 |
|---|---|---|---|
| 引第三方 DOM 截图库 | 几乎是零成本,DOM 写什么就出什么 | 包体积几十到上百 KB;对 CSS 支持有取舍;样式越花越容易翻车 | 需要还原整页 DOM、样式复杂且不可控 |
| 原生 canvas 重绘 | 体积极小、结果完全可控、性能好 | 每加一种内容就要写一段绘制代码 | 导出内容是固定的卡片、报表、分享图 |
这个项目的导出内容是一张固定版式的卡片,元素就那么几个:背景、头像、标题、三行数字。用第三方库去还原整个 DOM 反而是杀鸡用牛刀,而且它内部同样要处理跨域、字体、缩放这些问题,出问题时排查链路更长——对着一层封装猜它到底哪一步错了,比自己写两百行难受得多。
需要提醒的是,这套方案的前提是“导出内容由你定义”。如果哪天产品说“把整个页面原样截下来,包括表格和滚动区”,那就别硬撑,老老实实引库或者干脆让后端出图。
3. 渲染核心:画布尺寸、DPR 与绘制顺序的三个关键决定
渲染层是整个项目里代码量最大的部分,也是最容易出“玄学”问题的地方。下面三段分别对应尺寸、绘制、资源,基本覆盖了 90% 的现场故障。
3.1 画布尺寸与 devicePixelRatio 的正确乘法
新手最容易翻的车是:canvas.style.width设了 750,canvas.width也设了 750,导出来的图在手机上看着还行,放到电脑上一放大就糊。原因很简单,canvas 有两个尺寸——一个是 CSS 显示尺寸,一个是像素缓冲区尺寸,你只设了一个,另一个默认跟随,等于放弃了高分屏的像素密度。
我一般这么写:
// renderer.js —— 建画布,显式区分 CSS 尺寸与像素尺寸 export function createCanvas(cssWidth, cssHeight, scale = 2) { const canvas = document.createElement('canvas'); // 像素缓冲区 = 逻辑尺寸 × 导出倍率,决定最终图片的真实分辨率 canvas.width = Math.round(cssWidth * scale); canvas.height = Math.round(cssHeight * scale); // CSS 尺寸只影响页面上预览时的显示大小,与导出清晰度无关 canvas.style.width = cssWidth + 'px'; canvas.style.height = cssHeight + 'px'; const ctx = canvas.getContext('2d'); // 关键一步:缩放上下文,之后所有绘制都按逻辑像素写,不用自己乘倍率 ctx.scale(scale, scale); return { canvas, ctx }; }参数含义逐个说:cssWidth/cssHeight是设计稿上的逻辑尺寸,比如 750×520;scale是导出倍率,本项目固定传 2,意思是最终 PNG 是 1500×1040。调用ctx.scale(scale, scale)之后,你写ctx.fillRect(0, 0, 750, 520)就是铺满整张图,不需要在心算里乘 2,这是减少坐标算错最有效的一招。
这里有个隐藏限制,血泪经验:canvas 不是无限大的。主流浏览器单边上限在 16384 到 32767 之间,总面积也有上限(Chrome 大约 2.68 亿像素,某些移动端浏览器小得多)。750×520 乘 2 完全没问题,但如果哪天有人把scale改成 10 去导出一张长图,浏览器会直接给你返回一张空白画布,而且不报错。所以我在createCanvas外层加了一道保险:
// 超过阈值就自动降级,宁可糊一点也别出空白图 const MAX_PIXELS = 16000000; // 约 4000×4000,保守取值 function safeScale(w, h, want) { const maxByArea = Math.sqrt(MAX_PIXELS / (w * h)); return Math.max(1, Math.min(want, Math.floor(maxByArea * 100) / 100)); }3.2 绘制顺序与 save/restore 的状态管理
canvas 是状态机,fillStyle、font、globalAlpha这些属性一旦设了就一直是那个值,直到你改它。第一版代码我没注意这点,画完头像是蓝色圆形,结果后面的文字也变蓝了——因为fillStyle没还原。
规矩很简单:凡是改了会影响后续绘制的属性,先save(),画完restore()。下面这段是卡片渲染的主干:
// renderer.js —— 按图层顺序绘制,每一层自己管好自己的状态 export function renderCard(config, assets) { const { canvas, ctx } = createCanvas(config.width, config.height, config.scale); const { avatar, logo } = assets; // 第 1 层:背景。铺满整张画布,不受后续状态影响 ctx.fillStyle = config.background; ctx.fillRect(0, 0, config.width, config.height); // 第 2 层:圆角头像。save/restore 保证 clip 不会泄漏到后面的绘制 ctx.save(); roundRectPath(ctx, 32, 32, 96, 96, 12); ctx.clip(); ctx.drawImage(avatar, 32, 32, 96, 96); ctx.restore(); // 第 3 层:标题文字,字号和颜色只在这一段生效 ctx.save(); ctx.fillStyle = '#1f2329'; ctx.font = '600 28px "PingFang SC", "Microsoft YaHei", sans-serif'; ctx.textBaseline = 'top'; ctx.fillText(config.title, 152, 40); ctx.restore(); // 第 4 层:数据行,逐行下移,超宽自动换行 ctx.save(); ctx.fillStyle = '#5a6472'; ctx.font = '400 22px "PingFang SC", "Microsoft YaHei", sans-serif'; ctx.textBaseline = 'top'; let y = 96; for (const line of config.lines) { y = wrapText(ctx, line, 32, y, config.width - 64, 34); } ctx.restore(); // 第 5 层:右下角 logo,固定尺寸 ctx.drawImage(logo, config.width - 120, config.height - 56, 88, 24); return canvas; }两个容易忽略的点。textBaseline默认是alphabetic,中文按这个基线对齐会让文字位置比预期偏上,改成top之后 y 坐标就是文字顶边,对设计和还原都更友好。另外font的写法必须符合 CSS font 简写的顺序(字重、字号、字体族),少写一个px整个声明会被静默忽略,文字用回默认的 10px sans-serif,看着像没画出来。
配套的两个工具函数,实际项目里复用率很高:
// utils.js —— 圆角路径,兼容各种半径 export function roundRectPath(ctx, x, y, w, h, r) { const radius = Math.min(r, w / 2, h / 2); ctx.beginPath(); ctx.moveTo(x + radius, y); ctx.arcTo(x + w, y, x + w, y + h, radius); ctx.arcTo(x + w, y + h, x, y + h, radius); ctx.arcTo(x, y + h, x, y, radius); ctx.arcTo(x, y, x + w, y, radius); ctx.closePath(); } // utils.js —— 按字符逐个测宽实现换行,中文场景比按空格切更靠谱 export function wrapText(ctx, text, x, y, maxWidth, lineHeight) { let line = ''; let cursorY = y; for (const ch of text) { const next = line + ch; if (ctx.measureText(next).width > maxWidth && line) { ctx.fillText(line, x, cursorY); line = ch; cursorY += lineHeight; } else { line = next; } } if (line) { ctx.fillText(line, x, cursorY); cursorY += lineHeight; } return cursorY; }wrapText里逐个字符遍历是有意的:中文没有空格,按split(' ')切分等于不换行。英文混排的场景下这样会把单词拆开,但那属于可接受的取舍,真要做精细排版再引入断行算法。
3.3 资源预加载与就绪判断
画布上一片空白最常见的原因不是代码错,是图还没加载完就执行了drawImage。drawImage传入一个未完成的Image对象时,浏览器会静默忽略,不抛错,所以你看到的就是“代码跑了,图上啥也没有”。
我的做法是把所有异步资源统一在前面收口:
// assets.js —— 统一预加载,任何一张失败直接 reject,不带着残缺资源去画 export function loadImage(src) { return new Promise((resolve, reject) => { const img = new Image(); // 同源图片可省略;跨域图片必须加,且服务端要返回 CORS 头 img.crossOrigin = 'anonymous'; img.onload = () => resolve(img); img.onerror = () => reject(new Error('图片加载失败: ' + src)); img.src = src; }); } export async function preloadImages(urls) { // 字体也要等,否则首屏用兜底字体画出来的文字跟你看到的预览不一致 if (document.fonts && document.fonts.ready) { await document.fonts.ready; } const keyed = ['avatar', 'logo']; const list = await Promise.all(urls.map(loadImage)); return keyed.reduce((acc, key, i) => { acc[key] = list[i]; return acc; }, {}); }img.crossOrigin = 'anonymous'这一行是后面“画布被污染”那个坑的钥匙:加上它,浏览器会用 CORS 模式请求图片;不加,图片照样能显示,但 canvas 的像素数据会被标记为不干净,一调toBlob就直接抛异常。加了之后如果服务端没配Access-Control-Allow-Origin,图片会加载失败——两种失败方式不一样,但都比“最后一步才炸”好排查。
document.fonts.ready返回的 Promise 在所有字体加载完成后 resolve。如果页面用了自定义字体,不等它,canvas 绘制时拿不到字体文件,会自动回退到默认字体,导出图和页面预览就是两个样子。这个问题在本地开发环境几乎不会出现(字体被缓存了),一上生产就暴露,属于典型的“上线才翻车”。
4. 导出链路:toBlob、objectURL 和 a 标签下载的落地写法
渲染出 canvas 只是半成品,用户要的是硬盘上那个文件。这一段代码少,但细节密度很高,写错一个参数就是“点了没反应”。
4.1 toDataURL 和 toBlob 的取舍
两个 API 都能把 canvas 变成图片数据,选哪个取决于用途:
| 对比项 | toDataURL | toBlob |
|---|---|---|
| 返回形式 | 同步返回 Base64 字符串 | 异步回调返回 Blob |
| 内存占用 | 约为原图的 1.37 倍(Base64 编码膨胀) | 接近原图大小 |
| 大图表现 | 主线程阻塞明显,长图容易卡死 | 异步生成,不阻塞渲染 |
| 兼容性 | 全平台 | 老版本 Safari 需要 polyfill |
我选toBlob,原因很实际:这张卡片按 2 倍导出已经接近 1MB,如果哪天改成导出长图,toDataURL拼出来的字符串能到十几 MB,主线程会卡住一两秒,用户以为按钮坏了会连点。toBlob在主线程之外生成数据,体验上稳得多。
需要预览的场景(比如导出后先在弹窗里看一眼)才用toDataURL,因为可以直接塞进<img src>,不用管 URL 生命周期。
4.2 a 标签下载的完整写法
a标签的download属性是这套方案的核心,它的作用是告诉浏览器“别导航,把这个链接当文件存下来”。完整实现:
// exporter.js —— canvas 转 Blob 并触发下载 export function downloadCanvas(canvas, filename, type = 'image/png', quality = 0.92) { return new Promise((resolve, reject) => { canvas.toBlob((blob) => { // toBlob 在画布超限或被污染时会回调 null,不会抛异常 if (!blob) { reject(new Error('canvas 导出失败,检查画布尺寸与跨域资源')); return; } const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = filename; // 只在同源或 blob/data URL 下生效 a.rel = 'noopener'; a.style.display = 'none'; // 必须挂到文档上,某些浏览器对游离节点的 click 不响应 document.body.appendChild(a); a.click(); document.body.removeChild(a); // 立即 revoke 会让部分浏览器下载中断,延迟释放更稳 setTimeout(() => { URL.revokeObjectURL(url); resolve(); }, 1000); }, type, quality); }); }逐行说明几个容易被删掉的关键点。type传image/png时quality参数无效,PNG 是无损格式;要控制体积就传image/jpeg,此时quality才起作用,0.92是肉眼几乎看不出损失的常用值。a.style.display = 'none'之后仍然要appendChild,因为 Firefox 等浏览器要求元素在文档树中才响应程序化点击。setTimeout里延迟revokeObjectURL是踩过坑的:Chrome 上立即释放通常没问题,但在一些浏览器和大文件场景下,URL 释放后下载会中断,得到一个 0 字节文件。
download属性的值就是文件名,浏览器不会自动加后缀,所以命名时要把扩展名自己带上。文件名里的/ \ : * ? " < > |这些字符在 Windows 上非法,浏览器会替换成下划线,遇到中文标题建议先做一次清洗:
// utils.js —— 清洗文件名,保留中文,去掉非法字符 export function safeFileName(name, ext = 'png') { const cleaned = name.replace(/[\\/:*?"<>|\r\n]/g, '_').trim().slice(0, 80); return `${cleaned || 'export'}.${ext}`; }4.3 文件名、批量导出与移动端差异
桌面端这套代码基本一把过,移动端要额外留意。iOS Safari 在较老版本里对download属性支持不完整,点击后可能直接在新标签打开图片而不是保存,用户得手动长按保存。检测方式不用做 UA 判断,直接在导出后给个提示更省事:
// 兜底:新窗口打开图片,让用户长按保存 function fallbackOpen(url) { const win = window.open(url, '_blank'); if (!win) { // 弹窗被拦截时,退化为页内浮层展示 showPreviewModal(url); } }批量导出是二次开发里很快会遇到的需求,比如把十张卡片一次导完。这里有个硬限制:浏览器要求下载动作由用户手势触发,循环里连点十个a.click()会被拦截。我的处理方式是串行下载并加间隔:
// 批量导出:串行 + 间隔,避免被浏览器判定为异常行为 async function batchDownload(tasks, gap = 400) { for (const task of tasks) { await downloadCanvas(task.canvas, task.filename); await new Promise((r) => setTimeout(r, gap)); } }真要导几十张,更稳妥的做法是打成 zip 让用户下一次,这就要引压缩库了,属于另一个话题,这里不展开。
5. 避坑与排查:图片跨域污染、导出空白、下载变预览的四个现场
这一章是我这段代码里最值钱的部分。前面写的都是顺利路径,下面四条全是真实踩过的坑,每条按“看到什么现象 → 根因是什么 → 怎么改”说清楚,照着排查能省掉大半天。
5.1 画布一片空白,代码不报错
现象:点击导出,下载下来的 PNG 尺寸对、背景色对,但头像和 logo 的位置是空的,控制台干干净净没有任何报错。
原因:drawImage遇到尚未加载完成的Image对象时不会抛异常,直接跳过。另一种情况是元素上挂了淡入动画,截图那一刻opacity还是 0,画出来自然是透明的。
解决:所有图片走统一的preloadImages,加载失败就 reject 整个流程,宁可报错也不出残缺图。字体同理,await document.fonts.ready。如果页面元素带动画,在导出前把动画类临时摘掉,或者干脆按数据重绘一张独立卡片,不复用页面上的 DOM 状态——这也是本项目选择“数据驱动重绘”而不是“截取现有 DOM”的原因之一。
5.2 toBlob 抛 SecurityError: Tainted canvases may not be exported
现象:本地开发一切正常,图片用的是 CDN 地址,上线后点导出,控制台红字,下载没动静。
原因:图片来自另一个域,且没有带 CORS 头。这时候图片能正常绘制到 canvas 上,但画布被标记为“被污染”,为了防止通过 canvas 读取跨域私有图片,浏览器禁止调用toBlob、toDataURL、getImageData。上传到 CDN 时顺手加了防盗链或者忘了配置响应头,就会撞上这个。
解决:两步缺一不可。前端给img.crossOrigin = 'anonymous',服务端在图片响应里返回Access-Control-Allow-Origin,需要带凭证时还要配Access-Control-Allow-Credentials并改用use-credentials。最稳的兜底是同源代理:让自己后端转发一次图片,前端只请求同源地址,跨域问题从根上消失。
// 兜底方案:走同源代理,服务端转发 CDN 图片 const proxied = `/api/image-proxy?url=${encodeURIComponent(rawUrl)}`;5.3 导出大图时静默失败,返回 null
现象:小卡片正常,把画布高度调到 6000 像素以上,toBlob回调收到的参数是null,没有任何异常。
原因:超过浏览器的 canvas 尺寸或面积上限。不同浏览器阈值不一样,而且超限时的表现也不统一——有的返回空白画布,有的回调null,有的直接让标签页崩溃。
解决:绘制前先算一遍,超限就降倍率,实在降不下来就分段绘制再拼接。我在createCanvas外层包了safeScale,把总像素控制在 1600 万以内,这个值足够覆盖绝大多数分享图场景。另外记得导出前把中间画布显式置空,避免反复导出时内存越堆越高:
// 导出完成后释放画布引用,iOS 上尤其重要 canvas.width = 0; canvas.height = 0;5.4 点击后没下载,而是打开了新标签
现象:桌面 Chrome 正常,iOS 上点按钮直接跳到图片预览页,文件也没存下来。
原因:download属性在部分移动端浏览器里支持不完整,尤其是通过window.open或异步回调触发时,浏览器认为这不是用户直接发起的动作,于是退化成普通导航。
解决:尽量让a.click()靠近用户点击的那条调用链,中间的异步等待越少越好。实在躲不开(比如要先加载图片),就在导出完成后弹出预览浮层,提示“长按图片保存到相册”,这比强行触发下载的成功率高。另外注意 blob URL 在页面刷新后失效,不要把 URL 缓存起来下次再用。
6. 二次开发:把截图能力收成一个可配置的 exportCard 模块
这套代码用了一段时间后,隔壁两个页面也想要导出功能,版式还不一样。这时候就不能再复制粘贴了,得把它抽成模块。我抽出来的入口叫exportCard,配置项如下:
| 配置项 | 类型 | 默认值 | 说明 |
|---|---|---|---|
width | number | 750 | 逻辑宽度,按设计稿填 |
scale | number | 2 | 导出倍率,会自动做上限保护 |
background | string | #ffffff | 画布底色,支持渐变对象 |
images | object | {} | 键值对,值为图片 URL |
draw | function | 必填 | (ctx, cfg, assets) => void,绘制回调 |
filename | string | export | 自动补.png后缀并清洗非法字符 |
type | string | image/png | 需要控制体积时改image/jpeg |
关键在于draw这个回调:模块只负责建画布、加载资源、导出下载,具体画什么交给调用方。这样三个页面共用一套流程,各自的版式逻辑互不干扰。
// 通用导出入口,模块本身不关心画什么 export async function exportCard(cfg) { const { width = 750, scale = 2, images = {}, draw, filename = 'export' } = cfg; if (typeof draw !== 'function') throw new Error('exportCard: draw 必须是函数'); const assets = await preloadImages(Object.values(images), Object.keys(images)); const { canvas, ctx } = createCanvas(width, cfg.height, safeScale(width, cfg.height, scale)); ctx.fillStyle = cfg.background || '#ffffff'; ctx.fillRect(0, 0, width, cfg.height); await draw(ctx, cfg, assets); // 允许回调里再 await 二次加载 await downloadCanvas(canvas, safeFileName(filename), cfg.type, cfg.quality); canvas.width = 0; canvas.height = 0; }调用方写起来就很薄了,一个页面一个draw函数,新页面接入大概十分钟。验证方式我固定成三步:先在页面上挂个预览区把 canvas 塞进去肉眼看版式,再导出一张按 1 倍和 2 倍各跑一次对比清晰度,最后拿真机(尤其是 iOS)点一遍确认能落盘。这三步走完还没出问题,基本就能上线了。
有个改造技巧值得说:如果哪天要支持深色模式导出,不用改模块,在draw回调里根据cfg.theme换配色就行——把可变的部分放在回调里,模块本身保持稳定,这是这套拆分最值钱的地方。
从那以后我每次写导出类功能,都会先把cfg的字段列出来再动手,字段定不清楚就说明版式还没想明白,硬写下去大概率要返工三次。希望帮到你。
本文还有配套的精品资源,点击获取