免费获取学习方案
ARTICLE DETAIL

资讯详情

深耕编程基础知识与建站技术分享的一线实战洞察。

html2canvas导出图片黑块全解析:地图与Canvas跨域问题排查

html2canvas导出图片黑块全解析:地图与Canvas跨域问题排查 做可视化报表的朋友一定经历过这种场面功能开发得好好的一到导出图片就开始闹脾气。我上个月接了个大屏项目页面里嵌了高德地图和两张 ECharts 图表需求是整页导出。第一次实测html2canvas 生成的图片里地图区域一片死黑页面底部还有一条宽窄不一的黑色色带。第一反应是版本问题把 html2canvas 从 1.3.4 升到 1.4.1黑块还在翻 issue、试各种配置折腾了两天才搞清楚问题其实不在 html2canvas 本身而在我们对 canvas 和地图的理解方式上。这篇文章就把这次的完整排查过程记录下来。凡是遇到 html2canvas 导出图片出现黑块、地图区域转换失败、长页面截取不全的人应该都能从中找到对应的处理思路。我不打算只给结论会把每一步的定位逻辑和最终能直接抄的代码都写出来。1. 问题现场黑块不是一种黑地图也不是一种地图先描述我看到的现象因为黑块这个词太笼统了。我在项目里遇到的黑块至少有三类它们看起来都叫黑色区域但产生机制完全不同。1.1 页面底部和右侧的黑色条带大屏项目一般是宽屏布局很多内容块宽度超过 1920。我那次截图的容器是页面最外层#pageDOM 结构大概是div idpage div classheader.../div div classmap-wrapper.../div div classchart-grid.../div /div导出后图片右侧出现一条垂直黑带底部也有一块黑色区域。最初我以为页面背景色被绘制成了黑色但把backgroundColor改成#ffffff后黑带还在。后来才发现这块黑色其实是没有内容可绘制的区域——html2canvas 创建的 canvas 尺寸比内容实际需要的尺寸小多出来的部分就露了黑。这个现象在滚动容器、弹层、绝对定位元素上特别容易复现。1.2 地图区域的死亡全黑页面中高德地图所在的div大约占屏幕三分之一。直接调用html2canvas(document.getElementById(page))后地图容器位置变成了一个规整的纯黑矩形。这个矩形边界和地图容器完全重合不像条带那样不规则就是一块很干净的黑。与此同时ECharts 的两张图表表现不一致一张饼图能正常显示另一张热力地图变成了黑块。这一下子就让我意识到问题不是简单的样式问题而是 canvas 内容本身没有被成功复制到克隆文档里。1.3 不同黑块意味着不同的问题我把三类黑块整理成了对照表排查时很有用黑块现象典型发生位置第一嫌疑原因不规则黑色条带页面右侧、底部、滚动区域边缘目标元素尺寸计算错误超出绘制范围与容器等大的纯黑矩形地图、图表 canvas 区域html2canvas 无法读取原 canvas 内容报错后大面积黑屏整个截图跨域图片污染 canvas最终导出失败这个区分很重要。如果一上来就用统一配置去修很容易修了很久也不知道修的是哪个黑块。后面我会按这三种情况分别展开。2. 黑块的来历html2canvas 的绘制方式决定了哪些坑会踩要真正理解黑块必须先清楚 html2canvas 是怎么工作的。很多前端朋友以为它像浏览器截图一样把屏幕拍下来这个理解是错的。2.1 html2canvas 不是截图是照着 HTML 重画一遍html2canvas 的工作方式是这样的它遍历传入的目标 DOM 节点读取节点上的 CSS 样式、布局信息、文本内容和图片资源然后通过 Canvas 2D API 一像素一像素地重新绘制出一张 canvas。这意味着它不读取 GPU 上的帧缓冲也不读取浏览器合成器里的最终画面它读取的是 DOM CSS 描述的逻辑画面元素的实际渲染结果如果依赖 GPU、动画帧、跨域资源html2canvas 可能拿不到。所以页面肉眼看是正常的不代表 html2canvas 也能画出同样的画面。这个底层逻辑是理解后面所有坑的钥匙。2.2 超出边界、跨域污染、canvas 元素三种黑的三条路线黑块从哪来我总结出三条主要路线。第一条绘制范围不够。html2canvas 会以目标元素的clientWidth/clientHeight为准创建一个画布。如果子元素通过绝对定位、负 margin、transform 等方式超出了目标元素的盒子超出的那一部分就不会被绘制。canvas 比内容小剩下的区域如果背景透明最终图片里就会显露出黑色或脏底。第二条canvas 被跨域资源污染。页面上的图片、地图瓦片如果来自跨域地址且服务端没有返回 CORS 头浏览器会把这些 canvas 标记为被污染。html2canvas 在克隆节点时会使用canvas.toDataURL()读取内容一旦发现被污染直接抛出异常或者画出黑块。第三条canvas 元素内容无法被克隆。html2canvas 克隆文档时会把页面上的canvas节点复制到新的文档里但 canvas 上的像素内容存储在 GPU 或源缓冲区里并不存在于 DOM 中。克隆出来的 canvas 就是一个干净的空画布。对于 2D 场景html2canvas 在某些情况下能拿到getContext(2d)中的内容但需要原 canvas 没有被污染对于 WebGL 场景它根本拿不到任何像素。于是这些区域在导出结果里就是黑块。2.3 为什么地图必然踩中坑的组合地图页面几乎把上面三条路线全部踩了一遍地图本身用 canvas 渲染内容不在 DOM 中地图底图瓦片来自 CDN存在跨域问题地图渲染是异步的瓦片没加载完就截图得到的是空白或半成品地图还有缩放、平移、动画截取的瞬间可能处于中间帧。所以标题里超出部分黑色问题和地图无法正常转换放在一起不是偶然它们背后的根因都是 html2canvas 的绘制边界和 canvas 内容获取机制。明白了这些排查方向就清晰了。3. 从复现到定位我的五步排查过程如果你现在也遇到类似的坑建议不要直接复制网上的代码先花半小时做一次系统性排查。我的排查过程大致是五步。3.1 第一步搞一个最小页面来复现排查的第一件事是把问题从大屏项目里剥离出来。我新建了一个空页面只放三样东西一个普通div背景色设成浅灰色一张来自第三方 CDN 的图片模拟跨域资源一个手动绘制的 2D canvas模拟地图图层。然后用同一段 html2canvas 代码去截这个最小页面。结果很干净普通div正常跨域图片变成了黑块canvas 区域也是黑块。这说明问题在大屏页面上是真实存在的而且很可能是通用问题不是某个组件特有的。3.2 第二步把地图从页面里移除接着我把大屏页面里的地图组件整体display: none只截其他部分。结果页面主体基本正常但底部那条黑色条带依然存在。这一步可以得出两个结论底部黑块和地图无关属于绘制范围不够地图区域的黑块是独立的第二个问题。我当时的处理是先解决底部黑块再回来处理地图。因为两个问题纠缠在一起时你很难判断某次改动到底修好了哪个。3.3 第三步替换跨域图片验证 canvas 污染路径在最小复现页面里我把 CDN 图片的src换成一个同域图片黑块立刻消失。再把图片的crossOrigin属性设为anonymous并确认服务端返回了Access-Control-Allow-Origin跨域图片也能正常画出来了。这一步验证了跨域污染确实是黑块来源之一。当时控制台还输出了这样的错误Uncaught DOMException: Failed to execute toDataURL on HTMLCanvasElement: Tainted canvases may not be exported.看到这个错误就基本确认了是有跨域资源把 canvas 污染了。3.4 第四步检查尺寸、scale、滚动位置处理完跨域路径后回到大屏页面的底部黑块。我在调用 html2canvas 之前打印了相关尺寸const el document.getElementById(page); console.log({ clientWidth: el.clientWidth, scrollWidth: el.scrollWidth, clientHeight: el.clientHeight, scrollHeight: el.scrollHeight });发现clientWidth是 1920但scrollWidth是 2560。也就是说页面内容实际宽度超过了浏览器窗口html2canvas 默认只能按窗口尺寸绘制超出的内容自然就没了。底部那条黑带同理是因为容器在未展开状态下存在滚动内容或者子元素绝对定位超出了容器高度。3.5 第五步给根因定性到此问题定性就清楚了底部和右侧黑条带目标元素的实际内容尺寸大于浏览器可视区域html2canvas 默认绘制范围不够地图区域黑块高德地图的 canvas 内容没有被 html2canvas 复制ECharts 热力地图黑块ECharts 默认用 canvas 渲染同样没被复制。后续所有修复都围绕这三个根因展开。4. 地图不能转成图片的专项解法从瓦片到 canvas 拆开处理地图这类场景必须单独处理。不要指望一个useCORS: true就解决所有问题。我把地图场景拆成四层瓦片层、canvas 层、ECharts 图表层、WebGL 图层。4.1 跨域瓦片最隐蔽也最磨人的一个环节地图底图是大量瓦片图片拼接出来的。高德、天地图、Leaflet 的数据源大多是独立 CDN。如果这些瓦片响应头没有 CORS 头整个地图 canvas 会被污染不管你怎么处理最终导出都会失败。处理方法分三步第一步确认瓦片是否支持 CORS。打开浏览器 Network 面板看瓦片请求的响应头里有没有Access-Control-Allow-Origin。如果只有*一般没问题。第二步确保加载瓦片时带上跨域标记。不同地图库的写法不同// Leaflet L.tileLayer(https://webrd0{s}.is.autonavi.com/..., { subdomains: [1, 2, 3, 4], crossOrigin: anonymous }); // 原生 Image const img new Image(); img.crossOrigin anonymous; img.src tileUrl;很多瓦片加载库默认不设置crossOrigin需要在实例化时手动指定。第三步如果地图服务不支持 CORS就用 nginx 代理。在代理层转发瓦片请求并补上跨域响应头location /tiles/ { proxy_pass https://webrd0{s}.is.autonavi.com/; add_header Access-Control-Allow-Origin *; }然后地图库的瓦片地址改为/tiles/...。这样地图服务端永远看不到跨域请求canvas 也不会被污染。4.2 2D 渲染地图的通用打法onclone 里把原 canvas 拷贝过去对于高德 2D 渲染、Leaflet 自带 canvas 图层这类情况最可靠的解决方案是利用 html2canvas 的onclone回调在克隆文档生成后、正式绘制前把原页面的 canvas 像素同步到克隆 canvas 上。核心思路是原页面 canvas 已经画好了我们手动drawImage到克隆 canvas。const sourceCanvas document.querySelector(#map-container canvas); const canvas await html2canvas(document.getElementById(page), { useCORS: true, backgroundColor: #ffffff, onclone: function (doc) { const cloneCanvas doc.querySelector(#map-container canvas); if (sourceCanvas cloneCanvas) { const ctx cloneCanvas.getContext(2d); ctx.drawImage(sourceCanvas, 0, 0, cloneCanvas.width, cloneCanvas.height); } } });这里有个前提原 canvas 不能被污染否则克隆后的 canvas 会被连锁污染。所以 4.1 的跨域处理必须先做。这个方案的优点是同步执行不会出现图片异步加载导致截图已经完成但替代图还没就位的情况。4.3 ECharts 地图用 getDataURL 绕开整个绘制链路ECharts 默认也是 canvas 渲染但 ECharts 提供了一个比onclone拷贝更省事的方法先用chart.getDataURL()把图表内容单独导成图片再用onclone把图表容器里的内容替换成这张图片。// 截图前先生成 echarts 图片 const chart echarts.init(document.getElementById(echart-map)); const chartUrl chart.getDataURL({ type: png, pixelRatio: 2, backgroundColor: #fff }); const canvas await html2canvas(document.getElementById(page), { useCORS: true, backgroundColor: #ffffff, onclone: function (doc) { const wrap doc.getElementById(echart-map); wrap.innerHTML ; const img doc.createElement(img); img.src chartUrl; img.style.width 100%; img.style.height 100%; wrap.appendChild(img); } });这样相当于把读取 ECharts 内部 canvas这个难题替换成了读取一张正常图片html2canvas 对图片的处理非常成熟基本不会黑。如果 ECharts 实例的 canvas 没有被污染直接用chart.getDataURL()是没问题的。顺便提一句ECharts 5 开始还支持 SVG 渲染模式renderer: svg也能绕开 canvas 读取问题但大数据量地图用 SVG 性能会明显下降我还是建议用getDataURL方案。4.4 WebGL 地图别硬截换思路如果地图用的是 Mapbox GL、高德 WebGL 版、Cesium 这类 WebGL 渲染引擎html2canvas 几乎无能为力。WebGL 的像素在 GPU 缓冲区里克隆 canvas 后得不到任何内容唯一能做的就是截帧。工程上比较实用的做法是在调用 html2canvas 之前用 Canvas 的帧捕获能力把地图当前帧抓出来生成一张图片然后像 4.3 一样在onclone里替换节点。// 以 Mapbox GL 为例 map.once(idle, function () { // 触发一次渲染确保 canvas 内容完整 map.triggerRepaint(); }); map.once(render, function () { const webglCanvas map.getCanvas(); const dataUrl webglCanvas.toDataURL(image/png); // 在 onclone 中把 map.getContainer() 里的内容换成 img });注意 WebGL 上下文创建时如果preserveDrawingBuffer: false默认就是 falsetoDataURL可能在非渲染时机拿到空白。保险做法是先用triggerRepaint()让地图再渲染一帧并在render事件回调里读取。如果项目技术选型允许我甚至会建议对 WebGL 地图直接放弃 html2canvas改用后端 Puppeteer 截图或者在前端用canvas.captureStream()抓取视频帧。这不是妥协而是避免在错误的方向上消耗时间。4.5 截图时机瓦片没加载完截出来当然是空的很多黑块其实是背景色因为截图动作发生得太早地图瓦片还在加载中。观察一下 Network 面板里面的瓦片请求如果截图时瓦片请求还没完成导出图片里的地图区域就是灰底或黑底。地图库基本都有加载完成事件// 高德 map.on(complete, function () { runCapture(); }); // Leaflet map.whenReady(function () { map.once(load, function () { runCapture(); }); }); // ECharts chart.on(finished, function () { runCapture(); });我个人的习惯是在事件回调里再加一个setTimeout(..., 300)等所有图标、信息窗体之类的额外元素全部稳定后再截图。多等几百毫秒比反复截图试错划算得多。5. 可以直接抄走的修复配置与代码下面给出一套经过验证的配置组合覆盖了前面提到的绝大多数黑块场景。5.1 通用配置项const targetEl document.getElementById(page); const canvas await html2canvas(targetEl, { scale: window.devicePixelRatio || 2, useCORS: true, backgroundColor: #ffffff, logging: false, onclone: function (doc) { // 这里处理地图 canvas 拷贝、echarts 图片替换等 } }); const url canvas.toDataURL(image/png);逐项解释一下scale建议设置成devicePixelRatio或固定 2保证导出图片清晰。但要注意 scale 越大canvas 尺寸越大内存占用越高超大页面可能崩溃useCORS勾选后 html2canvas 会尝试以 CORS 方式加载跨域图片但前提是服务端返回了正确的 CORS 头backgroundColor统一设成白色避免透明背景在你自己的业务页面下显示出奇怪的黑色底onclone处理地图、图表等特殊节点的唯一可靠入口。5.2 处理超大容器和内容溢出的配置对于内容宽度超过可视区域、容器内部滚动的情况需要在配置里显式指定绘制范围或者在克隆文档里临时把 overflow 展开const el document.getElementById(page); // 方案一告诉 html2canvas 按内容实际尺寸绘制 const canvas await html2canvas(el, { width: el.scrollWidth, height: el.scrollHeight, windowWidth: el.scrollWidth, scrollX: 0, scrollY: 0, scale: 2, backgroundColor: #ffffff }); // 方案二在克隆文档里展开溢出再做常规截图 const canvas2 await html2canvas(el, { scale: 2, useCORS: true, backgroundColor: #ffffff, onclone: function (doc, clonedEl) { clonedEl.style.width el.scrollWidth px; clonedEl.style.height el.scrollHeight px; const nodes clonedEl.querySelectorAll(*); nodes.forEach((node) { node.style.overflow visible; }); } });方案一适合页面本身没有滚动、只是内容比窗口宽的情况方案二适合容器内部存在滚动条或者绝对定位子元素溢出的情况。如果项目里同时有这两种情况建议两个方案结合。5.3 一次性整合地图 ECharts 长页面的完整示例我这里给出一个综合示例做了三件事处理高德地图 canvas、替换 ECharts 图片、展开溢出容器。async function captureFullPage() { const pageEl document.getElementById(page); const mapCanvas document.querySelector(#map-container canvas); // 1. 先等待地图完成渲染 await new Promise((resolve) { if (window.__mapComplete) resolve(); else { const timer setInterval(() { if (window.__mapComplete) { clearInterval(timer); resolve(); } }, 100); } }); // 2. 准备 ECharts 图片 const chart echarts.getInstanceByDom(document.getElementById(echart-map)); const chartUrl chart.getDataURL({ pixelRatio: 2, backgroundColor: #fff }); // 3. 执行截图 const canvas await html2canvas(pageEl, { scale: window.devicePixelRatio || 2, useCORS: true, width: pageEl.scrollWidth, height: pageEl.scrollHeight, windowWidth: pageEl.scrollWidth, backgroundColor: #ffffff, onclone: function (doc) { // 展开溢出 const clonedPage doc.getElementById(page); clonedPage.style.width pageEl.scrollWidth px; clonedPage.style.height pageEl.scrollHeight px; // 拷贝地图 canvas const clonedMapCanvas doc.querySelector(#map-container canvas); if (mapCanvas clonedMapCanvas) { const ctx clonedMapCanvas.getContext(2d); ctx.drawImage(mapCanvas, 0, 0, clonedMapCanvas.width, clonedMapCanvas.height); } // 替换 ECharts const chartWrap doc.getElementById(echart-map); chartWrap.innerHTML ; const chartImg doc.createElement(img); chartImg.src chartUrl; chartImg.style.width 100%; chartImg.style.height 100%; chartWrap.appendChild(chartImg); } }); // 4. 下载或上传 const link document.createElement(a); link.download capture.png; link.href canvas.toDataURL(image/png); link.click(); }需要说明的是window.__mapComplete是占位变量实际项目中你可以在高德地图complete事件里设置它。这个示例的逻辑是把所有特殊处理集中到onclone里避免多个异步操作互相打架。5.4 上线前验证清单我每次改完截图功能都会按下面这张表过一遍能省掉很多线上反馈检查项预期结果普通文本、表格区域内容完整无黑块无缺失跨域图片正常显示控制台无 tainted canvas 报错2D canvas 地图地图底图和标记完整出现ECharts 图表图表与页面显示一致超出窗口宽度的大屏没有右侧黑带容器内部滚动区域滚动条内的内容被完整绘制高 DPR 屏幕图片清晰不模糊截图二次操作连续点击导出不会出错6. 事后复盘这些设计决策能让你少踩一半的坑问题修完后我做了一些复盘。坦白讲这类截图问题有一半是技术方案选型的问题另一半是架构设计的问题。6.1 截图需求应该作为独立功能设计我在项目里一开始把截图逻辑散落在各个组件里地图组件调一次图表组件调一次最后汇总导致问题出现时根本分不清是哪一层出了问题。后来我把截图需求收敛成一个独立的captureService统一管理流程等待资源加载、准备替代图片、调用 html2canvas、输出结果。这个服务的输入是页面根节点输出是canvas 图片所有特殊处理都集中在onclone中维护。这样做的好处是地图、图表这些复杂组件的处理逻辑只在一处修改不会影响主体业务。6.2 维护一份截图兼容性清单像高德地图、ECharts、Leaflet 这样的组件每次升级版本后截图表现都可能悄悄变化。我建了一份内部清单记录了每个组件使用的渲染方式、截图的特殊处理方案、已知坑位。比如高德地图 2.0 切换到 WebGL 渲染后清单里立即备注了需要截图时切换静态图片方案。这份文档已经成为团队接手这类需求的第一参考。6.3 那些传得很广但不要轻易信的偏方排查过程中我试过一些网上流传的方案效果并不理想简单说几句scale 调大就清晰了scale 只影响位图分辨率不影响黑块。黑块是要素未绘制跟分辨率无关加上 useCORS: true 就能解决跨域图片useCORS 只是让 html2canvas 尝试以跨域方式加载服务端不返回 CORS 头依然无效换 dom-to-image 就没事dom-to-image 同样需要面对跨域和 canvas 内容获取问题在复杂地图场景下并不会更好。这些偏方都属于听起来有道理实际治标不治本的类型。正确的思路是回到 html2canvas 的绘制原理上区分到底是尺寸越界、跨域污染还是 canvas 内容丢失然后对症处理。顺带分享一个最后的小经验不要迷信在线生成的截图 Demo。那些 Demo 大都只做了最简单的页面结构不会覆盖地图、超大屏、跨域 CDN 这些真实业务场景。真正可靠的测试环境是你自己的业务页面本身。建议每次改动完都拿真实大屏页面做一次回归把导出的图片放大到 100% 检查地图像素和文字边缘确认没有黑边、没有错位、没有模糊。html2canvas 这套东西就是这样看着是小事真要较真起来每个黑块背后都有一段值得写下来的故事。
返回列表