前言
最近遇到了这样一个需求:用户希望将统计数据分享到其他的渠道,如邮件、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节点
通过简易火焰图,我们已经对html2canvas的主流程有了一个基本的认识,接下来我们一层一层来分析。
html2canvas方法
html2canvas是入口方法,主要将用户选择的DOM节点和自定义配置项传递给renderElement方法。简要逻辑代码如下:
const html2canvas = (element: HTMLElement, options: Partial<Options> = {}):Promise<HTMLCanvasElement> => {
return renderElement(element, options);
};
renderElement方法
这个方法的主要目的是将页面中指定的DOM元素渲染到一个离屏canvas中,并将渲染好的canvas返回给用户。
它主要做了以下事情:
- 解析用户传入的options,将其与默认的options合并,得到用于渲染的配置数据renderOptions。
- 对传入的DOM元素进行解析,取到节点信息和样式信息,这些节点信息会和上一步的renderOptions配置一起传给canvasRenderer实例,用来绘制离屏canvas。
- 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节点进行分层,如下图所示:
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);
}
}