本文适用人群
- 需要在微信wap页开发分享海报功能的前端程序员们
- 想要了解html2canvas库的吃瓜群众
- 挣扎在html2canvas库中的开发者们
背景
产品大大的需求: 做一个生成海报的功能。在微信wap页使用。把html元素生成一张宣传海报,方便用户分享出去。
注意点:
- 在使用海报的地方,并不会显示海报来源的html,只显示海报的图片。 所以要求了html节点是隐藏的。
- 海报的数据来源于当前登录用户、当前课程有关(根据这些生成二维码),课程图片也是动态的。 所以图片不能写死保存在文件夹下,而是放在nos上。
思路
首先梳理需求。所谓的生成海报,简化后其实就是: 根据html元素生成图片。其中,html元素包括图片(背景图、课程图片、二维码)和文字(推广语、课程名)。
那么第一个问题就是,由前端实现还是后端实现?
首先看后端实现:云课堂工程的后端使用的语言是java,java是有比较成熟的库来实现html转成image这个功能的,并且我们之前也实现过一个类似的功能。但是后端实现这个功能有一些不足: 1. 比较慢,平均生成一张图需要2-10s ,2. 不适合需要实时的场景。而我们的需求明显是不能接受等待这么久的。3. 会产生白图或者黑图或者图片不完整的情况。
如果由前端实现呢,我们很容易想到用canvas,而在wap页,浏览器对canvas的支持还是比较好的。另外,这么有挑战性的任务,身为一个前端,怎么能不试一试呢?
所以经过权衡,最终的决定是由前端来实现。
下一个问题,怎么实现?
实现
于是开始我调研用canvas实现html转图片。
方案一
在张鑫旭大大的博客里发现了SVG <foreignObject>简介与截图等应用 这篇文章。用svg的foreignObject实现截图功能。文章的主要思路是:
先写一个svg,用foreignObject包围要写的html元素。
用canvas的drawImage方法把svg转成canvas。
用canvas的toDataURL方法把canvas转成图片。
简单说来,就是: html-->svg-->canvas-->img。
嗯,看起来很简单。看完DEMO后,年幼无知的我一头就扎进了实现业务逻辑的大坑里,开始按照这个思路实现第一版的生成海报功能。
第一版是简化版,复制了下zxx的DEMO,图片用本地图片代替,写完后在本地用Chrome尝试没有问题。
然鹅,事情怎么会这么简单……当把图片换成nos图片后——
报错"Tainted canvases may not be exported",这是因为: 当引用外域的图片,并且该图片并没有CORS认证时,canvas被“污染了”,而被污染的canvas不能使用toBlob(), toDataURL(), getImageData()方法。见CORS enabled image
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.
解决方法很简单,给img设置crossOrigin属性为anonymous
<img crossOrigin="anonymous" >
或者在js中指定
if (dom.tagName.toLowerCase() == 'img') {
dom.crossOrigin = "anonymous";
}
同时img的服务器也要有正确的Access-Control-Allow-Origin
响应头即可。
到此Chrome的问题解决了,然而在Safari下……
一切就是这么残忍。果断给我报错了。问题出在最后一步: canvas-->img,报错的是canvas.toDataURL方法。这是我遇到的第一个坑:
svg的foreignObject里面有外域的图片时,尽管指定了crossOrigin,在safari中,canvas.toDataURL方法仍然会有安全性问题。
这个坑应该和CORS有关,但是搜索了一番,没有找到更深层的原因和解决方案。(有人知道的话欢迎告诉我)
报错导致画不出来图,这也就意味着,这个方案拒绝了Safari。而拒绝了Safari,就等于拒绝了所有的苹果手机……如果你去跟策划小哥哥说:我们能不能不兼容iphone,相信我,他们一定会提着刀来见你的。
本方案,卒。
方案二
看来foreignObject的路子行不通了,我只好继续寻觅~
就在这时,我发现了一个js库: html2canvas。
那么下面给大家介绍一下html2canvas这个库。
html2canvas库
一个用js进行“截屏”操作的库。因为是基于dom元素的,并不是真的截屏,所以可能会有一些不准确。
This script allows you to take "screenshots" of webpages or parts of it, directly on the users browser. The screenshot is based on the DOM and as such may not be 100% accurate to the real representation as it does not make an actual screenshot, but builds the screenshot based on the information available on the page.
使用方式:
html2canvas(document.body, {
onrendered: function(canvas) {
document.body.appendChild(canvas);
}
});
文档地址: https://html2canvas.hertzen.com/documentation.html
原理
html2canvas的基本原理是,把dom树拉出来,挨个画到canvas上。比如div就取backgroud-color等,画一个长方形。最后返回这个画布。
The script renders the current page as a canvas image, by reading the DOM and the different styles applied to the elements.
所以我们基本可以猜想到html2canvas画图的整个流程:
- 递归处理每个节点,记录这个节点应该怎么画。(比如div就画边框和背景,文字就画文字等等)
- 考虑节点的层级问题。比如z-index,float, position等样式的影响。
- 从低层级开始画到canvas上,逐渐向上画。层级高的覆盖层级低的。
试了一下DEMO,基本可行。于是,就是你了!(毕竟调研+开发只有两天时间,我不想再寻找了)
踩坑经验及解决方案
决定了使用html2canvas后,还要再决定一个问题: 用哪个版本的?
目前html2canvas有最新版5.0beta4和正式版4.1。5.0beta版使用了promise等新技术;4.1作为正式版,社区里有更多的解决方案。而我,两个版本都试了……至于为什么,后面会告诉你们……
好,到这里方向是确定了,但是道路是艰难的,下面我分享一下在使用过程中遇到的问题们。
问题一,怎么画出不显示的元素
从文章最开始的需求背景,大家应该就知道了,我们的html元素是隐藏的,页面上并不会显示,只需要显示根据html元素画出来的图片。然而,html2canvas本质上是一个“截屏”工具,屏幕上有什么,它就画什么,而隐藏的元素,它不会画出来。
怎么解决呢?别急,本宝宝分别告诉大家5.0版的和4.1版的
5.0版
5.0版本,传入的options里面,有一个onclone参数,这个参数是做什么的呢?看一下源码,在这个版本中,会先复制传入的dom元素,然后再画出复制后的dom元素,onclone就是复制之后的回调。所以我们在clone dom后,给clone的dom节点加上display:block,就可以解决画不出display:none的问题了。
html2canvas(p, {
useCORS: true,
onrendered: function(canvas) {
$img.src = canvas.toDataURL('image/png');
},
onclone: function(doc){
hiddenDiv = doc.getElementById('parent');
hiddenDiv.style.display = 'block'; // 这里,设置display为block
}
});
4.1版
4.1版本的没有onclone回调了,稍微有点麻烦,因为我们只能改源码了。
源码中有一个isElementVisible方法,是判断元素是否显示的。那么我们修改这个方法为:
function isElementVisible(element) {
return (getCSS(element, 'display') !== "none");
// return (getCSS(element, 'display') !== "none" && getCSS(element, 'visibility') !== "hidden" && !element.hasAttribute("data-html2canvas-ignore"));
}
配合父元素的类:
.parent{
visibility: hidden;
position:fixed;
z-index: -1;
top:0;
}
这样就可以达到显示隐藏的元素的目的了。
问题二,图片模糊怎么办
最初,我们发现,生成的图片在Mac上看总是糊的。如下图:
canvas模糊的话,很容易想到像素点的原因。
于是有思路:我们尝试把canvas的width和height放大。
给canvas的宽高比canvas样式的宽高大,比如把200x200的画缩放到100x100,这样画出来的图点就更多,清晰度就更好。
放大多少呢?——根据window.devicePixelRatio来。
5.0
5.0版本支持自定义canvas并传进去。所以我们在调用html2canvas的时候,先创建好一个尺寸合适的canvas,作为参数传进去。
var p = document.getElementById(domId);
var scaleBy = backingScale();
var box = window.getComputedStyle(p);
var w = parsePixelValue(box.width, 10);
var h = parsePixelValue(box.height, 10);
var canvas = document.createElement('canvas');
function backingScale () {
if (window.devicePixelRatio && window.devicePixelRatio > 1) {
return window.devicePixelRatio;
}
return 1;
};
function parsePixelValue(value) {
return parseInt(value, 10);
};
// 就是这里了
canvas.width = w * scaleBy;
canvas.height = h * scaleBy;
canvas.style.width = w + 'px';
canvas.style.height = h + 'px';
var context = canvas.getContext('2d');
context.scale(scaleBy, scaleBy);
html2canvas(p, {
useCORS: true,
canvas: canvas, // 把canvas传进去
onrendered: function(canvas) {
cb(canvas.toDataURL('image/png', 1));
},
logging: true,
onclone: function(doc) {
hiddenDiv = doc.getElementById(domId);
hiddenDiv.style.display = 'block';
}
});
4.1
4.1版本尽管也支持自定义传canvas进去,但是在最后画图的时候,会改写canvas的width和height,
return function(parsedData, options, document, queue, _html2canvas) {
...
canvas.width = canvas.style.width = options.width || zStack.ctx.width;
canvas.height = canvas.style.height = options.height || zStack.ctx.height;
}
所以想要像用5.0版本一样传canvas参数进去的话,就要失望了,还是会一样的糊(废话,宽高都被改了,我还传进去干啥)。
所以,我们又要改源码了……翻到源码最后,首先加一个backingScale方法,根据window.devicePixelRatio计算缩放倍数。然后重写canvas的width和style.width
function backingScale () {
if (window.devicePixelRatio && window.devicePixelRatio > 1) {
return window.devicePixelRatio;
}
};
return function(parsedData, options, document, queue, _html2canvas) {
...
// 改成下面的
canvas.width = Math.ceil(options.width ||zStack.ctx.width)*scaleBy;
canvas.height = Math.ceil(options.height || zStack.ctx.height)*scaleBy;
canvas.style.width = options.width || zStack.ctx.width;
canvas.style.height = options.height || zStack.ctx.height;
...
if (options.elements.length === 1) {
if (typeof options.elements[0] === "object" && options.elements[0].nodeName !== "BODY") {
// 如果传入的element是一个dom元素的话,把图片里面的这个dom元素切出来,否则可能会有白边。
bounds = _html2canvas.Util.Bounds(options.elements[0]);
newCanvas = document.createElement('canvas');
// 这两句是原来的,注释掉
// newCanvas.width = Math.ceil(bounds.width);
// newCanvas.height = Math.ceil(bounds.height);
// 改成下面的
newCanvas.width = bounds.width*scaleBy;
newCanvas.height = bounds.height*scaleBy;
newCanvas.style.width = bounds.width+ 'px';
newCanvas.style.height = bounds.height+'px';
newctx = newCanvas.getContext("2d");
// 原来的
// newctx.drawImage(canvas, bounds.left, bounds.top, bounds.width, bounds.height, 0, 0, bounds.width, bounds.height);
// 同样改成下面的
newctx.drawImage(canvas, bounds.left, bounds.top, bounds.width, bounds.height, 0, 0, newCanvas.width, newCanvas.height);
// newctx.scale(4, 4);
canvas = null;
return newCanvas;
}
}
}
这样之后,图片显然清晰了许多:
问题三,图片跨域和CDN缓存导致报错
首先,图片跨域的问题,可以通过useCORS这个参数来解决。原理就是上面说过的crossOrigin。
html2canvas(p, {
useCORS: true,}) //先写这个参数
而关于CND缓存,是这样的:因为我们的图片一般都是上传到CDN上,而CDN为了更快的响应,会缓存图片的返回值,而缓存的值是不带跨域的头的。因为没有跨域的头,所以js请求会被拦截。而html2canvas中,画图片之前会先preload所有的图片,这就导致了js报错:图片跨域(此处没有图,相信前端小司机应该见过很多次这个报错)
解决的思路是这样的:js中,请求图片的时候,给请求的图片链接加上个时间戳参数,这样CDN就映射不到缓存了,会回源,回源到 NOS,而NOS的图片是带跨域的头的,这样返回就不会再报错。
5.0
修改html2canvas源码的imageContainer方法,self.image.src = src + (src.split('?')[1] ? '&':'?') + (+new Date());
function ImageContainer(src, cors) {
this.src = src;
this.image = new Image();
var self = this;
this.tainted = null;
this.promise = new Promise(function(resolve, reject) {
self.image.onload = resolve;
self.image.onerror = reject;
if (cors) {
self.image.crossOrigin = "anonymous";
}
// 原来是self.image.src = src,改为现在这句
self.image.src = src + (src.split('?')[1] ? '&':'?') + (+new Date());
if (self.image.complete === true) {
resolve(self.image);
}
});
}
4.1
基本类似,修改loadImage方法
loadImage: function( src ) {
var img, imageObj;
if ( src && images[src] === undefined ) {
// 这里,加时间戳参数
src = src + (src.split('?')[1] ? '&':'?') + (+new Date());
img = new Image();
另外,其实并不建议用CDN的图片,因为正好看到刘诗川的文章,开发富文本编辑器的一些经验教训,CDN会导致回源,因此请求会更慢返回。
但是,我上面提到的将含有跨域CDN图片的DOM节点渲染成图片的情况下,向CDN代理节点请求图片资源反而会比我们直接向静态资源源站点请求要来的慢,...CDN代理节点遇到一个自己没有缓存的资源,它就会向静态资源的源站点去请求,得到结果后再转发给用户,这等于说我们这个带有时间戳的图片URL的请求,不但没能利用的CDN的缓存提速,反而由CDN代理节点充当了一次中介,这显然会增加资源的返回耗时
所以建议使用NOS的图片,比如:http://nos.netease.com/edu-image/AE32703A6908FBD2A57F917F5E93A55D.jpg这种,而不要使用NOS CDN的图片,比如:http://edu-image.nosdn.127.net/AE32703A6908FBD2A57F917F5E93A55D.jpg
到此为止,demo已经没什么大问题了,剩下的就是应用到实际工程中了。然而,在实际使用的过程中,依然遇到了一些问题:
1. 画出来的图中,只有background-image一定会出现,其他图片有概率不出现
很令人费解的情况。这个几率不算很高,点个20次大概会有一次出现这种情况。最初以为是5.0beta版本的原因,所以换成了用4.1正式版,然而换了版本之后并没有解决这个问题。
v4.1源码中对img标签和background-image的处理其实是类似的。都是先preload,然后canvas.draw。只是background-image多了一个createPattern步骤,用于处理background-repeat属性。
解决方案是把html中的图片都写成background-image,尝试后没有再出现显示不出img的情况。
另外,因为使用了v4.1版本,所以后面的问题和解决方案都是针对v4.1。
2. 有一定几率截出来的图是全白屏
经过排查,在最后一步的canvas中,还是有所有图片的,但是canvas->newcanvas后,图片不见了,所以怀疑是这句产生了问题:
newctx.drawImage(canvas, bounds.left, bounds.top, bounds.width, bounds.height, 0, 0, newCanvas.width, newCanvas.height);
这里稍微解释下,为什么会有canvas->newcanvas这个步骤。html2canvas库中,画每个节点时 ,需要定位这个节点从canvas的哪里开始的,即需要一个(x,y)坐标。通过 ele.getBoundingClientRect可以得到这个元素在client(也就是窗口)的位置,然后从这个点开始画出该元素。如果传入的element不是body的话,意思就是我们只想要这个元素的canvas图,并不关心它在窗口中的位置。所以对前一步的canvas进行一下裁剪,重新画到一个新的canvas上面去。一图胜千言,下面上图:
红框框表示我们传进去的element,左边是canvas(最初的画布),右边是newcanvas(我们需要的画布)。
回到刚刚的问题,锁定了出问题的行之后,我们看一下原因。
第一,top,lef有问题,通过log查看,出问题时,top值会为-1,所以修改源码的bound方法,使top和Left始终大于等于0
_html2canvas.Util.Bounds = function (element) {
var clientRect, bounds = {};
if (element.getBoundingClientRect){
clientRect = element.getBoundingClientRect();
// bounds.top = clientRect.top;
bounds.top = clientRect.top > 0 ? clientRect.top : 0; // 改成这个
bounds.bottom = clientRect.bottom || (clientRect.top + clientRect.height);
// bounds.left = clientRect.left;
bounds.left = clientRect.left > 0 ? clientRect.left : 0; // 改成这个
bounds.width = element.offsetWidth;
bounds.height = element.offsetHeight;
}
return bounds;
};
第二,width和height有问题,可能超过了画布大小,导致画出来白图。所以同样修改源代码(经尝试,safari下width和height越界会导致白图,chrome不会),用Math.ceil取宽高
//canvas.width = (options.width ||zStack.ctx.width)*scaleBy;
//canvas.height = (options.height || zStack.ctx.height)*scaleBy;
// 改成下面
canvas.width = Math.ceil(options.width ||zStack.ctx.width)*scaleBy;
canvas.height = Math.ceil(options.height || zStack.ctx.height)*scaleBy;
3. v4.1的html2canvas对background-size: contain不兼容。
可以理解,毕竟它是解析css属性值之后画到canvas上的。不兼容background-size就导致背景图只能显示一部分。
解决方法: 不用background-size。。。
但是不用background-size时,宽高是rem的话,背景图片显示会被切断。
解决方案: 用px作为单位,根据背景图的比例来,同时用nos对背景图进行裁剪
width: 600px;
height: 800px;
background-image: url("http://edu-image.nosdn.127.net/F361D28EEC677CFD44D7C359D24E3DC0.png?imageView&thumbnail=600y800");
这里会遇到第四个问题,本来背景图是600x800的,外面的div宽高应该也写600x800,但是在手机端宽高大于屏幕尺寸时,会导致截图被切断(也就是只能画出来窗口内的内容),像这样:原因: 在createStack方法里,对于传进去的元素,生成的canvas的宽高最大值取的窗口宽高。canvas就这么大一点,画出来的内容当然不全了。
function createStack(element, parentStack, bounds, transform) {
h2cRenderContext((!parentStack) ? documentWidth() : bounds.width , (!parentStack) ? documentHeight() : bounds.height),
...
}
解决方案:
因为父元素是没有parentStack的,所以它的ctx的宽度会取document的宽度。因此把这段代码改成如下:
function createStack(element, parentStack, bounds, transform) {
//var ctx = h2cRenderContext((!parentStack) ? documentWidth() : bounds.width , (!parentStack) ? documentHeight() : bounds.height),
// 改成:
var ctx = h2cRenderContext( (!parentStack) ? (bounds.width + bounds.left): bounds.width, (!parentStack) ? (bounds.height + bounds.top) : bounds.height),
加上bounds.left是因为util.Bounds方法会计算每个元素距离client的左上顶点的距离,画canvas的时候就从这个点开始画。所以canvas的实际宽度应该是父元素宽度+父元素左边距离窗口的偏移值。
顺便说一下,这里改完了之后,canvas的模糊问题也解决了……因为元素600x800,所以canvas大小也是600x800,而实际应用中,在移动端显示的图片style的宽高并不大,所以看起来不糊了。
4. iphone手机升级IOS11后,当模板里有文字和《混排时,会发生文字位置错乱的现象
话不多说,看图(欢迎大家扫码买课):
本来的课程名应该是:Excel从入门到忘记。这里表现为:文字缺失和文字重叠
经过观察,有以下规律:
- 《后面的文字会被吞掉1-2个。
- 末尾的》始终显示不出来,
- 倒数第n个字会发生重叠。
最初怀疑是font-family对字符集的支持不够完善导致的,但是safari下看了下,html元素的显示是正确的,只有画到canvas上之后才错乱。
然后仔细观察html模板(我们使用的是regular),怀疑是模板渲染之后,《和{xxx}混排导致了这个问题,于是修改模板为:
<!--原来的-->
<div class="courseName">
《{courseData.productName}》
</div>
<div class="courseName">{'《'+courseData.productName+'》'}</div>
问题解决。
探究原因,是《导致的吗?于是把模板中的《换成%,无果,仍然错乱。所以,单个《是不会有问题的,有问题的是《{xxx}》混在一起。
再看下源码,html2canvas是怎么画文字的呢?
取textNode,然后遍历textNode中的每一个字,用document.createRange方法创建一个range,然后设置这个range的范围,最后用getBoundingClientRect计算出这个字符应该占的大小,然后画到canvas上。关键代码:
var range = doc.createRange();
range.setStart(textNode, textOffset);
range.setEnd(textNode, textOffset + text.length);
return range.getBoundingClientRect();
而符号与模板中变量引用混排时,会变成:
这其实是3个textNode。
打个log看一下在处理这3个textNode的时候,每个text的left和top值,发现:
- 第一个textNode,即内容为《的这个,位置是正确的
- 第二个textNode的开头,range.getBoundingClientRect()的结果是top:0,left:0,所以没有出现。导致文字缺失
- 第三个textNode,即》这个,top:0,left:0。所以同样没有出现,导致文字缺失
那么文字重叠是因为什么呢,有一组数据:
text=w,left=270.39...,right=287.609359
text=微,left=287.59375...
可以观察到,前一个字符的right比后一个字符的left大。这应该是"w"和"微"导致重叠的原因。那么为什么会这样呢?是升级后safari的bug?还是createRange和getBoundingClientRect的兼容性问题?到这里我也不知道了,毕竟IOS11的safari连不上电脑,只能真机打LOG调试,难度太大。
总结
文章写到这里就要结束了。总结一下,本文从我们产品大大的需求开始,分析了需求的实现方式和思路整理,并进行了html转成canvas的调研:
有两种方案
- 用svg的foreignObject作为中转。缺点是safari下对外域图片有安全性报错。
- 使用html2canvas.js库。
我们最后使用的是html2canvas库。
然后分享了在使用html2canvas过程中,遇到了一些问题和最后的解决方案:
- 怎么画出不显示的元素
- 图片模糊怎么办
- 图片跨域和CDN缓存导致报错
以及脱离了demo环境,在实际工程中使用时候遇到的问题和解决方案:
- 画出来的图中,只有background-image一定会出现,其他图片有概率不出现
- 有一定几率截出来的图是全白屏
- v4.1的html2canvas对background-size: contain不兼容
- iphone手机升级IOS11后,当模板里有文字和《混排时,会发生文字位置错乱的现象
希望能对需要做wap页生成海报功能的各位小伙伴,以及因为各种原因需要使用html2canvas.js库并且在踩坑的小司机们有所帮助。
(产品大大们,这些坑就是这个功能周五上线失败、周六上线失败、最后周日才上线的原因……希望你们满意这个解释。嗯。)
参考
CORS enabled image
SVG <foreignObject>简介与截图等应用
开发富文本编辑器的一些经验教训