JS前端html2canvas手寫示例問題剖析
前言
這兩天把 html2canvas 這玩意抽絲剝繭了一下,搞了個勉強能跑的小 demo,麻雀雖小五臟俱全,來看看實現(xiàn)的效果吧(跟基金一樣的綠,離離原上譜)????:

現(xiàn)在我們就來實現(xiàn)它??。這里是項目地址。
感性認識
目前我們已知的是 html,然后要畫到 canvas 上,具體該怎么操作呢?這里可以短暫思考幾秒中??。。。。ok,沒思緒的同學可以瞅瞅下面這張圖找找靈感????:

上圖就是把 html 轉成 canvas 的三個小例子(背景、圖片和文字),由此我們可以知道要想把 html 變成 canvas,只要把 html 轉換成對應的 canvas 語言即可,也就是把上圖中左邊的代碼變成右邊的代碼。有了這個感性認識,就可以動手開擼啦??!
第一步:解析 dom 樹
要想把一個元素畫到畫布上,就得知道在哪里畫(位置),畫什么(類型),畫成什么樣(樣式)。顯然位置的話可以用 getBoundingClientRect 來獲取,樣式用 getComputedStyle 來獲取,類型我們用 tagName 來區(qū)分是文本還是圖片等等,不同類型處理方式不同。
那要想獲取上述所有信息,我們肯定要對 dom 進行解析啦,先來看看解析前后的對比圖,有個直觀印象????:


顯然,要保持原來的樹形結構,遍歷 dom 節(jié)點是必不可少的啦!大家不要覺得這個很難,遍歷 dom 是件很簡單的事情??,看下面的代碼就能夠理解,注釋也是應有盡有????:
// 按照原有的樹結構遍歷整個 dom,變成我們自己需要的新對象 ElContainer
// ElContainer 主要包括坐標位置和大小、樣式、子元素等
class ElContainer {
constructor(global, el) { // global 就是存儲一些全局變量,目前只存儲全局的偏移量,因為計算位置的時候需要減去它
this.bounds = new Bounds(global, el); // 獲取位置和大小
this.styles = window.getComputedStyle(el); // 這里為了方便直接把所有的樣式拿過來,其實可以按需過濾一下
this.elements = []; // 子元素
this.textNodes = []; // 文本節(jié)點比較特殊,單獨處理
this.flags = 0; // falgs 標志是否要創(chuàng)建層疊上下文
this.el = el; // 元素的引用
}
}
// 計算元素的位置和大小信息
class Bounds {
constructor(global, el) {
const { x = 0, y = 0 } = global.offset;
const { top, left, width, height } = el.getBoundingClientRect();
this.top = top - y;
this.left = left - x;
this.width = width;
this.height = height;
}
}
parseTree(global, el) {
const container = this.createContainer(global, el);
this.parseNodeTree(global, el, container);
return container;
}
parseNodeTree(global, el, parent) {
[...el.childNodes].map((child) => {
if (child.nodeType === 3) {
// 如果是文本節(jié)點
if (child.textContent.trim().length > 0) {
// 文本節(jié)點不為空
const textElContainer = new TextElContainer(child.textContent, parent);
parent.textNodes.push(textElContainer);
}
} else {
// 如果是普通節(jié)點
const container = this.createContainer(global, child);
const { position, zIndex, opacity, transform } = container.styles;
if ((position !== 'static' && !isNaN(zIndex)) || opacity < 1 || transform !== 'none') { // 需不需要創(chuàng)建層疊上下文的標志,不理解可以先跳過,下面會講解
container.flags = 1;
}
parent.elements.push(container);
this.parseNodeTree(global, child, container);
}
});
}
上述代碼中要注意的就是:
- 我們計算
bounds時需要考慮最外層容器#app的偏移量,否則當你頁面一滾動,bounds.top值就會變成負數(shù),就會畫到畫布上方,于是就會出現(xiàn)空白。 - 文本節(jié)點比較特殊,因為文本不是容器,它的樣式和位置受父節(jié)點影響,所以我們用一個單獨的變量
textNodes來保存。 其實遍歷的結果和生成虛擬 dom 是一個挺像的過程。
第二步:按層疊規(guī)則分組(重點)
我們知道正常情況下頁面是流式布局的,元素從上往下、從左往右進行順序排列,彼此之間互不重疊。不過有時候這種規(guī)則會被打破,比如使用了浮動和定位。所以在一個層疊上下文中元素會根據(jù)下面的層疊順序表來展示(大家應該看過類似的圖):

上圖中,background/border 為裝飾屬性,float 和 block 一般用作布局,inline 則用來展示內(nèi)容。因為頁面中內(nèi)容最重要,所以 inline 的層疊等級會較高。這個層疊順序也是我們后面用 canvas 繪制的順序。前端是障眼法的技術,所以先畫哪個再畫哪個是很有講究的?,F(xiàn)在我們來簡單補充下層疊上下文的概念,大家最有印象的應該就是 z-index 了,其實形成層疊上下文的方法大致有三種:
- 頁面的根元素 html 本身就是層疊上下文,稱為根層疊上下文
- position 為非 static 并且 z-index 為數(shù)值
css3 中的一些新屬性 層疊上下文其實就是 photoshop 中圖層的概念,不理解的可以想象成一張透明的紙,頁面是由很多張紙疊起來的,每張紙上面又有自己的內(nèi)容。這里我們以 z-index 為例子,通過下面兩張圖來加深一下印象,假設頁面的 html 結構長下面這個樣子????:

那么我們可以劃分出幾個層疊上下文,并且他們是可以嵌套的,就像下面這樣????:

從上圖中可以看出 A-2 的 z-index 為 99,但是它卻被 C 蓋住了,這是因為他們兩個元素不在同一層疊上下文(同一張紙)中,所以不能相互比較,這也是我們經(jīng)常在開發(fā)中遇到的一個問題,把某個元素的 z-index 設置成 9999 了,卻沒有效果,就是這個原因。事實上一個好的頁面應該是很少用 z-index 的,除了全局遮罩會用上。
額??。。。講了這么多廢話,還沒說下這步要做什么,因為。。。這個東西不好描述,所以我們也是先看看這步處理之后的樣子吧????:

接下來要做的其實就是根據(jù)層疊規(guī)則再次遍歷上一步中返回的對象,生成上圖的樣子。思路很簡單,就是如果遇到滿足新建層疊上下文的條件(如z-index)就新建一個層疊上下文,否則就在當前層疊上下文中將子元素按層疊規(guī)則分組,這里需要自己花點時間品一品??,建議配合下面代碼食用????:
class StackingContext { // 這就是層疊上下文
constructor(container) {
this.container = container;
this.negativeZIndex = []; // zIndex為負的元素
this.nonInlineLevel = []; // 塊級元素
this.nonPositionedFloats = []; // 浮動元素
this.inlineLevel = []; // 內(nèi)聯(lián)元素
this.positiveZIndex = []; // z-index大于等于1的元素
this.zeroOrAutoZIndexOrTransformedOrOpacity = []; // 具有 transform、opacity、zIndex 為 auto 或 0 的元素
}
}
// 開始按層疊規(guī)則劃分
parseStackingContext(container) {
const root = new StackingContext(container);
this.parseStackTree(container, root);
return root;
}
parseStackTree(parent, stackingContext) { // 這里簡化了一些東西,stackingContext 是當前層疊上下文
parent.elements.map((child) => { // 開始分組
if (child.flags) { // 創(chuàng)建新的層疊上下文的標識,上文中有提到(比如在遇到 z-index 的時候會置為 1)
const stack = new StackingContext(child);
const zIndex = child.styles.zIndex;
if (zIndex > 0) { // zIndex 可能是 1、10、100,所以其實不是直接 push,而是要比較之后插入
stackingContext.positiveZIndex.push(stack);
} else if (zIndex < 0) {
stackingContext.negativeZIndex.push(stack);
} else {
stackingContext.zeroOrAutoZIndexOrTransformedOrOpacity.push(stack);
}
this.parseStackTree(child, stack);
} else {
if (child.styles.display.indexOf('inline') >= 0) {
stackingContext.inlineLevel.push(child);
} else {
stackingContext.nonInlineLevel.push(child);
}
this.parseStackTree(child, stackingContext);
}
});
}
第三步:創(chuàng)建畫布
這個就比較簡單了,但是要考慮到 dpr(設備像素比)的影響,這樣畫布才不會模糊,具體原因可以閱讀我的另一篇文章:?? 關于 canvas 模糊的問題(高清圖解),專門講為什么要這么寫,事實上一般也是這么創(chuàng)建畫布(把 canvas 放大 dpr 倍):
createCanvas(el) {
const { width, height } = el.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
const canvas = document.createElement('canvas');
const ctx2d = canvas.getContext('2d');
canvas.width = Math.round(width * dpr);
canvas.height = Math.round(height * dpr);
canvas.style.width = width + 'px';
canvas.style.height = height + 'px';
ctx2d.scale(dpr, dpr);
this.canvas = canvas;
this.ctx2d = ctx2d;
return canvas;
}
第四步:渲染
現(xiàn)在我們已經(jīng)有了各種數(shù)據(jù),接下來只要再遍歷一次第二步所返回的層級結果,按順序依次繪制就可以了。這步難的就是針對不同情況如何轉成與之對應的 canvas 語言,需要考慮很多東西的,當然我們這里都是些簡單的元素,哈哈哈嗝??。
// 根據(jù)劃分的層級數(shù)組,一層一層從下往上繪制,并且轉換成相對應的 canvas 繪圖語句
render(stack) {
const { negativeZIndex = [], nonInlineLevel = [], inlineLevel = [], positiveZIndex = [], zeroOrAutoZIndexOrTransformedOrOpacity = [] } = stack;
this.ctx2d.save();
// 1、先設置會影響全局的屬性,比如 transform 和 opacity
this.setTransformAndOpacity(stack.container);
// 2、繪制背景和邊框
this.renderNodeBackgroundAndBorders(stack.container);
// 3、繪制 zIndex < 0 的元素
negativeZIndex.map((el) => this.render(el));
// 4、繪制自身內(nèi)容
this.renderNodeContent(stack.container);
// 5、繪制塊狀元素
nonInlineLevel.map((el) => this.renderNode(el));
// 6、繪制行內(nèi)元素
inlineLevel.map((el) => this.renderNode(el));
// 7、繪制 z-index: auto || 0、transform: none、opacity小于1 的元素
zeroOrAutoZIndexOrTransformedOrOpacity.map((el) => this.render(el));
// 8、繪制 zIndex > 0 的元素
positiveZIndex.map((el) => this.render(el));
this.ctx2d.restore();
}
// 針對不同元素有不同的渲染方式,也就是開篇提到的方式
renderNodeContent(container) {
if (container.textNodes.length) {
container.textNodes.map((text) => this.renderText(text, container.styles));
} else if (container instanceof ImageElContainer) {
this.renderImg(container);
} else if (container instanceof InputElContainer) {
this.renderInput(container);
}
}
renderNode(container) {
this.renderNodeBackgroundAndBorders(container);
this.renderNodeContent(container);
}
renderText(text, styles) { // 這里只考慮影響字體的幾個因素,并不全面
const { ctx2d } = this;
ctx2d.save();
ctx2d.font = `${styles.fontWeight} ${styles.fontSize} ${styles.fontFamily}`;
ctx2d.fillStyle = styles.color;
ctx2d.fillText(text.text, text.bounds.left, text.bounds.top);
ctx2d.restore();
}
renderImg(container) { // 這里直接用頁面中的 img 元素進行繪制,所以得等到圖片加載完成,不然就看不見圖片。正常寫法應該是在 img.onload 的回調(diào)中進行繪制
const { ctx2d } = this;
const { el, bounds, styles } = container;
ctx2d.drawImage(el, 0, 0, parseInt(styles.width), parseInt(styles.height), bounds.left, bounds.top, bounds.width, bounds.height);
}
同樣說幾個注意點:
- 類似 transform 和 opacity 這樣的樣式會影響自身及其子元素,所以我們需要在渲染一開始的時候就設置畫布的全局屬性(比如
setTransformAndOpacity中透明度的設置ctx2d.globalAlpha = opacity;) - 對于有 transform 屬性的元素,畫出來的圖形應該是錯誤的。因為我們一開始獲取的位置信息 bounds 就是錯誤的,我們獲取的是元素經(jīng)過 transform 變換后的位置信息,事實上我們需要的是變換前的位置,所以在一開始遍歷的時候需要簡單處理下數(shù)據(jù),就像下面這樣????:
class ElContainer {
constructor(global, el) {
// 獲取位置和大小,如果元素用了 transform,我們需要將其先還原,再獲取樣式,因為我們沒有克隆整個 html,所以這里就這樣處理
const transform = this.styles.transform;
if (transform !== 'none') el.style.transform = 'none';
this.bounds = new Bounds(global, el);
if (transform !== 'none') el.style.transform = transform;
// ...
}
}
- 關于背景和邊框的繪制,其實就是算出四個點(點是有順序的,要么順時針要么逆時針)畫四條線然后進行填充或描邊;如果有圓角的話,我們就要畫四條線和四段圓弧;另外邊框的寬度也可能會影響其內(nèi)部元素的位置,否則會產(chǎn)生一些偏差,不過我們沒有處理,哈哈??。
- 關于文本的繪制,細心的同學會發(fā)現(xiàn)在一開始的效果圖中 canvas 上的文字和 html 的有些出入,比如位置會偏移一點,這是因為文字渲染也是件麻煩事,什么字體、怎么對齊、基線在哪、字間距、行高等各種屬性五花八門,所以我們也只是簡單處理,也不支持換行??。
- 關于圖片因為加載需要時間,所以渲染應該是異步的,不然可能就繪制不上(還可能受到跨域、圖片過大等影響),這里只是簡單的把加載好的 img 拿過來繪制。
那如果我們需要一些其他功能怎么辦???經(jīng)過前面的學習你應該有所了解,比如:
- 有些元素不需要繪制怎么辦?加個屬性或者加個類(
data-html2canvas-ignore),遍歷的時候過濾掉就好了。 - 文本有省略號怎么辦?這里我們得利用
ctx2d.measureText這個 api 算出文本寬度再自己拼接上...,另外這個 api 只能算寬度,不能算高度,高度需要自己(根據(jù)字號、行高等)繁瑣的計算下。
遇到 canvas 元素咋處理?直接把這個 canvas 繪制過來即可,其他元素呢,有 api 就直接用(比如 svg),沒 api 就手寫(比如復選框);屬性也是一樣的,沒有對應的 canvas 實現(xiàn)方式就慢慢手寫實現(xiàn)。
由此可見從 html 到 canvas 基本上都是要一個個轉換到對應寫法的,想想就頭大??,所以會有各種各樣的問題是很正常的,即便是像本文這么簡單的實現(xiàn)版本。
此外還很容易產(chǎn)生一些不可描述的bug??,然后你一查又會知道幾個生僻的屬性,最后就剩無奈了????♀?(我攤牌了,我不會,搞不動,也不想搞)。 知識看了容易忘,這里我們簡單看張流程圖回顧一下:

ps:其實我覺得最難的是獲取位置和樣式,不過好在瀏覽器已經(jīng)幫我們解決了。
另一種方法(html->svg->canvas)
沒興趣的同學可以跳過這一趴??。這種方法相對比較簡單,就是把 html 裝進 svg 里面,再將 svg 搞到 canvas,因為瀏覽器有提供相應的 api,所以可以這樣搞,當然也有它的局限性,這里只是簡單帶過下:
- 遍歷 dom,把所有外聯(lián)樣式寫到內(nèi)聯(lián)樣式中(因為 svg 需要這樣,否則樣式無效的)
- 把 html 序列化后拼接到 svg 中,然后導出成圖片,就像下面這樣:
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}">
<foreignObject height="100%" width="100%">${htmlString}</foreignObject>
</svg>`;
const img = new Image();
img.src = `data:image/svg+xml,${svg}`;
- 最后把 img 畫到畫布上即可。
ctx2d.drawImage(img, 0, 0);
看著簡單,實際應用也是問題百出。
結語
好了,上面就是 html2canvas 的兩種思路,當然在實際開發(fā)中,我們肯定是直接使用 html2canvas。不過這回如果在使用中出了問題,你心里就有底了,你就能估摸個大概為什么有的地方會轉不成功,這種情況大概率就是不兼容、不支持、沒有對應的轉換,所以最好的方案就是把 html 和 css 換種寫法,少用一些花里胡哨的樣式尤為重要。最后,如果你看過 html2canvas 的 README.md,你會發(fā)現(xiàn)這樣一句話??:

這里是項目地址傳送門。順便附上我 canvas 專欄的另外兩篇實戰(zhàn)文章:
以上就是JS前端html2canvas手寫示例問題剖析的詳細內(nèi)容,更多關于JS前端html2canvas的資料請關注腳本之家其它相關文章!
相關文章
微信小程序 出現(xiàn)47001 data format error原因解決辦法
這篇文章主要介紹了微信小程序 出現(xiàn)47001 data format error原因解決辦法的相關資料,需要的朋友可以參考下2017-03-03
微信小程序 實現(xiàn)拖拽事件監(jiān)聽實例詳解
這篇文章主要介紹了微信小程序 實現(xiàn)拖拽事件監(jiān)聽實例詳解的相關資料,在開發(fā)不少應用或者軟件都要用到這樣的方法,這里就對微信小程序實現(xiàn)該功能進行介紹,需要的朋友可以參考下2016-11-11
umi插件開發(fā)仿dumi項目實現(xiàn)頁面布局詳解
這篇文章主要為大家介紹了umi插件開發(fā)仿dumi項目實現(xiàn)頁面布局詳解,有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進步,早日升職加薪2023-01-01
JavaScript?CSS優(yōu)雅實現(xiàn)網(wǎng)頁多主題風格換膚功能詳解
這篇文章主要為大家介紹了JavaScript?CSS優(yōu)雅的實現(xiàn)網(wǎng)頁多主題風格換膚功能詳解,有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進步,早日升職加薪2023-02-02

