JS前端html2canvas手寫(xiě)示例問(wèn)題剖析
前言
這兩天把 html2canvas 這玩意抽絲剝繭了一下,搞了個(gè)勉強(qiáng)能跑的小 demo,麻雀雖小五臟俱全,來(lái)看看實(shí)現(xiàn)的效果吧(跟基金一樣的綠,離離原上譜)????:
現(xiàn)在我們就來(lái)實(shí)現(xiàn)它??。這里是項(xiàng)目地址。
感性認(rèn)識(shí)
目前我們已知的是 html,然后要畫(huà)到 canvas 上,具體該怎么操作呢?這里可以短暫思考幾秒中??。。。。ok,沒(méi)思緒的同學(xué)可以瞅瞅下面這張圖找找靈感????:
上圖就是把 html 轉(zhuǎn)成 canvas 的三個(gè)小例子(背景、圖片和文字),由此我們可以知道要想把 html 變成 canvas,只要把 html 轉(zhuǎn)換成對(duì)應(yīng)的 canvas 語(yǔ)言即可,也就是把上圖中左邊的代碼變成右邊的代碼。有了這個(gè)感性認(rèn)識(shí),就可以動(dòng)手開(kāi)擼啦??!
第一步:解析 dom 樹(shù)
要想把一個(gè)元素畫(huà)到畫(huà)布上,就得知道在哪里畫(huà)(位置),畫(huà)什么(類(lèi)型),畫(huà)成什么樣(樣式)。顯然位置的話(huà)可以用 getBoundingClientRect
來(lái)獲取,樣式用 getComputedStyle
來(lái)獲取,類(lèi)型我們用 tagName
來(lái)區(qū)分是文本還是圖片等等,不同類(lèi)型處理方式不同。
那要想獲取上述所有信息,我們肯定要對(duì) dom 進(jìn)行解析啦,先來(lái)看看解析前后的對(duì)比圖,有個(gè)直觀(guān)印象????:
顯然,要保持原來(lái)的樹(shù)形結(jié)構(gòu),遍歷 dom 節(jié)點(diǎn)是必不可少的啦!大家不要覺(jué)得這個(gè)很難,遍歷 dom 是件很簡(jiǎn)單的事情??,看下面的代碼就能夠理解,注釋也是應(yīng)有盡有????:
// 按照原有的樹(shù)結(jié)構(gòu)遍歷整個(gè) dom,變成我們自己需要的新對(duì)象 ElContainer // ElContainer 主要包括坐標(biāo)位置和大小、樣式、子元素等 class ElContainer { constructor(global, el) { // global 就是存儲(chǔ)一些全局變量,目前只存儲(chǔ)全局的偏移量,因?yàn)橛?jì)算位置的時(shí)候需要減去它 this.bounds = new Bounds(global, el); // 獲取位置和大小 this.styles = window.getComputedStyle(el); // 這里為了方便直接把所有的樣式拿過(guò)來(lái),其實(shí)可以按需過(guò)濾一下 this.elements = []; // 子元素 this.textNodes = []; // 文本節(jié)點(diǎn)比較特殊,單獨(dú)處理 this.flags = 0; // falgs 標(biāo)志是否要?jiǎng)?chuàng)建層疊上下文 this.el = el; // 元素的引用 } } // 計(jì)算元素的位置和大小信息 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é)點(diǎn) if (child.textContent.trim().length > 0) { // 文本節(jié)點(diǎn)不為空 const textElContainer = new TextElContainer(child.textContent, parent); parent.textNodes.push(textElContainer); } } else { // 如果是普通節(jié)點(diǎn) const container = this.createContainer(global, child); const { position, zIndex, opacity, transform } = container.styles; if ((position !== 'static' && !isNaN(zIndex)) || opacity < 1 || transform !== 'none') { // 需不需要?jiǎng)?chuàng)建層疊上下文的標(biāo)志,不理解可以先跳過(guò),下面會(huì)講解 container.flags = 1; } parent.elements.push(container); this.parseNodeTree(global, child, container); } }); }
上述代碼中要注意的就是:
- 我們計(jì)算
bounds
時(shí)需要考慮最外層容器#app
的偏移量,否則當(dāng)你頁(yè)面一滾動(dòng),bounds.top
值就會(huì)變成負(fù)數(shù),就會(huì)畫(huà)到畫(huà)布上方,于是就會(huì)出現(xiàn)空白。 - 文本節(jié)點(diǎn)比較特殊,因?yàn)槲谋静皇侨萜?,它的樣式和位置受父?jié)點(diǎn)影響,所以我們用一個(gè)單獨(dú)的變量
textNodes
來(lái)保存。 其實(shí)遍歷的結(jié)果和生成虛擬 dom 是一個(gè)挺像的過(guò)程。
第二步:按層疊規(guī)則分組(重點(diǎn))
我們知道正常情況下頁(yè)面是流式布局的,元素從上往下、從左往右進(jìn)行順序排列,彼此之間互不重疊。不過(guò)有時(shí)候這種規(guī)則會(huì)被打破,比如使用了浮動(dòng)和定位。所以在一個(gè)層疊上下文中元素會(huì)根據(jù)下面的層疊順序表來(lái)展示(大家應(yīng)該看過(guò)類(lèi)似的圖):
上圖中,background/border 為裝飾屬性,float 和 block 一般用作布局,inline 則用來(lái)展示內(nèi)容。因?yàn)轫?yè)面中內(nèi)容最重要,所以 inline 的層疊等級(jí)會(huì)較高。這個(gè)層疊順序也是我們后面用 canvas 繪制的順序。前端是障眼法的技術(shù),所以先畫(huà)哪個(gè)再畫(huà)哪個(gè)是很有講究的?,F(xiàn)在我們來(lái)簡(jiǎn)單補(bǔ)充下層疊上下文的概念,大家最有印象的應(yīng)該就是 z-index
了,其實(shí)形成層疊上下文的方法大致有三種:
- 頁(yè)面的根元素 html 本身就是層疊上下文,稱(chēng)為根層疊上下文
- position 為非 static 并且 z-index 為數(shù)值
css3 中的一些新屬性 層疊上下文其實(shí)就是 photoshop 中圖層的概念,不理解的可以想象成一張透明的紙,頁(yè)面是由很多張紙疊起來(lái)的,每張紙上面又有自己的內(nèi)容。這里我們以 z-index
為例子,通過(guò)下面兩張圖來(lái)加深一下印象,假設(shè)頁(yè)面的 html 結(jié)構(gòu)長(zhǎng)下面這個(gè)樣子????:
那么我們可以劃分出幾個(gè)層疊上下文,并且他們是可以嵌套的,就像下面這樣????:
從上圖中可以看出 A-2 的 z-index
為 99,但是它卻被 C 蓋住了,這是因?yàn)樗麄儍蓚€(gè)元素不在同一層疊上下文(同一張紙)中,所以不能相互比較,這也是我們經(jīng)常在開(kāi)發(fā)中遇到的一個(gè)問(wèn)題,把某個(gè)元素的 z-index
設(shè)置成 9999 了,卻沒(méi)有效果,就是這個(gè)原因。事實(shí)上一個(gè)好的頁(yè)面應(yīng)該是很少用 z-index
的,除了全局遮罩會(huì)用上。
額??。。。講了這么多廢話(huà),還沒(méi)說(shuō)下這步要做什么,因?yàn)?。。。這個(gè)東西不好描述,所以我們也是先看看這步處理之后的樣子吧????:
接下來(lái)要做的其實(shí)就是根據(jù)層疊規(guī)則再次遍歷上一步中返回的對(duì)象,生成上圖的樣子。思路很簡(jiǎn)單,就是如果遇到滿(mǎn)足新建層疊上下文的條件(如z-index
)就新建一個(gè)層疊上下文,否則就在當(dāng)前層疊上下文中將子元素按層疊規(guī)則分組,這里需要自己花點(diǎn)時(shí)間品一品??,建議配合下面代碼食用????:
class StackingContext { // 這就是層疊上下文 constructor(container) { this.container = container; this.negativeZIndex = []; // zIndex為負(fù)的元素 this.nonInlineLevel = []; // 塊級(jí)元素 this.nonPositionedFloats = []; // 浮動(dòng)元素 this.inlineLevel = []; // 內(nèi)聯(lián)元素 this.positiveZIndex = []; // z-index大于等于1的元素 this.zeroOrAutoZIndexOrTransformedOrOpacity = []; // 具有 transform、opacity、zIndex 為 auto 或 0 的元素 } } // 開(kāi)始按層疊規(guī)則劃分 parseStackingContext(container) { const root = new StackingContext(container); this.parseStackTree(container, root); return root; } parseStackTree(parent, stackingContext) { // 這里簡(jiǎn)化了一些東西,stackingContext 是當(dāng)前層疊上下文 parent.elements.map((child) => { // 開(kāi)始分組 if (child.flags) { // 創(chuàng)建新的層疊上下文的標(biāo)識(shí),上文中有提到(比如在遇到 z-index 的時(shí)候會(huì)置為 1) const stack = new StackingContext(child); const zIndex = child.styles.zIndex; if (zIndex > 0) { // zIndex 可能是 1、10、100,所以其實(shí)不是直接 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)建畫(huà)布
這個(gè)就比較簡(jiǎn)單了,但是要考慮到 dpr(設(shè)備像素比)的影響,這樣畫(huà)布才不會(huì)模糊,具體原因可以閱讀我的另一篇文章:?? 關(guān)于 canvas 模糊的問(wèn)題(高清圖解),專(zhuān)門(mén)講為什么要這么寫(xiě),事實(shí)上一般也是這么創(chuàng)建畫(huà)布(把 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ù),接下來(lái)只要再遍歷一次第二步所返回的層級(jí)結(jié)果,按順序依次繪制就可以了。這步難的就是針對(duì)不同情況如何轉(zhuǎn)成與之對(duì)應(yīng)的 canvas 語(yǔ)言,需要考慮很多東西的,當(dāng)然我們這里都是些簡(jiǎn)單的元素,哈哈哈嗝??。
// 根據(jù)劃分的層級(jí)數(shù)組,一層一層從下往上繪制,并且轉(zhuǎn)換成相對(duì)應(yīng)的 canvas 繪圖語(yǔ)句 render(stack) { const { negativeZIndex = [], nonInlineLevel = [], inlineLevel = [], positiveZIndex = [], zeroOrAutoZIndexOrTransformedOrOpacity = [] } = stack; this.ctx2d.save(); // 1、先設(shè)置會(huì)影響全局的屬性,比如 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(); } // 針對(duì)不同元素有不同的渲染方式,也就是開(kāi)篇提到的方式 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) { // 這里只考慮影響字體的幾個(gè)因素,并不全面 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) { // 這里直接用頁(yè)面中的 img 元素進(jìn)行繪制,所以得等到圖片加載完成,不然就看不見(jiàn)圖片。正常寫(xiě)法應(yīng)該是在 img.onload 的回調(diào)中進(jìn)行繪制 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); }
同樣說(shuō)幾個(gè)注意點(diǎn):
- 類(lèi)似 transform 和 opacity 這樣的樣式會(huì)影響自身及其子元素,所以我們需要在渲染一開(kāi)始的時(shí)候就設(shè)置畫(huà)布的全局屬性(比如
setTransformAndOpacity
中透明度的設(shè)置ctx2d.globalAlpha = opacity;
) - 對(duì)于有 transform 屬性的元素,畫(huà)出來(lái)的圖形應(yīng)該是錯(cuò)誤的。因?yàn)槲覀円婚_(kāi)始獲取的位置信息 bounds 就是錯(cuò)誤的,我們獲取的是元素經(jīng)過(guò) transform 變換后的位置信息,事實(shí)上我們需要的是變換前的位置,所以在一開(kāi)始遍歷的時(shí)候需要簡(jiǎn)單處理下數(shù)據(jù),就像下面這樣????:
class ElContainer { constructor(global, el) { // 獲取位置和大小,如果元素用了 transform,我們需要將其先還原,再獲取樣式,因?yàn)槲覀儧](méi)有克隆整個(gè) 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; // ... } }
- 關(guān)于背景和邊框的繪制,其實(shí)就是算出四個(gè)點(diǎn)(點(diǎn)是有順序的,要么順時(shí)針要么逆時(shí)針)畫(huà)四條線(xiàn)然后進(jìn)行填充或描邊;如果有圓角的話(huà),我們就要畫(huà)四條線(xiàn)和四段圓??;另外邊框的寬度也可能會(huì)影響其內(nèi)部元素的位置,否則會(huì)產(chǎn)生一些偏差,不過(guò)我們沒(méi)有處理,哈哈??。
- 關(guān)于文本的繪制,細(xì)心的同學(xué)會(huì)發(fā)現(xiàn)在一開(kāi)始的效果圖中 canvas 上的文字和 html 的有些出入,比如位置會(huì)偏移一點(diǎn),這是因?yàn)槲淖咒秩疽彩羌闊┦?,什么字體、怎么對(duì)齊、基線(xiàn)在哪、字間距、行高等各種屬性五花八門(mén),所以我們也只是簡(jiǎn)單處理,也不支持換行??。
- 關(guān)于圖片因?yàn)榧虞d需要時(shí)間,所以渲染應(yīng)該是異步的,不然可能就繪制不上(還可能受到跨域、圖片過(guò)大等影響),這里只是簡(jiǎn)單的把加載好的 img 拿過(guò)來(lái)繪制。
那如果我們需要一些其他功能怎么辦???經(jīng)過(guò)前面的學(xué)習(xí)你應(yīng)該有所了解,比如:
- 有些元素不需要繪制怎么辦?加個(gè)屬性或者加個(gè)類(lèi)(
data-html2canvas-ignore
),遍歷的時(shí)候過(guò)濾掉就好了。 - 文本有省略號(hào)怎么辦?這里我們得利用
ctx2d.measureText
這個(gè) api 算出文本寬度再自己拼接上...
,另外這個(gè) api 只能算寬度,不能算高度,高度需要自己(根據(jù)字號(hào)、行高等)繁瑣的計(jì)算下。
遇到 canvas 元素咋處理?直接把這個(gè) canvas 繪制過(guò)來(lái)即可,其他元素呢,有 api 就直接用(比如 svg),沒(méi) api 就手寫(xiě)(比如復(fù)選框);屬性也是一樣的,沒(méi)有對(duì)應(yīng)的 canvas 實(shí)現(xiàn)方式就慢慢手寫(xiě)實(shí)現(xiàn)。
由此可見(jiàn)從 html 到 canvas 基本上都是要一個(gè)個(gè)轉(zhuǎn)換到對(duì)應(yīng)寫(xiě)法的,想想就頭大??,所以會(huì)有各種各樣的問(wèn)題是很正常的,即便是像本文這么簡(jiǎn)單的實(shí)現(xiàn)版本。
此外還很容易產(chǎn)生一些不可描述的bug??,然后你一查又會(huì)知道幾個(gè)生僻的屬性,最后就剩無(wú)奈了????♀?(我攤牌了,我不會(huì),搞不動(dòng),也不想搞)。 知識(shí)看了容易忘,這里我們簡(jiǎn)單看張流程圖回顧一下:
ps:其實(shí)我覺(jué)得最難的是獲取位置和樣式,不過(guò)好在瀏覽器已經(jīng)幫我們解決了。
另一種方法(html->svg->canvas)
沒(méi)興趣的同學(xué)可以跳過(guò)這一趴??。這種方法相對(duì)比較簡(jiǎn)單,就是把 html 裝進(jìn) svg 里面,再將 svg 搞到 canvas,因?yàn)闉g覽器有提供相應(yīng)的 api,所以可以這樣搞,當(dāng)然也有它的局限性,這里只是簡(jiǎn)單帶過(guò)下:
- 遍歷 dom,把所有外聯(lián)樣式寫(xiě)到內(nèi)聯(lián)樣式中(因?yàn)?svg 需要這樣,否則樣式無(wú)效的)
- 把 html 序列化后拼接到 svg 中,然后導(dǎo)出成圖片,就像下面這樣:
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 畫(huà)到畫(huà)布上即可。
ctx2d.drawImage(img, 0, 0);
看著簡(jiǎn)單,實(shí)際應(yīng)用也是問(wèn)題百出。
結(jié)語(yǔ)
好了,上面就是 html2canvas 的兩種思路,當(dāng)然在實(shí)際開(kāi)發(fā)中,我們肯定是直接使用 html2canvas。不過(guò)這回如果在使用中出了問(wèn)題,你心里就有底了,你就能估摸個(gè)大概為什么有的地方會(huì)轉(zhuǎn)不成功,這種情況大概率就是不兼容、不支持、沒(méi)有對(duì)應(yīng)的轉(zhuǎn)換,所以最好的方案就是把 html 和 css 換種寫(xiě)法,少用一些花里胡哨的樣式尤為重要。最后,如果你看過(guò) html2canvas 的 README.md,你會(huì)發(fā)現(xiàn)這樣一句話(huà)??:
這里是項(xiàng)目地址傳送門(mén)。順便附上我 canvas 專(zhuān)欄的另外兩篇實(shí)戰(zhàn)文章:
以上就是JS前端html2canvas手寫(xiě)示例問(wèn)題剖析的詳細(xì)內(nèi)容,更多關(guān)于JS前端html2canvas的資料請(qǐng)關(guān)注腳本之家其它相關(guān)文章!
相關(guān)文章
微信小程序 scroll-view隱藏滾動(dòng)條詳解
這篇文章主要介紹了微信小程序 scroll-view隱藏滾動(dòng)條和跳轉(zhuǎn)頁(yè)面的相關(guān)資料,需要的朋友可以參考下2017-01-01Typescript?封裝?Axios攔截器方法實(shí)例
這篇文章主要為大家介紹了Typescript?封裝?Axios攔截器方法實(shí)例詳解,有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進(jìn)步,早日升職加薪2022-09-09微信小程序 出現(xiàn)47001 data format error原因解決辦法
這篇文章主要介紹了微信小程序 出現(xiàn)47001 data format error原因解決辦法的相關(guān)資料,需要的朋友可以參考下2017-03-03微信小程序 實(shí)現(xiàn)拖拽事件監(jiān)聽(tīng)實(shí)例詳解
這篇文章主要介紹了微信小程序 實(shí)現(xiàn)拖拽事件監(jiān)聽(tīng)實(shí)例詳解的相關(guān)資料,在開(kāi)發(fā)不少應(yīng)用或者軟件都要用到這樣的方法,這里就對(duì)微信小程序?qū)崿F(xiàn)該功能進(jìn)行介紹,需要的朋友可以參考下2016-11-11umi插件開(kāi)發(fā)仿dumi項(xiàng)目實(shí)現(xiàn)頁(yè)面布局詳解
這篇文章主要為大家介紹了umi插件開(kāi)發(fā)仿dumi項(xiàng)目實(shí)現(xiàn)頁(yè)面布局詳解,有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進(jìn)步,早日升職加薪2023-01-01JavaScript?CSS優(yōu)雅實(shí)現(xiàn)網(wǎng)頁(yè)多主題風(fēng)格換膚功能詳解
這篇文章主要為大家介紹了JavaScript?CSS優(yōu)雅的實(shí)現(xiàn)網(wǎng)頁(yè)多主題風(fēng)格換膚功能詳解,有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進(jìn)步,早日升職加薪2023-02-02