前端周末为了梦想

使用html2canvas在前端生成图片

2018-08-12  本文已影响34人  ChrisZ_B612

前言

流量之于互联网公司,就如同水之于万物一样重要,那么当今国内的移动互联网流量主要集中在哪里呢?答案是显而易见的,那就是我们每天都在使用的微信。

2018年年初,微信的月活用户数已经突破了10亿,成为了国内首个月活超过10亿的产品。“3Q大战”之后的腾讯逐渐由封闭走向了开放,而微信作为腾讯在移动互联网时代最重要最成功的战略级产品,也在切实实践着腾讯的开放战略。在这样的大背景下,如何利用好微信内的流量,引导用户去做分享和传播,就成为摆在我们面前的重要课题。但是微信方面基于自身利益以及用户体验等因素的考虑,对于在微信内做分享和传播的内容及形式都有着很严格的规定和诸多的限制,稍不注意违反了这些规则就有可能受到惩罚,严重的甚至被微信封杀。

但是风险和收益永远是成正比的,有的时候为了传播的效果更好,我们就不得不“合理地”采取一些措施。就目前来说,能够在微信内做分享和传播的形式无外乎以下五种:文字、图片、H5链接、小程序以及短视频。从技术的角度来讲,文字、H5链接和小程序这三种形式微信管控起来比较容易,而图片和短视频相对而言更容易绕过微信的监察。虽然短视频现在很火爆,但是我司用的不多(虽然我觉得应该充分利用起来),所以接下来我们重点说一下图片。

业务背景

在我们的业务中经常需要引导用户分享图片到微信。目前来讲我们生成图片的方式主要有两种,一种是在APP内通过自主研发的Autumn系统来生成图片,另外一种是通过PHP后端生成图片,上传到CDN后将图片链接返回给前端。这两种方法都有它的局限性,第一种方法的局限性在于它只能在APP内使用,无法在微信环境或手机浏览器中使用,第二种方法的问题在于它需要后端同学来完成分享海报图的布局开发,并且需要占用CDN资源(需要花钱),如果生成的图片只是临时使用的话,这种方法的弊端就很明显了。所以,我们的思路是找到一种可以直接在前端生成的,并且在APP、微信以及H5等各个环境都能使用的海报图生成方法。

技术选型

要在前端生成图片,自然会想到利用Canvas技术来做,但是如何利用Canvas在团队内有两种思路:第一种是完全自己封装Canvas API来作图,第二种是直接使用开源库,比如流行的html2canvas库。我个人主张用第二种方法,一方面是它能直接将DOM转成Canvas,用我们再熟悉不过的方式来作图简直不要太方便,如果是我们自己封装的话,很多基础性的工作,比如界面布局、各种元素的绘制等等都要做一遍,开发和维护的风险和成本都太高。另一方面是html2canvas库是开源已久的项目,应该是比较成熟稳定的。不过团队内仍然有老司机发出过预警,提及他们以前尝试过使用html2canvas库来做项目,但是在绘制一些比较复杂的页面时遇到了诸多的问题,所以后来他们决定放弃这种方案,转而采取了完全自主开发的方式,也就是前面的第一种方法。但是一方面我们需要生成的海报图并不复杂,另一方面直接使用DOM来作图对我的诱惑力极大,所以思量之后我决定采用html2canvas的作图方案。

html2canvas的基本介绍

根据html2canvas官方文档的介绍,html2canvas库的工作原理并不是真正的“截图”,而是读取网页上的目标DOM节点的信息来绘制canvas,所以它并不支持所有的css属性(详情参考这里),而且期望使用的图片跟当前域名同源,不过官方也提供了一些方法来解决跨域图片的加载问题。

其使用方法很简单,引入html2canvas库以后,拿到目标dom调用一下html2canvas方法就能生成canvas对象了,由于我们的目标是生成图片,所以还需要再调用canvas.toDataURL()方法生成<img>标签的可用数据:

html2canvas(targetDom).then(canvas => { // append canvas to page });

接下来,我来列举一下在这个过程中我遇到的一些问题,以及它们的解决办法。

问题一:生成的图片很模糊

当我按照官网的介绍写好了代码准备查看效果时,我发现生成的图片很模糊,具体可以看下图的对比,右边是海报图DOM,左边是html2canvas库生成的Canvas,下面如无特殊说明,都是左Canvas右DOM。

问题一:图片模糊

可以看到,红圈标出来的图片部分都很模糊。遇到这个问题后,我自然而然地去Google解决办法,你还别说,分分钟就搜出来一堆的结果(毕竟成熟的开源软件嘛🙃),然后我天真地以为很快就能解决这个问题了。

Google大法好!

我随便点开了几个链接,看了一下里面的解决办法,大致的思路都是将canvas放大n倍再作图。奇怪的是我尝试过之后发现这个很多人都说有效的方法在我这里却完全没什么用,即便我放大200倍绘制出来的图片还是一样的模糊,所以我只能遗憾地宣告这种方法对我无效了。

于是我在想这是不是html2canvas库自身的Bug,就换了一个类似的库dom-to-image做了一下尝试,结果出乎我的意料……这货不仅仅是图片模糊,它是整个canvas都很模糊啊!而且它的还原度极差,吓得我差点把手机都扔掉了🙄。截图在这里,大家自行感受一下……

Are you serious?

到这里我就有点灰心了,因为从这个情况来看,很有可能是某些底层的逻辑存在问题,也就是说这个问题有可能是无法解决的……不过好在天无绝人之路,转折点出现在有一次我们几个人一起讨论这个问题的时候,有位同学突然发现生成的海报图并不是所有的图片都很模糊,有一张小图还是很清晰的!这个发现让我喜出望外,测试了一下子后我发现,只有作为background-image的背景图会模糊,而<img>图片标签是没有这个问题的。那么解决的办法就很简单了,只要使用<img>来实现background-image的效果,问题就迎刃而解了。

问题二:删除线(text-decoration:line-through)线条偏下

这是个小问题,只是文本划线有些偏下而已,如图所示:

文本中横线偏下

解决办法也很简单,将文本元素设置成relative相对定位,然后应用伪元素模拟一下就好了,less代码如下:

&:after { .text-decoration-line-through();}

.text-decoration-line-through(@color: #fff) { position: absolute; width: 100%; height: 1px; top: 50%; left: 0; content: ''; background-color: @color;}

问题三:多行文本加省略号无法正确渲染

实测发现,使用多行文本加省略号样式时,会直接导致文本消失,如下所示:

overflow: hidden;display: -webkit-box;-webkit-box-orient: vertical;-webkit-line-clamp: 2;

这也是一个小问题,可能是上面某些样式html2canvas不支持(虽然我从文档里没有找到证据)。我的办法是干脆只使用overflow: hidden把文本截断处理即可。如果你不怕麻烦非要用js计算来加省略号那也行。

问题四(大Boss登场):图片无法渲染

好吧,其实这个问题要先于图片模糊的问题出现。因为需要加载的图片都在CDN上,而且我们知道html2canvas的工作原理是用js解析目标dom节点生成canvas的,所以需要使用跨域的方式来加载图片。刚开始时,为了让图片能够正常显示,我添加了allowTaint属性并设置为true。代码及截图如下:

添加allowTaint属性前,图片无法显示

html2canvas(targetDom, {allowTaint: true}).then(canvas => { // append canvas to page });

添加allowTaint属性后,图片显示正常 

注意!这里我们只是转成了canvas而已,还没有生成图片。所以接下来,我们尝试通过调用canvas.toDataURL()生成图片数据并设置到目标图片dom的src属性中:

html2canvas(targetDom, {allowTaint: true}).then(canvas => { targetImage.setAttribute('src', canvas.toDataURL() };);

这时候我发现代码报错了,报错信息如下:

从报错信息的意思来看,添加了allowTaint: true属性生成的canvas会导致toDataURL方法调用失败。于是我只能去掉allowTaint: true再试试看,结果图片直接就没渲染出来:

去掉allowTaint: true属性后图片消失

查了一下官方文档,找到了问题的答案:

Why aren't my images rendered?

html2canvas does not get around content policy restrictions set by your browser. Drawing images that reside outside of the origin of the current page taint the canvas that they are drawn upon. If the canvas gets tainted, it cannot be read anymore. As such, html2canvas implements methods to check whether an image would taint the canvas before applying it. If you have set the allowTaint option to false, it will not draw the image.

If you wish to load images that reside outside of your pages origin, you can use a proxy to load the images.

根据官方的说法,跨域加载的图片会污染canvas,进而导致canvas无法导出数据,还建议我们自己搭一个node代理服务器来解决这个问题。What?这么麻烦还要搭node服务器么?我们本来就是想在H5端独立完成这个事情,不想让服务器参与啊!果断找找看还有没有其他的解决办法。果不其然,让我在配置文件中找到了另一种选择:

useCORS

此时的我微微一笑🙂,很淡定地把useCORS:true属性加了进去,然后优雅地等待胜利果实🍊的到来。一切看起来都很完美~

WTF?

我勒个擦!发生什么了?说好的胜利果实呢🤦‍♀️?而且这个报错信息说我的图片加载跨域了,我不是已经设置了useCORS为true了吗🤷‍♀️?此时的我就跟世界杯上的里奥梅西一样慌得一批,赶紧求助Google找找原因。

可惜的是,我把搜出来的所有相关issue全部看了一遍,发现没有一个人真正地解决了这个问题,这群老外就像一颗颗懵逼树上的懵逼果一样挂在那里一脸茫然地晃来晃去……(其实现在回过头来看,上面截图的最后一个国人写的解决办法是最接近问题本质的,只是可惜他也没有完全弄清楚问题的根因)。没办法,只能另寻他径继续探究这个问题了。

我查阅了一些跨域相关的资料,然后试着把useCORS属性去掉,发现虽然报错信息没有了,但是图片依然渲染不出来。这个时候我就很怀疑是不是html2canvas库本身的Bug了,因为根据它的说法,useCORS属性就是用来解决图片跨域加载问题的,为什么会加上之后依然报错呢?而且官网上一大堆类似问题的issue无人处理,也加深了我的这种怀疑。所以我看了一下它的源代码,发现图片加载部分的代码是这样的:

为了验证到底是不是库的Bug,我把代码做了精简,然后把报错图片的链接拷贝出来,直接手动执行了一下,测试代码及console输出结果如下:

让我大感意外的是,图片居然加载成功了!我赶紧又测试了几种情况,发现测试代码在弹窗DOM节点渲染之前执行就能成功,但是在弹窗DOM渲染之后执行就会失败。直到这时,我才开始意识到这个问题很可能跟图片的浏览器缓存有关系。发现这个现象之后,我又查阅了很多相关的资料,迷雾终于渐渐散去。

首先是MDN上介绍CORS的这篇文章:Cross-Origin Resource Sharing (CORS)。其中有几个比较重要的知识点:

A web application makes a cross-origin HTTP request when it requests a resource that has a different origin (domain, protocol, and port) than its own origin.(请求跨域资源时需要发送特殊的跨域请求)

For security reasons, browsers restrict cross-origin HTTP requests initiated from within scripts.(出于安全因素的考虑,浏览器会限制脚本发出的跨域请求)

Note that in any access control request, the Origin header is always sent.(跨域请求一定会带上值为当前域名的Origin请求头)

再来看看关于CORS enabled image的介绍内容:

What is a "tainted" canvas?

Although you can use images without CORS approval in your canvas, doing so taints the canvas. Once a canvas has been tainted, you can no longer pull data back out of the canvas. For example, you can no longer use the canvas toBlob(), toDataURL(), or getImageData() methods; doing so will throw a security error.

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

根据这段话的描述,跨域的图片虽然可以被canvas读取,但是这也会导致canvas被污染,进而导致canvas无法导出<img>标签可用的图片数据。

再来看看它开头的一段话:

The HTML specification introduces a crossorigin attribute for images that, in combination with an appropriate CORS header, allows images defined by the element that are loaded from foreign origins to be used in canvas as if they were being loaded from the current origin.

意思是说,如果我们想让<img>标签加载的图片可以被canvas读取并导出图片数据的话,那么就应该在标签上添加crossorigin属性。crossorigin属性有两种可选值:anonymous和use-credentials,他们的差别可以查阅文末附录的参考链接,当前我们直接使用crossorigin='anonymous'就可以触发带跨域请求头Origin的HTTP请求了。

再来看看文中示例部分的第一段话:

You must have a server hosting images with the appropriate Access-Control-Allow-Origin header.  Adding crossOrigin attribute makes a request header.

关键就在这里了,除了请求头要添加Origin之外,服务器的响应头中也必须要包含正确的Access-Control-Allow-Origin才行,否则就说明服务器并不接受客户端的跨域请求,一切都是为了安全。

看到这里,跨域报错的基本原理我们已经了解了,接下来就是为什么即便我们添加了useCORS:true依然会报跨域请求错误呢?

原因跟html2canvas库的工作原理有很大的关系。如前文所说,html2canvas库需要我们先提供一段DOM节点,然后它再读取并解析这一段DOM节点生成canvas对象。如果DOM节点中已经使用了<img>标签的话,它也会解析这个<img>标签的src属性,然后重新创建一个Image对象,给它添加crossOrigin="anonymous"属性后尝试以跨域的方式重新读取图片数据。需要注意的是,一般CDN上的图片都是带有缓存响应头并且会在浏览器端缓存的,而且缓存的不仅仅是图片数据,还有HTTP响应头。所以问题的根本原因我们就找到了,当html2canvas尝试以跨域的方式去读取图片数据时,它读取到的是浏览器的缓存数据,而且因为我们没有给DOM节点中的<img>标签添加crossorigin="anonymous"属性,所以缓存数据是不带Access-Control-Allow-Origin响应头的,进而导致html2canvas库读取到的图片数据污染了生成的canvas对象,最终致使canvas导出数据报错。

看到这里已经真相大白了。所以我们要做的事情也很简单,就是给DOM节点中的每一个<img>标签都加上crossorigin="anonymous"属性就可以了。再回过头说一下为什么之前的那个国人的文章虽然解决了问题但是却并没有找到问题的根本原因,因为他修改了html2canvas读取图片的源代码,给每一个Image的src属性添加了一个随机字符串,意外地避开了读取到缓存数据的问题,但是却会导致CDN的缓存被击穿。

最后总结一下,其实说了一大堆,我们要做的事情却很简单:

1、添加useCORS:true属性;

2、给要生成canvas的DOM中包含的每一个<img>标签添加crossorigin="anonymous"属性;

3、确保你的图片CDN服务器支持CORS访问,也就是会返回Access-Control-Allow-Origin等响应头;

问题五(大Boss返场):图片无法渲染

也许你跟我一样,以为问题到这里就已经得到了彻底的解决,但是事实却并非如此。就像很多游戏关卡的大boss一样,好不容易干掉了它第一条命之后发现它居然还有第二条命。

事情是这样子的,如果你要用来生成canvas的dom中包含的<img>图片,之前已经被你的用户访问过(例如你是在对线上现有的业务进行改造),显然之前你应该没有给<img>标签添加crossorigin="anonymous"属性,那么请注意,这时候你的用户的浏览器已经把这些图片缓存在了本地,所以即便你按照上面的步骤都做了也没用,因为访问图片时读到的都是不带Access-Control-Allow-Origin等响应头的缓存数据。

这个时候你要做的,就是给要生成canvas的dom中的所有<img>标签的src添加一个任意的字符串,只要能起到重新发起图片读取请求,从而避免读取到浏览器缓存数据即可,如下所示:

'http://h0.hucdn.com/open/201819/9404b56f97e7df8a_750x1334.png?any_string_is_ok'

注意,不要添加随机字符串,那会击穿CDN缓存的,随便添加一个固定的字符串,能够避免读取到浏览器的缓存数据就可以了。这是本人血的教训!所以请大家千万千万不要忽视这一点!

写在结尾

到这里我想说的就已经基本说完了,其实在解决图片无法渲染问题的过程中,我还遇到了一些其他的给我造成过很大困扰的障碍和麻烦,限于篇幅我也就不再赘述了。html2canvas库在应用的过程中肯定还会有一些其他的问题,例如页面表现不一致什么的,这可能是DOM本身的样式就有兼容性问题,也可能是库的渲染跟DOM有差异,要看具体情况了。本来还想加入一些浏览器缓存和CDN缓存的相关知识介绍的,但是这个主题本身包含的内容就足够的多,还是今后另开一篇博客慢慢写吧。

在解决这个问题的过程中,我也给自己总结了一点经验,希望对你也有所启发:

1、善用charles、chrome等开发工具;

2、认真仔细,大胆假设,小心求证;

3、不要轻易放弃;

4、永远不要过于自信。

参考资料

Cross-Origin Resource Sharing (CORS)

CORS enabled image

HTMLCanvasElement.toDataURL()

CORS settings attributes

The Image Embed element::crossorigin

Web开发之html2canvas截图如何解决跨域的问题?

html2canvas: Screenshots with JavaScript

上一篇下一篇

猜你喜欢

热点阅读