本文深入剖析手写html2canvas的实现过程,涵盖DOM树解析、层叠上下文分组、画布创建及渲染四大核心步骤。重点解析z-index层级处理、Transform与Opacity的全局属性设置、文本与图片渲染细节,并对比HTML转SVG的替代方案,帮助开发者理解转换原理及常见兼容性问题。
前言
近期对 html2canvas 进行了深度拆解,完成了一个基础可用的 demo。尽管功能精简,但核心逻辑完整,具体效果如下(配色风格独特,颇具辨识度)👇🏻:

接下来我们将逐步实现该功能🌈。项目源码地址见文内链接。
感性认识
目前已知输入为 HTML 结构,目标是将其绘制到 Canvas 中。具体实现路径为何?此处可稍作思考🤔。。。。若暂无思路,可参考下方示意图寻找灵感👇🏻:

上图展示了将 HTML 转换为 Canvas 的三个典型场景(背景、图片及文字)。由此可知,实现 HTML 到 Canvas 的转换,本质是将 HTML 结构映射为对应的 Canvas 绘图指令,即把左侧代码逻辑转化为右侧绘图操作。确立这一认知后,即可开始编码实现🐶!
第一步:解析 dom 树
要将元素绘制到画布,需明确绘制位置、元素类型及样式。位置信息可通过 getBoundingClientRect 获取,样式信息通过 getComputedStyle 获取,而元素类型则依据 tagName 区分(如文本、图片等),不同类型需采用不同的处理策略。
为获取上述所有信息,必须对 DOM 进行解析。先观察解析前后的对比图,建立直观印象👇🏻:


显然,为保持原有的树形结构,遍历 DOM 节点是必要步骤。此过程并不复杂,参考下方代码即可理解,注释详尽👇🏻:
// 按照原有的树结构遍历整个 dom,变成我们自己需要的新对象 ElContainer
// ElContainer 主要包括坐标位置和大小、样式、子元素等
class ElContainer {
constructor(global, el) { // global 就是存储一些全局变量,目前只存储全局的偏移量,因为计算位置的时候需要减去它
this.bounds = new Bounds(global, el); // 获取位置和大小
this.styles = window.getComputedStyle(el); // 这里为了方便直接把所有的样式拿过来,其实可以按需过滤一下
this.elements = []; // 子元素
this.textNodes = []; // 文本节点比较特殊,单独处理
this.flags = 0; // falgs 标志是否要创建层叠上下文
this.el = el; // 元素的引用
}
}
// 计算元素的位置和大小信息
class Bounds {
constructor(global, el) {
const { x = 0, y = 0 } = global.offset;
const { top, left, width, height } = el.getBoundingClientRect();
this.top = top - y;
this.left = left - x;
this.width = width;
this.height = height;
}
}
parseTree(global, el) {
const container = this.createContainer(global, el);
this.parseNodeTree(global, el, container);
return container;
}
parseNodeTree(global, el, parent) {
[...el.childNodes].map((child) => {
if (child.nodeType === 3) {
// 如果是文本节点
if (child.textContent.trim().length > 0) {
// 文本节点不为空
const textElContainer = new TextElContainer(child.textContent, parent);
parent.textNodes.push(textElContainer);
}
} else {
// 如果是普通节点
const container = this.createContainer(global, child);
const { position, zIndex, opacity, transform } = container.styles;
if ((position !== 'static' && !isNaN(zIndex)) || opacity < 1 || transform !== 'none') { // 需不需要创建层叠上下文的标志,不理解可以先跳过,下面会讲解
container.flags = 1;
}
parent.elements.push(container);
this.parseNodeTree(global, child, container);
}
});
}
上述代码中需特别注意以下几点:
- 计算
bounds时,需考虑最外层容器#app的偏移量。若忽略此偏移,页面滚动时bounds.top值可能变为负数,导致图像绘制到画布上方空白区域。 - 文本节点较为特殊,因其非容器属性,其样式和位置受父节点影响,故使用独立变量
textNodes进行保存。此遍历结果与生成虚拟 DOM 的过程相似。
第二步:按层叠规则分组(重点)
正常页面采用流式布局,元素按从上至下、从左至右顺序排列且互不重叠。然而浮动和定位会打破此规则。在层叠上下文中,元素依据层叠顺序表展示(此类图表常见):

图中 background/border 属装饰属性,float 和 block 用于布局,inline 用于内容展示。因页面内容至关重要,inline 层叠等级较高。此层叠顺序决定了 Canvas 绘制的先后顺序。前端技术涉及视觉呈现的次序安排,故绘制顺序至关重要。此处补充层叠上下文概念,大家最熟悉的应为 z-index,形成层叠上下文的方法大致有三种:
- 页面根元素 html 自身即为层叠上下文,称为根层叠上下文
- position 为非 static 且 z-index 为数值
css3 中的一些新属性。层叠上下文类似 Photoshop 中的图层概念,可想象为多张透明纸叠加,每张纸上包含不同内容。此处以 z-index 为例,通过以下两张图加深理解,假设页面 HTML 结构如下👇🏻:

由此可划分出若干层叠上下文,且它们支持嵌套,结构如下👇🏻:

由上图可见,A-2 的 z-index 为 99,却被 C 覆盖。原因在于二者不在同一层叠上下文(同一张纸)中,无法直接比较层级。这也是开发中常见的问题:将某元素 z-index 设为 9999 却无效,原因即在此。实际上,优质页面应极少使用 z-index,仅全局遮罩等场景会使用。
言归正传,此步骤的核心任务是什么?因难以文字描述,先观察处理后的结果👇🏻:

接下来需根据层叠规则,再次遍历上一步返回的对象,生成上图结构。思路清晰:若遇到满足新建层叠上下文条件(如 z-index),则新建一个层叠上下文;否则在当前层叠上下文中按层叠规则对子元素分组。此部分需仔细体会🤔,建议结合下方代码理解👇🏻:
class StackingContext { // 这就是层叠上下文
constructor(container) {
this.container = container;
this.negativeZIndex = []; // zIndex为负的元素
this.nonInlineLevel = []; // 块级元素
this.nonPositionedFloats = []; // 浮动元素
this.inlineLevel = []; // 内联元素
this.positiveZIndex = []; // z-index大于等于1的元素
this.zeroOrAutoZIndexOrTransformedOrOpacity = []; // 具有 transform、opacity、zIndex 为 auto 或 0 的元素
}
}
// 开始按层叠规则划分
parseStackingContext(container) {
const root = new StackingContext(container);
this.parseStackTree(container, root);
return root;
}
parseStackTree(parent, stackingContext) { // 这里简化了一些东西,stackingContext 是当前层叠上下文
parent.elements.map((child) => { // 开始分组
if (child.flags) { // 创建新的层叠上下文的标识,上文中有提到(比如在遇到 z-index 的时候会置为 1)
const stack = new StackingContext(child);
const zIndex = child.styles.zIndex;
if (zIndex > 0) { // zIndex 可能是 1、10、100,所以其实不是直接 push,而是要比较之后插入
stackingContext.positiveZIndex.push(stack);
} else if (zIndex < 0) {
stackingContext.negativeZIndex.push(stack);
} else {
stackingContext.zeroOrAutoZIndexOrTransformedOrOpacity.push(stack);
}
this.parseStackTree(child, stack);
} else {
if (child.styles.display.indexOf('inline') >= 0) {
stackingContext.inlineLevel.push(child);
} else {
stackingContext.nonInlineLevel.push(child);
}
this.parseStackTree(child, stackingContext);
}
});
}
第三步:创建画布
此步相对简单,但需考虑 dpr(设备像素比)的影响,以确保画布清晰度。具体原因可参阅我的另一篇文章:🔥 关于 canvas 模糊的问题(高清图解),专门阐述如此编写的原因。通常做法是将 canvas 放大 dpr 倍:
createCanvas(el) {
const { width, height } = el.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
const canvas = document.createElement('canvas');
const ctx2d = canvas.getContext('2d');
canvas.width = Math.round(width * dpr);
canvas.height = Math.round(height * dpr);
canvas.style.width = width + 'px';
canvas.style.height = height + 'px';
ctx2d.scale(dpr, dpr);
this.canvas = canvas;
this.ctx2d = ctx2d;
return canvas;
}
第四步:渲染
现已具备各类数据,接下来只需再次遍历第二步返回的层级结果,按顺序依次绘制即可。此步难点在于针对不同情况转换为对应的 Canvas 绘图语言,需考虑诸多因素。当然,此处仅处理简单元素,故相对轻松😄。
// 根据划分的层级数组,一层一层从下往上绘制,并且转换成相对应的 canvas 绘图语句
render(stack) {
const { negativeZIndex = [], nonInlineLevel = [], inlineLevel = [], positiveZIndex = [], zeroOrAutoZIndexOrTransformedOrOpacity = [] } = stack;
this.ctx2d.save();
// 1、先设置会影响全局的属性,比如 transform 和 opacity
this.setTransformAndOpacity(stack.container);
// 2、绘制背景和边框
this.renderNodeBackgroundAndBorders(stack.container);
// 3、绘制 zIndex < 0 的元素
negativeZIndex.map((el) => this.render(el));
// 4、绘制自身内容
this.renderNodeContent(stack.container);
// 5、绘制块状元素
nonInlineLevel.map((el) => this.renderNode(el));
// 6、绘制行内元素
inlineLevel.map((el) => this.renderNode(el));
// 7、绘制 z-index: auto || 0、transform: none、opacity小于1 的元素
zeroOrAutoZIndexOrTransformedOrOpacity.map((el) => this.render(el));
// 8、绘制 zIndex > 0 的元素
positiveZIndex.map((el) => this.render(el));
this.ctx2d.restore();
}
// 针对不同元素有不同的渲染方式,也就是开篇提到的方式
renderNodeContent(container) {
if (container.textNodes.length) {
container.textNodes.map((text) => this.renderText(text, container.styles));
} else if (container instanceof ImageElContainer) {
this.renderImg(container);
} else if (container instanceof InputElContainer) {
this.renderInput(container);
}
}
renderNode(container) {
this.renderNodeBackgroundAndBorders(container);
this.renderNodeContent(container);
}
renderText(text, styles) { // 这里只考虑影响字体的几个因素,并不全面
const { ctx2d } = this;
ctx2d.save();
ctx2d.font = `${styles.fontWeight} ${styles.fontSize} ${styles.fontFamily}`;
ctx2d.fillStyle = styles.color;
ctx2d.fillText(text.text, text.bounds.left, text.bounds.top);
ctx2d.restore();
}
renderImg(container) { // 这里直接用页面中的 img 元素进行绘制,所以得等到图片加载完成,不然就看不见图片。正常写法应该是在 img.onload 的回调中进行绘制
const { ctx2d } = this;
const { el, bounds, styles } = container;
ctx2d.drawImage(el, 0, 0, parseInt(styles.width), parseInt(styles.height), bounds.left, bounds.top, bounds.width, bounds.height);
}
同样需注意几点:
- 类似 transform 和 opacity 的样式会影响自身及子元素,故需在渲染初期设置画布全局属性(如
setTransformAndOpacity中透明度设置ctx2d.globalAlpha = opacity;) - 对于含 transform 属性的元素,直接绘制会导致图形错误。因初期获取的位置信息 bounds 为变换后的位置,而实际需的是变换前的位置。故在初期遍历时需对数据进行简单处理,如下所示👇🏻:
class ElContainer {
constructor(global, el) {
// 获取位置和大小,如果元素用了 transform,我们需要将其先还原,再获取样式,因为我们没有克隆整个 html,所以这里就这样处理
const transform = this.styles.transform;
if (transform !== 'none') el.style.transform = 'none';
this.bounds = new Bounds(global, el);
if (transform !== 'none') el.style.transform = transform;
// ...
}
}
- 关于背景和边框的绘制,即计算四个点(顺时针或逆时针顺序)绘制四条线并进行填充或描边;若含圆角,需绘制四条线及四段圆弧。此外,边框宽度可能影响内部元素位置,否则会产生偏差,此处未作处理,哈哈😄。
- 关于文本绘制,细心的同学会发现初始效果图中 Canvas 文字与 HTML 存在差异,如位置偏移。因文字渲染复杂,涉及字体、对齐、基线、字间距、行高等属性,此处仅做简单处理,且不支持换行😂。
- 关于图片,因加载需时间,渲染应为异步操作,否则可能无法绘制(还可能受跨域、图片大小等因素影响)。此处仅简单获取加载完成的 img 进行绘制。
若需其他功能如何处理😰?经前述学习,你应已有所了解,例如:
- 某些元素无需绘制?添加属性或类(
data-html2canvas-ignore),遍历时过滤即可。 - 文本含省略号?需利用
ctx2d.measureText计算文本宽度,再自行拼接...。此外,该 API 仅能计算宽度,高度需自行(根据字号、行高等)繁琐计算。
遇到 canvas 元素如何处理?直接绘制该 canvas 即可;其他元素,有 API 直接使用(如 svg),无 API 则手写实现(如复选框);属性同理,无对应 Canvas 实现方式则手动编写。
由此可见,从 HTML 到 Canvas 均需逐一转换对应写法,令人头疼🤔,故出现各类问题属正常现象,即便如本文这般简单实现版本。
此外,易产生一些难以排查的 bug😂,查阅后可能发现几个生僻属性,最终只剩无奈🤷🏻♀️(坦白讲,我不懂,搞不定,也不想搞)。知识易忘,此处简要回顾流程图:

ps:其实最难的是获取位置和样式,好在浏览器已帮我们解决。
另一种方法(html->svg->canvas)
不感兴趣者可跳过此部分😂。此方法相对简单,即将 HTML 装入 SVG,再将 SVG 绘制到 Canvas。因浏览器提供相应 API,故可行,当然亦有局限性,此处简要带过:
- 遍历 DOM,将所有外联样式转为内联样式(因 SVG 需如此,否则样式无效)
- 将 HTML 序列化后拼接至 SVG,导出为图片,如下所示:
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}">
<foreignObject height="100%" width="100%">${htmlString}</foreignObject>
</svg>`;
const img = new Image();
img.src = `data:image/svg+xml,${svg}`;
- 最后将 img 绘制到画布即可。
ctx2d.drawImage(img, 0, 0);
看似简单,实际应用问题频发。
结语
以上即为 html2canvas 的两种思路。实际开发中,我们通常直接使用 html2canvas。但若使用中遇到问题,心中应有底,能大致判断为何部分转换失败,大概率是不兼容、不支持或缺乏对应转换。故最佳方案是调整 HTML 和 CSS 写法,少用花哨样式尤为重要。最后,若阅读 html2canvas 的 README.md,会发现一句话😂:

项目地址传送门。另附我 canvas 专栏另外两篇实战文章:
- 用 canvas 搞一个手势识别?醍醐灌顶 🤯
- 🔥用 canvas 来画个函数曲线吧!纵享丝滑
以上就是JS前端html2canvas手写示例问题剖析的详细内容,更多关于JS前端html2canvas的资料请关注本站其它相关文章!