簡介
剖析流行的截圖插件 html2canvas 的實現(xiàn)方案,探索其功能上的一些不足之處及不能正確截取的一些場景,比如不支持 CSS 的 box-shadow 截取情況等。探索一種新的實現(xiàn)方式,能夠避免多數(shù)目前 html2canvas 不支持的情況,解密其原理,深究 Canvas 繪圖的機制。
本篇文章你可以學(xué)到:
- 純前端網(wǎng)頁截圖的基本原理
- html2canvas 的核心原理
- SVG 內(nèi)嵌 HTML 的方式
- Canvas 渲染 SVG 的方式及各種問題的解決方案
適合人群:前端開發(fā)
開篇
平時很多時候,需要把當(dāng)前頁面或者頁面某一部分內(nèi)容保存為圖片分享出去,也或者有其他的業(yè)務(wù)用途,這種在很多的營銷場景和裂變的過程都會使用到,那我們要把一個頁面的內(nèi)容轉(zhuǎn)化為圖片的這個過程,就是比較需要探討的了。
首先這種情況,想到的實現(xiàn)方案就是使用 Canvas 來實現(xiàn),我們探索一下基本實現(xiàn)步驟:
- 把需要分享或者記錄的內(nèi)容繪制到 Canvas 上
- 把繪制之后的 Canvas 轉(zhuǎn)換為圖片
這里需要明確的一點就是,只要把數(shù)據(jù)繪制到 Canvas 上,這就在 Canvas 畫布上形成了被保存在內(nèi)存中的像素點信息,所以可以直接調(diào)用 Canvas 的 API 方法 toDataURL、toBlob,把已經(jīng)形成的像素信息轉(zhuǎn)化為可以被訪問的資源 URI,同時保存在服務(wù)器當(dāng)中。這就很輕松的解決了第二步(把 Canvas 轉(zhuǎn)為圖片鏈接),下面是代碼的實現(xiàn):

在實現(xiàn)了第二步的情況之下,需要關(guān)注的就是第一步的內(nèi)容,怎么把內(nèi)容繪制到 Canvas 上,我們知道 Canvas 的繪圖環(huán)境有一個方法是 ctx.drawImage,可以繪制部分元素到 Canvas 上,包含圖片元素 Image、svg 元素、視頻元素 Video、Canvas 元素、ImageBitmap 數(shù)據(jù)等,但是對于一般的其他 div 或者列表 li 元素它是不可以被繪制的。
所以,這不是直接調(diào)用繪圖的 API 就可以辦到的,我們就需要思考其他的方法。在一般的實現(xiàn)上,比較常見的就是使用 html2canvas,那么我們先來聊聊 html2canvas 的使用和實現(xiàn)。
html2canvas 的使用及實現(xiàn)
使用
首先看一下 html2cavas 的使用方法:

調(diào)用 html2canvas 方法傳入想要截取的 Dom,執(zhí)行之后,返回一個 Promise,接收到的 Canvas 上,就繪制了我們想要截取的 Dom 元素。到這一步之后,我們再調(diào)取 Canvas 轉(zhuǎn)圖片的方法,就可以對其做其他的處理。
這里它的 html2canvas 方法還支持第二個選項傳入一些用戶的配置參數(shù),比如是否啟用緩存、整個繪圖 Canvas 的寬高值等。
在這個轉(zhuǎn)換的過程,在 html2canvas 的內(nèi)部,是怎么把 Dom 元素繪制到 Canvas 上的,這是咱們需要思考的問題!
實現(xiàn)
首先咱們先獻上一個內(nèi)部的大致流程圖:

對比著內(nèi)部的流程圖,就可以理一下整體的思路,整體的思路就是遍歷目標(biāo)節(jié)點和子節(jié)點,收集樣式,計算節(jié)點本身的層級關(guān)系和根據(jù)不同的優(yōu)先級繪制到畫布中,下面基于這個思路,咱們深入一下整個過程。
1. 調(diào)用 html2canvs 函數(shù),直接返回一個執(zhí)行函數(shù),這一步?jīng)]有什么。
2. 在執(zhí)行函數(shù)的內(nèi)部第一步是構(gòu)建配置項 defaultOptions,在合并默認(rèn)配置的過程中,有一個緩存的配置,它會生成處理緩存的方法。
- 處理緩存類,對于一個頁面中的多個不同的地方渲染調(diào)用多次的情況做優(yōu)化,避免同一個資源被多次加載;
- 緩存類里面控制了所有圖片的加載和處理,包括使用 proxy 代理和使用 cors 跨域資源共享這兩種情況資源的處理,同時也對 base64 和 blob 這兩種形式資源的處理。比如如果渲染 Dom 里面包含一個圖片的鏈接類型是 blob,使用的方式就是如下處理,然后添加到緩存類中,下次使用就不需要再重新請求。

3. 在上一步生成了默認(rèn)配置的情況之下,傳入需要繪制的目標(biāo)節(jié)點 element 和配置到 DocumentCloner 里面,這個過程會克隆目標(biāo)節(jié)點所在的文檔節(jié)點 document,同時把目標(biāo)節(jié)點也克隆出來。這個過程中,只是克隆了開發(fā)者定義的對應(yīng)節(jié)點樣式,并不是結(jié)合瀏覽器渲染生成特定視圖最后的樣式。

如上這個 .box 的元素節(jié)點,定義的樣式只有高度,但是在瀏覽器渲染之下,會對它設(shè)置默認(rèn)的文字樣式等等。
4. 基于上一步的情況,就需要把克隆出來的目標(biāo)節(jié)點所在的文檔節(jié)點 document 進行一次瀏覽器的渲染,然后在收集最終目標(biāo)節(jié)點的樣式。于此,把克隆出來的目標(biāo)節(jié)點的 document 裝載到一個 iframe 里面,進行一次渲染,然后就可以獲取到經(jīng)過瀏覽器視圖真實呈現(xiàn)的節(jié)點樣式。

在這個過程中,就可以通過 window.getComputedStyle 這個 API 拿到要克隆的目標(biāo)節(jié)點上所有的樣式了(包含自定義和瀏覽器默認(rèn)的結(jié)合最終的樣式)。
5. 目標(biāo)節(jié)點的樣式和內(nèi)容都獲取到了之后,就需要把它所承載的數(shù)據(jù)信息轉(zhuǎn)化為 Canvas 可以使用的數(shù)據(jù)類型,比如某一個子節(jié)點的寬度設(shè)置為 50%或者 2rem,在這個過程中,就需要根據(jù)父級的寬度把它計算成為像素級別的單位。同時對于每一個節(jié)點而言需要繪制的包括了邊框、背景、陰影、內(nèi)容,而對于內(nèi)容就包含圖片、文字、視頻等。這個過程就需要對目標(biāo)節(jié)點的所有屬性進行解析構(gòu)造,分析成為可以理解的數(shù)據(jù)形式。


如上圖片這種數(shù)據(jù)結(jié)構(gòu)和我注釋一樣,在它內(nèi)部把每一個節(jié)點處理成為了一個 container,它的上面有一個 styles 字段,這個字段是所有節(jié)點上的樣式經(jīng)過轉(zhuǎn)換計算之后的數(shù)據(jù),還有一個 textNodes 屬性,它表示當(dāng)前節(jié)點下的文本節(jié)點,如上,每一個文本的點的內(nèi)容使用 text 來表示,位置和大小信息放置在 textBounds 中。對于 elements 字段存放的就是當(dāng)前節(jié)點下除了文本節(jié)點外,其他節(jié)點轉(zhuǎn)換成為的 container,最后一個就是 bounds 字段,存放的是當(dāng)前節(jié)點的位置和大小信息??梢钥匆幌?container 這個類的代碼:

基于這種情況,每一個 container 數(shù)據(jù)結(jié)構(gòu)的 elements 屬性都是子節(jié)點,整個節(jié)點就夠構(gòu)造成一個 container tree。
6. 在通過解析器把目標(biāo)節(jié)點處理成特定的數(shù)據(jù)結(jié)構(gòu) container 之后,就需要結(jié)合 Canvas 調(diào)用渲染方法了,我們在瀏覽器里面創(chuàng)建多個元素的時候,不同的元素設(shè)置不同的樣式,最后展示的結(jié)果就可能不一樣,比如下面代碼:

這個代碼的展示結(jié)果如下:

此時,如果修改了代碼中 .sta1 元素節(jié)點的 opacity 屬性為 0.999,此時整個布局的層級就會發(fā)生大變化,結(jié)果如下:

這個是什么原因?因為 Canvas 繪圖需要根據(jù)樣式計算哪些元素應(yīng)該繪制在上層,哪些在下層。元素在瀏覽器中渲染時,根據(jù) W3C 的標(biāo)準(zhǔn),所有的節(jié)點層級布局,需要遵循層疊上下文和層疊順序的標(biāo)準(zhǔn)。當(dāng)某一些屬性發(fā)生變化,層疊上下文的順序就可能發(fā)生變化,比如上列中透明度默認(rèn)為 1 和不為 1 的情況(對于如何形成一個層疊上下文此處不做深入講解,可以自行研究)。
更加直白的理解就是,一部分屬性會使一些元素形成一個單獨的層級,不同屬性的層級有一定的排列順序。如下就是我們對應(yīng)的順序:
- 形成層疊上下文環(huán)境的元素的背景與邊框(相當(dāng)于整個文檔的背景和邊框)
- 擁有負(fù) z-index 的子層疊上下文元素 (負(fù)的越高越層疊上下文層級越低)
- 正常流式布局,非 inline-block,無 position 定位(static 除外)的子元素
- 無 position 定位(static 除外)的 float 浮動元素
- 正常流式布局, inline-block 元素,無 position 定位(static 除外)的子元素(包括
display:table和display:inline) - 擁有
z-index:0或者 auto 的子堆疊上下文元素 - 擁有正
z-index:的子堆疊上下文元素(正的越低層疊上下文層級越低) - 在正常的元素情況下,沒有形成層疊上下文的時候,顯示順序準(zhǔn)守以上規(guī)則,在設(shè)置了一些屬性,形成了層疊上下文之后,準(zhǔn)守誰大誰上(z-index 比較)、后來居上(后寫的元素后渲染在上面)

此處,在清楚了元素的渲染需要遵循這個標(biāo)準(zhǔn)的情況之下,Canvas 繪制節(jié)點的時候,就需要先計算出整個目標(biāo)節(jié)點里子節(jié)點渲染時所展現(xiàn)的不同層級。先給出來內(nèi)部模擬層疊上下文的數(shù)據(jù)結(jié)構(gòu) StackingContext:

以上就是某一個節(jié)點對應(yīng)的層疊上下文在內(nèi)部所表現(xiàn)出來的數(shù)據(jù)結(jié)構(gòu)。很多屬性都會形成層疊上下文,不同的屬性形成的上下文,有不同的順序,所以需要對目標(biāo)節(jié)點的子節(jié)點解析,根據(jù)不同的樣式屬性分配到不同的數(shù)組中歸類,比如遍歷子節(jié)點的 container 上的 styles,發(fā)現(xiàn) opacity 為 0.5,此時會形成層疊上下文,然后就把它構(gòu)造成為上下文的數(shù)據(jù)結(jié)構(gòu) StackContext。添加到 zeroOrAutoZIndexOrTransformedOrOpacity 這個數(shù)組中,這樣一個遞歸查看子節(jié)點的過程,最后會形成一個層疊上下文的樹。
7. 基于上面構(gòu)造出的數(shù)據(jù)結(jié)構(gòu),就開始調(diào)用內(nèi)部的繪圖方法了,以下代碼是渲染某一個層疊上下文的代碼:

如上繪圖函數(shù)中,如果子元素形成了層疊上下文,就調(diào)用 renderStack,這個方法內(nèi)部繼續(xù)調(diào)用了 renderStackContent,這就形成了對于層疊上下文整個樹的遞歸。

如果子元素沒有形成層疊上下文,而是正常元素,就直接調(diào)用 renderNode 或者 renderNodeContent。這兩個的區(qū)別是 renderNodeContent 只負(fù)責(zé)渲染內(nèi)容,不會渲染節(jié)點的邊框和背景色。

對于 renderNodeContent 這個方法就是渲染一個元素節(jié)點里面的內(nèi)容,可能是正常元素、圖片、文字、SVG、Canvas、視頻、input、iframe。對于圖片、SVG、視頻、Canvas 這幾種元素,直接通過調(diào)用前文提到的 API,對于 input 需要根據(jù)樣式計算出繪圖數(shù)據(jù)來模擬完成,文字就直接根據(jù)提供的樣式來繪制。重點需要提一下的是 iframe,如果需要繪制的元素中包含了 iframe,就相當(dāng)于我們需要重新繪制一個新的文檔 document,處理方法是在內(nèi)部調(diào)用 html2canvas 的 API,繪制整個文檔。
以下為多個不同類型的元素的繪制方式。
對于文字的繪制方式:

對于圖片、SVG、Canvas 元素的繪制:

對于代碼中調(diào)用 renderReplacedElement 方法內(nèi)部的處理邏輯,就是調(diào)用 Canvas 的 drawImage 方法繪制以上三種數(shù)據(jù)形式。
對于需要繪制的元素是 iframe 的時候,做的處理邏輯就如同重新調(diào)用整個繪制方法,重新渲染頁面的過程:

對于單選或者多選框的處理情況,就是根據(jù)是否選中,來繪制對應(yīng)狀態(tài)的樣式:

對于 input 輸入框的情況,首先需要繪制邊框,然后把內(nèi)部的文字繪制到輸入框中,超出部分需要剪切掉,所以需要使用到 Canvas 的 clip 繪圖 API:

對于最后一種需要考慮的就是列表,對于 li、ol 這兩種列表,都可以設(shè)置不同類型的 list-style,所以需要區(qū)分繪制。

以上整個過程,就是 html2canvas 的整體內(nèi)部流程,最后的操作都是不同的線條、圖片、文字等等的繪制,概括起來就是遍歷目標(biāo)節(jié)點,收集樣式信息,轉(zhuǎn)化為繪制數(shù)據(jù),并且根據(jù)一定的優(yōu)先級策略遞歸繪制節(jié)點到 Canvas 畫布上。
html2canvas 實現(xiàn)上的缺點
在捋順了整個大流程的情況之下,咱們來看看 html2canvas 的一些缺點。
不支持的一些場景
- box-shadow 屬性,支持的不好,因為對于 Canvas 的陰影 API 沒有擴散半徑。所以對于樣式的陰影支持不是特別好;
- 邊框虛線的情況也不支持,這一點源碼里面沒有使用 setLineDash,是因為大多數(shù)瀏覽器原本不支持這個屬性,chrome 也是 64 版本之后才支持這個屬性;
- css 中元素的 zoom 屬性支持也不是也特別好,因為換算會出現(xiàn)問題;
- 計算問題是最大的問題?。?!因為每一次計算都會有精確度的省略問題,比如父元素的寬度是 100 像素,子元素是父元素的 30%,這個時候轉(zhuǎn)化為 Canvas 繪圖單位像素的時候,就會有省略的過程,在有多次省略的情況之下,精確度就會變得不精確。并且還涉及到一些圓弧的情況,這種弧度的計算,最后模仿出來,都會有失去精確度的問題。對于正常的瀏覽器渲染節(jié)點,渲染的內(nèi)部邏輯,直接是由瀏覽器處理,但是對于 html2canvas 的方案,需要先計算為像素單位,然后繪制到 Canvas 上,最后 Canvas 元素還要經(jīng)過瀏覽器的一次處理,才能夠渲染出來。這個過程不止是換算單位失去精度,渲染也會失去精度。
換一種思路實現(xiàn)截圖
基于我們對于上面 html2canvas 整個流程的實現(xiàn),會發(fā)現(xiàn)中間換算會出現(xiàn)很多不精準(zhǔn)的問題,那么怎么做一個可以精準(zhǔn)的繪制呢?能不能把所有內(nèi)部繪制的換算過程全部交給瀏覽器?
基本思路
上文提到 Canvas 還可以繪制 image、SVG 等等,此處就可以把 HTML 處理成 SVG 的結(jié)果,然后再繪制到 Canvas 上。
對于 SVG 是一種可擴展標(biāo)記語言,在轉(zhuǎn)化的過程中,就需要使用到 <foreignObject> 這個 SVG 元素。<foreignObject> 允許包含不同的 XML 命名空間,在瀏覽器的上下文中,很可能是 XHTML\HTML,如下是使用方式:

這樣只需要指定對應(yīng)的命名空間,就可以把它嵌套到 foreignObject 中,然后結(jié)合 SVG,直接渲染。
什么是命名空間,相當(dāng)于是元素名和屬性名的一種集合,元素和屬性可以有多種不同的集合,為了解決沖突,就需要有命名空間的指派,對于帶有屬性 xmlns="" 就是一個命名空間的表現(xiàn)形式。以下是多種命名空間:
- HTML:http://www.w3.org/1999/xhtml
- SVG:http://www.w3.org/2000/svg
- MathML:http://www.w3.org/1998/math/MathML
對于不同的命名空間,瀏覽器解析的方式也不一樣,所以在 SVG 中嵌套 HTML,解析 SVG 的時候遇到 http://www.w3.org/2000/svg 轉(zhuǎn)化 SVG 的解析方式,當(dāng)遇到了 http://www.w3.org/1999/xhtml 就使用 HTML 的解析方式。
這是為什么 SVG 中可以嵌套 HTML,并且瀏覽器能夠正常渲染。
實現(xiàn)
但是這個過程中,會存在一些問題:
- SVG 是不允許連接到外部的資源,比如 HTML 中圖片鏈接、CSS link 方式的資源鏈接等,在 SVG 中都會有限制;
- HTML 中會有腳本執(zhí)行的情況,比如 Vue 的 SPA 單頁項目,需要先執(zhí)行 JS 的邏輯才能夠渲染出 Dom 節(jié)點。但是 SVG 中,是不支持 JS 執(zhí)行的情況。
- SVG 的位置大小和 foreignObject 標(biāo)簽的位置大小不能夠確定,需要計算。
基于以上的情況,需要做一些其他的處理,以下為這個方案渲染的整個流程,看看如何解決存在的問題:

對于這種方案需要處理以上幾個流程:
- 初始化不同類型的截圖需要,比如 DrawHTML(截取部分文檔片段)、DrawDocument(截取完整 document 節(jié)點)、DrawURL(截取一個 HTML 資源鏈接)這幾種形式,最后都會處理成截取整個 document 文檔節(jié)點,以下是流程第一步的處理。
- DrawHTML 轉(zhuǎn)換部分文檔片段為一個完整的 document 文檔節(jié)點,然后使用 DrawDocument 的方式處理。

DrawURL 轉(zhuǎn)換一個 HTML 資源鏈接為截取一個完整的 document 文檔節(jié)點,再使用 DrawDocument 的方式處理。

可以看到最后的方式都是處理成一個 document 文檔,實現(xiàn)到 drawDocument 這個方法里面,使用繪制 document 的形式來渲染。
基于上面的思路,把 document 文檔轉(zhuǎn)為 SVG,但是 document 文檔里面包含了外部鏈接的圖片資源、外部樣式資源和腳本資源。這種情況在 SVG 是不支持的,所以這一步的處理方式是把所有的外部資源,處理為內(nèi)聯(lián)形式的,改造為新的 document,比如:

以上這種文檔結(jié)構(gòu)中,所有的資源都是屬于外部資源,如果要轉(zhuǎn)變?yōu)?SVG,就需要處理成內(nèi)聯(lián)的形式,構(gòu)造新的 document 文檔,如下:

所以上一步把所有截圖形式都處理成為了渲染一個 document 文檔之后,就需要對文檔進行重構(gòu)轉(zhuǎn)換,處理文檔內(nèi)部所有外部資源,不同的資源對應(yīng)不同的處理方式,這里需要處理的資源情況分為以下幾點:
在 HTML 文檔中存在 img 圖片標(biāo)簽的鏈接為外部資源,需要處理為 base64 資源,通過 loadAndInlineIages 函數(shù)進行處理,以下是 loadAndInlineIages 函數(shù)。

loadAndInlineImages 函數(shù)的處理流程是獲取到所有和圖片有關(guān)的標(biāo)簽,在通過 Ajajx 請求下來,然后處理成 Base64 的資源類型,對原有的圖片標(biāo)簽進行替換,這樣就把所有的標(biāo)簽圖片,處理成為了內(nèi)聯(lián)資源類型。以下是 encodeImageAsDataURI 方法內(nèi)部請求圖片資源且轉(zhuǎn)義 Base64 的邏輯:

通過了以上步驟之后,此時的 document 文檔里面的圖片標(biāo)簽元素的資源已經(jīng)全部為內(nèi)聯(lián)形式了
在 HTML 中同時也存在著腳本為外部資源的情況,對于腳本的處理邏輯,整體就比較簡單了,獲取到腳本的鏈接,請求腳本內(nèi)容,之后用請求的內(nèi)容替換原有的外部鏈接的 <script>,以下為腳本處理函數(shù) loadAndInlineScript 的實現(xiàn)方式:

以上處理腳本資源的方法整體比較簡單。
- 在處理完成了腳本和圖片的情況之后,目前剩余需要處理成為內(nèi)聯(lián)資源的情況還剩下外部樣式表。但是此處還需要注意一點,對于本來存在的內(nèi)聯(lián)樣式也需要處理,因為可能會出現(xiàn)使用外鏈背景圖的情況、通過@import 導(dǎo)入樣式表的情況。
所以對于外部樣式表請求下來的內(nèi)容會存在同樣的問題,所以對于外部樣式表而言,整體的流程就是通過 Ajax 請求外部樣式內(nèi)容,然后對內(nèi)容存在背景圖片和 @import 的情況做處理。先供上對于 CSS 處理不同情況的流程處理:

通過上面的架構(gòu)流程圖,可以看出來遠(yuǎn)端請求的樣式表需要和內(nèi)聯(lián)樣式做同樣的處理,把內(nèi)部的遠(yuǎn)端圖片資源和字體資源處理為內(nèi)聯(lián)形式。
對外部樣式表的請求邏輯,大致邏輯如下:

通過以上代碼,可以看見請求和處理邏輯全部在 requestStylesheetAndInlineResources 方法中,以下為代碼方法:

從以上的代碼邏輯中,可以清楚,有幾個 promise 的處理流程,每一個流程處理的內(nèi)容主要做了以下幾件事情:
- 請求遠(yuǎn)端樣式資源表,通過封裝的 Ajax 方法;
- 處理請求下來的樣式表中可能使用到的遠(yuǎn)端圖片或者字體資源鏈接,使用 inlineCss.adjustPathsOfCssResources 方法,把使用到資源的相對地址,處理成為絕對地址;
- 通過 inlineCss.loadCSSImportsForRule 方法處理@import 資源引入的情況;
- 請求樣式表中使用到的圖片和文字資源,并且處理成內(nèi)聯(lián),這一步的邏輯在 inlineCss.loadAndInlineCSSResourcesForRules 這個方法中;
- 基于原有樣式表構(gòu)造新的樣式表。
現(xiàn)在我們來看一下,對應(yīng)每一種處理情況具體所做的事情:
- Ajax 請求資源,這一步不做深入,簡單的 Ajax 封裝;
- 對于 adjustPathsOfCssResources 方法處理鏈接相對路勁變?yōu)榻^對路勁,整體的實現(xiàn)思路是遍歷查找所有的 CSSRule,查找到 background、font-face、@import 等對應(yīng)的 Rule,解析屬性設(shè)置的值,判斷引用的地址是否是外部 URL,處理路勁變換為絕對路勁。構(gòu)建新的 CSSRule。

通過上面的邏輯處理之后,此時所有的 CSS 中包含的外部資源的鏈接已經(jīng)處理為絕對路勁,對于整個資源 CSS 中的資源內(nèi)聯(lián)處理,第一步就已經(jīng)完成了。
對于處理完成路勁之后,對于上面整個資源處理的大流程 loadCSSImportsForRule 方法就是把 import 的外部 CSS 請求回來,然后重新構(gòu)建新的 CSS。大體的思路為搜集當(dāng)前 CSS 中所有的 import 資源地址,下載下來之后,構(gòu)建為新的 CSS,在分析新的 CSS 是否包含 import,遞歸寫入到最后的 CSSRule 中。

對于以上代碼處理 @import 的函數(shù)中,loadAndInlineCSSImport 方法就是核心的邏輯了,結(jié)合上面講的整體處理流程,看看以下代碼:

這樣就把所有的 CSS 中的 @import 的資源,也處理進來了。
對于 CSS 資源,處理到這一步之后,結(jié)合我們上面的流程圖,就只剩下把所有的資源諸如背景圖、font-face 等引用的外部鏈接變?yōu)閮?nèi)聯(lián)資源。這一步的實現(xiàn)和上面 CSS 中轉(zhuǎn)換資源相對路勁到絕對路勁,整個思路是一致的。區(qū)別在于對于最后一步替換相對路勁為絕對路勁的 URL 不一致,這里需要替換的是資源請求下來之后處理成為 base64 的 data 數(shù)據(jù)之后的鏈接。
- 首先遍歷所有 CSSRule,找出需要替換的所有 Rule;
- 獲取對應(yīng) Rule 中包含的外部鏈接;
- 請求資源回來之后,處理為 Base64 類型的 data 鏈接;
- 替換原有 Rule 中資源的地址,改為內(nèi)聯(lián)類型,構(gòu)造成為新的 CSSRule。
這樣整個流程中的資源就已經(jīng)處理完成,目前構(gòu)造出來的文檔,全是內(nèi)聯(lián)文檔,符合構(gòu)造 SVG 的要求。
在處理完成內(nèi)容之后,就需要計算整個文檔需要展示的大小,這是在 SVG 構(gòu)建的時候需要使用到的;因為在用戶截圖的時候回傳入對應(yīng)想要的大小,這個時候怎么去控制。大致的思路如下:
- 根據(jù)用戶傳入寬高大小創(chuàng)建 iframe,把上面處理過的內(nèi)聯(lián)文檔裝載到 iframe 中執(zhí)行;
- 獲取到執(zhí)行之后文檔的 clientWidth 和 clientHeight,同時根據(jù) zoom 計算縮放的大小來作為最后 SVG 需要渲染的結(jié)果;
- 獲取裝載之后 iframe 中的文檔的 font-size 來設(shè)置 SVG 的內(nèi)容字體大小。
經(jīng)過上面這些步驟,我們計算出來了大小,剩下最后一步,序列化處理之后的文檔節(jié)點構(gòu)建 SVG:
1. 序列化文檔節(jié)點的過程,就是把文檔節(jié)點處理成為整個字符串的過程,在大多數(shù)瀏覽器中都是有序列化 API 的支持,不過有少數(shù)兼容問題,所以最優(yōu)方法為自己實現(xiàn)序列化的過程,整個過程邏輯主要為遞歸遍歷文檔節(jié)點,處理節(jié)點名稱大小寫、文本內(nèi)容中包含<、>、&這幾個符號的轉(zhuǎn)義處理及對整個文檔添加指定的命名空間。
2. 在序列化文檔文檔之后,就需要使用序列化之后的內(nèi)容和計算出來的展示文檔大小值來構(gòu)建 SVG,整個構(gòu)建的過程代碼大致流程:

至此,SVG 構(gòu)建已經(jīng)構(gòu)建完成,剩下最后一步就是把 SVG 處理成圖片可以顯示的資源。
處理圖片顯示的資源這個過程,其實有兩種實現(xiàn):
- 第一種是通過 createObjectURL 把圖片資源處理為 blob 數(shù)據(jù),img 使用時直接使用 blob 數(shù)據(jù);
- 第二種是直接 Encode 對應(yīng)的 SVG 資源,構(gòu)建 data 資源鏈接。

這兩種生成的連接都可以對應(yīng)添加到圖片的 src 中;當(dāng)然,此時也可以拿到對應(yīng)的 SVG 調(diào)用 Canvas 繪圖的 API 來繪制 SVG,做二次加工。
至此,這個思路的實現(xiàn)全部完成。
這個思路的缺點
基于以上兩個思路的對比,明顯會發(fā)現(xiàn),使用 HTML 通過 foreignObject 構(gòu)建 SVG 的方法要簡單清晰,但是對于一些瀏覽器也會有一些小問題,不過已經(jīng)有一個比較不錯的庫通過 hack 的方式,處理了這些問題。rasterizeHTML.js 是一個比較不錯的截圖庫,實現(xiàn)的邏輯就是基于上面的思路。
不過這兩種方式都會涉及到一個問題,就是圖片資源跨域問題,如果圖片為跨域圖片,就需要通過 CORS 來處理。由于在 Canvas 位圖中的像素可能來自多種來源,包括從其他主機檢索的圖像或視頻,因此不可避免的會出現(xiàn)安全問題,所以對于除 CORS 以外的跨域圖片,Canvas 都會被處理成污染的情況,此時 getImageData、toBlob、toDataURL 都會被禁止調(diào)用,這種機制也可以避免未經(jīng)許可拉取遠(yuǎn)程網(wǎng)站信息而導(dǎo)致的用戶隱私泄露,這對于 webgl 的貼圖也是同樣的處理,不能使用除 CORS 以外的跨域圖片。
總結(jié)
以上總結(jié)了 html2canvas 的整體思路及優(yōu)缺點,目前 html2canvas 源碼里面也已經(jīng)開始融合第二種思路,這說明了第二種截圖思路的優(yōu)點。但是第二種思路的過程中自己手動處理的序列化性能相比瀏覽器處理而言略微慢一點,等到瀏覽器序列化都支持的特別好的時候,就可以替代這一部分。當(dāng)然,咱們也可以打開思路,結(jié)合 WebAssembly 來重寫序列化的部分,打開整個 BS 架構(gòu)大門。

歡迎關(guān)注微信訂閱號
字節(jié)跳動職位內(nèi)推:
- 社招和實習(xí)入口
可以打開下方鏈接
https://job.toutiao.com/s/cELvyp

- 校招入口:

掃碼加下方秘書微信,回復(fù)“前端” ,加入前端交流群,參與本文抽獎活動。