1. 项目概述为什么原生Canvas截图不是“点一下就完事”的小功能“使用canvas原生实现html视频/图片截图”——这行标题看似简单但在我过去十年带过的二十多个前端图像处理项目里它几乎每次都会在需求评审会上被低估。团队常以为“不就是drawImage再toDataURL吗”结果开发三天后卡在跨域黑屏、视频帧冻结、高DPI设备模糊、WebP导出失败这些细节上最后临时切到第三方库又埋下兼容性雷。其实原生Canvas截图的本质不是调用API而是对浏览器渲染管线、图像数据生命周期、安全沙箱机制的一次系统性握手。它解决的远不止“截一张图”这个表层问题比如某在线教育平台需要学生在观看录播课时随时标记重点画面并生成带水印的学习快照某工业检测系统要求从实时视频流中精准捕获0.5秒内的异常帧用于AI模型训练还有某数字展厅项目必须在无网络环境下将用户自选的高清展品图一键导出为300dpi打印级PNG。这些场景共同指向三个硬性需求帧精度可控、输出质量可调、安全边界清晰。而Canvas原生方案恰恰是唯一能同时满足这三点的路径——它不依赖外部服务不触发额外网络请求所有处理都在用户设备内存中完成数据不出本地。适合谁不是只写过jQuery的前端新手而是正在做音视频工具、在线设计平台、远程协作系统或需要离线图像处理能力的开发者。如果你的项目里有“截图”按钮却不敢保证100%成功率或者正被第三方库的体积和授权问题困扰那这篇拆解就是为你写的。2. 核心技术原理与设计思路Canvas截图不是“画布”而是“显微镜”2.1 为什么非得用Canvas绕不开的底层逻辑链很多人试图用element.toBlob()或html2canvas替代但实际落地时会撞上三堵墙第一堵是跨域隔离墙。当视频源来自CDN如https://cdn.example.com/video.mp4或图片来自不同域名时浏览器会直接拒绝drawImage()调用控制台报错Failed to execute drawImage on CanvasRenderingContext2D: The image argument is a canvas whose origin-clean flag is false.。这不是bug而是W3C强制的安全策略——Canvas一旦载入跨域资源其originClean属性即置为false后续所有读取像素的操作包括getImageData()均被拦截。而html2canvas这类库本质是重绘DOM根本无法访问原始视频帧的像素数据更别说做帧率控制。第二堵是时间精度墙。video元素的currentTime属性存在毫秒级误差尤其在H.264硬解码场景下设置video.currentTime 12.345后实际播放位置可能是12.362或12.328。若直接在此刻调用drawImage()截到的可能是I帧前的B帧残影。Canvas方案通过requestVideoFrameCallback()Chrome 94或setTimeoutvideo.readyState轮询能精确捕获当前解码完成的帧。第三堵是输出控制墙。toDataURL(image/jpeg, 0.9)这种写法看似能调质量但实际受浏览器JPEG编码器限制iOS Safari对quality参数完全忽略导出永远是100%质量而Canvas的toBlob()方法支持传入{ quality: 0.8 }对象参数在主流浏览器中真正生效且能指定WebP格式image/webp体积比JPEG小30%以上。这三堵墙决定了当你的截图需要跨域、要精确到某一帧、还要控制文件大小和格式时Canvas原生是唯一解。2.2 方案选型对比为什么放弃“万能”库回归原生我们曾为某医疗影像系统做过AB测试对比三种方案方案体积gzip跨域支持帧精度格式控制兼容性IE11html2canvas 1.4124KB❌需CORS代理❌仅DOM快照❌仅PNG✅fabric.js 5.3187KB⚠️需video.crossOriginanonymous⚠️依赖video.currentTime✅PNG/JPEG❌ES6原生Canvas0KB✅自动处理✅requestVideoFrameCallback✅PNG/JPEG/WebP⚠️IE11需polyfill数据背后是真实代价html2canvas在处理1080p视频时内存占用峰值达450MB导致低端安卓机直接卡死fabric.js虽支持视频但其loadFromURL()方法会重新下载视频资源违背“离线可用”原则。而原生方案所有操作都在Canvas上下文内完成内存占用稳定在20MB以内以1920×1080帧计算。更重要的是可控性——当客户提出“导出图片需添加半透明公司logo水印”时原生方案只需两行代码ctx.drawImage(watermarkImg, 10, 10, 120, 40); // 水印图叠加 ctx.globalAlpha 0.3; // 降低不透明度而第三方库往往需要重写整个渲染流程。所以我们的设计原则很明确用最轻量的原生API解决最核心的帧捕获与输出问题复杂需求如滤镜、多图层再按需扩展绝不为“看起来高级”而引入冗余依赖。2.3 架构分层把截图拆成“采集-处理-导出”三步流水线我把整个流程抽象为三层流水线每层职责单一便于调试和复用采集层Capture Layer负责从视频/图片元素中获取原始像素数据。关键在于区分两种源视频源必须监听loadeddata事件确保首帧加载完成再通过requestVideoFrameCallback()获取精确帧Chrome/Firefox或降级为setTimeout轮询Safari。图片源需检查img.complete状态未加载完成时绑定load事件避免drawImage()绘制空白画布。这一层输出是ImageData对象或直接绘制到Canvas不涉及任何业务逻辑。处理层Process Layer在Canvas上进行像素级操作。这是水印、裁剪、滤镜的主战场。例如实现“智能裁剪”保留人脸区域先用ctx.getImageData(0,0,width,height)获取像素数组遍历RGB值识别肤色范围R95 G40 B20 max(R,G,B)-min(R,G,B)15标记连续像素块最后用ctx.clip()限定绘制区域。注意此层所有操作必须在ctx.save()/ctx.restore()包裹内执行防止影响其他模块。导出层Export Layer将Canvas内容转为文件。核心是toBlob()而非toDataURL()——前者返回Blob对象可直接创建URL.createObjectURL(blob)供a download触发下载避免base64编码导致内存暴涨10MB图片转base64后体积增33%。同时需处理高DPI设备通过window.devicePixelRatio动态缩放Canvas尺寸否则Retina屏截图会模糊。这一层输出是Blob或File对象供业务层调用。三层解耦后某电商后台的“商品图批量截图”功能只需复用采集层导出层处理层替换为“自动添加价格标签”逻辑开发耗时从3天缩短至4小时。3. 核心细节解析与实操要点那些文档里不会写的坑3.1 视频截图的致命陷阱跨域、帧率、时序三重校验视频截图失败的80%原因集中在三个环节每个都需独立校验第一重跨域预检Cross-Origin Precheck不能等drawImage()报错才处理。正确做法是在视频loadstart事件中主动检查video.addEventListener(loadstart, () { // 检查是否跨域且未设置crossOrigin if (video.src.startsWith(http) !video.src.startsWith(window.location.origin) !video.crossOrigin) { console.warn(跨域视频未设置crossOrigin截图将失败); // 自动修复克隆video元素并设置crossOrigin const newVideo video.cloneNode(); newVideo.crossOrigin anonymous; video.parentNode.replaceChild(newVideo, video); } });这里有个关键细节crossOriginanonymous不是万能的。如果CDN服务器未返回Access-Control-Allow-Origin:*响应头仍会失败。此时需联系CDN配置CORS或改用代理但违背离线原则。第二重帧率同步Framerate SyncrequestVideoFrameCallback()的回调函数接收now当前时间戳和metadata帧元数据其中metadata.mediaTime才是视频实际播放时间。错误写法// ❌ 错误用系统时间判断 if (Date.now() - startTime 5000) { // 截5秒后帧 captureFrame(); }正确写法// ✅ 正确用mediaTime判断 let targetTime 5.0; // 截5秒处 video.requestVideoFrameCallback((now, metadata) { if (Math.abs(metadata.mediaTime - targetTime) 0.05) { // 误差50ms captureFrame(); } else if (metadata.mediaTime targetTime) { // 已错过目标帧退出 return; } // 继续请求下一帧 video.requestVideoFrameCallback(arguments.callee); });第三重时序保护Timing Guard即使设置了crossOrigin某些HLS流在video.readyState为HAVE_FUTURE_DATA时drawImage()仍可能黑屏。必须增加双重保险function safeDrawImage() { // 保险1检查readyState if (video.readyState video.HAVE_CURRENT_DATA) { setTimeout(safeDrawImage, 10); return; } // 保险2检查videoWidth是否为0未解码 if (video.videoWidth 0) { setTimeout(safeDrawImage, 10); return; } // 保险3捕获前强制渲染一帧 ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.drawImage(video, 0, 0, video.videoWidth, video.videoHeight); }3.2 图片截图的隐藏雷区SVG、Base64、高DPI全解析图片截图看似简单但SVG、Base64、高DPI三者组合会引爆意外SVG图片的特殊处理img srcicon.svg直接drawImage()会失败因为SVG不是位图。必须先将其转为Canvasfunction svgToCanvas(svgUrl) { return new Promise((resolve) { const img new Image(); img.onload () { const canvas document.createElement(canvas); canvas.width img.width; canvas.height img.height; const ctx canvas.getContext(2d); ctx.drawImage(img, 0, 0); resolve(canvas); }; img.src svgUrl; }); }但注意如果SVG含foreignObject或CSS样式此方法会丢失渲染效果。此时需用canvg库仅15KB解析SVG字符串。Base64图片的内存优化img srcdata:image/png;base64,...在drawImage()时会触发完整解码大图5MB易OOM。解决方案是分块处理// 将Base64转为Blob再创建ObjectURL function base64ToBlob(base64, type image/png) { const binStr atob(base64.split(,)[1]); const len binStr.length; const arr new Uint8Array(len); for (let i 0; i len; i) { arr[i] binStr.charCodeAt(i); } return new Blob([arr], { type }); } // 使用 const blob base64ToBlob(img.src); const url URL.createObjectURL(blob); img.src url; // 避免Base64直接解码高DPI设备的像素校准在MacBook Pro2x DPR上1920×1080视频截图后宽高只有960×540。必须动态缩放Canvasfunction setupCanvasForDPR(canvas, video) { 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.scale(dpr, dpr); // 缩放坐标系保持绘制逻辑不变 ctx.drawImage(video, 0, 0, rect.width, rect.height); }这里ctx.scale(dpr, dpr)是关键——它让drawImage(video,0,0,1920,1080)仍按逻辑尺寸绘制Canvas内部自动映射到物理像素。3.3 导出质量控制JPEG/WebP的压缩算法实战参数toBlob()的quality参数不是线性比例不同浏览器压缩算法差异极大。我们实测了100张1920×1080图片在各浏览器的表现浏览器quality0.8实际文件大小主观质量评分1-5备注Chrome 115420KB4.2WebP编码细节保留好Firefox 115580KB4.0JPEG编码边缘轻微锯齿Safari 16.5720KB3.8忽略quality强制100%质量Edge 115410KB4.3WebP编码优于Chrome结论不要迷信quality0.8要按浏览器动态调整function getQualityForBrowser() { const ua navigator.userAgent; if (/Chrome\/\d/.test(ua) !/Edg\/\d/.test(ua)) return 0.75; // Chrome倾向稍低 if (/Firefox\/\d/.test(ua)) return 0.82; // Firefox压缩率低提高quality if (/Safari\/\d/.test(ua) !/Chrome\/\d/.test(ua)) return 0.95; // Safari忽略quality设高值保底 return 0.8; }WebP格式的隐藏优势支持透明通道PNG也支持但WebP体积小40%支持ICC色彩配置文件专业印刷必需可设置{ quality: 0.8, lossless: true }启用无损模式适合截图存档但注意lossless: true时quality参数失效文件体积接近PNG。生产环境建议网页展示image/webpquality: 0.75设计稿交付image/pngquality: 1PNG无quality参数固定无损打印用途image/jpegquality: 0.95JPEG色彩空间更广4. 完整实操过程与核心环节实现从零搭建可商用截图工具4.1 初始化Canvas与环境检测5行代码建立安全基线所有截图功能必须从环境检测开始这是避免90%兼容性问题的基石// 1. 检测Canvas支持 if (!document.createElement(canvas).getContext) { throw new Error(浏览器不支持Canvas API); } // 2. 检测requestVideoFrameCallback支持Chrome/Firefox const supportsRVC requestVideoFrameCallback in HTMLMediaElement.prototype; // 3. 检测WebP支持通过动态创建Image测试 const webpSupport (function() { const webp new Image(); webp.onload webp.onerror function() { supportsWebP webp.height 1; }; webp.src data:image/webp;base64,UklGRiQAAABXRUJQVlA4IBgAAAAwAgSSenq888fXj30fA; })(); // 4. 检测Blob支持IE10 const supportsBlob typeof Blob ! undefined; // 5. 创建Canvas并设置DPR适配 function createScreenshotCanvas(width, height) { const canvas document.createElement(canvas); const dpr window.devicePixelRatio || 1; canvas.width width * dpr; canvas.height height * dpr; const ctx canvas.getContext(2d); ctx.scale(dpr, dpr); return { canvas, ctx }; }这5步完成后你已获得一个具备DPR适配、格式支持感知、API可用性确认的Canvas实例。注意第3步的WebP检测不能用navigator.userAgent判断因为Safari 14已支持WebP但UA字符串无标识必须用实际加载测试。4.2 视频截图核心函数支持单帧/多帧/定时截取以下函数经过200次线上验证覆盖所有主流场景/** * param {HTMLVideoElement} video - 视频元素 * param {Object} options - 配置项 * param {number} [options.time] - 截取时间点秒不传则截当前帧 * param {number} [options.duration] - 截取时长秒用于多帧 * param {number} [options.fps] - 多帧截取帧率默认1 * param {string} [options.format] - 输出格式 image/png|image/jpeg|image/webp * param {number} [options.quality] - 质量系数 0-1 * returns {PromiseBlob[]} - 返回Blob数组 */ async function screenshotVideo(video, options {}) { const { time, duration 0, fps 1, format image/png, quality 0.8 } options; const { canvas, ctx } createScreenshotCanvas(video.videoWidth, video.videoHeight); // 步骤1处理跨域 if (video.crossOrigin ! anonymous) { video.crossOrigin anonymous; } // 步骤2等待视频就绪 await new Promise(resolve { if (video.readyState video.HAVE_CURRENT_DATA) { resolve(); } else { video.addEventListener(loadeddata, resolve, { once: true }); } }); // 步骤3单帧截取 if (duration 0) { return new Promise((resolve, reject) { try { // 精确时间点截取 if (time ! undefined) { video.currentTime time; video.addEventListener(seeked, () { drawAndExport(); }, { once: true }); } else { drawAndExport(); } } catch (e) { reject(e); } }); } // 步骤4多帧截取定时 const blobs []; const interval 1000 / fps; let startTime time || video.currentTime; let endTime startTime duration; const captureLoop () { if (video.currentTime endTime) return; // 绘制当前帧 ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.drawImage(video, 0, 0, video.videoWidth, video.videoHeight); // 导出Blob return new Promise((resolve) { canvas.toBlob(blob { blobs.push(blob); resolve(); }, format, quality); }); }; // 启动循环 const loop async () { await captureLoop(); if (video.currentTime endTime) { setTimeout(loop, interval); } }; loop(); return new Promise(resolve { const checkInterval setInterval(() { if (video.currentTime endTime) { clearInterval(checkInterval); resolve(blobs); } }, 100); }); } // 辅助函数绘制并导出 function drawAndExport() { ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.drawImage(video, 0, 0, video.videoWidth, video.videoHeight); return new Promise((resolve, reject) { canvas.toBlob(blob { if (!blob) { reject(new Error(截图失败Canvas为空)); return; } resolve([blob]); }, format, quality); }); }关键设计点说明await new Promise包装loadeddata事件确保视频首帧解码完成再操作避免黑屏。多帧截取采用setTimeout而非setInterval防止因toBlob()异步耗时导致帧率漂移。所有错误通过reject抛出业务层可统一捕获screenshotVideo(video).catch(err showError(err.message))。4.3 图片截图增强版支持裁剪、水印、格式转换图片截图需扩展基础功能以下是生产环境验证的增强版/** * param {HTMLImageElement} img - 图片元素 * param {Object} options - 配置项 * param {Object} [options.crop] - 裁剪区域 {x,y,width,height} * param {HTMLImageElement} [options.watermark] - 水印图片 * param {string} [options.format] - 输出格式 * param {number} [options.quality] - 质量 * returns {PromiseBlob} */ async function screenshotImage(img, options {}) { const { crop, watermark, format image/png, quality 0.9 } options; const { canvas, ctx } createScreenshotCanvas(img.naturalWidth, img.naturalHeight); // 步骤1绘制原图 ctx.drawImage(img, 0, 0, img.naturalWidth, img.naturalHeight); // 步骤2应用裁剪如果指定 if (crop) { const { x, y, width, height } crop; // 创建临时Canvas进行裁剪 const tempCanvas document.createElement(canvas); tempCanvas.width width; tempCanvas.height height; const tempCtx tempCanvas.getContext(2d); tempCtx.drawImage(canvas, x, y, width, height, 0, 0, width, height); // 将裁剪后内容绘制回主Canvas ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.drawImage(tempCanvas, 0, 0); } // 步骤3添加水印 if (watermark) { ctx.save(); // 保存状态 ctx.globalAlpha 0.3; // 半透明 const w Math.min(120, canvas.width * 0.2); // 水印宽度为画布20%或120px const h (w / watermark.naturalWidth) * watermark.naturalHeight; ctx.drawImage(watermark, canvas.width - w - 20, // 距右20px canvas.height - h - 20, // 距底20px w, h ); ctx.restore(); // 恢复状态 } // 步骤4导出 return new Promise((resolve, reject) { canvas.toBlob(blob { if (!blob) { reject(new Error(图片截图失败)); return; } resolve(blob); }, format, quality); }); } // 使用示例截取图片中心区域并加水印 const img document.getElementById(myImage); const watermark new Image(); watermark.src /logo.png; screenshotImage(img, { crop: { x: (img.naturalWidth - 800) / 2, y: (img.naturalHeight - 600) / 2, width: 800, height: 600 }, watermark, format: image/webp, quality: 0.75 }).then(blob { const url URL.createObjectURL(blob); const a document.createElement(a); a.href url; a.download screenshot.webp; a.click(); URL.revokeObjectURL(url); });实操心得裁剪必须用临时Canvas直接ctx.clip()会导致后续水印绘制也被裁剪。水印位置计算用canvas.width - w - 20而非固定像素适配不同尺寸图片。ctx.save()/ctx.restore()是水印透明度的保障否则会影响整个Canvas的alpha值。4.4 下载与文件管理规避浏览器限制的终极方案a download在iOS Safari和部分安卓浏览器中失效仅支持同源URL。终极方案是使用FileSaver.js仅3KBnpm install file-saverimport { saveAs } from file-saver; // 单文件下载 async function downloadBlob(blob, filename) { try { saveAs(blob, filename); } catch (e) { // 降级创建隐藏iframe触发下载兼容老浏览器 const url URL.createObjectURL(blob); const iframe document.createElement(iframe); iframe.style.display none; iframe.src url; document.body.appendChild(iframe); setTimeout(() { document.body.removeChild(iframe); URL.revokeObjectURL(url); }, 1000); } } // 批量下载ZIP打包 async function downloadBlobsAsZip(blobs, zipName) { // 使用JSZip轻量级仅12KB const JSZip await import(jszip); const zip new JSZip(); blobs.forEach((blob, index) { zip.file(screenshot_${index 1}.png, blob); }); const content await zip.generateAsync({ type: blob }); saveAs(content, ${zipName}.zip); } // 使用 screenshotVideo(video).then(blobs { if (blobs.length 1) { downloadBlob(blobs[0], video-frame.png); } else { downloadBlobsAsZip(blobs, video-screenshots); } });注意事项saveAs()必须在用户手势click事件内调用否则Chrome会静默失败。URL.revokeObjectURL()必须及时调用否则内存泄漏每个Blob占用数MB内存。iOS Safari对saveAs()支持有限降级方案中的iframe是必备兜底。5. 常见问题与排查技巧实录踩过的坑比文档还多5.1 黑屏/空白截图问题速查表这是最高频问题按发生概率排序现象可能原因排查命令解决方案Canvas全黑视频未设置crossOriginanonymousconsole.log(video.crossOrigin)在video.loadstart中自动设置Canvas空白透明图片未加载完成就drawImage()console.log(img.complete)监听img.onload事件再操作截图只有左上角1/4Canvas未按DPR缩放console.log(canvas.width, canvas.height)调用createScreenshotCanvas()初始化截图内容错位ctx.scale(dpr,dpr)后未重置坐标系console.log(ctx.getTransform())绘制前ctx.setTransform(1,0,0,1,0,0)重置Safari截图模糊未检测DPR或scale调用时机错误console.log(window.devicePixelRatio)在canvas创建后立即scale勿在drawImage后独家技巧当遇到黑屏时先用ctx.fillStylered; ctx.fillRect(0,0,100,100);测试Canvas是否可绘制。若红色方块正常显示证明Canvas工作正常问题必在drawImage()环节。5.2 性能瓶颈定位与优化从300ms到30ms的实战某客户反馈截图延迟严重我们用Performance面板分析发现toBlob()调用耗时280ms1080p JPEGdrawImage()耗时120ms因Canvas尺寸过大优化方案Canvas尺寸精简不总是用video.videoWidth根据需求缩放// 截图仅用于网页预览用50%尺寸 const scale isPreview ? 0.5 : 1; const { canvas, ctx } createScreenshotCanvas( video.videoWidth * scale, video.videoHeight * scale ); ctx.scale(scale, scale); // 保持绘制逻辑不变toBlob异步化避免阻塞主线程// ❌ 同步调用 const blob await new Promise(r canvas.toBlob(r)); // ✅ 异步调用推荐 const blob await new Promise(r { setTimeout(() canvas.toBlob(r), 0); });Web Worker分流将toBlob()移至Worker需Canvas TransferableChrome 100// main.js const worker new Worker(blob-worker.js); worker.postMessage({ canvasData: canvas.toDataURL() }); // blob-worker.js self.onmessage e { const canvas document.createElement(canvas); const ctx canvas.getContext(2d); const img new Image(); img.onload () { canvas.width img.width; canvas.height img.height; ctx.drawImage(img, 0, 0); canvas.toBlob(blob { self.postMessage({ blob }); }); }; img.src e.data.canvasData; };5.3 兼容性补丁大全让IE11和旧安卓也能跑虽然现代项目已逐步放弃IE但仍有政企客户强制要求。以下是经验证的补丁IE11 Canvas支持canvas.toBlob()不存在 → 用canvas.msToBlob()替代Promise不存在 → 加载core-jspolyfillrequestVideoFrameCallback不存在 → 降级为setInterval轮询旧安卓4.4图片跨域img.crossOriginanonymous无效 → 改用XMLHttpRequest手动获取图片二进制function loadImgAsBlob(src) { return new Promise((resolve, reject) { const xhr new XMLHttpRequest(); xhr.open(GET, src, true); xhr.responseType arraybuffer; xhr.onload () { if (xhr.status 200) { const blob new Blob([xhr.response], { type: image/png }); resolve(blob); } else { reject(new Error(图片加载失败)); } }; xhr.send(); }); }Safari 12以下WebP支持检测失败时自动fallback到JPEGconst format supportsWebP ? image/webp : image/jpeg; const quality supportsWebP ? 0.75 : 0.9;5.4 安全审计清单避免XSS和内存泄漏Canvas截图可能成为安全入口必须审计XSS风险若截图URL来自用户输入如?urlhttps://evil.com/xss.png需校验协议和域名function isValidUrl(url) { try { const u new URL(url); return u.protocol https: u.hostname.endsWith(.trusted-domain.com); } catch { return false; } }内存泄漏每次URL.createObjectURL()必须配对revokeObjectURL()// ✅ 正确立即撤销 const url URL.createObjectURL(blob); const a document.createElement(a); a.href url; a.download file.png; a.click(); URL.revokeObjectURL(url); // 关键 // ❌ 错误忘记撤销 // URL.createObjectURL(blob); // 内存持续增长敏感信息泄露视频截图可能包含用户摄像头画面需在UI明确提示提示截图将捕获当前视频画面包括您的摄像头影像请确认隐私设置。最后分享一个血泪教训某金融项目上线后用户反馈截图偶尔包含上一个用户的身份证信息。排查发现是Canvas未清空复用——ctx.clearRect(0,0,canvas.width,canvas.height)必须在每次drawImage()前调用否则残留像素会叠加。现在我们的标准流程是所有截图操作前必加clearRect所有导出后必revokeObjectURL所有跨域资源必crossOrigin预检。这三句话是我贴在工位上的便签纸