关于网页截屏的那些事儿~

为什么要写

最近在做公司的项目中,遇到了需要对网页上特定区域导出图片的需求。在实现的过程中,遇到了一些坑并在填坑的过程中学习到了一些经验,为了方便以后回顾,因此决定记录下来。

初识 html2canvas

首先我们知道,浏览器没有提供原生的截屏api供js使用,所以必须以“曲线救国”的方式进行实现。目前社区上关于截屏这块最成熟的*是html2canvas,目前已经有2.3w 的star,它提供了开箱即用的简洁api,能帮助我们很轻易地实现截屏功能,下面贴出官网给出的示例代码,从中可以看到上手十分简单

<div id="capture" style="padding: 10px; background: #f5da55">
    <h4 style="color: #000; ">Hello world!</h4>
</div>

/*
 ....
*/

html2canvas(document.querySelector("#capture")).then(canvas => {
    document.body.appendChild(canvas)
})
复制代码

这里还可以传递第二个参数,形如html2canvas(element, options),用于自定义控制渲染结果,其中可选属性列举如下

名称 默认值 描述
allowTaint false 是否允许跨域图片被渲染到canvas上
backgroundColor #ffffff canvas的背景颜色,如果背景需要被设置为透明,请设为null
canvas null 指定使用页面中已经存在的canvas实例
foreignObjectRendering false 在浏览器支持foreignObject情况下,是否渲染foreignObject中的内容
ignoreElements (element) => false 指定需要被忽略渲染的元素
onclone null 当页面区域的dom被克隆完成时触发的钩子函数,在这个钩子里可以对克隆出的dom进行修改,从而改变渲染结果,同时不会影响原本的页面
proxy null 用于代理跨域图片资源加载的地址
scale window.devicePixelRatio 渲染的缩放比例
width Element width canvas 的宽度
height Element height canvas 的高度
x Element x-offset 调整canvas画布原点的x坐标
y Element y-offset 调整canvas画布原点的y坐标

可以通过给元素增加属性data-html2canvas-ignore从而让html2canvas在渲染时忽略该元素,它是ignoreElements选项的快捷使用方式,强烈推荐哦~

这里简要叙述下它的工作原理,html2canvas将页面区域的dom克隆出一个备份,然后在这个备份中搜集dom信息,将其解析成特定类型的数据,然后通过这些数据将页面上的内容绘制到canvas上,并最终返回这个canvas实例,下面贴出流程图方便理解

关于网页截屏的那些事儿~

我们在使用这个库时,会存在一些限制,列举如下

  1. 当页面区域中存在图片时,如果图片属于非跨域资源,那么图片是可以被渲染到canvas上并且canvas中的内容可以正常导出,如果属于跨域资源,需要分两种情况:
  • 当allowTaint为false时,是不会渲染图片到canvas上的,同时canvas中的内容是可以被正常导出的
  • 当allowTaint为true时,图片会被渲染到canvas上,但是canvas会被标记为Tainted状态,从而无法将其中的内容进行导出

对于跨域图片的处理,官方给出的解决方案是添加proxy

  1. 由于不是真正的截屏,而是通过dom转化而来,所以最终的效果不会百分百还原页面,究其原因,其实是html2canvas不支持部分css样式,从而导致差异的产生,下面列举目前最新版本(1.0.0)不支持的样式
  • background-blend-mode
  • border-image
  • box-decoration-break
  • box-shadow
  • filter
  • font-variant-ligatures
  • mix-blend-mode
  • object-fit
  • repeating-linear-gradient()
  • writing-mode
  • zoom

需要注意的是border-radius: 50%是没有效果的,必须要写成固定的数值才可以,正确写法:border-radius: 50px

  1. 输出的图片清晰度会比较低,下面贴出解决方案的代码片段
  /*
    获取想要转换的 DOM 节点
  */
  const dom = document.querySelector('.target');
  const box = window.getComputedStyle(dom);
  /*
    DOM 节点计算后宽高
  */
  const width = parseInt(box.width, 10);
  const height = parseInt(box.height, 10);
  /*
    获取像素比
  */
  const scaleBy = window.devicePixelRatio
  /*
    创建自定义 canvas 元素
  */
  const canvas = document.createElement('canvas');
   /*
    设定 canvas 元素属性宽高为 DOM 节点宽高 * 像素比
  */
  canvas.width = width * scaleBy;
  canvas.height = height * scaleBy;
  /*
    设定 canvas css宽高为 DOM 节点宽高
  */
  canvas.style.width = `${width}px`;
  canvas.style.height = `${height}px`;
  /*
    获取画笔
  */
  const context = canvas.getContext('2d');
  /*
    将所有绘制内容放大像素比倍
  */
  context.scale(scaleBy, scaleBy);
  /*
    将自定义 canvas 作为配置项传入,开始绘制
  */
  html2canvas(dom, {canvas, background: '#ffffff'}).then((canvas) => {
    let url= canvas.toDataURL();
    /*
      此时的url是图片的base64格式,可直接赋值到img的src上
    */
    console.log(url)
  })
复制代码

使用html2canvas遇到的问题

其实绝大多数场景下,html2canvas都可以很好地胜任,但是少数场景下也会出现或多或少的问题,特别是在转化svg时容易出现问题

当时的使用场景是需要导出页面中svg里的某一个子元素的内容。最开始使用的版本是1.0.0-rc.7,当我进行导出操作时,会报错unable to find element in cloned iframe,当时百思不得其解,在经过一番搜寻之后,终于是在其仓库的issue中发现了端倪,从那个人的提问中我推测应该是版本的问题,于是我将版本降为1.0.0-rc.6,再进行重新导出,没报错啦!开心!但是我只猜中了开头,却没猜中结尾,我看了下输出的图片,竟然只有文本内容,其他什么都没有!

于是我开始了摸索,我发现只要将目标元素的根节点选为<svg>标签,也就是全量导出,那么输出的内容就没有问题,如果将target设置为svg中某一个子节点时,那么输出的内容就会出现内容不完整的问题,所以想要实现需求,html2canvas这条路是走不通了,于是在经过一番苦苦寻找之后,我找到了可以替代html2canvas的方案,那就是canvg

这个库是专门用来将svg绘制到canvas上的。相比于html2canvas,这个库对于svg的处理是更加专业以及成熟的,我也是使用了这个库才最终完成了需求,但是在这个过程中也遇到了一些问题,接下来将围绕这些问题展开叙述

初识canvg

首先引用下官方对于这个库的描述

JavaScript SVG parser and renderer on Canvas. It takes the URL to the SVG file or the text of the SVG file, parses it in JavaScript and renders the result on Canvas.

其实简单来说就是一个专门用于解析svg并将其渲染到canvas上的引擎,并提供了简洁的api供我们使用,下面援引官网给出的示例

import Canvg from 'canvg';

let v = null;

window.onload = async () => {
    const canvas = document.querySelector('canvas');
    const ctx = canvas.getContext('2d');
    /*
      通过from来启动canvg引擎
    */
    v = await Canvg.from(ctx, './svgs/1.svg');
    /*
      Start SVG rendering with animations and mouse handling.
    */
    v.start();
};

window.onbeforeunload = () => {
    v.stop();
};
复制代码
import Canvg, {
    presets
} from 'canvg';

self.onmessage = async (event) => {
    const {
        width,
        height,
        svg
    } = event.data;
    const canvas = new OffscreenCanvas(width, height);
    const ctx = canvas.getContext('2d');
    const v = await Canvg.from(ctx, svg, presets.offscreen());

    /*
      Render only first frame, ignoring animations and mouse.
    */
    await v.render();

    const blob = await canvas.convertToBlob();
    const pngUrl = URL.createObjectURL(blob);

    self.postMessage({
        pngUrl
    });
};
复制代码

上述的示例中,我们有看到OffscreenCanvas这样一个陌生的构造函数名,它是用来提供一个可以脱离屏幕渲染的canvas对象,通过它我们就可以不必通过createElement('canvas')来实际生成一个canvas,从而避免了对document的污染,但是目前这个特性的兼容性不太好,IE和safari完全不支持,所以目前只能持观望态度

还有一个需要注意的地方是启动渲染的方法有两种:renderstart。这两个方法的区别是当需要绘制的svg是动态图时,render只会绘制第一帧的内容,也就是说绘制出来的图像是静态的,而start是会将svg的内容以及动效全部都绘制出来,也就是说图片是动态的形式,我们可以根据自己的需求进行选择

启动canvg引擎有三种方式,分别是

  • new Canvg(...)
  • Canvg.from(...)
  • Canvg.fromString(...)

from和fromString的区别是from需要传入的是svg本身,而fromString需要传入的是svg的字符串形式。这三种方式都可以传入三个参数,第一个参数是canvas画布的绘制上下文,第二个是需要绘制的svg,第三个是自定义配置选项,可以用于控制画布的渲染结果,具体可选配置如下

interface IOptions {
    /**
     * WHATWG-compatible `fetch` function.
     */
    fetch?: typeof fetch;
    /**
     * XML/HTML parser from string into DOM Document.
     */
    DOMParser?: typeof DOMParser;
    /**
     * Window object.
     */
    window?: Window;
    /**
     * Whether enable the redraw.
     */
    enableRedraw?: boolean;
    /**
     * Ignore mouse events.
     */
    ignoreMouse?: boolean;
    /**
     * Ignore animations.
     */
    ignoreAnimation?: boolean;
    /**
     * Does not try to resize canvas.
     */
    ignoreDimensions?: boolean;
    /**
     * Does not clear canvas.
     */
    ignoreClear?: boolean;
    /**
     * Scales horizontally to width.
     */
    scaleWidth?: number;
    /**
     * Scales vertically to height.
     */
    scaleHeight?: number;
    /**
     * Draws at a x offset.
     */
    offsetX?: number;
    /**
     * Draws at a y offset.
     */
    offsetY?: number;

    forceRedraw?(): boolean;      /*Will call the function on every frame, if it returns true, will redraw.*/

    rootEmSize?: number;     /*Default `rem` size.*/

    emSize?: number;       /* Default `em` size.*/

    createCanvas?: (width: number, height: number) => HTMLCanvasElement | OffscreenCanvas;    /*Function to create new canvas.*/

    createImage?: (src: string, anonymousCrossOrigin?: boolean) => Promise<CanvasImageSource>;     /* Function to create new image.*/
    
    anonymousCrossOrigin?: boolean;    /* Load images anonymously.*/
}
复制代码

使用canvg遇到的问题

首先说明下我使用的版本是3.0.7,我遇到的问题列举如下:

  • canvg跟html2canvas一样,对于部分css样式也是不支持的,我遇到的情况就是由于canvg不支持filter属性,并且如果元素上存在该属性,会导致所有图形的描边或者填充都失效,所以我在转换之前先将所有的元素的 filter去掉,使用stroke代替,从而解决了这个问题
  • 全量导出svg时不会报错,但是当想导出svg中某个子元素的内容时,会报错Uncaught (in promise) Error: This page contains the following errors:error on line 1 at column 2796: Namespace prefix xlink for href on image is not defined,Below is a rendering of the page up to the first error.从报错信息来看大致是缺少命名空间导致的,所以我很自然地将xmlns:xlink="http://www.w3.org/1999/xlink"xmlns="http://www.w3.org/2000/svg"添加到了目标元素的根节点上,这里的根元素是<g>,当我满心欢喜地再次进行导出时,却依然报同样的错误,经过网上的搜寻之后,发现命名空间的属性只能定义在<svg>标签上,所以问题的解决方案就很明朗了,下面贴出解决方案的代码示例
let canvas = document.createElement('canvas')
let ctx = canvas.getContext('2d')
    canvas.setAttribute('width', '8000px')
    canvas.setAttribute('height', '1500px')
    canvas.setAttribute('position', 'fixed')
    canvas.setAttribute('top', '99999999px')
    document.body.appendChild(canvas)

    /*
      由于canvg库不支持filter属性,如果加上,矩形和圆形的描边或者填充都失效,所以在转换之前先将filter去掉,使用stroke代替
    */
    let rectArr = Array.from(container.getElementsByTagName('rect'))
    rectArr.forEach(item=>{
      item.setAttribute('stroke', '#ccc')
      item.removeAttribute('filter')
    })
    let rootCircle = container.getElementsByTagName('circle')[0]
    rootCircle.setAttribute('stroke', '#ccc')
    rootCircle.removeAttribute('filter')

    /*
      这里手动添加svg标签,增加命名空间
    */
    let v = await Canvg.from(ctx, `<svg xmlns:xlink="http://www.w3.org/1999/xlink" xmlns="http://www.w3.org/2000/svg">${container.innerHTML.trim()}</svg>`, {
      offsetX: 3000
    })
    await v.render()
复制代码

这里说明下svg命名空间的含义与作用:命名空间声明xmlns只需要在根标记上提供一次,声明定义了默认命名空间,因此用户代理知道所有<svg>标签的后代标签也属于同一命名空间,xmlns是作为标签的命名空间,而xmlns:xlink是作为属性的命名空间,这里定义的就是xlink的命名空间,xlink一般是跟href属性搭配,形如xlink:href

结语

经过一番折腾,总算是达成目的了,虽然过程比较曲折,但在解决问题的过程中也收获了很多知识。其实前端截屏这块有很多学问,据我所知还可以在服务端进行截屏,但是自己还没有尝试过,我想那也应该是一个很有趣并且充满挑战的方案,以后有机会一定也会去尝试下的,就写这么多吧,完结,撒花~

 

上一篇:记一次用html2canvas将页面内容生成海报并保存图片到本地


下一篇:js利用html2canvas实现dom元素转图片下载