本文做者:ggvswildcss
将网页保存为图片(如下简称为快照),是用户记录和分享页面信息的有效手段,在各类兴趣测试和营销推广等形式的活动页面中尤其常见。html
快照环节一般处于页面交互流程的末端,汇总了用户最终的参与结果,直接影响到用户对于活动的完总体验。所以,生成高质量的页面快照,对于活动的传播和品牌的转化具备十分重要的意义。前端
本文基于云音乐往期优质活动的相关实践(例如「关于你的画」、「权力的游戏」和「你的使用说明书」等),从快照的内容完整性、清晰度和转换效率等多个方面,讨论将网页转换为高质量图片的实践探索。node
依据图片是否由设备本地生成,快照可分为前端处理和后端处理两种方式。webpack
因为后端生成的方案依赖于网络通讯,不可避免地存在通讯开销和等待时延,同时对于模板和数据结构变动也有必定的维护成本。git
所以,出于实时性和灵活性等综合考虑,咱们优先选用前端处理的方式。github
前端侧对于快照的处理过程,实质上是将 DOM 节点包含的视图信息转换为图片信息的过程。这个过程能够借助 canvas 的原生 API 实现,这也是方案可行性的基础。web
具体来讲,转换过程是将目标 DOM 节点绘制到 canvas 画布,而后 canvas 画布以图片形式导出。可简单标记为绘制阶段和导出阶段两个步骤:canvas
nodeType
调用 canvas 对象的对应 API,将目标 DOM 节点绘制到 canvas 画布(例如对于<img>
的绘制使用 drawImage 方法)。具体地,对于单个<img>
元素可按以下方式生成自身的快照:segmentfault
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。
在上一部分中,咱们能够看到基于 canvas 提供的相关基础 API,为前端侧的页面快照处理提供了可能。
然而,具体的业务应用每每更加复杂,上面的「低配版」实例显然未能覆盖多数的实际场景,例如:
drawImage
方法只接受 CanvasImageSource,而CanvasImageSource
并不包括文本节点、普通的div
等,将非<img>
的元素绘制到 canvas 须要特定处理。float
、z-index
、position
等布局定位的处理。所以,基于对综合业务场景的考虑,咱们采用社区中承认度较高的方案:html2canvas
和canvas2image
做为实现快照功能的基础库。
提供将 DOM 绘制到 canvas 的能力
这款来自社区的神器,为开发者简化了将逐个 DOM 绘制到 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);
});
复制代码
提供由 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.js
const Canvas2Image = function () {
...
}();
// 如下为定制添加的内容
export default Canvas2Image;
复制代码
调用示例:
import Canvas2Image from './canvas2Image.js';
// 其中,canvas表明传入的canvas对象,width, height分别为导出图片的宽高数值
Canvas2Image.convertToPNG(canvas, width, height)
复制代码
接下来,咱们基于以上两个工具库,实现一个基础版的快照生成方案。一样是分为两个阶段,对应 3.2 节的基本原理:
html2canvas
实现 DOM 节点绘制到 canvas 对象中;canvas2image
,进而按需导出快照图片信息。具体地,咱们封装一个convertToImage
的函数,用于输入目标节点以及配置项参数,输出快照图片信息。
JavaScript
:
// convertToImage.js
import 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;
});
}
复制代码
经过上一节的实例,咱们基于html2canvas
和canvas2image
,实现了相比原生方案通用性更佳的基础页面快照方案。然而面对实际复杂的应用场景,以上基础方案生成的快照效果每每不尽如人意。
快照效果的差别性,一方面是因为html2canvas
导出的视图信息是经过各类 DOM 和 canvas 的 API 复合计算二次绘制的结果(并不是一键栅格化)。所以不一样宿主环境的相关 API 实现差别,可能致使生成的图片效果存在多端不一致性或者显示异常的状况。
另外一方面,业务层面的因素,例如对于开发者html2canvas
的配置有误或者是页面布局不当等缘由,也会对生成快照的结果带来误差。
社区中也能够常见到一些对于生成快照质量的讨论,例如:
下面咱们从内容完整性、清晰度优化和转换效率,进一步探究高质量的快照解决方案。
首要问题:保证目标节点视图信息完整导出
因为真机环境的兼容性和业务实现方式的不一样,在一些使用html2canvas
过程当中常会出现快照内容与原视图不一致的状况。内容不完整的常见自检checklist
以下:
常见于引入的图片素材相对于部署工程跨域的场景。例如部署在https://st.music.163.com/
上面的页面中引入了来源为https://p1.music.126.net
的图片,这类图片便是属于跨域的图片资源。
因为 canvas 对于图片资源的同源限制,若是画布中包含跨域的图片资源则会污染画布( Tainted canvases ),形成生成图片内容混乱或者html2canvas
方法不执行等异常问题。
对于跨域图片资源处理,能够从如下几方面着手:
(1)useCORS 配置
开启html2canvas
的useCORS
配置项,示例以下:
// 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 Headers
access-control-allow-credentials: true
access-control-allow-headers: DNT,X-CustomHeader,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type
access-control-allow-methods: GET,POST,OPTIONS
access-control-allow-origin: *
复制代码
不一样的 CDN 服务商配置资源跨域头的方式不一样,具体应咨询 CDN 服务商。
特殊状况下,部分 CDN 提供方可能会存在图片缓存不含 CORS 跨域头的状况。为保证快照显示正常,建议优先联系 CDN 寻求技术支持,不推荐经过图片连接后缀时间戳等方式强制回源,避免影响源站性能和 CDN 计费。
(3)服务端转发
在微信等第三方 APP 中,平台的用户头像等图片资源是不直接提供 CORS 支持的。此时须要借助服务端做代理转发,从而绕过跨域限制。
即经过服务端代为请求平台用户的头像地址并转发给客户端(浏览器),固然这个服务端接口自己要与页面同源或者支持 CORS。
为简洁表述,假设前端与后端针对跨域图片转发做以下约定,且该接口与前端工程部署在相同域名下:
请求地址 | 请求方式 | 传入参数 | 返回信息 |
---|---|---|---|
/api/redirect/image | GET |
redirect,表示原图地址 | Content-Type 为image/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());
复制代码
在浏览器看来,页面请求的图片资源还是相同域名下的资源,转发过程对前端透明。建议在需求开发前了解图片资源的来源状况,明确是否须要服务端支持。
在云音乐早期的活动「权力的游戏」中,使用了同类方案,实现了微信平台中用户头像的完整绘制和快照导出。
资源加载不全,是形成快照不完整的一个常见因素。在生成快照时,若是部分资源没有加载完毕,那么生成的内容天然也谈不上完整。
除了设置必定的延迟外,若是要确保资源加载完毕,能够基于 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
方法前执行。
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);
});
复制代码
特别地,对于存在局部滚动布局的状况,也能够操做对应滚动元素置顶避免容器顶部空白的状况。
清晰度是快照质量的分水岭
下图取自「权力的游戏」中两张优化先后的结果页快照对比。能够看到优化前的左图,不管是在文字边缘仍是图像细节上,相较优化后的清晰度存在明显可辨的差距。
最终生成快照的清晰度,源头上取决于第一步中 DOM 转换成的 canvas 的清晰度。
如下介绍 5 种行之有效的清晰度优化方法。
为了给到html2canvas
明确的整数计算值,避免因小数舍入而致使的拉伸模糊,建议将布局中使用中使用%
、vw
、vh
或rem
等单位的元素样式,统一改成使用px
。
good:
<div style="width: 100px;"></div>
复制代码
bad:
<div style="width: 30%;"></div>
复制代码
不少状况下,导出图片模糊是由原视图中的图片是以 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>
复制代码
对于高分辨率的屏幕,canvas 可经过将 css 像素与高分屏的物理像素对齐,实现必定程度的清晰度提高(这里对两类像素有详细描述和讨论)。
在具体操做中,建立由 devicePixelRatio 放大的图像,而后使用 css 将其缩小相同的倍数,有效地提升绘制到 canvas 中的图像清晰度表现。
在使用html2canvas
时,咱们能够配置一个放缩后的 canvas 画布用于导入节点的绘制。
// convertToImage.js
import 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;
});
}
复制代码
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;
}
复制代码
受到 canvas 画布放缩的启发,咱们对特定的 DOM 元素也能够采用相似的优化操做,即设置待优化元素宽高设置为 2 倍或devicePixelRatio
倍,而后经过 css 缩放的方式控制其展现大小不变。
例如,对于必须用背景图background
的元素,采用如下方式可明显提升快照的清晰度:
.box {
background: url(/path/to/image) no-repeat;
width: 100px;
height: 100px;
transform: scale(0.5);
transform-origin: 0 0;
}
复制代码
其中,width
和height
为实际显示宽高的 2 倍值,经过transform: scale(0.5)
实现了元素大小的缩放,transform-origin
根据实际状况设置。
快照的转换效率直接关系到用户的等待时长。咱们能够在目标节点传入阶段和快照导出两个阶段对其进行必定优化。
传入节点的视图信息越精简,生成快照处理的计算量就越小
如下方式适用于传入视图信息“瘦身”:
html2canvas
递归遍历的计算量。scale
值以缩放 canvas 画布(5.2.3节)。一般状况下 2~3 倍就已经知足通常的场景,没必要要传入过大的放大倍数。canvas2image
提供了多个 API 用于导出图片信息,上文已有介绍。包括:
不一样的导出格式,对于生成快照的文件体积存在较大的影响。 一般对于没有透明度展现要求的图片素材,可使用jpeg
格式的导出。在咱们的相关实践中,jpeg
相比于png
甚至可以节约 80% 以上的文件体积。
实际场景中的的图片导出格式,按业务需求选用便可。
本文基于html2canvas
和canvas2image
,从快照的内容完整性、清晰度和转换效率等多个方面,介绍了前端页面生成高质量快照的解决方案。
因为实际应用的复杂性,以上方案可能没法覆盖到每一处具体场景,欢迎你们交流和探讨。
本文发布自 网易云音乐前端团队,可自由转载,转载请在标题标明转载并在显著位置保留出处。咱们一直在招人,若是你刚好准备换工做,又刚好喜欢云音乐,那就 加入咱们!