canvas图片业务可能遇到的两个坑

达芬奇密码2018-06-20 11:49

问题

Failed to execute 'toDataURL' on 'HTMLCanvasElement': Tainted canvases may not be exported.

'xxx' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource.

这是我写canvas图片业务遇到的两个问题。

前言

更好的阅读体验请戳这里。 欢迎打脸(比如说哪些地方没说明白啦,哪些地方存在知识点问题啦)!

正文

一、 先简单说下跟本文相关的需求:涂鸦板里能嵌图片;能把图片导出;由于有多张图,为了让体验更好还需要有个预加载方案。

写demo的时候我用的本地图片,调canvas toDataURL方法并没有报错。

但是在联调的时候,换成外域图片,却报错了:

Failed to execute 'toDataURL' on 'HTMLCanvasElement': Tainted canvases may not be exported.

按惯例去stackoverflow上查了查,找到了解决方案(详情可以看这里):

var img = new Image();
img.setAttribute('crossOrigin', 'anonymous');
img.src = url;

当时没想那么多,加进去试试再说,不出意料地解决了问题,不禁再次感叹so大法好!

然而在加了图片预加载代码之后,发现有的图片就加载不出来了,打开控制台报错:

开始以为是图片服务器那边没有设CORS,联系那边说设了;然后说「你们怎么用的源站域名,源站的域名可能导致种种问题,改用CDN域名试试」,但发现还是有问题。然后逐步定位到是图片预加载代码的问题,改了之后似乎就好了。

好景不长,后来由于QA哥哥的一个「误操作」,又出现了同样的问题,我的内心是崩溃的。。

二、 上面简单地说了下我遇到问题与解决问题(赶进度)的过程,接下来要入坑辣~

先说说 Tainted canvases may not be exported 的问题。对于外域图片,浏览器仍然是允许你画到canvas上的,但是toDataURL就会报错(toBlob也是)。为什么会这样呢?

This protects users from having private data exposed by using images to pull information from remote web sites without permission.

上面这段引用摘抄自这里。在对应的语境里,大意就是说:如果你请求外域的图片without permission,可能会暴露你的隐私数据,所以浏览器为了保护你的隐私会限制这样的请求。

「wtf?请求外域图片怎么就会暴露我的隐私数据了?」其实我也不明白,这个坑请先自己填一下。

那么怎么绕过浏览器的「关照」呢?答案是:你允许就行了~而img.setAttribute('crossOrigin', 'anonymous');就是告诉浏览器,我允许!

再说说'xxx' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource.

这个报错的根源是:

var img = new Image();
img.setAttribute('crossOrigin', 'anonymous');
img.src = url;  // 外域url

(这个异常实际上在控制台里是拿不到调用栈的,浏览器并不会告诉你是这里出了问题)

这个异常信息本身是说「reponse header中不带Access-Control-Allow-Origin(以下简称AC)这个字段,所以'xxx'被同源策略阻止了」。

(如果你想进一步了解同源策略,可以看看阮老师的这篇文章。)

这时候你可能会想起,我之前不加img.setAttribute('crossOrigin', 'anonymous');,也去请求外域图片,怎么就没报过错?

这里我简单补充一下:img.setAttribute('crossOrigin', 'anonymous');加了这句,就意味着你这次的图片请求变成了CORS请求,就要受同源策略的限制了(而这个报错就说明你受到了浏览器同学的关怀:D)。

其实因果关系是这样的:img.setAttribute('crossOrigin', 'anonymous');会让request header加上Origin字段,从而变成了一个CORS请求

(如果你想进一步了解CORS,可以看看阮老师的这篇文章。)

回到正题,既然问题是response header中不带AC,那让服务端返回应该就可以了吧?

如果服务端真的没有配置CORS,那先让他们配置好。

但是,即使配置了,仍然可能存在问题。

在我遇到的情况里,其实服务端是做了配置的,那谁来背锅?

==================== 缓存 ====================

首先,第一锅要给浏览器缓存

这里先赘述一下:我们第一次访问一个页面时,会发现图片会慢慢加载出来;当我们再次访问同一个页面时,会发现图片很快就加载出来了。主要就是因为浏览器第一次已经把图片缓存下来了,第二次不需要再从服务端请求,而直接从缓存里取。

虽然方便了,但这可能引发其它问题。上面提到过,原先的图片预加载代码有问题,简化版如下:

var img;
for(var i in images){
  img = new Image();
  img.src = images[i].url;
}

注意,这段代码没带img.setAttribute('crossOrigin', 'anonymous');其实本质上并不是因为没带这句才出的问题,跟实际的场景有关

当时的场景是:图片预加载先行;然后编译第一个涂鸦板,之后选中其它的涂鸦板再编译该涂鸦板;每个涂鸦板编译的时候也会去发送图片请求(CORS请求)。

问题的现象是:第一个涂鸦板的图片加载出来了,后面几个都没加载出来。

why?

对于第一张图片,两个请求(来自预加载和涂鸦板编译)几乎是同时发送的;而其它几张图片,都是预加载在先,编译在后。如此,在编译其它几个涂鸦板时,浏览器会直接取缓存里取图片。

而我们预加载时发送的是普通请求,这意味着这些请求的response不会带AC(不是必然的,取决于服务端怎么做):

所以,当其它涂鸦板编译时,发出的是CORS请求,拿到的却是不带AC的response,结果必然出错。

这里我得再强调一下,并不是普通请求的response就一定不带AC,这个取决于服务端怎么处理。比如像请求七牛公共空间的图片,不管是普通请求还是CORS请求,都会带AC。

知道原理之后解决问题就简单了,先清清缓存,然后加上crossOrigin

var img;
for(var i in images){
  img = new Image();
  img.setAttribute('crossOrigin', 'anonymous');
  img.src = images[i].url;
}

So,到此为止?No,我们有请第二位背锅先生:CDN缓存

上面提到过,我们的图片域名由源站改为了CDN。

先还原一下当时的场景:

有一位老师用涂鸦板批改作业,当她保存的时候发现保存不了(这是另一个无关的问题,不赘述),就请QA哥哥帮忙。QA哥哥打开控制台......(省略一万字),然后在一个新tab里打开了一张图片。当他再回到原页面时,一刷新,发现这张图片没了。当时我就跪地上了。。。

我是束手无策了,于是找了NOS的gg们帮忙。他们说的确存在这种问题,正在修复中。。

在进一步讲之前,结合我的手残图,先普及几个CDN相关的知识:

  1. CDN会缓存response,源站不会。
  2. CDN接收到请求时,如果没有缓存,会将请求发送到源站,将结果回传给请求端,并且缓存结果(response),简称回源。
  3. CDN是根据url进行缓存的,比如你请求一次http://a.b.c/1.jpg,之后再请求相同的url,那你拿到的是缓存下来的response;如果你加了个参数比如http://a.b.c/1.jpg?100,这个时候就会回源,但是并不会破坏掉http://a.b.c/1.jpg对应的缓存。
  4. 以上3点只是我们这边的情况,也许有特殊性。

现在可以简单理理,这是个怎样的问题:

  • 老师的图片本来是可以加载到的,并且在没「打开图片」之前,都是发送的CORS请求(在涂鸦板预加载和编译时发送),这些CORS请求的response早已在A节点缓存了下来。
  • 而打开这张图片,意味着一次普通请求,奇怪的是,请求去到了B节点,而B节点尚未缓存,所以进行了回源。
  • 而刷新页面后,请求虽然是CORS请求,但是却又走到了B节点,结果就是:一个CORS请求拿到一个普通请求的response,浏览器由于同源策略而报错。

(正常情况下,如果一开始去到A节点,那么应该一直都是去A节点。)

嗯,道理明白了。那除了等gg们修复问题,还有什么解决办法吗?

我猜你已经想到了:加随机数。

最终的做法是在图片onerror的时候带随机数(比如时间戳)重发请求,大概就是:

function requestImg(src){
  var img = new Image();
  img.src = src;
  img.onerror = function(){
    var timeStamp = +new Date();
    requestImg(src+'?'+timeStamp);
  }
}

解决方法

总得来说,当你遇到这两个问题的时候,需要做两件事:

  1. img.setAttribute('crossOrigin', 'anonymous');
  2. 图片请求失败时,带随机数重发请求。

参考

  1. 同源策略
  2. CORS
  3. canvas-todataurl-securityerror
  4. what-are-the-integrity-and-crossorigin-attribute
  5. CORS_settings_attributes

本文来自网易实践者社区,经作者刘宗源授权发布。