整合营销服务商

电脑端+手机端+微信端=数据同步管理

免费咨询热线:

VUE前端编程:用Html2Canvas实现html页面转图片

条APP上有个截图分享功能,就是把文章转成一张图片,然后分享到各渠道中去,如微信、QQ等,非常实用,因此,打算就这项功能自己封装为一个组件ImageGenerator,后期专门进行图片生成,html是其中的一个源。


头条截图分享的实际效果图


目前前端使用较多的html转图片的工具是Html2Canvas,考虑技术储备和问题讨论的充裕性,决定封装一下这个工具。步骤如下:


安装HTML2Canvas

按照官方要求,做安装操作:

npm

npm install html2canvas


我当时下载的是1.4.1的版本。



这个工具有自身的一些限制,使用时要注意:

[1] 并非真正的截图软件,而是根据DOM绘制出来的,其绘制能力,完全依赖于工具对DOM和对应属性的支持和理解;

[2] 因为使用了Canvas支持,生成图片的区域不能再有Canvas应用,否则会干扰工具的生成,不能保证生成预期,因此,如果使用了Canvas图表的应用这个工具不推荐使用


封装ImageGenerator

这个很简单,这里就是封装一个组件,用于后期引入html之外的源生成图片,同时也做一下图片的统一显示,从而和系统整体的设计进行配合。大致的实现思路如下:



上图,我们引入了工具本身,并设置的结果的显示区。生成的结果将以节点的方式注入 #image-box 中。


上图,封装了一个方法,用途是利用Html2Canvas工具获得图片,这里我们引入了一个组件的数据imageData用以存储和干预生成结果。在这里,我把ImageGenerator封装为全局组件。


应用场景

我们在文章的尾部加入一个share功能,点击弹出分享设置的弹窗,实际效果如下图所示:




以上技术实现比较简单,这里就不进行赘述了。上图中,我们设置了一个生成图像按钮,点击该按钮则可以触发我们组件中的对应操作。关键思路包括:

【1】这里设置了一个封装组件shareHandler,封装了前导模块和imageGenerator,这两个模块的显示通过一个开关进行控制,该开关则通过图像生成成功事件进行赋值,这样的话,我们可以实现图片生成后,不再显示前导模块,而是显示图片结果,即ImageGenerator。


【2】这里有一个比较关键的操作是shareHandler通过触发事件将转换器发射到文章转换现场,为什么用事件,还是那句话,事件对于解耦和消除组件依赖是最自然的实现。注意,这里我把imageGenerator通过引用的方式作为参数传出了,这样的好处是事件将转换器代入了转换现场,并可以携带回现场转换结果。



【3】在文章查看器,solutionViewer中,自然会订阅事件、事件处理和取消订阅。注意这里的事件处理,实际上是调用了转换器中我们封装的函数,参数则是现场取得的,这里的机制很简单,定义要转换div的id,作为参数传入函数。



那么,点击图像后,我们可以看到效果图:



点击右键另存图像,我们可以获得一张png格式的图片,至于后续对下载和到粘贴板的支持,大家可以自行研究和实现。



注意事项

实现过程中有几个注意事项:

【1】Canvas返回时,其长宽都是按照实际大小生成的,而我们的例子中,则要根据右抽屉式的弹窗做width=100%,height=auto的处理,这个要如何实现,就是要通过我们在imageGenerator中引入的imageData。


【2】我们的文章显示中,引入了文件管理的微服务,因此,文章中图片的链接都是跨域的,所以,必须打开html2Canvas的跨域选项,在封装的组件里,我是通过一共一个defaultOptions来实现这一点的。



这个选项可以在转换场景提供,也可提供一系列的默认值,最常用的除跨域外,还有是否允许log输出等开关,大家感兴趣可以自行查阅html2Canvas的官网。


内容比较简单,大家如果有这个应用场景,可以参考实现一下,有问题欢迎大家随时交流。谢谢大家的支持。

者开源了一个Web思维导图,在做导出为图片的功能时走了挺多弯路,所以通过本文来记录一下。

思维导图的节点和连线都是通过 svg渲染的,作为一个纯 js 库,我们不考虑通过后端来实现,所以只能思考如何通过纯前端的方式来实现将svg或html转换为图片。

使用img标签结合canvas导出

我们都知道 img 标签可以显示 svg,然后 canvas 又可以渲染 img,那么是不是只要将svg渲染到img标签里,再通过canvas导出为图片就可以呢,答案是肯定的。

const svgToPng = async (svgStr) => {
    // 转换成blob数据
    let blob = new Blob([svgStr], {
      type: 'image/svg+xml'
    })
    // 转换成data:url数据
    let svgUrl = await blobToUrl(blob)
    // 绘制到canvas上
    let imgData = await drawToCanvas(svgUrl)
    // 下载
    downloadFile(imgData, '图片.png')
}

svgStr是要导出的svg字符串,比如:

然后通过Blob构造函数创建一个类型为image/svg+xml的blob数据,接下来将blob数据转换成data:URL:

const blobToUrl = (blob) => {
    return new Promise((resolve, reject) => {
        let reader = new FileReader()
        reader.onload = evt => {
            resolve(evt.target.result)
        }
        reader.onerror = err => {
            reject(err)
        }
        reader.readAsDataURL(blob)
    })
}

其实就是base64格式的字符串。

接下来就可以通过img来加载,并渲染到canvas里进行导出:

const drawToCanvas = (svgUrl) => {
    return new Promise((resolve, reject) => {
      const img = new Image()
      // 跨域图片需要添加这个属性,否则画布被污染了无法导出图片
      img.setAttribute('crossOrigin', 'anonymous')
      img.onload = async () => {
        try {
          let canvas = document.createElement('canvas')
          canvas.width = img.width
          canvas.height = img.height
          let ctx = canvas.getContext('2d')
          ctx.drawImage(img, 0, 0, img.width, img.height)
          resolve(canvas.toDataURL())
        } catch (error) {
          reject(error)
        }
      }
      img.onerror = e => {
        reject(e)
      }
      img.src = svgUrl
    })
}

canvas.toDataURL()方法返回的也是一个base64格式的data:URL字符串:

最后就可以通过a标签来下载:

const downloadFile = (file, fileName) => {
  let a = document.createElement('a')
  a.href = file
  a.download = fileName
  a.click()
}

实现很简单,效果也不错,不过这样就没问题了吗,接下来我们插入两张图片试试。

处理存在图片的情况

第一张图片是使用base64的data:URL方式插入的,第二张图片是使用普通url插入的:

导出结果如下:

可以看到,第一张图片没有问题,第二张图片裂开了,可能你觉得同源策略的问题,但实际上换成同源的图片,同样也是裂开的,解决方法很简单,遍历svg节点树,将图片都转换成data:URL的形式即可:

// 操作svg使用了@svgdotjs/svg.js库
const transfromImg = (svgNode) => {
    let imageList = svgNode.find('image')
    let task = imageList.map(async item => {
      // 获取图片url
      let imgUlr = item.attr('href') || item.attr('xlink:href')
      // 已经是data:URL形式不用转换
      if (/^data:/.test(imgUlr)) {
        return
      }
      // 转换并替换图片url
      let imgData = await drawToCanvas(imgUlr)
      item.attr('href', imgData)
    })
    await Promise.all(task)
    return svgNode.svg()// 返回svg html字符串
}

这里使用了前面的drawToCanvas方法来将图片转换成data:URL,这样导出就正常了:

到这里,将纯 svg 转换为图片就基本没啥问题了。

处理存在foreignObject标签的情况

svg提供了一个foreignObject标签,可以插入html节点,实际上,笔者就是使用它来实现节点的富文本编辑效果的:

接下来使用前面的方式来导出,结果如下:

明明显示没有问题,导出时foreignObject内容却发生了偏移,这是为啥呢,其实是因为默认样式的问题,页面全局清除了margin和padding,以及将box-sizing设置成了border-box:

那么当svg存在于文档树中时是没有问题的,但是导出时使用的是svg字符串,是脱离于文档的,所以没有这个样式覆盖,那么显示自然会出现问题,知道了原因,解决方法有两种,一是遍历所有嵌入的html节点,手动添加内联样式,注意一定要给所有的html节点都添加,只给svg、foreignObject或最外层的html节点添加都是不行的;第二种是直接在foreignObject标签里添加一个style标签,通过style标签来加上样式,并且只要给其中一个foreignObject标签添加就可以了,两种方式看你喜欢哪种,笔者使用的是第二种:

const transformForeignObject = (svgNode) => {
    let foreignObjectList = svgNode.find('foreignObject')
    if (foreignObjectList.length > 0) {
        foreignObjectList[0].add(SVG(`<style>
        * {
            margin: 0;
            padding: 0;
            box-sizing: border-box;
        }
        </style>`))
    }
    return svgNode.svg()
}

导出结果如下:

可以看到,一切正常。

关于兼容性的问题,笔者测试了最新的chrome、firefox、opera、safari、360急速浏览器,运行都是正常的。

踩坑记录

前面介绍的是笔者目前采用的方案,看着实现其实非常简单,但是过程漫长且坎坷,接下来,开始我的表演。

foreignObject标签内容在firefox浏览器上无法显示

对于svg的操作笔者使用的是svg.js库,创建富文本节点的核心代码大致如下:

import { SVG, ForeignObject } from '@svgdotjs/svg.js'

let html = `<div>节点文本</div>`
let foreignObject = new ForeignObject()
foreignObject.add(SVG(html))
g.add(foreignObject)

SVG方法是用来将一段html字符串转换为dom节点的。

在chrome浏览器和opera浏览器上渲染非常正常,但是在firefox浏览器上foreignObject标签的内容完全渲染不出来:

检查元素也看不出有任何问题,并且神奇的是只要在控制台元素里编辑一下嵌入的html内容,它就可以显示了,百度搜索了一圈,也没找到解决方法,然后因为firefox浏览器占有率并不高,于是这个问题就搁浅了。

使用img结合canvas导出图片里foreignObject标签内容为空

chrome浏览器虽然渲染是正常的:

但是使用前面的方式导出时foreignObject标签内容却是跟在firefox浏览器里显示一样是空的:

firefox能忍这个不能忍,于是尝试使用一些将html转换为图片的库。

使用html2canvas、dom-to-image等库

使用html2canvas:

import html2canvas from 'html2canvas'

const useHtml2canvas = async (svgNode) => {
    let el = document.createElement('div')
    el.style.position = 'absolute'
    el.style.left = '-9999999px'
    el.appendChild(svgNode)
    document.body.appendChild(el)// html2canvas转换需要被转换的节点在文档中
    let canvas = await html2canvas(el, {
        backgroundColor: null
    })
    mdocument.body.removeChild(el)
    return canvas.toDataURL()
}

html2canvas可以成功导出,但是存在一个问题,就是foreignObject标签里的文本样式会丢失:

这应该是html2canvas的一个bug,不过看它这issues数量和提交记录:

指望html2canvas改是不现实的,于是又尝试使用dom-to-image:

import domtoimage from 'dom-to-image'

const dataUrl = domtoimage.toPng(el)

发现dom-to-image更不行,导出完全是空白的:

并且它上一次更新时间已经是五六年前,所以没办法,只能回头使用html2canvas。

后来有人建议使用dom-to-image-more,粗略看了一下,它是在dom-to-image库的基础上修改的,尝试了一下,发现确实可以,于是就改为使用这个库,然后又有人反馈在一些浏览器上导出节点内容是空的,包括firefox、360,甚至chrome之前的版本都不行,笔者只能感叹,太难了,然后又有人建议使用上一个大版本,可以解决在firefox上的导出问题,但是笔者试了一下,在其他一些浏览器上依旧存在问题,于是又在考虑要不要换回html2canvas,虽然它存在一定问题,但至少不是完全空的。

解决foreignObject标签内容在firefox浏览器上无法显示的问题

用的人多了,这个问题又有人提了出来,于是笔者又尝试看看能不能解决,之前一直认为是firefox浏览器的问题,毕竟在chrome和opera上都是正常的,这一次就想会不会是svgjs库的问题,于是就去搜它的issue,没想到,还真的搜出来了issue,大意就是因为通过SVG方法转换的dom节点是在svg的命名空间下,也就是使用document.createElementNS方法创建的,导致部分浏览器渲染不出来,归根结底,这还是不同浏览器对于规范的不同实现导致的:

你说chrome很强吧,确实,但是无形中它阻止了问题的暴露。

知道了原因,那么修改也很简单了,只要将SVG方法第二个参数设为true即可,或者自己来创建节点也可以:

foreignObject.add(document.createElemnt('div'))

果然,在firefox浏览器上正常渲染了。

解决img结合canvas导出图片为空的问题

解决了在firefox浏览器上foreignObject标签为空的问题后,自然会怀疑之前使用img结合canvas导出图片时foreignObject标签为空会不会也是因为这个问题,同时了解了一下dom-to-image库的实现原理,发现它也是通过将dom节点添加到svg的foreignObject标签中实现将html转换成图片的,那么就很搞笑了,我本身要转换的内容就是一个嵌入了foreignObject标签的svg,使用dom-to-image转换,它会再次把传给它的svg添加到一个foreignObject标签中,这不是套娃吗,既然dom-to-image-more能通过foreignObject标签成功导出,那么不用它必然也可以,到这里基本确信之前不行就是因为命名空间的问题。

果然,在去掉了dom-to-image-more库后,重新使用之前的方式成功导出了,并且在firefox、chrome、opera、360等浏览器中都不存在问题,兼容性反而比dom-to-image-more库好。

总结

虽然笔者的实现很简单,但是dom-to-image-more这个库实际上有一千多行代码,那么它到底多做了些什么呢,点个关注,我们下一篇文章再见。

om-to-image库可以帮你把dom节点转换为图片,它的核心原理很简单,就是利用svg的foreignObject标签能嵌入html的特性,然后通过img标签加载svg,最后再通过canvas绘制img实现导出,好了,本文到此结束。

另一个知名的html2canvas库其实也支持这种方式。

虽然原理很简单,但是dom-to-image毕竟也有1000多行代码,所以我很好奇它具体都做了哪些事情,本文就来详细剖析一下,需要说明的是dom-to-image库已经六七年前没有更新了,可能有点过时,所以我们要看的是基于它修改的dom-to-image-more库,这个库修复了一些bug,以及增加了一些特性,接下来我们就来详细了解一下。

将节点转换成图片

我们用的最多的api应该就是toPng(node),所以以这个方法为入口:

function toPng(node, options) {
    return draw(node, options).then(function (canvas) {
        return canvas.toDataURL();
    });
}

toPng方法会调用draw方法,然后返回一个canvas,最后通过canvas的toDataURL方法获取到图片的base64格式的data:URL,我们就可以直接下载为图片。

看一下draw方法:

function draw(domNode, options) {
    options = options || {};
    return toSvg(domNode, options)// 转换成svg
        .then(util.makeImage)// 转换成图片
        .then(function (image) {// 通过canvas绘制图片
            // ...
      });
}

一共分为了三个步骤,一一来看。

将节点转换成svg

toSvg方法如下:

function toSvg(node, options) {
    const ownerWindow = domtoimage.impl.util.getWindow(node);
    options = options || {};
    copyOptions(options);
    let restorations = [];
    return Promise.resolve(node)
        .then(ensureElement)// 检查和包装元素
        .then(function (clonee) {// 深度克隆节点
            return cloneNode(clonee, options, null, ownerWindow);
        })
        .then(embedFonts)// 嵌入字体
        .then(inlineImages)// 内联图片
        .then(makeSvgDataUri)// svg转data:URL
        .then(restoreWrappers)// 恢复包装元素
}

node就是我们要转换成图片的DOM节点,首先调用了getWindow方法获取window对象:

function getWindow(node) {
    const ownerDocument = node ? node.ownerDocument : undefined;
    return (
        (ownerDocument ? ownerDocument.defaultView : undefined) ||
        global ||
        window
    );
}

说实话前端写了这么多年,但是ownerDocument和defaultView两个属性我完全没用过,ownerDocument属性会返回当前节点的顶层的 document对象,而在浏览器中,defaultView属性会返回当前 document 对象所关联的 window 对象,如果没有,会返回 null。

所以这里优先通过我们传入的DOM节点获取window对象,可能是为了处理iframe嵌入之类的情况把。

接下来合并了选项后,就通过Promise实例的then方法链式的调用一系列的方法,一一来看。

检查和包装元素

ensureElement方法如下:

function ensureElement(node) {
    // ELEMENT_NODE:1
    if (node.nodeType === ELEMENT_NODE) return node;
    const originalChild = node;
    const originalParent = node.parentNode;
    const wrappingSpan = document.createElement('span');
    originalParent.replaceChild(wrappingSpan, originalChild);
    wrappingSpan.append(node);
    restorations.push({
        parent: originalParent,
        child: originalChild,
        wrapper: wrappingSpan,
    });
    return wrappingSpan;
}

html节点的nodeType有如下类型:

值为1也就是我们普通的html标签,其他的比如文本节点、注释节点、document节点也是比较常用的,如果我们传入的节点的类型为1,ensureElement方法什么也不做直接返回该节点,否则会创建一个span标签替换掉原节点,并把原节点添加到该span标签里,可以猜测这个主要是处理文本节点,毕竟应该没有人会传其他类型的节点进行转换了。

同时它还把原节点,原节点的父节点,span标签都收集到restorations数组里,很明显,这是为了后面进行还原。

克隆节点

接下来执行了cloneNode方法:

cloneNode(clonee, options, null, ownerWindow)
// 参数:需要克隆的节点、选项、父节点的样式、所属window对象
function cloneNode(node, options, parentComputedStyles, ownerWindow) {
    const filter = options.filter;
    if (
        node === sandbox ||
        util.isHTMLScriptElement(node) ||
        util.isHTMLStyleElement(node) ||
        util.isHTMLLinkElement(node) ||
        (parentComputedStyles !== null && filter && !filter(node))
    ) {
        return Promise.resolve();
    }
    return Promise.resolve(node)
        .then(makeNodeCopy)// 处理canvas元素
        .then(function (clone) {// 克隆子节点
            return cloneChildren(clone, getParentOfChildren(node));
        })
        .then(function (clone) {// 处理克隆的节点
            return processClone(clone, node);
        });
}

先做了一堆判断,如果是script、style、link标签,或者需要过滤掉的节点,那么会直接返回。

sandbox、parentComputedStyles后面会看到。

接下来又调用了几个方法,没办法,跟着它一起入栈把。

处理canvas元素的克隆

function makeNodeCopy(original) {
    if (util.isHTMLCanvasElement(original)) {
        return util.makeImage(original.toDataURL());
    }
    return original.cloneNode(false);
}

如果元素是canvas,那么会通过makeImage方法将其转换成img标签:

function makeImage(uri) {
    if (uri === 'data:,') {
        return Promise.resolve();
    }
    return new Promise(function (resolve, reject) {
        const image = new Image();
        if (domtoimage.impl.options.useCredentials) {
            image.crossOrigin = 'use-credentials';
        }
        image.onload = function () {
            if (window && window.requestAnimationFrame) {
                // 解决 Firefox 的一个bug (webcompat/web-bugs#119834) 
                // 需要等待一帧
                window.requestAnimationFrame(function () {
                    resolve(image);
                });
            } else {
                // 如果没有window对象或者requestAnimationFrame方法,那么立即返回
                resolve(image);
            }
        };
        image.onerror = reject;
        image.src = uri;
    });
}

crossOrigin属性用于定义一些元素如何处理跨域请求,主要有两个取值:

anonymous:元素的跨域资源请求不需要凭证标志设置。

use-credentials:元素的跨域资源请求需要凭证标志设置,意味着该请求需要提供凭证。

除了use-credentials,给crossOrigin设置其他任何值都会解析成anonymous,为了解决跨域问题,我们一般都会设置成anonymous,这个就相当于告诉服务器,你不需要返回任何非匿名信息过来,例如cookie,所以肯定是安全的。不过在使用这两个值时都需要服务端返回Access-Control-Allow-Credentials响应头,否则肯定无法跨域使用的。

非canvas元素的其他元素,会直接调用它们的cloneNode方法进行克隆,参数传了false,代表只克隆自身,不克隆子节点。

克隆子节点

接下来调用了cloneChildren方法:

cloneChildren(clone, getParentOfChildren(node));

getParentOfChildren方法如下:

function getParentOfChildren(original) {
    // 如果该节点是Shadow DOM的附加节点,那么返回附加的Shadow DOM的根节点
    if (util.isElementHostForOpenShadowRoot(original)) {
        return original.shadowRoot; 
    }
    return original;
}
function isElementHostForOpenShadowRoot(value) {
    return isElement(value) && value.shadowRoot !== null;
}

这里涉及到了shadow DOM,有必要先简单了解一下。

shadow DOM是一种封装技术,可以将标记结构、样式和行为隐藏起来,比如我们熟悉的video标签,我们看到的只是一个video标签,但实际上它里面有很多我们看不到的元素,这个特性一般会和Web components结合使用,也就是可以创建自定义元素,就和Vue和React组件一样。

先了解一些术语:

Shadow host:一个常规 DOM 节点,Shadow DOM 会被附加到这个节点上。

Shadow tree:Shadow DOM 内部的 DOM 树。

Shadow boundary:Shadow DOM 结束的地方,也是常规 DOM 开始的地方。

Shadow root: Shadow tree 的根节点。

一个普通的DOM元素可以使用attachShadow方法来添加shadow DOM:

let shadow = div.attachShadow({ mode: "open" });

这样就可以给div元素附加一个shadow DOM,然后我们可以和创建普通元素一样创建任何元素添加到shadow下:

let para = document.createElement('p');
shadow.appendChild(para);

当mode设为open,我们就可以通过div.shadowRoot获取到Shadow DOM,如果设置的是closed,那么外部就获取不到。

所以前面的getParentOfChildren方法会判断当前节点是不是一个Shadow host节点,是的话就返回它内部的Shadow root节点,否则返回自身。

回到cloneChildren方法,它接收两个参数:克隆的节点、原节点。

function cloneChildren(clone, original) {
    // 获取子节点,如果原节点是slot节点,那么会返回slot内的节点,
    const originalChildren = getRenderedChildren(original);
    let done = Promise.resolve();
    if (originalChildren.length !== 0) {
        // 获取原节点的计算样式,如果原节点是shadow root节点,那么会获取它所附加到的普通元素的样式
        const originalComputedStyles = getComputedStyle(
            getRenderedParent(original)
        );
        // 遍历子节点
        util.asArray(originalChildren).forEach(function (originalChild) {
            done = done.then(function () {
                // 递归调用cloneNode方法
                return cloneNode(
                    originalChild,
                    options,
                    originalComputedStyles,
                    ownerWindow
                ).then(function (clonedChild) {
                    // 克隆完后的子节点添加到该节点
                    if (clonedChild) {
                        clone.appendChild(clonedChild);
                    }
                });
            });
        });
    }
    return done.then(function () {
        return clone;
    });
}

首先通过getRenderedChildren方法获取子节点:

function getRenderedChildren(original) {
    // 如果是slot元素,那么通过assignedNodes方法返回该插槽中的节点
    if (util.isShadowSlotElement(original)) {
        return original.assignedNodes();
    }
    // 普通元素直接通过childNodes获取子节点
    return original.childNodes;
}
// 判断是否是html slot元素
function isShadowSlotElement(value) {
    return (
        isInShadowRoot(value) && value instanceof getWindow(value).HTMLSlotElement
    );
}
// 判断一个节点是否处于shadow DOM树中
function isInShadowRoot(value) {
    // 如果是普通节点,getRootNode方法会返回document对象,如果是Shadow DOM,那么会返回shadow root
    return (
        value !== null &&
        Object.prototype.hasOwnProperty.call(value, 'getRootNode') &&
        isShadowRoot(value.getRootNode())
    );
}
// 判断是否是shadow DOM的根节点
function isShadowRoot(value) {
    return value instanceof getWindow(value).ShadowRoot;
}

这一连串的判断,如果对于shadow DOM不熟悉的话大概率很难看懂,不过没关系,跳过这部分也可以,反正就是获取子节点。

获取到子节点后又调用了如下方法:

const originalComputedStyles = getComputedStyle(
    getRenderedParent(original)
);
function getRenderedParent(original) {
    // 如果该节点是shadow root,那么返回它附加到的普通的DOM节点
    if (util.isShadowRoot(original)) {
        return original.host;
    }
    return original;
}

调用getComputedStyle获取原节点的样式,这个方法其实就是window.getComputedStyle方法,会返回节点的所有样式和值。

接下来就是遍历子节点,然后对每个子节点再次调用cloneNode方法,只不过会把原节点的样式也传进去。对于子元素又会递归处理它们的子节点,这样就能深度克隆完整棵DOM树。

处理克隆的节点

对于每个克隆节点,又调用了processClone(clone, node)方法:

function processClone(clone, original) {
    // 如果不是普通节点,或者是slot节点,那么直接返回
    if (!util.isElement(clone) || util.isShadowSlotElement(original)) {
        return Promise.resolve(clone);
    }
    return Promise.resolve()
        .then(cloneStyle)// 克隆样式
        .then(clonePseudoElements)// 克隆伪元素
        .then(copyUserInput)// 克隆输入框
        .then(fixSvg)// 修复svg
        .then(function () {
            return clone;
        });
}

又是一系列的操作,稳住,我们继续。

克隆样式

function cloneStyle() {
    copyStyle(original, clone);
}

调用了copyStyle方法,传入原节点和克隆节点:

function copyStyle(sourceElement, targetElement) {
    const sourceComputedStyles = getComputedStyle(sourceElement);
    if (sourceComputedStyles.cssText) {
       // ...
    } else {
       // ...
    }
}

window.getComputedStyle方法返回的是一个CSSStyleDeclaration对象,和我们使用div.style获取到的对象类型是一样的,但是div.style对象只能获取到元素的内联样式,使用div.style.color = '#fff'设置的也能获取到,因为这种方式设置的也是内联样式,其他样式是获取不到的,但是window.getComputedStyle能获取到所有css样式。

div.style.cssText属性我们都用过,可以获取和批量设置内联样式,如果要设置多个样式,比单个调用div.style.xxx方便一点,但是cssText会覆盖整个内联样式,比如下面的方式设置的字号是会丢失的,内联样式最终只有color:

div.style.fontSize = '23px'
div.style.cssText = 'color: rgb(102, 102, 102)'

但是window.getComputedStyle方法返回的对象的cssText和div.style.cssText不是同一个东西,即使有内联样式,window.getComputedStyle方法返回对象的cssText值也是空,并且它无法修改,所以不清楚什么情况下它才会有值。

假设有值的话,接下来的代码我也不是很能理解:

if (sourceComputedStyles.cssText) {
    targetElement.style.cssText = sourceComputedStyles.cssText;
    copyFont(sourceComputedStyles, targetElement.style);
}

function copyFont(source, target) {
    target.font = source.font;
    target.fontFamily = source.fontFamily;
    // ...
}

为什么不直接把原节点的style.cssText复制给克隆节点的style.cssText呢,另外为啥文本相关的样式又要单独设置一遍呢,无法理解。

我们看看另外一个分支:

else {
    copyUserComputedStyleFast(
        options,
        sourceElement,
        sourceComputedStyles,
        parentComputedStyles,
        targetElement
    );
    // ...
}

先调用了copyUserComputedStyleFast方法,这个方法内部非常复杂,就不把具体代码放出来了,大致介绍一下它都做了什么:

1.首先会获取原节点的所谓的默认样式,这个步骤也比较复杂:

1.1.先获取原节点及祖先节点的元素标签列表,其实就是一个向上递归的过程,不过存在终止条件,就是当遇到块级元素的祖先节点。比如原节点是一个span标签,它的父节点也是一个span,再上一个父节点是一个div,那么获取到的标签列表就是[span, span, div]。

​ 1.2.接下来会创建一个沙箱,也就是一个iframe,这个iframe的DOCTYPE和charset会设置成和当前页面的一样。

​ 1.3.再接下来会根据前面获取到的标签列表,在iframe中创建对应结构的DOM节点,也就是会创建这样一棵DOM树:div -> span -> span。并且会给最后一个节点添加一个零宽字符的文本,并返回这个节点。

​ 1.4.使用iframe的window.getComputedStyle方法获取上一步返回节点的样式,对于width和height会设置成auto。

​ 1.5.删除iframe里前面创建的节点。

​ 16.返回1.4步获取到的样式对象。

2.遍历原节点的样式,也就是sourceComputedStyles对象,对于每一个样式属性,都会获取到三个值:sourceValue、defaultValue、parentValue,分别来自原节点的样式对象sourceComputedStyles、第一步获取到的默认样式对象、父节点的样式对象parentComputedStyles,然后会做如下判断:

if (
    sourceValue !== defaultValue ||
    (parentComputedStyles && sourceValue !== parentValue)
) {
    // 样式优先级,比如important
    const priority = sourceComputedStyles.getPropertyPriority(name);
    // 将样式设置到克隆节点的style对象上
    setStyleProperty(targetStyle, name, sourceValue, priority);
}

如果原节点的某个样式值和默认的样式值不一样,并且和父节点的也不一样,那么就需要给克隆的节点手动设置成内联样式,否则其实就是继承样式或者默认样式,就不用管了,不得不说,还是挺巧妙的。

copyUserComputedStyleFast方法执行完后还做了如下操作:

if (parentComputedStyles === null) {
    [
        'inset-block',
        'inset-block-start',
        'inset-block-end',
    ].forEach((prop) => targetElement.style.removeProperty(prop));
    ['left', 'right', 'top', 'bottom'].forEach((prop) => {
        if (targetElement.style.getPropertyValue(prop)) {
            targetElement.style.setProperty(prop, '0px');
        }
    });
}

对于我们传入的节点,parentComputedStyles是null,本质相当于根节点,所以直接移除它的位置信息,防止发生偏移。

克隆伪元素

克隆完样式,接下来就是处理伪元素了:

function clonePseudoElements() {
    const cloneClassName = util.uid();
    [':before', ':after'].forEach(function (element) {
        clonePseudoElement(element);
    });
}

分别调用clonePseudoElement方法处理两种伪元素:

function clonePseudoElement(element) {
    // 获取原节点伪元素的样式
    const style = getComputedStyle(original, element);
    // 获取伪元素的content
    const content = style.getPropertyValue('content');
    // 如果伪元素的内容为空就直接返回
    if (content === '' || content === 'none') {
        return;
    }
    // 获取克隆节点的类名
    const currentClass = clone.getAttribute('class') || '';
    // 给克隆元素增加一个唯一的类名
    clone.setAttribute('class', `${currentClass} ${cloneClassName}`);
 // 创建一个style标签
    const styleElement = document.createElement('style');
    // 插入伪元素的样式
    styleElement.appendChild(formatPseudoElementStyle());
    // 将样式标签添加到克隆节点内
    clone.appendChild(styleElement);
}

window.getComputedStyle方法是可以获取元素的伪元素的样式的,通过第二个参数指定要获取的伪元素即可。

如果伪元素的content为空就不管了,总感觉有点不妥,毕竟我经常会用伪元素渲染一些三角形,content都是设置成空的。

如果不为空,那么会给克隆的节点新增一个唯一的类名,并且创建一个style标签添加到克隆节点内,这个style标签里会插入伪元素的样式,通过formatPseudoElementStyle方法获取伪元素的样式字符串:

function formatPseudoElementStyle() {
    const selector = `.${cloneClassName}:${element}`;
    // style为原节点伪元素的样式对象
    const cssText = style.cssText
    ? formatCssText()
    : formatCssProperties();

    return document.createTextNode(`${selector}{${cssText}}`);
}

如果样式对象的cssText有值,那么调用formatCssText方法:

function formatCssText() {
    return `${style.cssText} content: ${content};`;
}

但是前面说了,这个属性一般都是没值的,所以会走formatCssProperties方法:

function formatCssProperties() {
    const styleText = util
        .asArray(style)
        .map(formatProperty)
        .join('; ');
    return `${styleText};`;

    function formatProperty(name) {
        const propertyValue = style.getPropertyValue(name);
        const propertyPriority = style.getPropertyPriority(name)
        ? ' !important'
        : '';
        return `${name}: ${propertyValue}${propertyPriority}`;
    }
}

很简单,遍历样式对象,然后拼接成css的样式字符串。

克隆输入框

对于输入框的处理很简单:

function copyUserInput() {
    if (util.isHTMLTextAreaElement(original)) {
        clone.innerHTML = original.value;
    }
    if (util.isHTMLInputElement(original)) {
        clone.setAttribute('value', original.value);
    }
}

如果是textarea或者input元素,直接将原节点的值设置到克隆后的元素上即可。但是我测试发现克隆输入框也会把它的值给克隆过去,所以这一步可能没有必要。

修复svg

最后就是处理svg节点:

function fixSvg() {
    if (util.isSVGElement(clone)) {
        clone.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
        if (util.isSVGRectElement(clone)) {
            ['width', 'height'].forEach(function (attribute) {
                const value = clone.getAttribute(attribute);
                if (value) {
                    clone.style.setProperty(attribute, value);
                }
            });
        }
    }
}

给svg节点添加命名空间,另外对于rect节点,还把宽高的属性设置成对应的样式,这个是何原因,我们也不得而知。

到这里,节点的克隆部分就结束了,不得不说,还是有点复杂的,很多操作其实我们也没有看懂为什么要这么做,开发一个库就是这样,要处理很多边界和异常情况,这个只有遇到了才知道为什么。

嵌入字体

节点克隆完后接下来会处理字体:

function embedFonts(node) {
    return fontFaces.resolveAll().then(function (cssText) {
        if (cssText !== '') {
            const styleNode = document.createElement('style');
            node.appendChild(styleNode);
            styleNode.appendChild(document.createTextNode(cssText));
        }
        return node;
    });
}

调用resolveAll方法,会返回一段css字符串,然后创建一个style标签添加到克隆的节点内,接下来看看resolveAll方法都做了什么:

function resolveAll() {
    return readAll()
        // ...
}

又调用了readAll方法:

function readAll() {
    return Promise.resolve(util.asArray(document.styleSheets))
        .then(getCssRules)
        .then(selectWebFontRules)
        .then(function (rules) {
            return rules.map(newWebFont);
        });
}

document.styleSheets属性可以获取到文档中所有的style标签和通过link标签引入的样式,结果是一个类数组,数组的每一项是一个CSSStyleSheet对象。

function getCssRules(styleSheets) {
    const cssRules = [];
    styleSheets.forEach(function (sheet) {
        if (
            Object.prototype.hasOwnProperty.call(
                Object.getPrototypeOf(sheet),
                'cssRules'
            )
        ) {
            util.asArray(sheet.cssRules || []).forEach(
                cssRules.push.bind(cssRules)
            );
        }
    });
    return cssRules;
}

通过CSSStyleSheet对象的cssRules属性可以获取到具体的css规则,cssRules的每一项也就是我们写的一条css语句:

function selectWebFontRules(cssRules) {
    return cssRules
        .filter(function (rule) {
            return rule.type === CSSRule.FONT_FACE_RULE;
        })
        .filter(function (rule) {
            return inliner.shouldProcess(rule.style.getPropertyValue('src'));
        });
}

遍历所有的css语句,找出其中的@font-face语句,shouldProcess方法会判断@font-face语句的src属性是否存在url()值,找出了所有存在的字体规则后会遍历它们调用newWebFont方法:

function newWebFont(webFontRule) {
    return {
        resolve: function resolve() {
            const baseUrl = (webFontRule.parentStyleSheet || {}).href;
            return inliner.inlineAll(webFontRule.cssText, baseUrl);
        },
        src: function () {
            return webFontRule.style.getPropertyValue('src');
        },
    };
}

inlineAll方法会找出@font-face语句中定义的所有字体的url,然后通过XMLHttpRequest发起请求,将字体文件转换成data:URL形式,然后替换css语句中的url,核心就是使用下面这个正则匹配和替换。

const URL_REGEX = /url\(['"]?([^'"]+?)['"]?\)/g;

继续resolveAll方法:

function resolveAll() {
    return readAll()
        .then(function (webFonts) {
            return Promise.all(
                webFonts.map(function (webFont) {
                    return webFont.resolve();
                })
            );
        })
        .then(function (cssStrings) {
            return cssStrings.join('\n');
        });
}

将所有@font-face语句的远程字体url都转换成data:URL形式后再将它们拼接成css字符串即可完成嵌入字体的操作。

说实话,Promise链太长,看着容易晕。

内联图片

内联完了字体后接下来就是内联图片:

function inlineImages(node) {
    return images.inlineAll(node).then(function () {
        return node;
    });
}

处理图片的inlineAll方法如下:

function inlineAll(node) {
    if (!util.isElement(node)) {
        return Promise.resolve(node);
    }
    return inlineCSSProperty(node).then(function () {
        // ...
    });
}

inlineCSSProperty方法会判断节点background和 background-image属性是否设置了图片,是的话也会和嵌入字体一样将远程图片转换成data:URL嵌入:

function inlineCSSProperty(node) {
    const properties = ['background', 'background-image'];
    const inliningTasks = properties.map(function (propertyName) {
        const value = node.style.getPropertyValue(propertyName);
        const priority = node.style.getPropertyPriority(propertyName);
        if (!value) {
            return Promise.resolve();
        }
        // 如果设置了背景图片,那么也会调用inliner.inlineAll方法将远程url的形式转换成data:URL形式
        return inliner.inlineAll(value).then(function (inlinedValue) {
            // 将样式设置成转换后的值
            node.style.setProperty(propertyName, inlinedValue, priority);
        });
    });
    return Promise.all(inliningTasks).then(function () {
        return node;
    });
}

处理完节点的背景图片后:

function inlineAll(node) {
    return inlineCSSProperty(node).then(function () {
        if (util.isHTMLImageElement(node)) {
            return newImage(node).inline();
        } else {
            return Promise.all(
                util.asArray(node.childNodes).map(function (child) {
                    return inlineAll(child);
                })
            );
        }
    });
}

会检查节点是否是图片节点,是的话会调用newImage方法处理,这个方法也很简单,也是发个请求获取图片数据,然后将它转换成data:URL设置回图片的src。

如果是其他节点,那么就递归处理子节点。

将svg转换成data:URL

图片也处理完了接下来就可以将svg转换成data:URL了:

function makeSvgDataUri(node) {
    let width = options.width || util.width(node);
    let height = options.height || util.height(node);

    return Promise.resolve(node)
        .then(function (svg) {
            svg.setAttribute('xmlns', 'http://www.w3.org/1999/xhtml');
            return new XMLSerializer().serializeToString(svg);
        })
        .then(util.escapeXhtml)
        .then(function (xhtml) {
            const foreignObjectSizing =
                  (util.isDimensionMissing(width)
                   ? ' width="100%"'
                   : ` width="${width}"`) +
                  (util.isDimensionMissing(height)
                   ? ' height="100%"'
                   : ` height="${height}"`);
            const svgSizing =
                  (util.isDimensionMissing(width) ? '' : ` width="${width}"`) +
                  (util.isDimensionMissing(height) ? '' : ` height="${height}"`);
            return `<svg xmlns="http://www.w3.org/2000/svg"${svgSizing}> 
    <foreignObject${foreignObjectSizing}>${xhtml}</foreignObject>
   </svg>`;
        })
        .then(function (svg) {
            return `data:image/svg+xml;charset=utf-8,${svg}`;
        });
}

其中的isDimensionMissing方法就是判断是否是不合法的数字。

主要做了四件事。

一是给节点添加命名空间,并使用XMLSerializer对象来将DOM节点序列化成字符串。

二是转换DOM字符串中的一些字符:

function escapeXhtml(string) {
    return string.replace(/%/g, '%25').replace(/#/g, '%23').replace(/\n/g, '%0A');
}

第三步就是拼接svg字符串了,将序列化后的字符串使用foreignObject标签包裹,同时会计算一下DOM节点的宽高设置到svg上。

最后一步是拼接成data:URL的形式。

恢复包装元素

在最开始的【检查和包装元素】步骤会替换掉节点类型不为1的节点,这一步就是用来恢复这个操作:

function restoreWrappers(result) {
    while (restorations.length > 0) {
        const restoration = restorations.pop();
        restoration.parent.replaceChild(restoration.child, restoration.wrapper);
    }
    return result;
}

这一步结束后将节点转换成svg的操作就结束了。

将svg转换成图片

现在我们可以回到draw方法:

function draw(domNode, options) {
        options = options || {};
        return toSvg(domNode, options)
            .then(util.makeImage)
            .then(function (image) {
                // ...
            });
}

获取到了svg的data:URL后会调用makeImage方法将它转换成图片,这个方法前面我们已经看过了,这里就不重复说了。

将图片通过canvas导出

继续draw方法:

function draw(domNode, options) {
        options = options || {};
        return toSvg(domNode, options)
            .then(util.makeImage)
            .then(function (image) {
                const scale = typeof options.scale !== 'number' ? 1 : options.scale;
                const canvas = newCanvas(domNode, scale);
                const ctx = canvas.getContext('2d');
                ctx.msImageSmoothingEnabled = false;// 禁用图像平滑
                ctx.imageSmoothingEnabled = false;// 禁用图像平滑
                if (image) {
                    ctx.scale(scale, scale);
                    ctx.drawImage(image, 0, 0);
                }
                return canvas;
            });
}

先调用newCanvas方法创建一个canvas:

function newCanvas(node, scale) {
    let width = options.width || util.width(node);
    let height = options.height || util.height(node);
 // 如果宽度高度都没有,那么默认设置成300
    if (util.isDimensionMissing(width)) {
        width = util.isDimensionMissing(height) ? 300 : height * 2.0;
    }
    // 如果高度没有,那么默认设置成宽度的一半
    if (util.isDimensionMissing(height)) {
        height = width / 2.0;
    }
 // 创建canvas
    const canvas = document.createElement('canvas');
    canvas.width = width * scale;
    canvas.height = height * scale;
 // 设置背景颜色
    if (options.bgcolor) {
        const ctx = canvas.getContext('2d');
        ctx.fillStyle = options.bgcolor;
        ctx.fillRect(0, 0, canvas.width, canvas.height);
    }
    return canvas;
}

把svg图片绘制到canvas上后,就可以通过canvas.toDataURL()方法转换成图片的data:URL,你可以渲染到页面,也可以直接进行下载。

总结

本文通过源码详细介绍了dom-to-image-more的原理,核心就是克隆节点和节点样式,内联字体、背景图片、图片,然后通过svg的foreignObject标签嵌入克隆后的节点,最后将svg转换成图片,图片绘制到canvas上进行导出。

可以看到源码中大量的Promise,很多不是异步的逻辑也会通过then方法来进行管道式调用,大部分情况会让代码很清晰,一眼就知道大概做了什么事情,但是部分地方串联了太长,反倒不太容易理解。

限于篇幅,源码中其实还要很多有意思的细节没有介绍,比如为了修改iframe的DOCTYPE和charset,居然写了三种方式,虽然我觉得第一种就够了,又比如获取节点默认样式的方式,通过iframe创建同样标签同样层级的元素,说实话我是从来没见过,再比如解析css中的字体的url时用的是如下方法:

function resolveUrl(url, baseUrl) {
    const doc = document.implementation.createHTMLDocument();
    const base = doc.createElement('base');
    doc.head.appendChild(base);
    const a = doc.createElement('a');
    doc.body.appendChild(a);
    base.href = baseUrl;
    a.href = url;
    return a.href;
}

base标签我也是从来没有见过。等等。

所以看源码还是挺有意思的一件事,毕竟平时写业务代码局限性太大了,很多东西都了解不到,强烈推荐各位去阅读一下。