记一次canvas导出图片问题

Yukino 924 2022-06-02

背景:之前在系统中做了一个将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上调用getImageDatatoBlobtoDataURL会抛出上面的异常(其他来源是指资源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,但过了一个星期,作者也没有回复,就先这样吧...