簡介
剖析流行的截圖插件 html2canvas 的實現方案,探索其功能上的一些不足之處及不能正確截取的一些場景,比如不支持 CSS 的 box-shadow 截取情況等。探索一種新的實現方式,能夠避免多數目前 html2canvas 不支持的情況,解密其原理,深究 Canvas 繪圖的機制。
本篇文章你可以學到:
- 純前端網頁截圖的基本原理
- html2canvas 的核心原理
- SVG 內嵌 HTML 的方式
- Canvas 渲染 SVG 的方式及各種問題的解決方案
適合人群:前端開發
開篇
平時很多時候,需要把當前頁面或者頁面某一部分內容保存為圖片分享出去,也或者有其他的業務用途,這種在很多的營銷場景和裂變的過程都會使用到,那我們要把一個頁面的內容轉化為圖片的這個過程,就是比較需要探討的了。
首先這種情況,想到的實現方案就是使用 Canvas 來實現,我們探索一下基本實現步驟:
- 把需要分享或者記錄的內容繪制到 Canvas 上
- 把繪制之后的 Canvas 轉換為圖片
這里需要明確的一點就是,只要把數據繪制到 Canvas 上,這就在 Canvas 畫布上形成了被保存在內存中的像素點信息,所以可以直接調用 Canvas 的 API 方法 toDataURL、toBlob,把已經形成的像素信息轉化為可以被訪問的資源 URI,同時保存在服務器當中。這就很輕松的解決了第二步(把 Canvas 轉為圖片鏈接),下面是代碼的實現:
在實現了第二步的情況之下,需要關注的就是第一步的內容,怎么把內容繪制到 Canvas 上,我們知道 Canvas 的繪圖環境有一個方法是 ctx.drawImage,可以繪制部分元素到 Canvas 上,包含圖片元素 Image、svg 元素、視頻元素 Video、Canvas 元素、ImageBitmap 數據等,但是對于一般的其他 div 或者列表 li 元素它是不可以被繪制的。
所以,這不是直接調用繪圖的 API 就可以辦到的,我們就需要思考其他的方法。在一般的實現上,比較常見的就是使用 html2canvas,那么我們先來聊聊 html2canvas 的使用和實現。
html2canvas 的使用及實現
使用
首先看一下 html2cavas 的使用方法:
調用 html2canvas 方法傳入想要截取的 Dom,執行之后,返回一個 Promise,接收到的 Canvas 上,就繪制了我們想要截取的 Dom 元素。到這一步之后,我們再調取 Canvas 轉圖片的方法,就可以對其做其他的處理。
這里它的 html2canvas 方法還支持第二個選項傳入一些用戶的配置參數,比如是否啟用緩存、整個繪圖 Canvas 的寬高值等。
在這個轉換的過程,在 html2canvas 的內部,是怎么把 Dom 元素繪制到 Canvas 上的,這是咱們需要思考的問題!
實現
首先咱們先獻上一個內部的大致流程圖:
對比著內部的流程圖,就可以理一下整體的思路,整體的思路就是遍歷目標節點和子節點,收集樣式,計算節點本身的層級關系和根據不同的優先級繪制到畫布中,下面基于這個思路,咱們深入一下整個過程。
1. 調用 html2canvs 函數,直接返回一個執行函數,這一步沒有什么。
2. 在執行函數的內部第一步是構建配置項 defaultOptions,在合并默認配置的過程中,有一個緩存的配置,它會生成處理緩存的方法。
- 處理緩存類,對于一個頁面中的多個不同的地方渲染調用多次的情況做優化,避免同一個資源被多次加載;
- 緩存類里面控制了所有圖片的加載和處理,包括使用 proxy 代理和使用 cors 跨域資源共享這兩種情況資源的處理,同時也對 base64 和 blob 這兩種形式資源的處理。比如如果渲染 Dom 里面包含一個圖片的鏈接類型是 blob,使用的方式就是如下處理,然后添加到緩存類中,下次使用就不需要再重新請求。
3. 在上一步生成了默認配置的情況之下,傳入需要繪制的目標節點 element 和配置到 DocumentCloner 里面,這個過程會克隆目標節點所在的文檔節點 document,同時把目標節點也克隆出來。這個過程中,只是克隆了開發者定義的對應節點樣式,并不是結合瀏覽器渲染生成特定視圖最后的樣式。
如上這個 .box 的元素節點,定義的樣式只有高度,但是在瀏覽器渲染之下,會對它設置默認的文字樣式等等。
4. 基于上一步的情況,就需要把克隆出來的目標節點所在的文檔節點 document 進行一次瀏覽器的渲染,然后在收集最終目標節點的樣式。于此,把克隆出來的目標節點的 document 裝載到一個 iframe 里面,進行一次渲染,然后就可以獲取到經過瀏覽器視圖真實呈現的節點樣式。
在這個過程中,就可以通過 window.getComputedStyle 這個 API 拿到要克隆的目標節點上所有的樣式了(包含自定義和瀏覽器默認的結合最終的樣式)。
5. 目標節點的樣式和內容都獲取到了之后,就需要把它所承載的數據信息轉化為 Canvas 可以使用的數據類型,比如某一個子節點的寬度設置為 50%或者 2rem,在這個過程中,就需要根據父級的寬度把它計算成為像素級別的單位。同時對于每一個節點而言需要繪制的包括了邊框、背景、陰影、內容,而對于內容就包含圖片、文字、視頻等。這個過程就需要對目標節點的所有屬性進行解析構造,分析成為可以理解的數據形式。
如上圖片這種數據結構和我注釋一樣,在它內部把每一個節點處理成為了一個 container,它的上面有一個 styles 字段,這個字段是所有節點上的樣式經過轉換計算之后的數據,還有一個 textNodes 屬性,它表示當前節點下的文本節點,如上,每一個文本的點的內容使用 text 來表示,位置和大小信息放置在 textBounds 中。對于 elements 字段存放的就是當前節點下除了文本節點外,其他節點轉換成為的 container,最后一個就是 bounds 字段,存放的是當前節點的位置和大小信息。可以看一下 container 這個類的代碼:
基于這種情況,每一個 container 數據結構的 elements 屬性都是子節點,整個節點就夠構造成一個 container tree。
6. 在通過解析器把目標節點處理成特定的數據結構 container 之后,就需要結合 Canvas 調用渲染方法了,我們在瀏覽器里面創建多個元素的時候,不同的元素設置不同的樣式,最后展示的結果就可能不一樣,比如下面代碼:
這個代碼的展示結果如下:
此時,如果修改了代碼中 .sta1 元素節點的 opacity 屬性為 0.999,此時整個布局的層級就會發生大變化,結果如下:
這個是什么原因?因為 Canvas 繪圖需要根據樣式計算哪些元素應該繪制在上層,哪些在下層。元素在瀏覽器中渲染時,根據 W3C 的標準,所有的節點層級布局,需要遵循層疊上下文和層疊順序的標準。當某一些屬性發生變化,層疊上下文的順序就可能發生變化,比如上列中透明度默認為 1 和不為 1 的情況(對于如何形成一個層疊上下文此處不做深入講解,可以自行研究)。
更加直白的理解就是,一部分屬性會使一些元素形成一個單獨的層級,不同屬性的層級有一定的排列順序。如下就是我們對應的順序:
- 形成層疊上下文環境的元素的背景與邊框(相當于整個文檔的背景和邊框)
- 擁有負 z-index 的子層疊上下文元素 (負的越高越層疊上下文層級越低)
- 正常流式布局,非 inline-block,無 position 定位(static 除外)的子元素
- 無 position 定位(static 除外)的 float 浮動元素
- 正常流式布局, inline-block 元素,無 position 定位(static 除外)的子元素(包括
display:table
和display:inline
) - 擁有
z-index:0
或者 auto 的子堆疊上下文元素 - 擁有正
z-index:
的子堆疊上下文元素(正的越低層疊上下文層級越低) - 在正常的元素情況下,沒有形成層疊上下文的時候,顯示順序準守以上規則,在設置了一些屬性,形成了層疊上下文之后,準守誰大誰上(z-index 比較)、后來居上(后寫的元素后渲染在上面)
此處,在清楚了元素的渲染需要遵循這個標準的情況之下,Canvas 繪制節點的時候,就需要先計算出整個目標節點里子節點渲染時所展現的不同層級。先給出來內部模擬層疊上下文的數據結構 StackingContext:
以上就是某一個節點對應的層疊上下文在內部所表現出來的數據結構。很多屬性都會形成層疊上下文,不同的屬性形成的上下文,有不同的順序,所以需要對目標節點的子節點解析,根據不同的樣式屬性分配到不同的數組中歸類,比如遍歷子節點的 container 上的 styles,發現 opacity 為 0.5,此時會形成層疊上下文,然后就把它構造成為上下文的數據結構 StackContext。添加到 zeroOrAutoZIndexOrTransformedOrOpacity 這個數組中,這樣一個遞歸查看子節點的過程,最后會形成一個層疊上下文的樹。
7. 基于上面構造出的數據結構,就開始調用內部的繪圖方法了,以下代碼是渲染某一個層疊上下文的代碼:
如上繪圖函數中,如果子元素形成了層疊上下文,就調用 renderStack,這個方法內部繼續調用了 renderStackContent,這就形成了對于層疊上下文整個樹的遞歸。
如果子元素沒有形成層疊上下文,而是正常元素,就直接調用 renderNode 或者 renderNodeContent。這兩個的區別是 renderNodeContent 只負責渲染內容,不會渲染節點的邊框和背景色。
對于 renderNodeContent 這個方法就是渲染一個元素節點里面的內容,可能是正常元素、圖片、文字、SVG、Canvas、視頻、input、iframe。對于圖片、SVG、視頻、Canvas 這幾種元素,直接通過調用前文提到的 API,對于 input 需要根據樣式計算出繪圖數據來模擬完成,文字就直接根據提供的樣式來繪制。重點需要提一下的是 iframe,如果需要繪制的元素中包含了 iframe,就相當于我們需要重新繪制一個新的文檔 document,處理方法是在內部調用 html2canvas 的 API,繪制整個文檔。
以下為多個不同類型的元素的繪制方式。
對于文字的繪制方式:
對于圖片、SVG、Canvas 元素的繪制:
對于代碼中調用 renderReplacedElement 方法內部的處理邏輯,就是調用 Canvas 的 drawImage 方法繪制以上三種數據形式。
對于需要繪制的元素是 iframe 的時候,做的處理邏輯就如同重新調用整個繪制方法,重新渲染頁面的過程:
對于單選或者多選框的處理情況,就是根據是否選中,來繪制對應狀態的樣式:
對于 input 輸入框的情況,首先需要繪制邊框,然后把內部的文字繪制到輸入框中,超出部分需要剪切掉,所以需要使用到 Canvas 的 clip 繪圖 API:
對于最后一種需要考慮的就是列表,對于 li、ol 這兩種列表,都可以設置不同類型的 list-style,所以需要區分繪制。
以上整個過程,就是 html2canvas 的整體內部流程,最后的操作都是不同的線條、圖片、文字等等的繪制,概括起來就是遍歷目標節點,收集樣式信息,轉化為繪制數據,并且根據一定的優先級策略遞歸繪制節點到 Canvas 畫布上。
html2canvas 實現上的缺點
在捋順了整個大流程的情況之下,咱們來看看 html2canvas 的一些缺點。
不支持的一些場景
- box-shadow 屬性,支持的不好,因為對于 Canvas 的陰影 API 沒有擴散半徑。所以對于樣式的陰影支持不是特別好;
- 邊框虛線的情況也不支持,這一點源碼里面沒有使用 setLineDash,是因為大多數瀏覽器原本不支持這個屬性,chrome 也是 64 版本之后才支持這個屬性;
- css 中元素的 zoom 屬性支持也不是也特別好,因為換算會出現問題;
- 計算問題是最大的問題!!!因為每一次計算都會有精確度的省略問題,比如父元素的寬度是 100 像素,子元素是父元素的 30%,這個時候轉化為 Canvas 繪圖單位像素的時候,就會有省略的過程,在有多次省略的情況之下,精確度就會變得不精確。并且還涉及到一些圓弧的情況,這種弧度的計算,最后模仿出來,都會有失去精確度的問題。對于正常的瀏覽器渲染節點,渲染的內部邏輯,直接是由瀏覽器處理,但是對于 html2canvas 的方案,需要先計算為像素單位,然后繪制到 Canvas 上,最后 Canvas 元素還要經過瀏覽器的一次處理,才能夠渲染出來。這個過程不止是換算單位失去精度,渲染也會失去精度。
換一種思路實現截圖
基于我們對于上面 html2canvas 整個流程的實現,會發現中間換算會出現很多不精準的問題,那么怎么做一個可以精準的繪制呢?能不能把所有內部繪制的換算過程全部交給瀏覽器?
基本思路
上文提到 Canvas 還可以繪制 image、SVG 等等,此處就可以把 HTML 處理成 SVG 的結果,然后再繪制到 Canvas 上。
對于 SVG 是一種可擴展標記語言,在轉化的過程中,就需要使用到 <foreignObject> 這個 SVG 元素。<foreignObject> 允許包含不同的 XML 命名空間,在瀏覽器的上下文中,很可能是 XHTML\HTML,如下是使用方式:
這樣只需要指定對應的命名空間,就可以把它嵌套到 foreignObject 中,然后結合 SVG,直接渲染。
什么是命名空間,相當于是元素名和屬性名的一種集合,元素和屬性可以有多種不同的集合,為了解決沖突,就需要有命名空間的指派,對于帶有屬性 xmlns=""
就是一個命名空間的表現形式。以下是多種命名空間:
- 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 轉化 SVG 的解析方式,當遇到了 http://www.w3.org/1999/xhtml 就使用 HTML 的解析方式。
這是為什么 SVG 中可以嵌套 HTML,并且瀏覽器能夠正常渲染。
實現
但是這個過程中,會存在一些問題:
- SVG 是不允許連接到外部的資源,比如 HTML 中圖片鏈接、CSS link 方式的資源鏈接等,在 SVG 中都會有限制;
- HTML 中會有腳本執行的情況,比如 Vue 的 SPA 單頁項目,需要先執行 JS 的邏輯才能夠渲染出 Dom 節點。但是 SVG 中,是不支持 JS 執行的情況。
- SVG 的位置大小和 foreignObject 標簽的位置大小不能夠確定,需要計算。
基于以上的情況,需要做一些其他的處理,以下為這個方案渲染的整個流程,看看如何解決存在的問題:
對于這種方案需要處理以上幾個流程:
- 初始化不同類型的截圖需要,比如 DrawHTML(截取部分文檔片段)、DrawDocument(截取完整 document 節點)、DrawURL(截取一個 HTML 資源鏈接)這幾種形式,最后都會處理成截取整個 document 文檔節點,以下是流程第一步的處理。
- DrawHTML 轉換部分文檔片段為一個完整的 document 文檔節點,然后使用 DrawDocument 的方式處理。
DrawURL 轉換一個 HTML 資源鏈接為截取一個完整的 document 文檔節點,再使用 DrawDocument 的方式處理。
可以看到最后的方式都是處理成一個 document 文檔,實現到 drawDocument 這個方法里面,使用繪制 document 的形式來渲染。
基于上面的思路,把 document 文檔轉為 SVG,但是 document 文檔里面包含了外部鏈接的圖片資源、外部樣式資源和腳本資源。這種情況在 SVG 是不支持的,所以這一步的處理方式是把所有的外部資源,處理為內聯形式的,改造為新的 document,比如:
以上這種文檔結構中,所有的資源都是屬于外部資源,如果要轉變為 SVG,就需要處理成內聯的形式,構造新的 document 文檔,如下:
所以上一步把所有截圖形式都處理成為了渲染一個 document 文檔之后,就需要對文檔進行重構轉換,處理文檔內部所有外部資源,不同的資源對應不同的處理方式,這里需要處理的資源情況分為以下幾點:
在 HTML 文檔中存在 img 圖片標簽的鏈接為外部資源,需要處理為 base64 資源,通過 loadAndInlineIages 函數進行處理,以下是 loadAndInlineIages 函數。
loadAndInlineImages 函數的處理流程是獲取到所有和圖片有關的標簽,在通過 Ajajx 請求下來,然后處理成 Base64 的資源類型,對原有的圖片標簽進行替換,這樣就把所有的標簽圖片,處理成為了內聯資源類型。以下是 encodeImageAsDataURI 方法內部請求圖片資源且轉義 Base64 的邏輯:
通過了以上步驟之后,此時的 document 文檔里面的圖片標簽元素的資源已經全部為內聯形式了
在 HTML 中同時也存在著腳本為外部資源的情況,對于腳本的處理邏輯,整體就比較簡單了,獲取到腳本的鏈接,請求腳本內容,之后用請求的內容替換原有的外部鏈接的 <script>,以下為腳本處理函數 loadAndInlineScript 的實現方式:
以上處理腳本資源的方法整體比較簡單。
- 在處理完成了腳本和圖片的情況之后,目前剩余需要處理成為內聯資源的情況還剩下外部樣式表。但是此處還需要注意一點,對于本來存在的內聯樣式也需要處理,因為可能會出現使用外鏈背景圖的情況、通過@import 導入樣式表的情況。
所以對于外部樣式表請求下來的內容會存在同樣的問題,所以對于外部樣式表而言,整體的流程就是通過 Ajax 請求外部樣式內容,然后對內容存在背景圖片和 @import 的情況做處理。先供上對于 CSS 處理不同情況的流程處理:
通過上面的架構流程圖,可以看出來遠端請求的樣式表需要和內聯樣式做同樣的處理,把內部的遠端圖片資源和字體資源處理為內聯形式。
對外部樣式表的請求邏輯,大致邏輯如下:
通過以上代碼,可以看見請求和處理邏輯全部在 requestStylesheetAndInlineResources 方法中,以下為代碼方法:
從以上的代碼邏輯中,可以清楚,有幾個 promise 的處理流程,每一個流程處理的內容主要做了以下幾件事情:
- 請求遠端樣式資源表,通過封裝的 Ajax 方法;
- 處理請求下來的樣式表中可能使用到的遠端圖片或者字體資源鏈接,使用 inlineCss.adjustPathsOfCssResources 方法,把使用到資源的相對地址,處理成為絕對地址;
- 通過 inlineCss.loadCSSImportsForRule 方法處理@import 資源引入的情況;
- 請求樣式表中使用到的圖片和文字資源,并且處理成內聯,這一步的邏輯在 inlineCss.loadAndInlineCSSResourcesForRules 這個方法中;
- 基于原有樣式表構造新的樣式表。
現在我們來看一下,對應每一種處理情況具體所做的事情:
- Ajax 請求資源,這一步不做深入,簡單的 Ajax 封裝;
- 對于 adjustPathsOfCssResources 方法處理鏈接相對路勁變為絕對路勁,整體的實現思路是遍歷查找所有的 CSSRule,查找到 background、font-face、@import 等對應的 Rule,解析屬性設置的值,判斷引用的地址是否是外部 URL,處理路勁變換為絕對路勁。構建新的 CSSRule。
通過上面的邏輯處理之后,此時所有的 CSS 中包含的外部資源的鏈接已經處理為絕對路勁,對于整個資源 CSS 中的資源內聯處理,第一步就已經完成了。
對于處理完成路勁之后,對于上面整個資源處理的大流程 loadCSSImportsForRule 方法就是把 import 的外部 CSS 請求回來,然后重新構建新的 CSS。大體的思路為搜集當前 CSS 中所有的 import 資源地址,下載下來之后,構建為新的 CSS,在分析新的 CSS 是否包含 import,遞歸寫入到最后的 CSSRule 中。
對于以上代碼處理 @import 的函數中,loadAndInlineCSSImport 方法就是核心的邏輯了,結合上面講的整體處理流程,看看以下代碼:
這樣就把所有的 CSS 中的 @import 的資源,也處理進來了。
對于 CSS 資源,處理到這一步之后,結合我們上面的流程圖,就只剩下把所有的資源諸如背景圖、font-face 等引用的外部鏈接變為內聯資源。這一步的實現和上面 CSS 中轉換資源相對路勁到絕對路勁,整個思路是一致的。區別在于對于最后一步替換相對路勁為絕對路勁的 URL 不一致,這里需要替換的是資源請求下來之后處理成為 base64 的 data 數據之后的鏈接。
- 首先遍歷所有 CSSRule,找出需要替換的所有 Rule;
- 獲取對應 Rule 中包含的外部鏈接;
- 請求資源回來之后,處理為 Base64 類型的 data 鏈接;
- 替換原有 Rule 中資源的地址,改為內聯類型,構造成為新的 CSSRule。
這樣整個流程中的資源就已經處理完成,目前構造出來的文檔,全是內聯文檔,符合構造 SVG 的要求。
在處理完成內容之后,就需要計算整個文檔需要展示的大小,這是在 SVG 構建的時候需要使用到的;因為在用戶截圖的時候回傳入對應想要的大小,這個時候怎么去控制。大致的思路如下:
- 根據用戶傳入寬高大小創建 iframe,把上面處理過的內聯文檔裝載到 iframe 中執行;
- 獲取到執行之后文檔的 clientWidth 和 clientHeight,同時根據 zoom 計算縮放的大小來作為最后 SVG 需要渲染的結果;
- 獲取裝載之后 iframe 中的文檔的 font-size 來設置 SVG 的內容字體大小。
經過上面這些步驟,我們計算出來了大小,剩下最后一步,序列化處理之后的文檔節點構建 SVG:
1. 序列化文檔節點的過程,就是把文檔節點處理成為整個字符串的過程,在大多數瀏覽器中都是有序列化 API 的支持,不過有少數兼容問題,所以最優方法為自己實現序列化的過程,整個過程邏輯主要為遞歸遍歷文檔節點,處理節點名稱大小寫、文本內容中包含<、>、&這幾個符號的轉義處理及對整個文檔添加指定的命名空間。
2. 在序列化文檔文檔之后,就需要使用序列化之后的內容和計算出來的展示文檔大小值來構建 SVG,整個構建的過程代碼大致流程:
至此,SVG 構建已經構建完成,剩下最后一步就是把 SVG 處理成圖片可以顯示的資源。
處理圖片顯示的資源這個過程,其實有兩種實現:
- 第一種是通過 createObjectURL 把圖片資源處理為 blob 數據,img 使用時直接使用 blob 數據;
- 第二種是直接 Encode 對應的 SVG 資源,構建 data 資源鏈接。
這兩種生成的連接都可以對應添加到圖片的 src 中;當然,此時也可以拿到對應的 SVG 調用 Canvas 繪圖的 API 來繪制 SVG,做二次加工。
至此,這個思路的實現全部完成。
這個思路的缺點
基于以上兩個思路的對比,明顯會發現,使用 HTML 通過 foreignObject 構建 SVG 的方法要簡單清晰,但是對于一些瀏覽器也會有一些小問題,不過已經有一個比較不錯的庫通過 hack 的方式,處理了這些問題。rasterizeHTML.js 是一個比較不錯的截圖庫,實現的邏輯就是基于上面的思路。
不過這兩種方式都會涉及到一個問題,就是圖片資源跨域問題,如果圖片為跨域圖片,就需要通過 CORS 來處理。由于在 Canvas 位圖中的像素可能來自多種來源,包括從其他主機檢索的圖像或視頻,因此不可避免的會出現安全問題,所以對于除 CORS 以外的跨域圖片,Canvas 都會被處理成污染的情況,此時 getImageData、toBlob、toDataURL 都會被禁止調用,這種機制也可以避免未經許可拉取遠程網站信息而導致的用戶隱私泄露,這對于 webgl 的貼圖也是同樣的處理,不能使用除 CORS 以外的跨域圖片。
總結
以上總結了 html2canvas 的整體思路及優缺點,目前 html2canvas 源碼里面也已經開始融合第二種思路,這說明了第二種截圖思路的優點。但是第二種思路的過程中自己手動處理的序列化性能相比瀏覽器處理而言略微慢一點,等到瀏覽器序列化都支持的特別好的時候,就可以替代這一部分。當然,咱們也可以打開思路,結合 WebAssembly 來重寫序列化的部分,打開整個 BS 架構大門。
歡迎關注微信訂閱號
字節跳動職位內推:
- 社招和實習入口
可以打開下方鏈接
https://job.toutiao.com/s/cELvyp
- 校招入口:
掃碼加下方秘書微信,回復“前端” ,加入前端交流群,參與本文抽獎活動。