2 种方法快速实现前端截图
导语 | 本文将介绍在前端开发中页面截图的两种方式,包括对其实现原理和使用方式进行详细阐述,希望能为更多前端开发者提供一些经验和帮助。
背景
页面截图功能在前端开发中,特别是营销场景相关的需求中, 是比较常见的。比如截屏分享,相对于普通的链接分享,截屏分享具有更丰富的展示、更多的信息承载等优势。最近在需求开发中遇到了相关的功能,所以调研了相关的实现和原理。
相关技术
前端要实现页面截图的功能,现在比较常见的方式是使用开源的截图 npm 库,一般使用比较多的 npm 库有以下两个:
- dom-to-image:https://github.com/tsayen/dom-to-image
- html2canvas:https://github.com/niklasvh/html2canvas
以上两种常见的 npm 库,对应着两种常见的实现原理。实现前端截图,一般是使用图形 API 重新绘制页面生成图片,基本就是 SVG(dom-to-image)和 Canvas(html2canvas)两种实现方案,两种方案目标相同,即把 DOM 转为图片,下面我们来分别看看这两类方案。
dom-to-image
dom-to-image 库主要使用的是 SVG 实现方式,简单来说就是先把 DOM 转换为 SVG 然后再把 SVG 转换为图片。
使用方式
首先,我们先来简单了解一下 dom-to-image 提供的核心 api,有如下一些方法:
- toSvg (dom 转 svg)
- toPng (dom 转 png)
- toJpeg (dom 转 jpg)
- toBlob (dom 转二进制格式)
- toPixelData (dom 转原始像素值)
如需要生成一张 png 的页面截图,实现代码如下:
import domtoimage from "domtoimage" const node = document.getElementById('node'); domtoimage.toPng(node,options).then((dataUrl) => { const img = new Image(); img.src = dataUrl; document.body.appendChild(img); })
toPng 方法可传入两个参数 node 和 options。
node 为要生成截图的 dom 节点;options 为支持的属性配置,具体如下:filter,backgroundColor,width,height,style,quality,imagePlaceholder,cacheBust。
原理分析
dom to image 的源码代码不是很多,总共不到千行,下面就拿 toPng 方法做一下简单的源码解析,分析一下其实现原理,简单流程如下:
整体实现过程用到了几个函数:
- toPng(调用 draw,实现 canvas=>png )
- Draw(调用 toSvg,实现 dom=>canvas)
- toSvg(调用 cloneNode 和 makeSvgDataUri,实现 dom=>svg)
- cloneNode(克隆处理 dom 和 css)
- makeSvgDataUri(实现 dom=>svg data:url)
toPng
toPng 函数比较简单,通过调用 draw 方法获取转换后的 canvas,利用 toDataURL 转化为图片并返回。
function toPng(node, options) { return draw(node, options || {}) .then((canvas) => canvas.toDataURL()); }
draw
draw 函数首先调用 toSvg 方法获得 dom 转化后的 svg,然后将获取的 url 形式的 svg 处理成图片,并新建 canvas 节点,然后借助 drawImage() 方法将生成的图片放在 canvas 画布上。
function draw(domNode, options) { return toSvg(domNode, options) // 拿到的 svg 是 image data URL, 进一步创建 svg 图片 .then(util.makeImage) .then(util.delay(100)) .then((image) => { // 创建 canvas,在画布上绘制图像并返回 const canvas = newCanvas(domNode); canvas.getContext("2d").drawImage(image, 0, 0); return canvas; }); // 新建 canvas 节点,设置一些样式的 options 参数 function newCanvas(domNode) { const canvas = document.createElement("canvas"); canvas.width = options.width || util.width(domNode); canvas.height = options.height || util.height(domNode); if (options.bgcolor) { const ctx = canvas.getContext("2d"); ctx.fillStyle = options.bgcolor; ctx.fillRect(0, 0, canvas.width, canvas.height); } return canvas; } }
toSvg
- toSvg 函数实现从 dom 到 svg 的处理,大概步骤如下:
- 递归去克隆 dom 节点(调用 cloneNode 函数)
- 处理字体,获取所有样式,找到所有的 @font-face 和内联资源,解析并下载对应的资源,将资源转为 dataUrl 给 src 使用。把上面处理完的 css rules 放入<style>中,并把标签加入到 clone 的节点中去。
- 处理图片,将 img 标签的 src 的 url 和 css 中 backbround 中的 url,转为 dataUrl 使用。
- 获取 dom 节点转化的 dataUrl 数据(调用 makeSvgDataUri 函数)
function toSvg(node, options) { options = options || {}; // 处理 imagePlaceholder、cacheBust 值 copyOptions(options); return Promise.resolve(node) .then((node) => // 递归克隆 dom 节点 cloneNode(node, options.filter, true)) // 把字体相关的 csstext 放入 style .then(embedFonts) // clone 处理图片,将图片链接转换为 dataUrl .then(inlineImages) // 添加 options 里的 style 放入 style .then(applyOptions) .then((clone) => // node 节点转化成 svg makeSvgDataUri(clone, options.width || util.width(node), options.height || util.height(node))); // 处理一些 options 的样式 function applyOptions(clone) { ... return clone; } }
cloneNode
cloneNode 函数主要处理 dom 节点,内容比较多,简单总结实现如下:
- 递归 clone 原始的 dom 节点,其中, 其中如果有 canvas 将转为 image 对象。
- 处理节点的样式,通过 getComputedStyle 方法获取节点元素的所有 CSS 属性的值,并将这些样式属性插入新建的 style 标签上面, 同时要处理“:before,:after”这些伪元素的样式, 最后处理输入内容和 svg。
function cloneNode(node, filter, root) { if (!root && filter && !filter(node)) return Promise.resolve(); return Promise.resolve(node) .then(makeNodeCopy) .then((clone) => cloneChildren(node, clone, filter)) .then((clone) => processClone(node, clone)); function makeNodeCopy(node) { // 将 canvas 转为 image 对象 if (node instanceof HTMLCanvasElement) return util.makeImage(node.toDataURL()); return node.cloneNode(false); } // 递归 clone 子节点 function cloneChildren(original, clone, filter) { const children = original.childNodes; if (children.length === 0) return Promise.resolve(clone); return cloneChildrenInOrder(clone, util.asArray(children), filter) .then(() => clone); function cloneChildrenInOrder(parent, children, filter) { let done = Promise.resolve(); children.forEach((child) => { done = done .then(() => cloneNode(child, filter)) .then((childClone) => { if (childClone) parent.appendChild(childClone); }); }); return done; } } function processClone(original, clone) { if (!(clone instanceof Element)) return clone; return Promise.resolve() .then(cloneStyle) .then(clonePseudoElements) .then(copyUserInput) .then(fixSvg) .then(() => clone); // 克隆节点上的样式。 function cloneStyle() { ... } // 提取伪类样式,放到 css function clonePseudoElements() { ... } // 处理 Input、TextArea 标签 function copyUserInput() { ... } // 处理 svg function fixSvg() { ... } } }
makeSvgDataUri
首先,我们需要了解两个特性:
- SVG 有一个<foreignObject>元素,这个元素的作用是可以在其中使用具有其它 XML 命名空间的 XML 元素,换句话说借助<foreignObject>标签,我们可以直接在 SVG 内部嵌入 XHTML 元素,举个例子:
<svg xmlns="http://www.w3.org/2000/svg"> <foreignObject width="120" height="50"> <body xmlns="http://www.w3.org/1999/xhtml"> <p>文字。</p> </body> </foreignObject> </svg>
可以看到<foreignObject>标签里面有一个设置了 xmlns=“http://www.w3.org/1999/xhtml”命名空间的<body>标签,此时<body>标签及其子标签都会按照 XHTML 标准渲染,实现了 SVG 和 XHTML 的混合使用。
XMLSerializer 对象能够把一个 XML 文档或 Node 对象转化或“序列化”为未解析的 XML 标记的一个字符串。
基于以上特性,我们再来看一下 makeSvgDataUri 函数,该方法实现 node 节点转化为 svg,就用到刚刚提到的两个重要特性。首先将 dom 节点通过 XMLSerializer().serializeToString() 序列化为字符串,然后在 <foreignobject> 标签 中嵌入转换好的字符串,foreignObject 能够在 svg 内部嵌入 XHTML,再将 svg 处理为 dataUrl 数据返回,具体实现如下:
function makeSvgDataUri(node, width, height) { return Promise.resolve(node) .then((node) => { // 将 dom 转换为字符串 node.setAttribute("xmlns", "http://www.w3.org/1999/xhtml"); return new XMLSerializer().serializeToString(node); }) .then(util.escapeXhtml) .then((xhtml) => `<foreignObject x="0" y="0" width="100%" height="100%">${xhtml}</foreignObject>`) // 转化为 svg .then((foreignObject) => // 不指定 xmlns 命名空间是不会渲染的 `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}">${ foreignObject}</svg>`) // 转化为 data:url .then((svg) => `data:image/svg+xml;charset=utf-8,${svg}`); }
html2canvas
html2canvas 库主要使用的是 Canvas 实现方式,主要过程是手动将 dom 重新绘制成 canvas,因此,它只能正确渲染可以理解的属性,有许多 CSS 属性无法正确渲染。
- 支持的 CSS 属性的完整列表:http://html2canvas.hertzen.com/features/
- 浏览器兼容性:Firefox 3.5+ Google Chrome Opera 12+ IE9+ Edge Safari 6+
- 官方文档地址:http://html2canvas.hertzen.com/documentation
使用方式
// dom 即是需要绘制的节点, option 为一些可配置的选项 import html2canvas from 'html2canvas' html2canvas(dom, option).then(canvas=>{ canvas.toDataURL() })
常用的 option 配置:
全部配置文档:http://html2canvas.hertzen.com/configuration
原理分析
html2canvas 的内部实现相对 dom-to-image 来说要复杂一些, 基本原理是读取 DOM 元素的信息,基于这些信息去构建截图,并呈现在 canvas 画布中。其中重点就在于将 dom 重新绘制成 canvas 的过程,该过程整体的思路是:遍历目标节点和目标节点的子节点,遍历过程中记录所有节点的结构、内容和样式,然后计算节点本身的层级关系,最后根据不同的优先级绘制到 canvas 画布中。
由于 html2canvas 的源码量比较大,可能无法像 dom-to-image 一样详细的分析,但还是可以大致了解一下整体的流程,首先可以看一下源码中 src 文件夹中的代码结构,如下图:
简单解析一下:
- index:入口文件,将 dom 节点渲染到一个 canvas 中,并返回。
- core:工具函数的封装,包括对缓存的处理函数、Context 方法封装、日志模块等。
- css:对节点样式的处理,解析各种 css 属性和特性,进行处理。
- dom:遍历 dom 节点的方法,以及对各种类型 dom 的处理。
- render:基于 clone 的节点生成 canvas 的处理方法。
基于以上这些核心文件,我们来简单了解一下 html2canvas 的解析过程, 大致的流程如下:
构建配置项
在这一步会结合传入的 options 和一些 defaultOptions,生成用于渲染的配置数据 renderOptions。在过程中会对配置项进行分类,比如 resourceOptions(资源跨域相关)、contextOptions(缓存、日志相关)、windowOptions(窗口宽高、滚动配置)、cloneOptions(对指定 dom 的配置)、renderOptions(render 结果的相关配置,包括生成图片的各种属性)等,然后分别将各类配置项传到下接下来的步骤中。
clone 目标节点并获取样式和内容
在这一步中,会将目标节点到指定的 dom 解析方法中,这个过程会 clone 目标节点和其子节点,获取到节点的内容信息和样式信息,其中 clone dom 的解析方法也是比较复杂的,这里不做详细展开。获取到目标节点后,需要把克隆出来的目标节点的 dom 装载到一个 iframe 里,进行一次渲染,然后就可以获取到经过浏览器视图真实呈现的节点样式。
解析目标节点
目标节点的样式和内容都获取到了之后,就需要把它所承载的数据信息转化为 Canvas 可以使用的数据类型。在对目标节点的解析方法中,递归整个 DOM 树,并取得每一层节点的数据,对于每一个节点而言需要绘制的部分包括边框、背景、阴影、内容,而对于内容就包含图片、文字、视频等。在整个解析过程中,对目标节点的所有属性进行解析构造,转化成为指定的数据格式,基础数据格式可见以下代码:
class ElementContainer { // 所有节点上的样式经过转换计算之后的信息 readonly styles: CSSParsedDeclaration; // 节点的文本节点信息, 包括文本内容和其他属性 readonly textNodes: TextContainer[] = []; // 当前节点的子节点 readonly elements: ElementContainer[] = []; // 当前节点的位置信息(宽/高、横/纵坐标) bounds: Bounds; flags = 0; ... }
具体到不同类型的元素如图片、IFrame、SVG、input 等还会 extends ElementContainer 拥有自己的特定数据结构,在此不详细贴出。
构建内部渲染器
把目标节点处理成特定的数据结构之后,就需要结合 Canvas 调用渲染方法了,Canvas 绘图需要根据样式计算哪些元素应该绘制在上层,哪些在下层,那么这个规则是什么样的呢?这里就涉及到 CSS 布局相关的一些知识。默认情况下,CSS 是流式布局的,元素与元素之间不会重叠。不过有些情况下,这种流式布局会被打破,比如使用了浮动(float) 和定位(position)。因此需要需要识别出哪些脱离了正常文档流的元素,并记住它们的层叠信息,以便正确地渲染它们。那些脱离正常文档流的元素会形成一个层叠上下文。
元素在浏览器中渲染时,根据 W3C 的标准,所有的节点层级布局,需要遵循层叠上下文和层叠顺序的规则,具体规则如下:
在了解了元素的渲染需要遵循这个标准后,Canvas 绘制节点的时候,需要生成指定的层叠数据,就需要先计算出整个目标节点里子节点渲染时所展现的不同层级,构造出所有节点对应的层叠上下文在内部所表现出来的数据结构,具体数据结构如下:
// 当前元素 element: ElementPaint; // z-index 为负, 形成层叠上下文 negativeZIndex: StackingContext[]; // z-index 为 0、auto、transform 或 opacity, 形成层叠上下文 zeroOrAutoZIndexOrTransformedOrOpacity: StackingContext[]; // 定位和 z-index 形成的层叠上下文 positiveZIndex: StackingContext[]; // 没有定位和 float 形成的层叠上下文 nonPositionedFloats: StackingContext[]; // 没有定位和内联形成的层叠上下文 nonPositionedInlineLevel: StackingContext[]; // 内联节点 inlineLevel: ElementPaint[]; // 不是内联的节点 nonInlineLevel: ElementPaint[];
基于以上数据结构,将元素子节点分类,添加到指定的数组中,解析层叠信息的方式和解析节点信息的方式类似,都是递归整棵树,收集树的每一层的信息,形成一颗包含层叠信息的层叠树。
绘制数据
基于上面两步构造出的数据,就可以开始调用内部的绘制方法,进行数据处理和绘制了。使用节点的层叠数据,依据浏览器渲染层叠数据的规则,将 DOM 元素一层一层渲染到 canvas 中,其中核心具体源码如下:
async renderStackContent(stack: StackingContext): Promise<void> { if (contains(stack.element.container.flags, FLAGS.DEBUG_RENDER)) { debugger; } // 1. the background and borders of the element forming the stacking context. await this.renderNodeBackgroundAndBorders(stack.element); // 2. the child stacking contexts with negative stack levels (most negative first). for (const child of stack.negativeZIndex) { await this.renderStack(child); } // 3. For all its in-flow, non-positioned, block-level descendants in tree order: await this.renderNodeContent(stack.element); for (const child of stack.nonInlineLevel) { await this.renderNode(child); } // 4. All non-positioned floating descendants, in tree order. For each one of these, // treat the element as if it created a new stacking context, but any positioned descendants and descendants // which actually create a new stacking context should be considered part of the parent stacking context, // not this new one. for (const child of stack.nonPositionedFloats) { await this.renderStack(child); } // 5. the in-flow, inline-level, non-positioned descendants, including inline tables and inline blocks. for (const child of stack.nonPositionedInlineLevel) { await this.renderStack(child); } for (const child of stack.inlineLevel) { await this.renderNode(child); } // 6. All positioned, opacity or transform descendants, in tree order that fall into the following categories: // All positioned descendants with 'z-index: auto' or 'z-index: 0', in tree order. // For those with 'z-index: auto', treat the element as if it created a new stacking context, // but any positioned descendants and descendants which actually create a new stacking context should be // considered part of the parent stacking context, not this new one. For those with 'z-index: 0', // treat the stacking context generated atomically. // // All opacity descendants with opacity less than 1 // // All transform descendants with transform other than none for (const child of stack.zeroOrAutoZIndexOrTransformedOrOpacity) { await this.renderStack(child); } // 7. Stacking contexts formed by positioned descendants with z-indices greater than or equal to 1 in z-index // order (smallest first) then tree order. for (const child of stack.positiveZIndex) { await this.renderStack(child); } }
在 renderStackContent 方法中,首先对元素本身调用 renderNodeContent 和 renderNodeBackgroundAndBorders 进行渲染处理。
然后处理各个分类的子元素,如果子元素形成了层叠上下文,就调用 renderStack 方法,这个方法内部继续调用了 renderStackContent,这就形成了对于层叠上下文整个树的递归。如果子元素是正常元素没有形成层叠上下文,就直接调用 renderNode,renderNode 包括两部分内容,渲染节点内容和渲染节点边框背景色。
async renderNode(paint: ElementPaint): Promise<void> { if (paint.container.styles.isVisible()) { // 渲染节点的边框和背景色 await this.renderNodeBackgroundAndBorders(paint); // 渲染节点内容 await this.renderNodeContent(paint); } }
其中 renderNodeContent 方法是渲染一个元素节点里面的内容,其可能是正常元素、文字、图片、SVG、Canvas、input、iframe,对于不同的内容也会有不同的处理。
以上过程,就是 html2canvas 的整体内部流程,在了解了大致原理之后,我们再来看一个更为详细的源码流程图,对上述流程进行一个简单的总结。
常见问题总结
在使用 html2canvas 的过程中,会有一些常见的问题和坑,总结如下:
截图不全
要解决这个问题,只需要在截图之前将页面滚动到顶部即可:
document.documentElement.scrollTop = 0;
document.body.scrollTop = 0;
图片跨域
插件在请求图片的时候会有图片跨域的情况,这是因为,如果使用跨域的资源画到 canvas 中,并且资源没有使用 CORS 去请求,canvas 会被认为是被污染了,canvas 可以正常展示,但是没办法使用 toDataURL() 或者 toBlob() 导出数据,详情可参考:https://developer.mozilla.org/en-US/docs/Web/HTML/CORS_enabled_image
解决方案:在 img 标签上设置 crossorigin,属性值为 anonymous,可以开启 CROS 请求。当然,这种方式的前提还是服务端的响应头 Access-Control-Allow-Origin 已经被设置过允许跨域。如果图片本身服务端不支持跨域,可以使用 canvas 统一转成 base64 格式,方法如下。
function getUrlBase64_pro( len,url ) { //图片转成 base64 var canvas = document.createElement("canvas"); //创建 canvas DOM 元素 var ctx = canvas.getContext("2d"); return new Promise((reslove, reject) => { var img = new Image(); img.crossOrigin = "Anonymous"; img.onload = function() { canvas.height = len; canvas.width = len; ctx.drawImage(img, 0, 0, len, len); var dataURL = canvas.toDataURL("image/"); canvas = null; reslove(dataURL); }; img.onerror = function(err){ reject(err) } img.src = url; }); }
截图与当前页面有区别
方式一:如果要从渲染中排除某些 elements,可以向这些元素添加 data-html2canvas-ignore 属性,html2cnavas 会将它们从渲染中排除,例如,如果不想截图 iframe 的部分,可以如下:
html2canvas(ele,{ useCORS: true, ignoreElements: (element: any) => { if (element.tagName.toLowerCase() === 'iframe') { return element; } return false; }, })
方式二:可以将需要转化成图片的部分放在一个节点内,再把整个节点,透明度设置为 0, 其他部分层级设置高一些,即可实现截图指定区域。
小结
本文针对前端截图实现的方式,对两个开源库 dom-to-image 和 html2canvas 的使用和原理进行了简单的使用方式、实现原理方面,进行介绍和分析。
如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。
绑定邮箱获取回复消息
由于您还没有绑定你的真实邮箱,如果其他用户或者作者回复了您的评论,将不能在第一时间通知您!
发布评论