html2canvas浏览器截图原理及源码解析

avatar
前端工程师 @天鹅到家

image.png

前言

最近遇到了这样一个需求:用户希望将统计数据分享到其他的渠道,如邮件、PPT等,但每次都需要自己截图,这样的操作不仅麻烦,截出来的图大小还各不相同。有没有办法在页面提供一个下载报表页面的功能,用户只需要点击按钮,就自动将当前的报表页面以图片形式下载下来呢?

html2canvas

html2canvas这个库便完美符合了我们的需求。

一、如何使用

在Vue中,通过npm安装

npm install html2canvas

在使用界面引入依赖

import html2canvas from 'html2canvas'

完整代码

<template>
  <div>
    <!-- 把需要生成截图的元素放在一个元素容器里,设置一个ref -->
    <div ref="imageTofile">
      <!-- 这里放需要截图的元素,自定义组件元素也可以 -->
      <div>我是截取的内容</div>
      <div>我是截取的内容</div>
      <div>我是截取的内容</div>
      <div>我是截取的内容</div>
      <div>我是截取的内容</div>
    </div>
    <!-- 如果需要展示截取的图片,给一个img标签 -->
    <img :src="htmlUrl" v-show="isShow" />
    <button @click="toImage">截取</button>
  </div>
</template>

<script>
import html2canvas from "html2canvas";
export default {
  data() {
    return {
      htmlUrl: "",
      isShow: false,
    };
  },
  methods: {
    // 页面元素转图片
    toImage() {
      // 第一个参数是需要生成截图的元素,第二个是自己需要配置的参数,宽高等
      html2canvas(this.$refs.imageTofile, {
        backgroundColor: null,
      }).then((canvas) => {
        let url = canvas.toDataURL("image/png");
        this.htmlUrl = url; // 把生成的base64位图片上传到服务器,生成在线图片地址
        if (this.htmlUrl) {
          // 渲染完成之后让图片显示,用v-show可以避免一些bug
          this.isShow = true;
        }
        // this.sendUrl();
      });
    }, // 图片上传服务器
    sendUrl() {
      // 如果图片需要formData格式,就自己组装一下,主要看后台需要什么参数
      // const formData = new FormData()
      // formData.append('base64', this.company.fileUrl)
      // formData.append('userId', 123)
      // formData.append('pathName', 'pdf')
     //   ==================
    //   this.$ajax({
    //     url: apiPath.common.uploadBase,
    //     method: "post",
    //     data: this.htmlUrl,
    //   }).then((res) => {
    //     if (res.code && res.data) {
    //     }
    //   });
    },
  },
};
</script>

<style scoped>
</style>

二、原理及源码解析

流程图

如下图所示,将html2canvas原理图形化,主要分成出口供用户使用的主要流程和两部分核心逻辑:克隆并解析DOM节点、渲染DOM节点

image.png

通过简易火焰图,我们已经对html2canvas的主流程有了一个基本的认识,接下来我们一层一层来分析。

html2canvas方法

html2canvas是入口方法,主要将用户选择的DOM节点和自定义配置项传递给renderElement方法。简要逻辑代码如下:

const html2canvas = (element: HTMLElement, options: Partial<Options> = {}):Promise<HTMLCanvasElement> => { 
    return renderElement(element, options);
};

renderElement方法

这个方法的主要目的是将页面中指定的DOM元素渲染到一个离屏canvas中,并将渲染好的canvas返回给用户。

它主要做了以下事情:

  1. 解析用户传入的options,将其与默认的options合并,得到用于渲染的配置数据renderOptions。
  2. 对传入的DOM元素进行解析,取到节点信息和样式信息,这些节点信息会和上一步的renderOptions配置一起传给canvasRenderer实例,用来绘制离屏canvas。
  3. canvasRenderer将依据浏览器渲染层叠内容的规则,将用户传入的DOM元素渲染到一个离屏canvas中,这个离屏canvas我们可以在then方法的回调中取到。

核心代码如下:

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中
};

CanvasRenderer

CanvasRenderer是canvas渲染类,后续使用的渲染方法均是该类的方法。在克隆并解析DOM节点部分,主要是将renderOptions传给canvasRenderer实例,调用render方法来绘制canvas。

DocumentCloner

DocumentCloner是DOM克隆类,主要是生成documentCloner实例,克隆用户所选择的DOM节点。其核心方法cloneNode通过递归整个DOM结构树,匹配查询用户选择的DOM节点并进行克隆,简要逻辑代码如下:

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节点 
        return clone; 
    } 
    return node.cloneNode(false);
} // 输出格式为DOM节点格式

parseTree

该方法层层递归克隆DOM节点,获取DOM节点的位置、宽高、样式等信息。

parseTree的入参就是一个普通的DOM元素,返回值是一个ElementContainer对象,该对象主要包含DOM元素的位置信息(bounds: width|height|left|top)、样式数据、文本节点数据等(只是节点树的相关信息,不包含层叠数据,层叠数据在parseStackingContexts方法中取得)。 解析的方法就是递归整个DOM树,并取得每一层节点的数据。

简要逻辑代码如下:

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节点,解析获取节点信息};

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 - 文本节点信息

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方法,该方法是整个渲染流程中最为关键的方法,下一章单独分析。

renderStackContent

将DOM元素一层一层得渲染到离屏canvas中,是html2canvas所做的最核心的事情,这件事由renderStackContent方法来实现。

这个方法的实现原理涉及到CSS布局相关的一些知识,下面做一个简单的介绍。

层叠上下文

默认情况下,CSS是流式布局的,元素与元素之间不会重叠。

流式布局的意思可以理解:在一个矩形的水面上,放置很多矩形的浮块,浮块会漂浮在水面上,且彼此之间依次排列,不会重叠在一起。

这是要绘制它们其实非常简单,一个个按顺序绘制即可。

不过有些情况下,这种流式布局会被打破,比如使用了浮动(float)和定位(position)。

因此需要需要识别出哪些脱离了正常文档流的元素,并记住它们的层叠信息,以便正确地渲染它们。

那些脱离正常文档流的元素会形成一个层叠上下文,可以将层叠上下文简单理解为一个个的薄层(类似Photoshop的图层),薄层中有很多DOM元素,这些薄层叠在一起,最终形成了我们看到的多彩的页面。

层叠上下文(stacking content),是HTML中的一种三维概念。如果一个节点含有层叠上下文,那么在下图的Z轴中距离用户更近。

当一个节点满足以下条件中的任意一个,则该节点含有层叠上下文。

  • 文档根元素
  • 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节点进行分层,如下图所示:

image.png

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);
    }
}

该文章借鉴了如下资料:文章文章