快照-网页保存为图片

1. 背景

将网页保存为图片(如下简称为快照),是用户记录和分享页面信息的有效手段,在各类兴趣测试和营销推广等形式的活动页面中尤其常见。css

快照环节一般处于页面交互流程的末端,汇总了用户最终的参与结果,直接影响到用户对于活动的完总体验。所以,生成高质量的页面快照,对于活动的传播和品牌的转化具备十分重要的意义。html

本文基于云音乐往期优质活动的相关实践(例如「关于你的画」、「权力的游戏」和「你的使用说明书」等),从快照的内容完整性清晰度转换效率等多个方面,讨论将网页转换为高质量图片的实践探索。前端

2. 适用场景

  • 适用于将页面转为图片,特别是对实时性要求较高的场景。
  • 但愿在快照中展现跨域图片资源的场景。
  • 针对生成图片内容不完整、模糊或者转换过程缓慢等问题,寻求有效解决方案的场景。

3. 原理简析

3.1 方案选型

依据图片是否由设备本地生成,快照可分为前端处理和后端处理两种方式。node

因为后端生成的方案依赖于网络通讯,不可避免地存在通讯开销和等待时延,同时对于模板和数据结构变动也有必定的维护成本。webpack

所以,出于实时性灵活性等综合考虑,咱们优先选用前端处理的方式。web

3.2 基本原理

前端侧对于快照的处理过程,实质上是将 DOM 节点包含的视图信息转换为图片信息的过程。这个过程能够借助 canvas 的原生 API 实现,这也是方案可行性的基础。canvas

theory后端

具体来讲,转换过程是将目标 DOM 节点绘制到 canvas 画布,而后 canvas 画布以图片形式导出。可简单标记为绘制阶段和导出阶段两个步骤:api

  • 绘制阶段:选择但愿绘制的 DOM 节点,根据nodeType调用 canvas 对象的对应 API,将目标 DOM 节点绘制到 canvas 画布(例如对于<img>的绘制使用 drawImage 方法)。
  • 导出阶段:经过 canvas 的 toDataURL 或 getImageData 等对外接口,最终实现画布内容的导出。

3.3 原生示例

具体地,对于单个<img>元素可按以下方式生成自身的快照:跨域

HTML:

<img id="target" src="./music-icon.png" />

JavaScript:

// 获取目标元素const target = document.getElementById('target');// 新建canvas画布const canvas = document.createElement('canvas');canvas.width = 100;canvas.height = 100;const ctx = canvas.getContext("2d");// 导出阶段:从canvas导出新的图片const exportNewImage = (canvas) => {    const exportImage = document.createElement('img');    exportImage.src = canvas.toDataURL();    document.body.appendChild(exportImage);}// 绘制阶段:待图片内容加载完毕后绘制画布target.onload = () => {    // 将图片内容绘入画布    ctx.drawImage(target, 0, 0, 100, 100);    // 将画布内容导出为新的图片    exportNewImage(canvas);}

其中,drawImage是 canvas 上下文对象的实例方法,提供多种方式将 CanvasImageSource 源绘制到 canvas 画布上。exportNewImage用于将 canvas 中的视图信息导出为包含图片展现的 data URI。

4. 基础方案

在上一部分中,咱们能够看到基于 canvas 提供的相关基础 API,为前端侧的页面快照处理提供了可能。

然而,具体的业务应用每每更加复杂,上面的「低配版」实例显然未能覆盖多数的实际场景,例如:

  • canvas 的drawImage方法只接受 CanvasImageSource,而CanvasImageSource并不包括文本节点、普通的div等,将非<img>的元素绘制到 canvas 须要特定处理。
  • 当有多个 DOM 元素须要绘制时,层级优先级处理较为复杂。
  • 须要关注floatz-indexposition等布局定位的处理。
  • 样式合成绘制计算较为繁琐。

所以,基于对综合业务场景的考虑,咱们采用社区中承认度较高的方案:html2canvascanvas2image做为实现快照功能的基础库。

4.1 html2canvas

提供将 DOM 绘制到 canvas 的能力

这款来自社区的神器,为开发者简化了将逐个 DOM 绘制到 canvas 的过程。简单来讲,其基本原理为:

  • 递归遍历目标节点及其子节点,收集节点的样式信息;
  • 计算节点自己的层级关系,根据必定优先级策略将节点逐一绘制到 canvas 画布中;
  • 重复这一过程,最终实现目标节点内容的所有绘制。

在使用方面,html2canvas对外暴露了一个可执行函数,它的第一个参数用于接收待绘制的目标节点(必选);第二个参数是可选的配置项,用于设置涉及 canvas 导出的各个参数:

// element 为目标绘制节点,options为可选参数html2canvas(element[,options]);

简易调用示例以下:

import html2canvas from 'html2canvas';const options = {};// 输入body节点,返回包含body视图内容的canvas对象html2canvas(document.body, options).then(function(canvas) {    document.body.appendChild(canvas);});

4.2 canvas2image

提供由 canvas 导出图片信息的多种方法

相比于html2canvas承担的复杂绘制流程,canvas2image 所要作的事情简单的多。

canvas2image仅用于将输入的 canvas 对象按特定格式转换和存储操做,其中这两类操做均支持 PNG,JPEG,GIF,BMP 四种图片类型:

// 格式转换Canvas2Image.convertToPNG(canvasObj, width, height);Canvas2Image.convertToJPEG(canvasObj, width, height);Canvas2Image.convertToGIF(canvasObj, width, height);Canvas2Image.convertToBMP(canvasObj, width, height);// 另存为指定格式图片Canvas2Image.saveAsPNG(canvasObj, width, height);Canvas2Image.saveAsJPEG(canvasObj, width, height);Canvas2Image.saveAsGIF(canvasObj, width, height);Canvas2Image.saveAsBMP(canvasObj, width, height);

实质上,canvas2image只是提供了针对 canvas 基础 API 的二次封装(例如 getImageData、toDataURL),而自己并不依赖html2canvas

在使用方面,因为目前做者并未提供 ES6 版本的canvas2image(v1.0.5),暂时不能直接以 import 方式引入该模块。

对于支持现代化构建的工程中(例如 webpack),开发者能够自助 clone 源码并手动添加 export 得到 ESM 支持:

支持 ESM 导出

// canvas2Image.jsconst Canvas2Image = function () {    ...}();// 如下为定制添加的内容export default Canvas2Image;

调用示例

import Canvas2Image from './canvas2Image.js';// 其中,canvas表明传入的canvas对象,width, height分别为导出图片的宽高数值Canvas2Image.convertToPNG(canvas, width, height)

4.3 组合技

接下来,咱们基于以上两个工具库,实现一个基础版的快照生成方案。一样是分为两个阶段,对应 3.2 节的基本原理:

  • 第一步,经过html2canvas实现 DOM 节点绘制到 canvas 对象中;
  • 第二步,将上一步返回的 canvas 对象传入canvas2image,进而按需导出快照图片信息。

具体地,咱们封装一个convertToImage的函数,用于输入目标节点以及配置项参数,输出快照图片信息。

JavaScript

// convertToImage.jsimport html2canvas from 'html2canvas';import Canvas2Image from './canvas2Image.js';/** * 基础版快照方案 * @param {HTMLElement} container * @param {object} options html2canvas相关配置 */function convertToImage(container, options = {}) {    return html2canvas(container, options).then(canvas => {        const imageEl = Canvas2Image.convertToPNG(canvas, canvas.width, canvas.height);        return imageEl;    });}

5. 进阶优化

经过上一节的实例,咱们基于html2canvascanvas2image,实现了相比原生方案通用性更佳的基础页面快照方案。然而面对实际复杂的应用场景,以上基础方案生成的快照效果每每不尽如人意。

快照效果的差别性,一方面是因为html2canvas导出的视图信息是经过各类 DOM 和 canvas 的 API 复合计算二次绘制的结果(并不是一键栅格化)。所以不一样宿主环境的相关 API 实现差别,可能致使生成的图片效果存在多端不一致性或者显示异常的状况。

另外一方面,业务层面的因素,例如对于开发者html2canvas的配置有误或者是页面布局不当等缘由,也会对生成快照的结果带来误差。

社区中也能够常见到一些对于生成快照质量的讨论,例如:

  • 为何有些内容显示不完整、残缺、白屏或黑屏?
  • 明明原页面清晰可辨,为何生成的图片模糊如毛玻璃?
  • 将页面转换为图片的过程十分缓慢,影响后续相关操做,有什么好办法么?
  • ...

下面咱们从内容完整性清晰度优化转换效率,进一步探究高质量的快照解决方案。

5.1 内容完整性

首要问题:保证目标节点视图信息完整导出

因为真机环境的兼容性和业务实现方式的不一样,在一些使用html2canvas过程当中常会出现快照内容与原视图不一致的状况。内容不完整的常见自检checklist以下:

  • 跨域问题:存在跨域图片污染 canvas 画布。
  • 资源加载:生成快照时,相关资源还未加载完毕。
  • 滚动问题:页面中滚动元素存在偏移量,致使生成的快照顶部出现空白。

5.1.1 跨域问题

常见于引入的图片素材相对于部署工程跨域的场景。例如部署在https://st.music.163.com/上面的页面中引入了来源为https://p1.music.126.net的图片,这类图片便是属于跨域的图片资源。

因为 canvas 对于图片资源的同源限制,若是画布中包含跨域的图片资源则会污染画布( Tainted canvases ),形成生成图片内容混乱或者html2canvas方法不执行等异常问题。

对于跨域图片资源处理,能够从如下几方面着手:

(1)useCORS 配置

开启html2canvasuseCORS配置项,示例以下:

// doc: http://html2canvas.hertzen.com/configuration/const opts = {    useCORS   : true,   // 容许使用跨域图片    allowTaint: false   // 不容许跨域图片污染画布};html2canvas(element, opts);

html2canvas的源码中对于useCORS配置项置为true的处理,实质上是将目标节点中的<img>标签注入 crossOrigin 为anonymous的属性,从而容许载入符合 CORS 规范的图片资源。

其中,allowTaint默认为false,也能够不做显式设置。即便该项置为true,也不能绕过 canvas 对于跨域图片的限制,由于在调用 canvas 的toDataURL时依然会被浏览器禁止。

(2)CORS 配置

上一步的useCORS的配置,只是容许<img>接收跨域的图片资源,而对于解锁跨域图片在 canvas 上的绘制并导出,须要图片资源自己须要提供 CORS 支持。

这里介绍下跨域图片使用 CDN 资源时的注意事项:

验证图片资源是否支持 CORS 跨域,经过 Chrome 开发者工具能够看到图片请求响应头中应含有Access-Control-Allow-Origin的字段,即坊间常提到的跨域头。

例如,某个来自 CDN 图片资源的响应头示例:

// Response Headersaccess-control-allow-credentials: trueaccess-control-allow-headers: DNT,X-CustomHeader,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Typeaccess-control-allow-methods: GET,POST,OPTIONSaccess-control-allow-origin: *

不一样的 CDN 服务商配置资源跨域头的方式不一样,具体应咨询 CDN 服务商。

特殊状况下,部分 CDN 提供方可能会存在图片缓存不含 CORS 跨域头的状况。为保证快照显示正常,建议优先联系 CDN 寻求技术支持,不推荐经过图片连接后缀时间戳等方式强制回源,避免影响源站性能和 CDN 计费。

(3)服务端转发

在微信等第三方 APP 中,平台的用户头像等图片资源是不直接提供 CORS 支持的。此时须要借助服务端做代理转发,从而绕过跨域限制。

即经过服务端代为请求平台用户的头像地址并转发给客户端(浏览器),固然这个服务端接口自己要与页面同源或者支持 CORS。

为简洁表述,假设前端与后端针对跨域图片转发做以下约定,且该接口与前端工程部署在相同域名下:

请求地址

请求方式

传入参数

返回信息

/api/redirect/image

GET

redirect,表示原图地址

Content-Typeimage/png的图片资源

页面中的<img>经过拼接/api/redirect/image与表明原图地址的查询参数redirect,发出一个 GET 请求图片资源。因为接口与页面同源,所以不会触发跨域限制:

<img src="/api/redirect/image?redirect=thirdwx.qlogo.cn/somebody/avatar" alt="user-pic" class="avatar" crossorigin="anonymous">

对于服务端接口的实现,这里基于 koa 提供了一则简易示例:

const Koa = require('koa');const router = require('koa-router')();const querystring = require('querystring');const app = new Koa();/** * 图片转发接口 * - 接收 redirect 入参,即须要代为请求的图片URL * - 返回图片资源 */router.get('/api/redirect/image', async function(ctx) {    const querys = ctx.querystring;    if (!querys) return;    const { redirect } = querystring.parse(querys);    const res = await proxyFetchImage(redirect);    ctx.set('Content-Type', 'image/png');    ctx.set('Cache-Control', 'max-age=2592000');    ctx.response.body = res;})/** * 请求并返回图片资源 * @param {String} url 图片地址 */async function proxyFetchImage(url) {    const res = await fetch(url);    return res.body;}const res = await proxyFetchImage(redirect);app.use(router.routes());

在浏览器看来,页面请求的图片资源还是相同域名下的资源,转发过程对前端透明。建议在需求开发前了解图片资源的来源状况,明确是否须要服务端支持。

在云音乐早期的活动「权力的游戏」中,使用了同类方案,实现了微信平台中用户头像的完整绘制和快照导出。

5.1.2 资源加载

资源加载不全,是形成快照不完整的一个常见因素。在生成快照时,若是部分资源没有加载完毕,那么生成的内容天然也谈不上完整。

除了设置必定的延迟外,若是要确保资源加载完毕,能够基于 Promise.all 实现。

加载图片

const preloadImg = (src) => {    return new Promise((resolve, reject) => {        const img = new Image();        img.onload = () => {            resolve();        }        img.src = src;    });}

确保在所有加载后生成快照

const preloadList = [    './pic-1.png',    './pic-2.png',    './pic-3.png',];Promise.all(preloadList.map(src => preloadImg(src))).then(async () => {    convertToImage(container).then(canvas => {        // ...    })});

实际上,以上方法只是解决页面图片的显示问题。在真实场景中,即便页面上的图片显示完整,保存快照后依然可能出现内容空白的状况。缘由是 html2canvas 库内部处理时,对图片资源仍会作一次加载请求;若是此时加载失败,那么该部分保存快照后便是空白的。

下面介绍图片资源转 Blob 的方案,保证图片的地址来自本地,避免在快照转化时加载失败的状况。这里提到的 Blob 对象表示一个不可变、表明二进制原始数据的类文件对象,在特定的使用场景会使用到。

图片资源转 Blob:

// 返回图片Blob地址const toBlobURL = (function () {    const urlMap = {};    // @param {string} url 传入图片资源地址    return function (url) {        // 过滤重复值        if (urlMap[url]) return Promise.resolve(urlMap[url]);        return new Promise((resolve, reject) => {            const canvas = document.createElement('canvas');            const ctx = canvas.getContext('2d');            const img = document.createElement('img');            img.src = url;            img.onload = () => {                canvas.width = img.width;                canvas.height = img.height;                ctx.drawImage(img, 0, 0);                // 关键👇                canvas.toBlob((blob) => {                    const blobURL = URL.createObjectURL(blob);                    resolve(blobURL);                });            };            img.onerror = (e) => {                reject(e);            };        });    };}());

以上toBlobURL方法实现将加载<img>的资源连接转为 blobURL。

进一步地,经过convertToBlobImage方法,实现对于传入的目标节点中的<img>批量处理为Blob格式。

// 批量处理function convertToBlobImage(targetNode, timeout) {    if (!targetNode) return Promise.resolve();    let nodeList = targetNode;    if (targetNode instanceof Element) {        if (targetNode.tagName.toLowerCase() === 'img') {            nodeList = [targetNode];        } else {            nodeList = targetNode.getElementsByTagName('img');        }    } else if (!(nodeList instanceof Array) && !(nodeList instanceof NodeList)) {        throw new Error('[convertToBlobImage] 必须是Element或NodeList类型');    }    if (nodeList.length === 0) return Promise.resolve();    // 仅考虑<img>    return new Promise((resolve) => {        let resolved = false;        // 超时处理        if (timeout) {            setTimeout(() => {                if (!resolved) resolve();                resolved = true;            }, timeout);        }        let count = 0;        // 逐一替换<img>资源地址        for (let i = 0, len = nodeList.length; i < len; ++i) {            const v = nodeList[i];            let p = Promise.resolve();            if (v.tagName.toLowerCase() === 'img') {                p = toBlobURL(v.src).then((blob) => {                    v.src = blob;                });            }            p.finally(() => {                if (++count === nodeList.length && !resolved) resolve();            });        }    });}export default convertToBlobImage;

使用方面,convertToBlobImage应在调用生成快照convertToImage方法前执行。

5.1.3 滚动问题

  • 典型特征:生成快照的顶部存在空白区域。
  • 缘由:通常是保存长图(超过一屏),而且滚动条不在顶部时致使(常见于 SPA 类应用)。
  • 解决办法:在调用convertToImage以前,先记录此时的scrollTop,而后调用window.scroll(0, 0)将页面移动至顶部。待快照生成后,再调用window.scroll(0, scrollTop)恢复原有纵向偏移量。

示例

// 待保存的目标节点(按实际修改👇)const container = document.body;// 实际的滚动元素(按实际修改👇)const scrollElement = document.documentElement;// 记录滚动元素纵向偏移量const scrollTop = scrollElement.scrollTop;// 针对滚动元素是 body 先做置顶window.scroll(0, 0);convertToImage(container)    .then(() => {        // ...    }).catch(() => {        // ...    }).finally(() => {        // 恢复偏移量        window.scroll(0, scrollTop);    });

特别地,对于存在局部滚动布局的状况,也能够操做对应滚动元素置顶避免容器顶部空白的状况。

5.2 清晰度优化

清晰度是快照质量的分水岭

下图取自「权力的游戏」中两张优化先后的结果页快照对比。能够看到优化前的左图,不管是在文字边缘仍是图像细节上,相较优化后的清晰度存在明显可辨的差距。

clear

最终生成快照的清晰度,源头上取决于第一步中 DOM 转换成的 canvas 的清晰度。

如下介绍 5 种行之有效的清晰度优化方法。

5.2.1 使用px单位

为了给到html2canvas明确的整数计算值,避免因小数舍入而致使的拉伸模糊,建议将布局中使用中使用%vwvhrem等单位的元素样式,统一改成使用px

good:

<div style="width: 100px;"></div>

bad:

<div style="width: 30%;"></div>

5.2.2 优先使用 img 标签展现图片

不少状况下,导出图片模糊是由原视图中的图片是以 css 中 background 的方式显示的。由于 background-size 并不会反馈一个具体的宽高数值,而是经过枚举值如 contain、cover 等表明图片缩放的类型;相对于<img>标签, background 方式最终生成的图片会较为模糊。将 background 改成<img>方式呈现,对于图片清晰度会有必定的改观。对于必需要使用 background 的场景,参见 5.25 节的解决方案。

good:

<img class="u-image" src="./music.png" alt="icon">

bad:

<div class="u-image" style="background: url(./music.png);"></div>

5.2.3  配置高倍的 canvas 画布

对于高分辨率的屏幕,canvas 可经过将 css 像素与高分屏的物理像素对齐,实现必定程度的清晰度提高(这里对两类像素有详细描述和讨论)。

在具体操做中,建立由 devicePixelRatio 放大的图像,而后使用 css 将其缩小相同的倍数,有效地提升绘制到 canvas 中的图像清晰度表现。

在使用html2canvas时,咱们能够配置一个放缩后的 canvas 画布用于导入节点的绘制。

// convertToImage.jsimport html2canvas from 'html2canvas';// 建立用于绘制的基础canvas画布function createBaseCanvas(scale) {    const canvas = document.createElement("canvas");    canvas.width = width * scale;    canvas.height = height * scale;    canvas.getContext("2d").scale(scale, scale);    return canvas;}// 生成快照function convertToImage(container, options = {}) {    // 设置放大倍数    const scale = window.devicePixelRatio;    // 建立用于绘制的基础canvas画布    const canvas = createBaseCanvas(scale);    // 传入节点原始宽高    const width = container.offsetWidth;    const height = container.offsetHeight;    // html2canvas配置项    const ops = {        scale,        width,        height,        canvas,        useCORS: true,        allowTaint: false,        ...options    };    return html2canvas(container, ops).then(canvas => {        const imageEl = Canvas2Image.convertToPNG(canvas, canvas.width, canvas.height);        return imageEl;    });}

5.2.4  关闭抗锯齿

imageSmoothingEnabled 是 Canvas 2D API 用来设置图片是否平滑的属性,true表示图片平滑(默认值),false表示关闭 canvas 抗锯齿。

默认状况下,canvas 的抗锯齿是开启的,能够经过关闭抗锯齿来实现必定程度上的图像锐化,提升线条边缘的清晰度。

据此,咱们将以上createBaseCanvas方法升级为:

// 建立用于绘制的基础canvas画布function createBaseCanvas(scale) {    const canvas = document.createElement("canvas");    canvas.width = width * scale;    canvas.height = height * scale;    const context = canvas.getContext("2d");    // 关闭抗锯齿    context.mozImageSmoothingEnabled = false;    context.webkitImageSmoothingEnabled = false;    context.msImageSmoothingEnabled = false;    context.imageSmoothingEnabled = false;    context.scale(scale, scale);    return canvas;}

5.2.5  锐化特定元素

受到 canvas 画布放缩的启发,咱们对特定的 DOM 元素也能够采用相似的优化操做,即设置待优化元素宽高设置为 2 倍或devicePixelRatio倍,而后经过 css 缩放的方式控制其展现大小不变。

scale

例如,对于必须用背景图background的元素,采用如下方式可明显提升快照的清晰度:

.box {    background: url(/path/to/image) no-repeat;    width: 100px;    height: 100px;    transform: scale(0.5);    transform-origin: 0 0;}

其中,widthheight为实际显示宽高的 2 倍值,经过transform: scale(0.5)实现了元素大小的缩放,transform-origin根据实际状况设置。

5.3 转换效率

快照的转换效率直接关系到用户的等待时长。咱们能够在目标节点传入阶段和快照导出两个阶段对其进行必定优化。

5.3.1 传入阶段

传入节点的视图信息越精简,生成快照处理的计算量就越小

如下方式适用于传入视图信息“瘦身”:

  • 减小 DOM 规模,下降html2canvas递归遍历的计算量。
  • 压缩图片素材自己的体积,使用 tinypng 或 ImageOptim 等工具压缩素材。
  • 若是使用了自定义字体,请使用 fontmin 工具对文字进行按需裁剪,避免动辄数兆的无效资源引入。
  • 传入合适的scale值以缩放 canvas 画布(5.2.3节)。一般状况下 2~3 倍就已经知足通常的场景,没必要要传入过大的放大倍数。
  • 在 5.1.2 节中提到的图片资源转 blob,可将图片资源本地化,避免了生成快照时 html2canvas 的二次图片加载处理,同时所生成的资源连接具有 URL 长度较短等优点。

5.3.2 导出优化

canvas2image提供了多个 API 用于导出图片信息,上文已有介绍。包括:

  • convertToPNG
  • convertToJPEG
  • convertToGIF
  • convertToBMP

不一样的导出格式,对于生成快照的文件体积存在较大的影响。一般对于没有透明度展现要求的图片素材,可使用jpeg格式的导出。在咱们的相关实践中,jpeg相比于png甚至可以节约 80% 以上的文件体积。

实际场景中的的图片导出格式,按业务需求选用便可。

6. 小结

本文基于html2canvascanvas2image,从快照的内容完整性、清晰度和转换效率等多个方面,介绍了前端页面生成高质量快照的解决方案。

因为实际应用的复杂性,以上方案可能没法覆盖到每一处具体场景,欢迎你们交流和探讨。

7. 参考连接

  • 基于html2canvas实现网页保存为图片及图片清晰度优化
  • 微信wap页生成分享海报功能踩坑经验
  • H5 实现保存图片的采坑记录
  • 实现微信H5实现网页长按保存图片及识别二维码
  • MDN: Allowing cross-origin use of images and canvas
相关文章
相关标签/搜索