JS前端html2canvas手写示例问题剖析

作者:像素漫游时间:2026-10-25 17:12:43浏览:270

本文深入剖析手写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) =&gt; { // 开始分组
        if (child.flags) { // 创建新的层叠上下文的标识,上文中有提到(比如在遇到 z-index 的时候会置为 1)
            const stack = new StackingContext(child);
            const zIndex = child.styles.zIndex;
            if (zIndex &gt; 0) { // zIndex 可能是 1、10、100,所以其实不是直接 push,而是要比较之后插入
                stackingContext.positiveZIndex.push(stack);
            } else if (zIndex &lt; 0) {
                stackingContext.negativeZIndex.push(stack);
            } else {
                stackingContext.zeroOrAutoZIndexOrTransformedOrOpacity.push(stack);
            }
            this.parseStackTree(child, stack);
        } else {
            if (child.styles.display.indexOf('inline') &gt;= 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的资料请关注本站其它相关文章!

作者最新文章