避坑指南!手把手帶你解讀html2canvas的實現(xiàn)原理

導語 | html2canvas在前端通常用于合成海報、生成截圖等場景。本文從一次蒙層截圖失敗對html2canvas的實現(xiàn)原理展開詳細探討,帶你完美避坑!
一、問題背景
在一個前端項目中,有對當前頁面進行截屏并上傳的需求。安裝了html2canvas的npm包后,實現(xiàn)頁面截圖時,發(fā)現(xiàn)html2canvas將原本有透明度的蒙層截圖為了沒有透明度的蒙層,如下面兩張圖所示:


顯然這并不能滿足前端截屏的需求,于是進行google,終于查到了相關問題。原來html2canvas渲染opacity失敗的問題自2015年起就已存在,雖然niklasvh在2020年12月修復了該問題,但是并沒有合并入npm包中。所以當使用html2canvas的npm包實現(xiàn)截圖時,仍然存在opacity渲染失敗的問題。
為了徹底搞明白html2canvas渲染opacity失敗的問題,我們先對html2canvas的實現(xiàn)原理進行剖析。
二、html2canvas原理剖析
(一)流程圖
如下圖所示,將html2canvas原理圖形化,主要分成出口供用戶使用的主要流程和兩部分核心邏輯:克隆并解析DOM節(jié)點、渲染DOM節(jié)點。

(二)html2canvas方法
html2canvas是出口方法,主要將用戶選擇的DOM節(jié)點和自定義配置項傳遞給renderElement方法。簡要邏輯代碼如下:
const html2canvas = (element: HTMLElement, options: Partial<Options> = {}): Promise<HTMLCanvasElement> => {return renderElement(element, options);};
renderElement方法,主要把用戶自定義配置與默認配置進行合并,生成CanvasRenderer實例,克隆、解析并渲染用戶選擇的DOM節(jié)點。簡要邏輯代碼如下:
const renderElement = async (element: HTMLElement, opts: Partial<Options>): Promise<HTMLCanvasElement> => {const renderOptions = {...defaultOptions, ...opts}; // 合并默認配置與用戶自定義配置const renderer = new CanvasRenderer(renderOptions); // 根據(jù)渲染配置數(shù)據(jù)生成CanvasRenderer實例const documentCloner = new DocumentCloner(element, options); // 生成DocumentCloner實例const clonedElement = documentCloner.clonedReferenceElement; // createNewHtml層層遞歸查找用戶選擇的DOM元素,并克隆const root = parseTree(clonedElement); // 解析克隆的DOM元素,獲取節(jié)點信息const canvas = await renderer.render(root); // CanvasRenderer實例將克隆的DOM元素內(nèi)容渲染到離屏canvas中return canvas;};
(三)克隆并解析DOM節(jié)點
CanvasRenderer是canvas渲染類,后續(xù)使用的渲染方法均是該類的方法。在克隆并解析DOM節(jié)點部分,主要是將renderOptions傳給canvasRenderer實例,調(diào)用render方法來繪制canvas。
DocumentCloner是DOM克隆類,主要是生成documentCloner實例,克隆用戶所選擇的DOM節(jié)點。其核心方法cloneNode通過遞歸整個DOM結構樹,匹配查詢用戶選擇的DOM節(jié)點并進行克隆,簡要邏輯代碼如下:
cloneNode(node: Node): Node {const window = node.ownerDocument.defaultView;if (window && isElementNode(node) && (isHTMLElementNode(node) || isSVGElementNode(node))) {const clone = this.createElementClone(node);if (this.referenceElement === node && isHTMLElementNode(clone)) {this.clonedReferenceElement = clone;}...for (let child = node.firstChild; child; child = child.nextSibling) {if (!isElementNode(child) || (!isScriptElement(child) && !child.hasAttribute(IGNORE_ATTRIBUTE) && (typeof this.options.ignoreElements !== 'function' || !this.options.ignoreElements(child)))) {if (!this.options.copyStyles || !isElementNode(child) || !isStyleElement(child)) {clone.appendChild(this.cloneNode(child));}}} // 層層遞歸DOM樹,查找匹配并克隆用戶所選擇的DOM節(jié)點...return clone;}return node.cloneNode(false);} // 輸出格式為DOM節(jié)點格式
parseTree方法是解析克隆DOM節(jié)點,獲取節(jié)點的相關信息。parseTree層層遞歸克隆DOM節(jié)點,獲取DOM節(jié)點的位置、寬高、樣式等信息,簡要邏輯代碼如下:
export const parseTree = (element: HTMLElement): ElementContainer => {const container = createContainer(element);container.flags |= FLAGS.CREATES_REAL_STACKING_CONTEXT;parseNodeTree(element, container, container);return container;};const parseNodeTree = (node: Node, parent: ElementContainer, root: ElementContainer) => {for (let childNode = node.firstChild, nextNode; childNode; childNode = nextNode) {nextNode = childNode.nextSibling;if (isTextNode(childNode) && childNode.data.trim().length > 0) {parent.textNodes.push(new TextContainer(childNode, parent.styles));} else if (isElementNode(childNode)) {const container = createContainer(childNode);if (container.styles.isVisible()) {...parent.elements.push(container);if (!isTextareaElement(childNode) && !isSVGElement(childNode) && !isSelectElement(childNode)) {parseNodeTree(childNode, container, root);}}}}// 層層遞歸克隆DOM節(jié)點,解析獲取節(jié)點信息};
parseTree輸出的格式如下:
const ElementContainer = {bounds: Bounds {left: 8, top: 8, width: 389, height: 313.34375},elements: [{bounds: Bounds {left: 33, top: 33, width: 339, height: 263.34375}elements: [],flags: 0,style: CSSParsedDeclaration {backgroundClip: Array(1), backgroundColor: 4289003775, …},textNodes: [],},...],flags: 4,style: styles: CSSParsedDeclaration {backgroundClip: Array(1), backgroundColor: 4278190335, …},textNodes: [],}bounds:位置、寬高elements:子元素flags:如何渲染的標志style:樣式textNodes:文本節(jié)點
(四)層疊上下文
在探討html2canvas渲染DOM節(jié)點的實現(xiàn)原理之前,先來闡明一下什么是層疊上下文。
層疊上下文(stacking content),是HTML中的一種三維概念。如果一個節(jié)點含有層疊上下文,那么在下圖的Z軸中距離用戶更近。

當一個節(jié)點滿足以下條件中的任意一個,則該節(jié)點含有層疊上下文。
文檔根元素<html>
position為absolute或relative,且z-index不為auto
position為fixed或sticky
flex容器的子元素,且z-index不為auto
grid容器的子元素,且z-index不為auto
opacity小于1
mix-blend-mode不為normal
transform、filter、perspective、clip-path、mask/mask-imag/mask-border不為none
isolation為isolate
-webkit-overflow-scrolling為touch
will-change為任意屬性值
contain為layout、paint、strict、content
著名的7階層疊水平對DOM節(jié)點進行分層,如下圖所示:

通過以下html結構對7階層疊水平進行驗證時,發(fā)現(xiàn)層疊水平為:z-index為負的節(jié)點在background/border的下面,與7階層疊水平有所出入。
<div style="width: 300px; height: 120px;background: #ccc; border: 20px solid #F56C6C"><span style="color: #fff;margin-left: -20px;">內(nèi)聯(lián)元素內(nèi)聯(lián)元素內(nèi)聯(lián)元素內(nèi)聯(lián)元素內(nèi)聯(lián)元素</span><div style="width: 200px;height: 100px;background: #67C23A; margin-left: -20px; margin-top: -10px;"></div><div style="float: left; width: 150px; height: 100px; background: #409EFF; margin-top: -110px;"></div><div style="position: relative; background: #E6A23C; width: 100px; height: 100px; margin-top: -100px;"></div><div style="position: absolute; z-index: 1; background: yellow; width: 50px; height: 50px; top: 110px;"></div><div style="position: absolute; z-index: -1; background: #000; height: 200px; width: 100px; top: 90px"></div></div>

但是,當父元素具有定位和z-index屬性時,z-index為負的節(jié)點在background/border上面,與7階層疊水平相印證。
<div style="width: 300px; height: 120px; background: #ccc; border: 20px solid #F56C6C; position: relative; z-index: 0; "><span style="color: #fff; margin-left: -20px;">內(nèi)聯(lián)元素內(nèi)聯(lián)元素內(nèi)聯(lián)元素內(nèi)聯(lián)元素內(nèi)聯(lián)元素</span><div style="width: 200px; height: 100px; background: #67C23A; margin-left: -20px; margin-top: -10px;"></div><div style="float: left; width: 150px; height: 100px; background: #409EFF; margin-top: -110px;"></div><div style="position: relative; width: 100px; height: 100px; background: #E6A23C; margin-top: -100px;"></div><div style="position: absolute; width: 50px; height: 50px; z-index: 1; background: yellow; top: -10px;"></div><div style="position: absolute; height: 200px; width: 100px; z-index: -1; background: #000; top: -30px"></div></div>

(五)渲染DOM節(jié)點
html2canvas是依據(jù)層疊上下文對DOM節(jié)點進行渲染。所以,在渲染DOM節(jié)點之前,需要先獲取DOM節(jié)點的層疊上下文。parseStackingContexts方法對克隆的DOM節(jié)點進行解析,獲取了克隆DOM節(jié)點的層疊上下文關系,其輸出的格式如下:
const StackingContext = {element: ElementPaint {container: ElementContainer, effects: Array(0), curves: BoundCurves},inlineLevel: [],negativeZIndex: [],nonInlineLevel: [ElementPaint],nonPositionedFloats: [],nonPositionedInlineLevel: [],positiveZIndex: [],zeroOrAutoZIndexOrTransformedOrOpacity: [],};// element: parseTree輸出的ElementContainer、DOM節(jié)點邊界信息、特殊渲染效果// inlineLevel:內(nèi)聯(lián)元素// negativeZIndex:z-index為負的元素// nonInlineLevel:非內(nèi)聯(lián)元素// nonPositionedFloats:未定位的浮動元素// nonPositionedInlineLevel:未定位的內(nèi)聯(lián)元素// positiveZIndex:z-index為正的元素// zeroOrAutoZIndexOrTransformedOrOpacity:z-index: auto|0、opacity小于1,transform不為none的元素
然后,renderStack方法調(diào)用renderStackContent方法遵循層疊上下文,自底層向上層層渲染DOM節(jié)點,簡要邏輯代碼如下:
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. 第六層z-index: auto 或 z-index: 0, transform: none, opacity < 1for (const child of stack.zeroOrAutoZIndexOrTransformedOrOpacity) {await this.renderStack(child);}// 7. 第七層正z-index.for (const child of stack.positiveZIndex) {await this.renderStack(child);}}
最后,在方法renderNodeBackgroundAndBorders和方法renderNodeContent內(nèi)部,調(diào)用了方法applyeffects的特殊效果進行渲染。而html2canvas的npm包中,缺少了透明度渲染效果的處理邏輯。這正是文章開頭出現(xiàn)的透明蒙層截圖失敗的根源所在。
三、問題定位與解決
通過對比niklasvh提交的版本記錄fix: opacity with overflow hidden #2450,發(fā)現(xiàn)新增了一個透明度渲染效果的處理邏輯,簡要代碼邏輯如下:
export class OpacityEffect implements IElementEffect {readonly type: EffectType = EffectType.OPACITY;readonly target: number = EffectTarget.BACKGROUND_BORDERS | EffectTarget.CONTENT;readonly opacity: number;constructor(opacity: number) {this.opacity = opacity;}}export const isOpacityEffect = (effect: IElementEffect): effect is OpacityEffect => effect.type === EffectType.OPACITY;
在parseStackingContexts解析DOM節(jié)點層疊上下文,輸出StackingContext時,在element的ElementContainer中新增了記錄節(jié)點透明度的邏輯,簡要代碼邏輯如下:
if (element.styles.opacity < 1) {this.effects.push(new OpacityEffect(element.styles.opacity));}
最后在applyEffects方法中,對DOM節(jié)點的透明度進行渲染,簡要代碼邏輯如下:
if (isOpacityEffect(effect)) {this.ctx.globalAlpha = effect.opacity;}
至此,將上述邏輯融合進html2canvas的npm包后,可解決透明蒙層截圖失敗的問題。
參考資料
1.深入理解CSS中的層疊上下文和層疊順序
2.css的層疊上下文
3.html2canvas實現(xiàn)瀏覽器截圖的原理(包含源碼分析的通用方法)
作者簡介
劉孟
騰訊前端開發(fā)工程師
劉孟,騰訊前端開發(fā)工程師,畢業(yè)于上海大學。目前負責騰訊優(yōu)聯(lián)項目的前端開發(fā)工作,有豐富的系統(tǒng)平臺及游戲營銷活動前端開發(fā)經(jīng)驗。
推薦閱讀
如何在C++20中實現(xiàn)Coroutine及相關任務調(diào)度器?(實例教學)
10個技巧!實現(xiàn)Vue.js極致性能優(yōu)化(建議收藏)


