html2canvas实现原理

1,320 阅读6分钟

html2canvas是一个用于实现传入指定页面元素,基于该元素DOM视图,绘制成canvas对象并返回的工具库,可以用于html元素截屏,生成诸如pdf文件之类的需求开发。

const container = document.body // canvas绘制的element容器
html2canvas(container).then(function(canvas) {
    document.body.appendChild(canvas);
});

想想Faker会怎么做?

看源码实现前,可以先思考一个问题,如果让你自己来实现将html页面上的节点绘制到canvas上,会怎么设计,先不考虑复杂的DOM节点,先从简单DOM入手,比如有以下DOM,如何将.container容器绘制到canvas上。

<!DOCTYPE html>
<html lang="en">
    <head>
        <meta charset="UTF-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1.0" />
        <title>Document</title>
        <style>
            body { margin: 0; padding: 0; }
            .container { width: 750px; background: gray; }
            .title { color: #d84476; }
        </style>
    </head>
    <body>
        <div class="container">
            <div class="title">这个是标题</div>
            <img src="./img.jpeg" alt="" />
        </div>
    </body>
</html>

在浏览器能看到的是这样一个页面,大概就是一个背景色,一行文字,一个图片。

image.png

正如把「大象放进冰箱」问题,拆解html节点容器到canvas绘制,只有两步:

  1. 容器样式解析,生成用于渲染相关的节点数据信息集合,主要是样式收集工作;

  2. canvas实例基于收集上来的带样式信息的节点渲染

1. 样式采集

对DOM的样式信息采集,以demo中的.container来说,涉及宽度、高度、背景色、字体属性、图片属性。

先补充两个API:getBoundingClientRectgetComputedStyle

getBoundingClientRect

mdn上对该方法的说明

返回一个DOMRect对象,包含整个元素的最小矩形,包括其填充和边框宽度: left、top、right、bottom

image.png

getComputedStyle

mdn上对该方法的说明

返回一个包含元素所有 CSS 属性值的对象,可以通过对象提供的 API 或使用 CSS 属性名称进行索引来访问各个 CSS 属性值

工具已经准备就位,那么可以开搞了

  1. 获取容器的位置信息和样式对象
const elContainer = document.querySelector('.container')
const rect = elContainer.getBoundingClientRect()
const baseStyle = getComputedStyle(elContainer)
const containerPos = {
    width: parseInt(rect.width),
    height: parseInt(rect.height),
    x: parseInt(rect.x),
    y: parseInt(rect.y),
}
  1. 遍历DOM树,获取样式,子节点信息,收集render信息

考虑到不同的节点下所需绘制的样式信息不同,比如图片不需要字体信息,文本不需要收集src属性,所以拆分为不同的方法去处理。

// 将DOM绘制节点相关信息保存到nodeRenderList集合
const nodeRenderList = []
generateRenderTree(elContainer)

function generateRenderTree() {
	getChildrenNodeInfo(elContainer)
	function getChildrenNodeInfo(el) {
		const childNodeList = el.childNodes
		const len = childNodeList.length
		if (len === 0) return
		for (let i = 0; i < len; i++) {
			const node = childNodeList[i]
			if (node.nodeType === 1 && node.tagName.toLowerCase() === 'img') {
				// 图片标签处理
				const imgItem = handleNodeImg(node)
				nodeRenderList.push(imgItem)
			} else if (node.nodeType === 3) {
				// 文本节点处理
				const textItem = handleNodeText(node)
				textItem && nodeRenderList.push(textItem)
				continue
			} else if (node.nodeType === 1) {
				// 对于每一个标签,要处理元素可能的边框/背景色等属性
				// handleNodeFill(node)
				getChildrenNodeInfo(node)
			}
		}
	}
	function handleNodeFill(node) {
		/** 处理块级元素 */
		const styleInfo = getComputedStyle(node)
		if (styleInfo.backgroundColor === 'rgba(0, 0, 0, 0)') return
	}
	function handleNodeText(node) {
		if (node.nodeValue.trim() === '') return
		if (node.parentNode) {
			const styleInfo = getComputedStyle(node.parentNode)
			return {
				type: 'TEXT',
				content: node.nodeValue,
				fontSize: styleInfo.fontSize,
				fontFamily: styleInfo.fontFamily,
				color: styleInfo.color,
				x: 0,
				y: 0,
			}
		}
	}
	function handleNodeImg(node) {
		return {
			type: 'IMAGE',
			src: node.src,
			width: node.width,
			height: node.height,
			x: node.offsetLeft - parseInt(rect.x),
			y: node.offsetTop - parseInt(rect.y),
		}
	}
}

最终我们拿到的nodeRenderList数据如下:

image.png

2. canvas绘制

有了渲染数据,接下来就用canvas执行绘制操作,那么绘制流程就是遍历之前的样式集合,并依次将各个绘制对象渲染到canvas上。

const canvasImgData = {
	base64Data: undefined,
	width: 0,
	height: 0,
}
generateCanvas()

function generateCanvas() {
    const canvas = document.createElement('canvas')
    canvas.width = containerPos.width
    canvas.height = containerPos.height
    const ctx = canvas.getContext('2d')
    ctx.fillStyle = baseStyle.backgroundColor
    ctx.fillRect(0, 0, canvas.width, canvas.height)

    for (let i = 0; i < nodeRenderList.length; i++) {
        const type = nodeRenderList[i].type
        switch (type) {
            case 'TEXT':
                fillText(nodeRenderList[i])
                break
            case 'IMAGE':
                fillImg(nodeRenderList[i])
                break
            default:
                break
        }
    }
    setTimeout(() => {
        const img = new Image()
        const base64Data = canvas.toDataURL('image/png')
        img.src = base64Data
        canvasImgData.width = canvas.width
        canvasImgData.height = canvas.height
        canvasImgData.base64Data = base64Data
        document.body.appendChild(img)
    }, 1000)

    function fillText(item) {
        ctx.fillStyle = item.color
        ctx.font = `${item.fontSize} ${item.fontFamily}`
        // fillText 的坐标是基于文本的 基线 来决定的
        ctx.textBaseline = 'middle'
        // 基于中线对齐,所以文本的y轴为初始的item.y + 原始文本区域高度 / 2, 这里为了省事,就不详细计算了
        ctx.fillText(item.content, item.x, item.y + 11)
    }
    function fillImg(item) {
        const img = new Image()
        img.src = item.src
        img.onload = function () {
            ctx.drawImage(img, item.x, item.y, item.width, item.height) // 在指定位置绘制图片
        }
    }
}

最终绘制出来的效果:

image.png

到这里,我们自己实现的html2canvs-青春版就完成了,基本可以完成对简单DOM的绘制,当然我们会发现,还有很多没有考虑的地方,比如padding,margin等属性对于x,y轴绘制的偏移影响;border边框的绘制,子元素中块级元素背景色的处理;文本换行,文本超出限制...的计算,子元素的display校验,z-index的层级权重覆盖,iframe嵌套,诸如此类,如果要自己一一实现,涉及到比较复杂的节点属性与重合样式处理,还是比较多工作量的。

看看Faker会怎么做

「base on html2canvas@1.4.1」

以之前提到的核心实现为例,我们还是从两个方向去看html2canvas的核心设计(仅展示部分代码,有兴趣自行阅读源码)

1. 样式采集

html2canvasDOM整个node节点的解析核心实现是parseNodeTree函数,在后续的canvas绘制阶段,还有一次递归解析过程-parseStackingContexts(src/render/stacking-context.ts)

/** src/dom/node-parser.ts */
const parseNodeTree = (context: Context, 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(context, childNode, parent.styles));
        } else if (isElementNode(childNode)) {
            if (isSlotElement(childNode) && childNode.assignedNodes) {
                childNode.assignedNodes().forEach((childNode) => parseNodeTree(context, childNode, parent, root));
            } else {
                const container = createContainer(context, childNode);
                if (container.styles.isVisible()) {
                    if (createsRealStackingContext(childNode, container, root)) {
                        container.flags |= FLAGS.CREATES_REAL_STACKING_CONTEXT;
                    } else if (createsStackingContext(container.styles)) {
                        container.flags |= FLAGS.CREATES_STACKING_CONTEXT;
                    }

                    if (LIST_OWNERS.indexOf(childNode.tagName) !== -1) {
                        container.flags |= FLAGS.IS_LIST_OWNER;
                    }
                    parent.elements.push(container);
                    childNode.slot;
                    if (childNode.shadowRoot) {
                        parseNodeTree(context, childNode.shadowRoot, container, root);
                    } else if (
                        !isTextareaElement(childNode) &&
                        !isSVGElement(childNode) &&
                        !isSelectElement(childNode)
                    ) {
                        parseNodeTree(context, childNode, container, root);
                    }
                }
            }
        }
    }
};

还是将源码处理简单化,以文本节点类型的处理为例,它保存了文本内容本身以及它的节点文本偏移量计算后的范围信息

export class TextBounds {
    readonly text: string;
    readonly bounds: Bounds;

    constructor(text: string, bounds: Bounds) {
        this.text = text;
        this.bounds = bounds;
    }
}

export const parseTextBounds = (
    context: Context,
    value: string,
    styles: CSSParsedDeclaration,
    node: Text
): TextBounds[] => {
    const textList = breakText(value, styles);
    const textBounds: TextBounds[] = [];
    let offset = 0;
    textList.forEach((text) => {
        if (styles.textDecorationLine.length || text.trim().length > 0) {
            if (FEATURES.SUPPORT_RANGE_BOUNDS) {
                const clientRects = createRange(node, offset, text.length).getClientRects();
                if (clientRects.length > 1) {
                    const subSegments = segmentGraphemes(text);
                    let subOffset = 0;
                    subSegments.forEach((subSegment) => {
                        textBounds.push(
                            new TextBounds(
                                subSegment,
                                Bounds.fromDOMRectList(
                                    context,
                                    createRange(node, subOffset + offset, subSegment.length).getClientRects()
                                )
                            )
                        );
                        subOffset += subSegment.length;
                    });
                } else {
                    textBounds.push(new TextBounds(text, Bounds.fromDOMRectList(context, clientRects)));
                }
            } else {
                const replacementNode = node.splitText(text.length);
                textBounds.push(new TextBounds(text, getWrapperBounds(context, node)));
                node = replacementNode;
            }
        } else if (!FEATURES.SUPPORT_RANGE_BOUNDS) {
            node = node.splitText(text.length);
        }
        offset += text.length;
    });

    return textBounds;
};
2. canvas绘制

以文本节点为例,收集后的文本节点如何绘制呢

/** src/index.ts */
const renderer = new CanvasRenderer(context, renderOptions);
canvas = await renderer.render(root);

/** renderer.render(root): src/render/canvas/canvas-renderer.ts */
async render(element: ElementContainer): Promise<HTMLCanvasElement> {
	if (this.options.backgroundColor) {
		this.ctx.fillStyle = asString(this.options.backgroundColor);
		this.ctx.fillRect(this.options.x, this.options.y, this.options.width, this.options.height);
	}

	const stack = parseStackingContexts(element);

	await this.renderStack(stack);
	this.applyEffects([]);
	return this.canvas;
}

/** 以下操作都在:src/render/canvas/canvas-renderer.ts 中执行 */


async renderNodeContent(paint: ElementPaint): Promise<void> {
	this.applyEffects(paint.getEffects(EffectTarget.CONTENT));
	const container = paint.container;
	const curves = paint.curves;
	const styles = container.styles;
	for (const child of container.textNodes) {
		await this.renderTextNode(child, styles);
	}
}
async renderTextNode(text: TextContainer, styles: CSSParsedDeclaration): Promise<void> {
        const [font, fontFamily, fontSize] = this.createFontStyle(styles);

        this.ctx.font = font;

        this.ctx.direction = styles.direction === DIRECTION.RTL ? 'rtl' : 'ltr';
        this.ctx.textAlign = 'left';
        this.ctx.textBaseline = 'alphabetic';
        const {baseline, middle} = this.fontMetrics.getMetrics(fontFamily, fontSize);
        const paintOrder = styles.paintOrder;

        text.textBounds.forEach((text) => {
            paintOrder.forEach((paintOrderLayer) => {
                switch (paintOrderLayer) {
                    case PAINT_ORDER_LAYER.FILL:
                        this.ctx.fillStyle = asString(styles.color);
                        this.renderTextWithLetterSpacing(text, styles.letterSpacing, baseline);
    
/** renderTextWithLetterSpacing:文本最终的canvas绘制操作 */
renderTextWithLetterSpacing(text: TextBounds, letterSpacing: number, baseline: number): void {
	if (letterSpacing === 0) {
		this.ctx.fillText(text.text, text.bounds.left, text.bounds.top + baseline);
	} else {
		const letters = segmentGraphemes(text.text);
		letters.reduce((left, letter) => {
			this.ctx.fillText(letter, left, text.bounds.top + baseline);

			return left + this.ctx.measureText(letter).width;
		}, text.bounds.left);
	}
}

思考

html2canvs的实现中,并没有像我们一开始设计的那样,先通过集合收集所有的绘制元素,然后遍历集合进行绘制;而是通过对container的clone操作,之后对clone节点进行遍历,收集样式相关数据,再进行渲染,这样的好处是避免了直接操作用户传入的 DOM 元素,避免对于一些样式的获取可能触发的回流,如offsetWidth等;确保生成的画布能够正确地进行渲染,且不干扰原始文档的结构;并且更有利于iframe的操作(如果直接操作用户传入的元素,可能会涉及到权限问题或跨域限制),之后再对clone出来的元素进行一系列的递归绘制操作。

不提倡造轮子,不过尝试了解库的实现原理,便于我们解决在使用过程中遇到的异常问题,触类旁推,后续如果遇到没办法引用库的情况,我们也能自己简单实现一个类似的工具,也是极好的。

foreignObject

这个是html2canvas的另一种渲染方式支持,跟svg技术有关,感兴趣的同学可以去了解一下。

# SVGForeignObject元素