背景:之前在系统中做了一个将dom导出图片的功能https://yukinoyukino.com/archives/dom导出为图片,本以为解决了跨域问题,但结果从灰度上到正式环境之后还是存在这个问题
一、问题出现
问题出现在这一行代码:
const image = canvas.toDataURL('image/jpeg', 1);
控制台抛出了一个异常Uncaught SecurityError: Failed to execute 'toDataURL' on 'HTMLCanvasElement': Tainted canvases may not be exported.
二、问题排查
首先是需要知道这个tainted canvas
是个什么概念,是如何出现的,然后在MDN-Allowing cross-origin use of images and canvas找到了原因和解决方法。
**【原因】**当canvas使用了其他来源的图片、视频时,画布就会被“污染”,也就是上面说的tainted canvas
,当在tainted canvas
上调用getImageData
、toBlob
和toDataURL
会抛出上面的异常(其他来源是指资源domain、port和页面不一致)
但上篇文章已经说了已经开启了useCORS
,后端返回的Header里也有Access-Control-Allow-Origin: *
,那问题是出在哪里?
抛开useCORS
参数,因为这个是框架的参数,并不知道里面的实际效果,但原因是肯定的,就是图片跨域了,既然是跨域,根据MDN的那篇文章:
- 对于后端,返回的Header里包含
Access-Control-Allow-Origin: *
; - 对于前端,canvas使用图片时,设置
crossOrigin = 'Anonymous'
;
const imageURL = 'xxxxxxxx';
const image = new Image();
image.crossOrigin = "Anonymous";
image.src = imageURL;
那现在问题就锁定在这个useCORS
参数设置之后是否是设置了crossOrigin,首先需要还原环境,测试环境中并没有CDN或者重定向的环境,直接在正式上测试也不太现实,所以想到了在本地开发时用vue-cli起一个代理:
// vue.config.js
module.exports = {
devServer: {
proxy: {
'/uploads': {
target: 'https://domain.com/uploads',
changeOrigin: true
},
'/api': {
target: 'https://domain.com/api',
changeOrigin: true
}
}
}
}
其中/uploads
是文件路径,/api
是接口路径,这样我们就可以在本地模拟线上的环境,接下来就是在本地进行调试了。
然后我们要去检验在html2canvas
的过程中是否将创建的Image设置了crossOrigin = "Anonymous"
,直接去看源码相对来说有点麻烦,打断点一步步进去也不太容易(至今不明白JS代码往往层级复杂,debugger究竟有什么用),所以还是想直接监听Image的crossOrigin的变化,这就联想到Vue的响应式实现了,Vue2的方法是defineProperty重写get、set,Vue3的方法是使用Proxy代理,考虑了一下,感觉Proxy可能更简单一点,于是就有了下面的代码:
const ImageConstructor = window.Image;
window.Image = function () {
const image = new ImageConstructor();
const proxy = new Proxy(image, {
get(target, prop) {
return target[prop]
},
set(target, prop, value) {
console.log(`[set]:${prop}=${value}`)
target[prop] = value
}
});
return proxy
}
这样,在给任何一个Image实例修改属性时,都会打印在控制台上,然后把这段代码放在main.js中,让它在Vue App创建前生效,再走一遍导出图片的逻辑,发现整个过程中只打印了一个[set]src=xxxxxx
,也就是说问题就是没有设置crossOrigin = "Anonymous"
,确定了问题,那剩下的就是找到这个问题发生的位置并证明;
直接在html2canvas目录下搜索crossOrigin,很快就找到了目标代码:
// lib/core/cache-storage.js
if (isInlineBase64Image(src) || useCORS) {
img.crossOrigin = 'anonymous';
}
从字面上理解,第一个应该是判断是否使用base64内联的图片,后面就是是否设置了useCORS了,找到useCORS声明的地方:
var isSameOrigin, useCORS, useProxy, src;
// ...
isSameOrigin = CacheStorage.isSameOrigin(key);
useCORS = !isInlineImage(key) && this._options.useCORS === true && features_1.FEATURES.SUPPORT_CORS_IMAGES && !isSameOrigin;
显然这里的useCORS并不是单纯的从构造函数中的选项来的,还判断了是否是內联图片、浏览器是否支持以及是否“同源”,问题就在这个isSameOrigin,图片链接地址和网页地址本来是同源的,但是重定向到CDN之后,CDN来源的图片与网站就不同源了,useCORS也就变成了false,也就没有设置crossOrigin = "Anonymous"
了;
三、解决问题
解决方式也比较简单,毕竟不改源码基本没有什么操作空间,直接覆盖Image的构造函数,导出完成后再还原:
const ImageConstructor = window.Image;
window.Image = function () {
const image = new ImageConstructor();
image.crossOrigin = 'Anonymous'
return image;
}
return new Promise((resolve, reject) => {
html2canvas(targetElement, option).then(function(canvas) {
const image = canvas.toDataURL('image/jpeg', 1);
window.Image = ImageConstructor;
resolve(image);
});
})
最后给仓库提了一个issue,但过了一个星期,作者也没有回复,就先这样吧...