引语
html2canvas是一个非常流行的前端开源工具,在GitHub上已有25k+的stars。在项目开发中,我发现了它在渲染某些DOM时存在裁剪错误的bug,出于好奇,我对它的渲染原理进行深入研究,并且修复了这个bug。如果你也遇到过类似的问题,不妨来看看这篇文章。
一、起因
在前端项目中,我们很多时候会用到html2canvas这个开源工具实现一些生成截图的需求。就像它的名字一样,html2canvas的主要功能是将前端页面中的HTML按照一定规则绘制在Canvas中,之后我们可以对Canvas进行读取和导出,从而达到类似于截图的效果。
在某次测试中,我们发现项目中的一些多选框tag在渲染出的pdf中会被错误地截断,如下图所示:
通过排查 【HTML-> Canvas -> 图片 -> PDF】这条链路上的各个环节,我将问题定位到了【HTML绘制至Canvas】这个环节。
通过检查项目中的样式代码,我发现这个问题是由以下这组样式(仅作为示意)导致的:
.tags {
position: absolute;
top: 50%;
transform: translateY(-50%);
overflow-x: hidden;
}
复制代码
经过测试,我认为问题出在transform
和overflow
这两个属性上。
二、渲染原理解析
通过前面的简单测试,我初步将问题定位为html2canvas本身的bug。同时我对它的渲染原理也产生了好奇心,于是产生了阅读源码并且找到bug根源的想法。
html2canvas源码中的东西非常多,初次阅读的时候不知如何下手,这时我在掘金找到了《 html2canvas实现浏览器截图的原理》这篇文章进行了阅读,对这个开源库的大概结构和基本原理有了一定程度的认识。
之后我对源码进行了更为深入的阅读和调试,尤其是针对上述文章末尾没有详细介绍的【渲染阶段】的原理,最后发现bug就出现在这一阶段,下面进行详细介绍。
2.1 原理概述
html2canvas的基本原理是对页面中已渲染的DOM元素的结构与样式信息进行解析,并且根据这些信息生成层叠结构,最后根据这些层叠结构按照顺序一层一层地在Canvas上绘制各个元素。
我将这一过程分为以下三个阶段
-
解析原始DOM为
ElementContainer
(与原始DOM的层级结构类似) -
解析
ElementContainer
为一组树状的层叠上下文(stackingContext,与原始DOM的层级结构区别较大) -
从最上层的层叠上下文开始,递归地对层叠上下文各层中的节点和子层叠上下文进行解析并按顺序绘制在Canvas上,针对要绘制的每个节点,主要有以下两个过程:
- 解析节点的”效果“(变换、剪切、透明度)并应用于Canvas上
- 在Canvas上绘制
下面将对三个阶段进行有针对性的介绍,由于本文的重点在于渲染过程,而且上面提到的文章已经对源码中的各个方法、函数进行了比较全面的介绍,下面将不再进行赘述,仅重点介绍其中比较关键的部分。
2.2 第一阶段 – 解析DOM结构
在这一阶段中,首先使用了DocumentCloner
对原始的DOM在iframe中进行了克隆。在此之后,根据这个工具提供了两种渲染模式:
- Canvas渲染模式,常规方案
- foreignObject渲染模式,将DOM节点作为foreignObject插入SVG节点中进行渲染
这是两条完全不同的路线,在此我们只对常规的Canvas渲染模式进行介绍,这条路线的第一步是使用parseTree
函数对克隆得到的DOM进行解析:
parseTree
函数从最顶端的节点开始,对DOM进行深度优先遍历,对每一个遍历到的节点创建一个ElementContainer
对象,这个对象中存储了该节点的边界bounds
、样式styles
、所有子节点(分为文字节点textNodes
与其他节点elements
)以及一组特殊的标志位flags
其中,可能的标志位如下:
export const enum FLAGS {
CREATES_STACKING_CONTEXT = 1 << 1, // 是否为层叠上下文
CREATES_REAL_STACKING_CONTEXT = 1 << 2, // 是否为”真“层叠上下文
IS_LIST_OWNER = 1 << 3, // 是否为ol、ul、menu等列表容器
DEBUG_RENDER = 1 << 4 // 该工程自身的debug相关
}
复制代码
这些标记中主要关注的是CREATES_STACKING_CONTEXT
和CREATES_REAL_STACKING_CONTEXT
,这两个标记的作用都是在下一阶段中提示该元素需要被视作一个单独的层叠上下文,而它们的处理方式又略有不同,在下一阶段中将进行详细介绍。
2.3 第二阶段 – 生成层叠上下文
在这一阶段中html2canvas通过parseStackingContexts
方法解析上一阶段产生的ElementContainer
树来生成层叠上下文。树中的每一个ElementContainer
节点都会产生一个ElementPaint
对象,这个对象将会被直接作为第三阶段绘制该节点的依据。
ElementPaint
export class ElementPaint {
// ...
/**
* @param container 原始的ElementContainer对象
* @param parent 父节点的ElementPaint
*/
constructor(readonly container: ElementContainer, readonly parent: ElementPaint | null) {
// ...
}
// ...
}
复制代码
层叠上下文简介
CSS层叠(层叠上下文 – CSS(层叠样式表) | MDN)描述了一种划分元素显示层级的规则,在这套规则下,不同元素被划分到不同的层叠上下文中,形成了一种类似于图层的关系。单个层叠上下文描述了该上下文中元素的堆叠顺序,而层叠上下文之间可以进行嵌套,形成更为复杂的层级结构。
层叠上下文的划分标准
为了使得绘制的表现尽量接近浏览器中的真实结果,html2canvas将ElementContainer
树中的每个节点划分到不同的层叠上下文:
export class StackingContext {
element: ElementPaint; // 层叠上下文顶端节点
negativeZIndex: StackingContext[]; // z-index为负的次级上下文
// z-index为0|auto || 设置了tranform || 设置了Opacity的次级上下文
zeroOrAutoZIndexOrTransformedOrOpacity: StackingContext[];
positiveZIndex: StackingContext[]; // z-index为正的次级层叠
nonPositionedFloats: StackingContext[]; // 浮动次级层叠
nonPositionedInlineLevel: StackingContext[]; // 内联次级层叠
inlineLevel: ElementPaint[]; // 内联子元素 非上下文
nonInlineLevel: ElementPaint[]; // 块级子元素 非上下文
constructor(container: ElementPaint) {
this.element = container;
this.inlineLevel = [];
this.nonInlineLevel = [];
this.negativeZIndex = [];
this.zeroOrAutoZIndexOrTransformedOrOpacity = [];
this.positiveZIndex = [];
this.nonPositionedFloats = [];
this.nonPositionedInlineLevel = [];
}
}
复制代码
在上一阶段,我们提到了两个特殊标记CREATES_STACKING_CONTEXT
和CREATES_REAL_STACKING_CONTEXT
,如果一个ElementContainer
元素拥有这两个标记中的一个,就意味着它将被被视作一个独立的层叠上下文来管理它的子节点:
parseStackingContexts
函数通过调用parseStackTree
函数来构建层叠上下文:
const parseStackTree = (
parent: ElementPaint,
stackingContext: StackingContext, // 第一种层叠上下文
realStackingContext: StackingContext, // 第二种层叠上下文
listItems: ElementPaint[]
) => {
// ...
}
export const parseStackingContexts = (container: ElementContainer): StackingContext => {
const paintContainer = new ElementPaint(container, null);
const root = new StackingContext(paintContainer);
const listItems: ElementPaint[] = [];
// 在这里划分层叠上下文
// 初始的stackingContext和realStackingContext均为要解析的顶部节点root
parseStackTree(paintContainer, root, root, listItems);
processListItems(paintContainer.container, listItems);
return root;
};
复制代码
parseStackTree
通过递归的方式构建层叠上下文,它的初始调用将顶部节点同时作为stackingContext和realStackingContext传入(在递归过程中会发生变化),然后在遍历的过程中也会对每个节点的这两个标记进行判断和划分,从而决定它要放入的层叠上下文同时更新递归中的层叠上下文,这两种层叠上下文的判断标准与处理方式如下:
-
stackingContext
- 判断规则 :设置了position || 设置了float
- 归纳去向 :当前stackingContext
- 后续递归 :作为新的stackingContext
-
realStackingContext
- 判断规则 :设置了position && 设置了z-index || opacity < 1 || 设置了transform || 是body节点并且root是透明的
- 归纳去向 :当前realStackingContext
- 后续递归 :作为新的realStackingContext
层叠上下文总结
html2Canvas的中的层叠上下文本质上是一颗包含了所有待渲染节点的N叉树,而后续的绘制顺序则是这颗N叉树的深度优先遍历。一个节点的position
/opacity
/ transform
/z-index
/float
等属性可以决定它被在这颗N叉树中所处的层级(处于哪一个层叠上下文)以及在一个层级中所处的位置(在一个层叠上下文中所处的层叠数组),这些将会最终影响它在整个绘制顺序中所处的位置。
2.4 第三阶段 – 遍历层叠上下文并绘制节点
过程概述
在这一过程中,html2canvas对层叠上下文这颗N叉树中的每一个“叶子结点”进行以下顺序的两次绘制:
- 绘制节点背景和边框
- 绘制节点内容
在每一次绘制中,又存在以下顺序的两次操作:
- 解析并在Canvas上应用节点的”效果“(Effects)
- 在Canvas上填充内容
在绘制前解析并应用效果
async renderNodeContent(paint: ElementPaint): Promise<void> {
this.applyEffects(paint.getEffects(EffectTarget.CONTENT));
// ...
}
复制代码
通过对源码的分析,我认为【在Canvas上填充内容】这一阶段与文章开头提到的bug无关,然后将源码解析的重点放在了前一步【解析并在Canvas上应用节点的”效果“】,大家对第二步感兴趣的可自行阅读源码,在此不进行讲述。
“效果”简介
在第二阶段 – 解析ElementContainer
树并构建层叠上下文时,每个ElementContainer
节点在构建ElementPaint
时,节点的一些css属性设置可能会触发一些对应的“效果”,这些“效果”会被存放在节点的ElementPaint.effects
中,有以下三种“效果”:
export const enum EffectType {
TRANSFORM = 0, // 变换效果,设置transform时触发
CLIP = 1, // 剪切效果,overflow不为visible时触发
OPACITY = 2 // 不透明度效果,opacity < 1时触发
}
复制代码
这三种“效果”通过调用CanvasRenderingContext2D
(CanvasRenderingContext2D – Web API 接口参考 | MDN)的API来发挥作用:
OpacityEffect
this.ctx.globalAlpha = effect.opacity;
复制代码
TransformEffect
this.ctx.translate(effect.offsetX, effect.offsetY);
this.ctx.transform(
effect.matrix[0],
effect.matrix[1],
effect.matrix[2],
effect.matrix[3],
effect.matrix[4],
effect.matrix[5]
);
this.ctx.translate(-effect.offsetX, -effect.offsetY);
复制代码
OpacityEffect
this.path(effect.path);
this.ctx.clip();
复制代码
“效果”的归集
回到本阶段的过程概述,我们知道在一次绘制中第一步要做的是解析并应用该节点的“效果”。在这一步操作前,节点ElementPaint
对象中已经存储了从自身解析到的所有“效果”,而这一步的操作则是从节点自身出发,不断向前查找祖先节点,并将祖先节点的效果归集到自身的effects
中
ElementPaint中的“效果”
export class ElementPaint {
// 层叠上下文构建阶段解析到的节点自身效果
readonly effects: IElementEffect[] = [];
// ...
/**
* 归集该节点上的所有“效果”,包括自身效果和从祖先节点继承的效果
* @param target “效果”作用的目标:背景&边框 或 内容
*/
getEffects(target: EffectTarget): IElementEffect[] {
// ...
let parent = this.parent;
const effects = this.effects.slice(0);
while (parent) {
// ...
// 判断并归集效果
// ...
parent = parent.parent; // 寻找上一个祖先节点
}
return effects.filter((effect) => contains(effect.target, target));
}
}
复制代码
“效果”的解析和归集
“效果”的应用
CanvasRenderer
从效果数组的头部开始遍历,将归集到的效果应用到canvas上,每次应用效果前都会将当前canvas的状态用CanvasRenderingContext2D.save()
(CanvasRenderingContext2D.save() – Web API 接口参考 | MDN)进行存储,并在应用效果后,将所应用的效果记录在_activeEffects
中。
在绘制下一个结果时,_activeEffects
中记录的所有效果会被依次弹出,每弹出一个效果,便使用CanvasRenderingContext2D.restore()
(CanvasRenderingContext2D.restore() – Web API 接口参考 | MDN)对canvas进行一次状态回溯,这样的最终结果是将先前节点绘制时应用的所有效果全部清除。
从这一点我们可以得知,节点绘制的顺序并不会对节点的效果应用产生任何影响,因为每一次绘制都会清除上一个节点造成的效果,每一次绘制都是独立的。
export class CanvasRenderer extends Renderer {
canvas: HTMLCanvasElement;
ctx: CanvasRenderingContext2D;
// canvas上当前被应用的效果
private readonly _activeEffects: IElementEffect[] = [];
// ...
applyEffects(effects: IElementEffect[]): void {
// 清除canvas上当前所有效果
while (this._activeEffects.length) {
this.popEffect();
}
// 应用每一个效果
effects.forEach((effect) => this.applyEffect(effect));
}
applyEffect(effect: IElementEffect): void {
this.ctx.save(); // 存储应用效果前canvas的状态
// ...
// 调用CanvasRenderingContext2D的API来应用效果
// ...
this._activeEffects.push(effect); // 记录应用过的效果
}
popEffect(): void {
this._activeEffects.pop();
this.ctx.restore(); // canvas状态回退一步
}
}
复制代码
三、BUG分析
最后回到文章开头提到的bug上,我们最终的目的是要找出DOM被错误裁剪的问题根源。首先,我在手动对这个问题进行了复现以简化问题场景,减少调试的工作量。
3.1 最小问题场景复现
我们可以看到,我们在父节点(蓝色div)上应用了transform
和overflow
两个属性,该容器的绘制表现是正常的,而它的后代节点(红色div和p文字部分)均出现了不正确的裁剪。
示例代码
<html>
<head lang="en">
<meta charset="UTF-8">
<style>
canvas {
}
#background {
position: relative;
height: 100px;
background-color: rgb(141, 141, 141);
}
#parent {
background: rgba(100, 255, 255, 0.5);
position: absolute;
width: 100%;
height: 50px;
transform: translateY(40%);
overflow-y: hidden;
}
#child {
background-color: red;
width: 100%;
height: 80%;
}
</style>
</head>
<body>
<h2>待绘制HTML DOM</h2>
<div id="background">
<div id="parent">
<div id="child">
<p>子元素子元素子元素子元素子元素子元素子元素子元素</p>
</div>
</div>
</div>
<h2>绘制得到的Canvas</h2>
<canvas width="1700" height="200"></canvas>
<script type="text/javascript" src="https://html2canvas.hertzen.com/dist/html2canvas.min.js"></script>.min.js"></script>
<script type="text/javascript">
var canvas = document.querySelector("canvas");
html2canvas(document.querySelector("#background"), {canvas: canvas, scale: 1, logging: true})
</script>
</body>
</html>
复制代码
3.2 初步定位问题
根据前面的源码解析,以及问题的发生场景,我们可以对问题发生的阶段进行大致定位:
- 第一阶段:仅仅是解析归纳各节点信息,问题场景中的css属性不会在此阶段产生差异,排除。
- 第二阶段:transform属性可能造成层叠上下文的划分差异,可疑。
- 第三阶段:transform属性和overflow属性分别会触发transform效果和clip效果,高度可疑。
起初项目中出现该bug时,我们曾考虑过这些元素被错误遮挡的可能性,这看起来与第二阶段的分析结果有着很大的关联性。但是通过进一步的调试,我发现最小问题场景这个bad case中,transform属性并没有对层叠上下文的解析结果产生影响,而且也不存在可以遮挡这些元素的其他元素。因此,我们可以排除第二阶段,确定问题的本质是裁剪而非遮挡。
3.3 问题原因详解
提出假设
回看第三阶段的解析过程,我们可以发现一个奇怪的事情:归集效果时,节点自身效果的排列顺序是【非剪切效果在前,剪切效果在后】,而从祖先节点继承到的效果则是【剪切效果在前,非剪切效果在后】。这种导致了节点绘制在应用祖先的效果时先应用了剪切效果,而应用自身效果时则是后应用剪切效果。
为什么对相似的两组(或多组)效果,应用效果的顺序却完全相反?这种差异看起来是毫无缘由的,而代码中又没有相关的注释,我们也无法马上得知这是处于疏忽还是有意为之。对此,我们可以先假设这种差异是有问题的:
验证假设
这种效果应用顺序的差异最终体现在clip()
和translate()
两个CanvasRenderingContext2D
API的调用顺序差异,这样我们就进一步减少了验证假设的成本,可以通过手动调用者两个API来验证。
我们可以发现,在变化画布前设置剪切区域,确实会造成类似的问题:
<h2>HTML</h2>
<div id="rect"></div>
<h2>Canvas</h2>
<canvas id="canvas"></canvas>
<style>
#rect {
width: 100px;
height: 100px;
transform: translateX(50px);
background: red;
margin-bottom: 20px;
}
</style>
<script>
var canvas = document.getElementById("canvas");
var ctx = canvas.getContext("2d");
// 手工模拟ClipEffect
ctx.rect(0, 0, 100, 100)
ctx.clip();
/* 调换顺序即可正常绘制 */
// 手工模拟TransformEffect
ctx.translate(50, 0)
ctx.fillRect(0, 0, 100,100);
</script>
复制代码
到目前为止,我们已经可以确认问题出在”效果“的应用顺序上,但是为了得到更具体的原因,我们需要对原理进行进一步的解析。
原理解析
通过手动操作Canvas进行验证,我们可以发现出现这种问题的原因是剪切区域没有随画布网格的移动而移动造成的。
在预想的状况中,在画布网格的(0,0) – (50,50)的正方形区域设置剪切区域,之后对画布网格进行移动,剪切区域应该随之移动。但是实际的情况是,剪切区域一旦设置就不会发生变化,画布网格移动时剪切区域会留在原地,因此造成了绘制内容被错误裁切。
通过进一步阅读MDN上Canvas的文档,我发现关键的问题并不是出在clip()
这个API上面,而是在于路径相关的API。这是MDN上对于clip()
的介绍:
CanvasRenderingContext2D.clip()
是 Canvas 2D API 将当前创建的路径设置为当前剪切路径的方法。
我认为clip()
只是根据当前路径进行剪切,而具体的剪切区域则由当前路径决定,而已设置的路径不会跟随画布网格移动。对这个假设我再次进行了以下验证:
- 用蓝色从画布网格左上角开始部分填充来标记画布初始的网格位置
- 在画布网格左上角设置一个正方形路径,并用红色标记
- 向右移动画布网格
- 再次从网格左上角开始用绿色填充来标记画布最后的网格位置
<canvas id="canvas"></canvas>
<style>
#canvas {
background: white;
border: 1px solid black;
}
</style>
<script>
var canvas = document.getElementById("canvas");
var ctx = canvas.getContext("2d");
// 从画布(0,0)开始用蓝色填充画布,记录移动前画布网格位置
ctx.fillStyle = 'rgba(106, 211, 252, 0.39)'
ctx.fillRect(0, 0, 200, 150);
// 在画布(0,0)处画一条100*100的正方形路径
ctx.rect(0, 0, 100, 100)
ctx.strokeStyle = 'red'
ctx.stroke()
// 向右移动画布网格50px
ctx.translate(50, 0)
// 以相同的方式用绿色填充画布,显示当前画布网格位置
ctx.fillStyle = 'rgba(123, 252, 106, 0.39)'
ctx.fillRect(0, 0, 200, 150);
</script>
复制代码
我们可以看到,已设置的路径并不会跟随画布网格移动,这也印证了前面的想法。
3.4 问题解决
既然已经知道了问题的根本原因是剪切区域与画布移动的错位,而错误发生的地方是归集祖先节点”效果“的部分,那么解决方案就是将归集”效果“的顺序修正过来,非常简单,只需要移动一行代码:
结语
根据GitHub上的提交记录,这部分代码(fix: overflows with absolutely positioned content by niklasvh · Pull Request #2663 · niklasvh/html2canvas · GitHub) 提交于2021年8月,是一个比较新的提交。这个提交也是为了修复一个overflow属性造成的可见性问题,对归集Effects这部分代码做了比较大的重构,然后在这一部分引入了新的问题,大家感兴趣的可以自行到GitHub查看相关记录。
通过自己的测试用例以及项目本身的测试用例进行测试,这个改动成功修复了问题,并且目前没有发现引入新的问题。目前这个改动已经作为PR提交,但是作者目前还没有给出任何回应。
如果大家在开发中遇到了类似的问题,可以参考这篇文章中的原因解析和修复方式。如果文章中的解析或者代码存在错误,欢迎指正,谢谢!