# 如何优雅的生成网页截图

这篇文章起源于一个页面生成图片进行下载分享的需求,因为浏览器没有原生的截图 API,所以需要借助 canvas 来实现导出图片实现需求。

首先要知道,svg 到图像的转换过程涉及:

  1. 创建 Blob/Blob URL(参阅什么是 Blob URL 以及为什么使用它?
  2. 将其渲染到画布上
  3. 返回数据 url

# 可行性方案

  • 方案 1: 将 DOM 改写成 canvas ,调用 canvas 的 toBlob 或者 toDataURL 方法即刻上传到七牛云或服务器
  • 方案 2: 使用第三方库实现 canvas , 在不更改页面已有 DOM 的情况下优雅产生 canvas

# 解决方案的选择

  • 方案 1:需要手动计算每个 DOM 元素的 Computed Style,然后需要计算好元素在 canvas 的大小位置等属性。

    方案1难点

    1. 需要弃用已有的 html 页面,改用 canvas 重写。
    2. 页面结构层复杂的情况下用 canvas 写,不易重构。
    3. 有一定 canvas 基础。
  • 方案 2:在 Github 上有很多此功能的第三方库,并且作者仍在积极维护。API 非常简单,在已有项目中开箱即用。因为是常见的需求,所以社区会有成熟的解决方案,首先试试社区的解决方案。

    • html2canvas
    • html-to-image
    • dom-to-image

# 使用第三方库

# 1. html2canvas

const saveAsImage = async (format: any) => {
    try {
      const dataURL = await new Promise<string>((resolve) => {
        setTimeout(() => {
          html2canvas(ref.current as HTMLDivElement, { imageTimeout: 5000, allowTaint: true, useCORS: true }).then(
            (canvas) => {
              const dataURL = canvas.toDataURL(`image/${format}`);
              resolve(dataURL);
            }
          );
        }, 1000);
      });
      saveAs(dataURL, `report.${format}`);
    } catch (error) {
      console.error(error);
    }
  };

痛点:

  1. 截取过程中如果页面中遇到 iframe 是不生效的,表现为直接空白。
  2. 对于使用第三方 UI 组件库的页面进行截屏,会存在文本(像素)偏移问题。
  3. 需要遍历所有 dom,截图时间太长,要通过 ignoreElements 过滤掉大部分没用的标签。
  4. 单文件 js 过大,增大打包体积。

# 2. html-to-image

const saveAsImage = async (format: any) => {
    try {
      const dataURL = await new Promise<string>((resolve) => {
        setTimeout(() => {
          htmlToImage
            .toPng(ref.current as HTMLDivElement)
            .then((dataURL) => {
              resolve(dataURL);
            })
            .catch((error: any) => {
              console.error(error);
              resolve('');
            });
        }, 1000);
      });
      if (dataURL) {
        saveAs(dataURL, `report.${format}`);
      } else {
        console.log('保存失败');
      }
    } catch (error) {
      console.error(error);
    }
  };

优点:

  1. 生成速度快
  2. js 文件小,打包速度影响较小

痛点:

  1. 不兼容 Safari 浏览器,会遇到跨域问题

# 3. dom-to-image

const saveAsImage = async (format: any) => {
    try {
      const dataURL = await new Promise<string>((resolve) => {
        setTimeout(() => {
          domtoimage
            .toPng(ref.current as HTMLDivElement, {
              width: ref.current.offsetWidth,
              height: ref.current.offsetHeight,
              style: {
                transform: 'scale(1)',
                'transform-origin': 'top left',
                width: `${ref.current.offsetWidth}px`,
                height: `${ref.current.offsetHeight}px`,
              },
              // @ts-ignore
              filter: (node: HTMLElement) => {
                return node.tagName !== 'IFRAME';
              },
              cacheBust: true,
            })
            .then((dataURL) => {
              resolve(dataURL);
            })
            .catch((error: any) => {
              console.error(error);
              resolve('');
            });
        }, 1000);
      });
      if (dataURL) {
        saveAs(dataURL, `report.${format}`);
      } else {
       console.log('保存失败');
      }
    } catch (error) {
      console.error(error);
    }
  };

最后选用了这个方案,原因是

  1. toSVG 可以兼容 Safari 的跨域问题

  2. 包体积相对于 html-to-image 更小

    最后学习一下它的原理:

    dom-to-image 使用 SVG 的一个特性,它允许在标记中包含任意 HTML 内容。

    • 递归地克隆原始 DOM 节点
    • 计算节点和每个子节点的样式,并将其复制到相应的克隆
      • 创建伪元素,因为它们不是以任何方式克隆的
    • 嵌入 web 字体
      • 查找所有 @font face 声明的 web 字体
      • 解析文件 URL,下载相应文件
      • base64 编码的内联作为 data:URLs
      • 将所有已处理的 CSS 放入中,然后将其附加到克隆
    • 嵌入图片
      • 再嵌入图片 URL
      • 使用 background CSS 属性的图片,方法类似于字体
    • 将克隆的节点序列化为 XML
    • 将 XML 包装到标记中,然后包装到 SVG 中,然后使其成为 data URL
    • 或者,要以 Uint8Array 的形式获取 PNG 内容或原始像素数据,可以创建一个以 SVG 为源的图像元素,并将其呈现在已经创建的 canvas 上,从 canvas 读取内容

    domtoimage 的核心 api:

    • toSvg
    • toPng
    • toJpeg
    • toBlob
    • toPixelData

    例:toJpeg:将 draw 函数返回的 canvas 实例,使用 canvas 的 toDataURL 方法生成 jpeg 图片。toSvg 函数将递归地克隆原始 DOM 节点,将克隆的节点序列化为 XML, 将 XML 包装到标记中,然后包装到 SVG 中,然后使其转成 dataURL。

    # 总结

    在一开始优化下载速度的过程时我一直考虑的是哪个第三方库的速度更快?兼容性更强?没有考虑过每个库的实现方式差异,项目上线后才意识到,其实有些步骤是可以在页面渲染完就开始的,比如生成 dataURL。因为最耗时的地方就是生成 URL 的过程,提前生成的话在用户点击时几乎可以达到秒下载。从这也体现出了我思维的局限性,能想到的解决方案还是太少了。最后写这篇文章的时候还学习到了很多别的办法,比如 Puppeteer + Nodejs 截图,由于对我的项目不适用还没有进行过尝试,但是可以发现一个简单的需求也有很多可以学习的点。今日记一事,明日悟一理,积久而成学~