本文適用人群
- 需要在微信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>簡介與截圖等應用
開發富文本編輯器的一些經驗教訓