DevUI是一支兼具設計視角和工程視角的團隊,服務於華為雲DevCloud平臺和華為內部數箇中後臺系統,服務於設計師和前端工程師。
官方網站:devui.design
Ng元件庫:ng-devui(歡迎Star)
官方交流:新增DevUI小助手(devui-official)
DevUIHelper外掛:DevUIHelper-LSP(歡迎Star)
引言
有時使用者希望將我們的報表頁面分享到其他的渠道,比如郵件、PPT等,每次都需要自己截圖,一是很麻煩,二是截出來的圖大小不一。
有沒有辦法在頁面提供一個下載報表頁面的功能,使用者只需要點選按鈕,就自動將當前的報表頁面以圖片形式下載下來呢?
html2canvas庫就能幫我們做到,無需後臺支援,純瀏覽器實現截圖,即使頁面有滾動條也是沒問題的,截出來的圖非常清晰。
這個庫的維護時間非常長,早在2013年9月8日
它就釋出了第一個版本,比Vue的第一個版本(2013年12月8日)還要早。
截止到今天2020年12月18日,html2canvas庫在github已經有22.3k
star,在npm的周下載量也有506k
,非常了不起!
上一次提交是在2020年8月9日,可見作者依然在很熱情地維護著這個庫,而且用TypeScript
重構過,不過這個庫的作者非常保守,哪怕已經持續不斷地維護了7年
,他在README裡依然提到這個庫目前還在實驗階段,不建議在生產環境使用。
事實上我很早就將這個庫用在了生產環境,這篇文章就來分析下這個神奇和了不起的JavaScript庫,看看它是怎麼實現瀏覽器端截圖的。
1 如何使用
在介紹html2canvas的原理之前,先來看看怎麼使用它,使用起來真的非常簡單,幾乎是1分鐘上手。
使用html2canvas只要以下3步:
- 安裝
- 引入
- 呼叫
Step 1: 安裝
npm i html2canvas
Step 2: 引入
隨便在一個現代框架的工程專案中引入html2canvas
import html2canvas from 'html2canvas';
Step 3: 截圖並下載
html2canvas就是一個函式,在頁面渲染完成之後直接呼叫即可。
檢視渲染完成的事件:
1. Angular的ngAfterViewInit方法
2. React的componentDidMount方法
3. Vue的mounted方法
可以只傳一個引數,就是你要截圖的DOM元素,該函式返回一個Promise物件,在它的then方法中可以獲取到繪製好的canvas物件,通過呼叫canvas物件的toDataURL方法就可以將其轉換成圖片。
拿到圖片的URL之後,我們可以
- 將其放到
<img>
標籤的src屬性中,讓其顯示在網頁中; - 也可以將其放到
<a>
標籤的href屬性中,將該圖片下載到本地磁碟中。
我們選擇後者。
html2canvas(document.querySelector('.main')).then(canvas => {
const link = document.createElement('a'); // 建立一個超連結物件例項
const event = new MouseEvent('click'); // 建立一個滑鼠事件的例項
link.download = 'Button.png'; // 設定要下載的圖片的名稱
link.href = canvas.toDataURL(); // 將圖片的URL設定到超連結的href中
link.dispatchEvent(event); // 觸發超連結的點選事件
});
是不是非常簡單?
引數
我們再來大致看一眼它的API,該函式的簽名如下:
html2canvas(element: HTMLElement, options: object): Promise<HTMLCanvasElement>
options物件可選的值如下:
Name | Default | Description |
---|---|---|
allowTaint | false |
是否允許跨域影像汙染畫布 |
backgroundColor | #ffffff |
畫布背景顏色,如果在DOM中沒有指定,設定“null”(透明) |
canvas | null |
使用現有的“畫布”元素,用來作為繪圖的基礎 |
foreignObjectRendering | false |
是否使用ForeignObject渲染(如果瀏覽器支援的話) |
imageTimeout | 15000 |
載入影像的超時時間(毫秒),設定為“0”以禁用超時 |
ignoreElements | (element) => false |
從呈現中移除匹配元素 |
logging | true |
為除錯目的啟用日誌記錄 |
onclone | null |
回撥函式,當文件被克隆以呈現時呼叫,可以用來修改將要呈現的內容,而不影響原始源文件。 |
proxy | null |
用來載入跨域圖片的代理URL,如果設定為空(預設),跨域圖片將不會被載入 |
removeContainer | true |
是否清除html2canvas臨時建立的克隆DOM元素 |
scale | window.devicePixelRatio |
用於渲染的縮放比例,預設為瀏覽器裝置畫素比 |
useCORS | false |
是否嘗試使用CORS從伺服器載入影像 |
width | Element width |
canvas 的寬度 |
height | Element height |
canvas 的高度 |
x | Element x-offset |
canvas 的x軸位置 |
y | Element y-offset |
canvas 的y軸位置 |
scrollX | Element scrollX |
渲染元素時使用的x軸位置(例如,如果元素使用position: fixed ) |
scrollY | Element scrollY |
渲染元素時使用的y軸位置(例如,如果元素使用position: fixed ) |
windowWidth | Window.innerWidth |
渲染元素時使用的視窗寬度,這可能會影響諸如媒體查詢之類的事情 |
windowHeight | Window.innerHeight |
渲染元素時使用的視窗高度,這可能會影響諸如媒體查詢之類的事情 |
忽略元素
options有一個ignoreElements引數可以用來忽略某些元素,從渲染過程中移除,除了設定該引數外,還有一種忽略元素的方法,就是在需要忽略的元素上增加data-html2canvas-ignore
屬性。
<div data-html2canvas-ignore>Ignore element</div>
2 基本原理
介紹完html2canvas的使用,我們先來了解下它的基本原理,然後再分析細節實現。
它的基本原理其實很簡單,就是去讀取已經渲染好的DOM元素的結構和樣式資訊,然後基於這些資訊去構建截圖,呈現在canvas畫布中。
它無法繞過瀏覽器的內容策略限制,如果要呈現跨域圖片,需要設定一個代理。
3 主流程 html2canvas方法
基本原理很簡單,但原始碼裡面其實東西很多,我們一步一步來,先找到入口,然後慢慢除錯,走一遍大致的流程。
尋找入口檔案
拉取到原始碼,有很多方法可以找到入口檔案:
- 方法一:最簡單的方法是直接全域性搜尋
html2canvas
,這種方法效率很低,而且要碰運氣,不推薦 - 方法二:在專案中引入這個庫,呼叫它,跑起來,並在該方法前面打斷點進行除錯,一般能精確地找到入口檔案,推薦
- 方法三:觀察下是否有
webpack.config.js
或者rollup.config.js
的構建工具的配置檔案,然後在配置檔案中找到精確的入口檔案(一般是entry
或input
之類的屬性),推薦 - 方法四:直接掃一眼目錄結構,一般入口檔案在
src
/core
/packages
之類的目錄下,檔名是index
或者main
,或者是模組的名字,有經驗的話可以用這個方法,找起來很快,強烈推薦
方法一:全域性搜尋
最簡單最容易想到的的方法,就是全域性搜尋關鍵字html2canvas
,因為我們在不瞭解html2canvas的實現之前,我們接觸到的關鍵字就只有這一個。
但是全域性搜尋運氣不好的話,很可能搜出來很多結果,在裡面找入口檔案費時費力,比如:
42個檔案285個結果,找起來很麻煩,不推薦。
方法二:打斷點
在呼叫html2canvas的地方打一個斷點。
然後在執行到斷點處時,點選向下的小箭頭,進入該方法。
因為在開發環境,很快我們就能發現入口檔案和入口方法在哪兒,這裡顯示的是html2canvas檔案,實際上這個檔案是構建之後的檔案,但是這個檔案的上下文給我們提供了找入口方法的資訊,這裡我們發現了renderElement
方法。
這時我們可以嘗試全域性搜尋這個方法,很幸運直接找到了?
方法三:找配置檔案
尋找配置檔案一般也要靠經驗,一般配置檔案都會帶.config
字尾常見構建工具的配置檔案:
構建工具 | 配置檔案 |
---|---|
Webpack | webpack.config.js |
Rollup | rollup.config.js |
Gulp | glupfile.config.js |
Grunt | Gruntfile.js |
配置檔案找到,入口檔案一般很容易就找到
方法四:
方法四一般也要靠經驗,我們掃一眼目錄結構,其實很容易就能發現主入口src/index.ts
從主入口出發
我們已經找到了入口方法在src/index.ts
檔案中,先從主入口出發,把大致的呼叫關係梳理出來,對全域性有個基本的瞭解,然後再深入細節。
入口方法幾乎啥也沒做,直接返回了另一個方法renderElement
的呼叫結果。
// 入口方法
const html2canvas = (element: HTMLElement, options: Partial<Options> = {}): Promise<HTMLCanvasElement> => {
return renderElement(element, options);
};
沿著呼叫關係往下,很快我們就梳理出瞭如下簡易火焰圖(帶方法註釋)
這張簡易的火焰圖主要有兩點需要注意:
- 簡易火焰圖只是幫助我們對整個流程有個粗略的認識,這種認識既不細緻也不全面,需要進一步分析裡面的關鍵方法
- renderStackContent這個渲染層疊內容的方法是整個html2canvas最核心的方法,我們將在
4 渲染層疊內容
一章中單獨分析
將頁面中指定的DOM元素渲染到離屏canvas中 renderElement
通過簡易火焰圖,我們已經對html2canvas的主流程有了一個基本的認識,接下來我們一層一層來分析,先看renderElement方法。
這個方法的主要目的是將頁面中指定的DOM元素渲染到一個離屏canvas中,並將渲染好的canvas返回給使用者。
它主要做了以下事情:
- 解析使用者傳入的options,將其與預設的options合併,得到用於渲染的配置資料renderOptions
- 對傳入的DOM元素進行解析,取到節點資訊和樣式資訊,這些節點資訊會和上一步的renderOptions配置一起傳給canvasRenderer例項,用來繪製離屏canvas
- canvasRenderer將依據瀏覽器渲染層疊內容的規則,將使用者傳入的DOM元素渲染到一個離屏canvas中,這個離屏canvas我們可以在then方法的回撥中取到
renderElement方法的核心程式碼如下:
const renderElement = async (element: HTMLElement, opts: Partial<Options>): Promise<HTMLCanvasElement> => {
const renderOptions = {...defaultOptions, ...opts}; // 合併預設配置和使用者配置
const renderer = new CanvasRenderer(renderOptions); // 根據渲染的配置資料生成canvasRenderer例項
const root = parseTree(element); // 解析使用者傳入的DOM元素(為了不影響原始的DOM,實際上會克隆一個新的DOM元素),獲取節點資訊
return await renderer.render(root); // canvasRenderer例項會根據解析到的節點資訊,依據瀏覽器渲染層疊內容的規則,將DOM元素內容渲染到離屏canvas中
};
合併配置的邏輯比較簡單,我們直接略過,重點分析下解析節點資訊(parseTree)和渲染離屏canvas(renderer.render)兩個邏輯。
解析節點資訊 parseTree
parseTree的入參就是一個普通的DOM元素,返回值是一個ElementContainer物件,該物件主要包含DOM元素的位置資訊(bounds
: width
|height
|left
|top
)、樣式資料、文字節點資料等(只是節點樹的相關資訊,不包含層疊資料,層疊資料在parseStackingContexts方法中取得)。
解析的方法就是遞迴整個DOM樹,並取得每一層節點的資料。
ElementContainer物件是一顆樹狀結構,大致如下:
{
bounds: {height: 1303.6875, left: 8, top: -298.5625, width: 1273},
elements: [
{
bounds: {left: 8, top: -298.5625, width: 1273, height: 1303.6875},
elements: [
{
bounds: {left: 8, top: -298.5625, width: 1273, height: 1303.6875},
elements: [
{styles: CSSParsedDeclaration, textNodes: Array(1), elements: Array(0), bounds: Bounds, flags: 0},
{styles: CSSParsedDeclaration, textNodes: Array(1), elements: Array(0), bounds: Bounds, flags: 0},
{styles: CSSParsedDeclaration, textNodes: Array(1), elements: Array(0), bounds: Bounds, flags: 0},
{styles: CSSParsedDeclaration, textNodes: Array(3), elements: Array(2), bounds: Bounds, flags: 0},
...
],
flags: 0,
styles: {backgroundClip: Array(1), backgroundColor: 0, backgroundImage: Array(0), backgroundOrigin: Array(1), backgroundPosition: Array(1), …},
textNodes: []
}
],
flags: 0,
styles: CSSParsedDeclaration {backgroundClip: Array(1), backgroundColor: 0, backgroundImage: Array(0), backgroundOrigin: Array(1), backgroundPosition: Array(1), …},
textNodes: []
}
],
flags: 4,
styles: CSSParsedDeclaration {backgroundClip: Array(1), backgroundColor: 0, backgroundImage: Array(0), backgroundOrigin: Array(1), backgroundPosition: Array(1), …},
textNodes: []
}
裡面包含了每一層節點的:
- bounds - 位置資訊(寬/高、橫/縱座標)
- elements - 子元素資訊
- flags - 用來決定如何渲染的標誌
- styles - 樣式描述資訊
- textNodes - 文字節點資訊
渲染離屏canvas renderer.render
有了節點樹資訊,就可以用來渲染離屏canvas了,我們來看看渲染的邏輯。
渲染的邏輯在CanvasRenderer類的render方法中,該方法主要用來渲染層疊內容:
- 使用上一步解析到的節點資料,生成層疊資料
- 使用節點的層疊資料,依據瀏覽器渲染層疊資料的規則,將DOM元素一層一層渲染到離屏canvas中
render方法的核心程式碼如下:
async render(element: ElementContainer): Promise<HTMLCanvasElement> {
/**
* StackingContext {
* element: ElementPaint {container: ElementContainer, effects: Array(0), curves: BoundCurves}
* inlineLevel: []
* negativeZIndex: []
* nonInlineLevel: [ElementPaint]
* nonPositionedFloats: []
* nonPositionedInlineLevel: []
* positiveZIndex: [StackingContext]
* zeroOrAutoZIndexOrTransformedOrOpacity: [StackingContext]
* }
*/
const stack = parseStackingContexts(element);
// 渲染層疊內容
await this.renderStack(stack);
return this.canvas;
}
其中的
inlineLevel
- 內聯元素negativeZIndex
- zIndex為負的元素nonInlineLevel
- 非內聯元素nonPositionedFloats
- 未定位的浮動元素nonPositionedInlineLevel
- 內聯的非定位元素,包含內聯表和內聯塊positiveZIndex
- z-index大於等於1的元素zeroOrAutoZIndexOrTransformedOrOpacity
- 所有有層疊上下文的(z-index: auto|0)、透明度小於1的(opacity小於1)或變換的(transform不為none)元素
代表的是層疊資訊,渲染層疊內容時會根據這些層疊資訊來決定渲染的順序,一層一層有序進行渲染。
parseStackingContexts解析層疊資訊的方式和parseTree解析節點資訊的方式類似,都是遞迴整棵樹,收集樹的每一層的資訊,形成一顆包含層疊資訊的層疊樹。
而渲染層疊內容的renderStack方式實際上呼叫的是renderStackContent方法,該方法是整個渲染流程中最為關鍵的方法,下一章單獨分析。
4 渲染層疊內容 renderStackContent
將DOM元素一層一層得渲染到離屏canvas中,是html2canvas所做的最核心的事情,這件事由renderStackContent方法來實現。
因此有必要重點分析這個方法的實現原理,這裡涉及到CSS佈局相關的一些知識,我先做一個簡單的介紹。
CSS層疊佈局規則
預設情況下,CSS是流式佈局的,元素與元素之間不會重疊。
流式佈局的意思可以理解:在一個矩形的水面上,放置很多矩形的浮塊,浮塊會漂浮在水面上,且彼此之間依次排列,不會重疊在一起
這是要繪製它們其實非常簡單,一個個按順序繪製即可。
不過有些情況下,這種流式佈局會被打破,比如使用了浮動(float)和定位(position)。
因此需要需要識別出哪些脫離了正常文件流的元素,並記住它們的層疊資訊,以便正確地渲染它們。
那些脫離正常文件流的元素會形成一個層疊上下文,可以將層疊上下文簡單理解為一個個的薄層(類似Photoshop的圖層),薄層中有很多DOM元素,這些薄層疊在一起,最終形成了我們看到的多彩的頁面。
這些不同型別的層的層疊順序規則如下:
這張圖很重要,html2canvas渲染DOM元素的規則也是一樣的,可以認為html2canvas就是對這張圖描述的規則的一個實現。
詳細的規則在w3官方文件中有描述,大家可以參考:
https://www.w3.org/TR/css-position-3/#painting-order
renderStackContent就是對CSS層疊佈局規則的一個實現
有了這些基礎知識,我們分析renderStackContent就一目瞭然了,它的原始碼如下:
async renderStackContent(stack: StackingContext) {
// 1. 最底層是background/border
await this.renderNodeBackgroundAndBorders(stack.element);
// 2. 第二層是負z-index
for (const child of stack.negativeZIndex) {
await this.renderStack(child);
}
// 3. 第三層是block塊狀盒子
await this.renderNodeContent(stack.element);
for (const child of stack.nonInlineLevel) {
await this.renderNode(child);
}
// 4. 第四層是float浮動盒子
for (const child of stack.nonPositionedFloats) {
await this.renderStack(child);
}
// 5. 第五層是inline/inline-block水平盒子
for (const child of stack.nonPositionedInlineLevel) {
await this.renderStack(child);
}
for (const child of stack.inlineLevel) {
await this.renderNode(child);
}
// 6. 第六層是以下三種:
// (1) ‘z-index: auto’或‘z-index: 0’。
// (2) ‘transform: none’
// (3) opacity小於1
for (const child of stack.zeroOrAutoZIndexOrTransformedOrOpacity) {
await this.renderStack(child);
}
// 7. 第七層是正z-index
for (const child of stack.positiveZIndex) {
await this.renderStack(child);
}
}
小結
本文主要介紹html2canvas實現瀏覽器截圖的原理。
首先簡單介紹html2canvas是做什麼的,如何使用它;
然後從主入口出發,分析html2canvas渲染DOM元素的大致流程(簡易火焰圖);
接著按火焰圖的順序,依次對renderElement方法中執行的parseTree/parseStackingContextrenderer.render三個方法進行分析,瞭解這些方法的作用和原理;
最後通過介紹CSS佈局規則和7階層疊水平,自然地引出renderStackContent關鍵方法實現原理的介紹。
加入我們
我們是DevUI團隊,歡迎來這裡和我們一起打造優雅高效的人機設計/研發體系。招聘郵箱:muyang2@huawei.com。
文/DevUI Kagol
往期文章推薦
《在瀑布下用火焰烤餅:三步法助你快速定位網站效能問題(超詳細)》