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>
在浏览器能看到的是这样一个页面,大概就是一个背景色,一行文字,一个图片。
正如把「大象放进冰箱」问题,拆解html节点容器到canvas绘制,只有两步:
-
容器样式解析,生成用于渲染相关的节点数据信息集合,主要是样式收集工作;
-
canvas实例基于收集上来的带样式信息的节点渲染
1. 样式采集
对DOM的样式信息采集,以demo中的.container
来说,涉及宽度、高度、背景色、字体属性、图片属性。
先补充两个API:getBoundingClientRect
和 getComputedStyle
getBoundingClientRect
mdn上对该方法的说明
返回一个
DOMRect
对象,包含整个元素的最小矩形,包括其填充和边框宽度: left、top、right、bottom
getComputedStyle
mdn上对该方法的说明
返回一个包含元素所有 CSS 属性值的对象,可以通过对象提供的 API 或使用 CSS 属性名称进行索引来访问各个 CSS 属性值
工具已经准备就位,那么可以开搞了
- 获取容器的位置信息和样式对象
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),
}
- 遍历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
数据如下:
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) // 在指定位置绘制图片
}
}
}
最终绘制出来的效果:
到这里,我们自己实现的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技术有关,感兴趣的同学可以去了解一下。