前言
需求背景:
- 目标:将页面内容变成图片分享出去
- 持久化:不需要服务端存储
- 客户端能力:不依赖客户端端能力
使用pupeteer做截图服务
优点:
- 前端使用简单,只需调用接口即可
- 分享页面可以和给用户展示的页面内容分离,不用额外处理样式等问题
缺点:
- 新开服务&维护服务
- 接口响应时间较长会影响体验
结论
调研发现,可以使用html2canvas
纯前端做页面转化成图片的功能。
正文
前端如何产出一张图片,首先能想到的就是使用canvas绘制区域,然后转成一张图片。但是手动canvas绘制存在以下问题:
- 不能使用ctx.drawImage直接将分享区域进行整体截图:
ctx.drawImage
对 图片元素Image
、svg
元素、视频元素Video
、Canvas
元素、ImageBitmap
数据等可以被绘制,但是对于一般的其他div
或者列表li
元素它是不可以被绘制的 - 调用
canvas
绘制:需要进行布局计算,绘制起来也很繁琐,造成开发量大 - 需要解决一些样式问题/设备兼容问题
介于以上原因,打算使用开源npm来实现dom转图片功能。
以下是使用比较多的库:
dome-to-image
(上次更新时间4年前): https://github.com/tsayen/dom... 7.2✨html-to-image
(dome-to-image
优化版,一直在维护): https://github.com/bubkoo/htm... 650✨html2canvas
: https://github.com/niklasvh/h... 23.1K✨
库优劣分析
html-to-image
/* ES6 */
import * as htmlToImage from 'html-to-image';
import { toPng, toJpeg, toBlob, toPixelData, toSvg } from 'html-to-image';
支持的配置:
filter / backgroundColor / width, height / style / cacheBust / inagePlaceholder / pixelRatio / fontEmbedCss
总结:
- 优点:库小 183 kB;用起来比较简单,一般不需要配置
- 缺点:历史上兼容问题较多;截图区域内容图片src不识别base64; 清晰度问题(现在看来还可以)
html2canvas
支持的配置:https://html2canvas.hertzen.c...
总结:
- 优点:23K+ 的start量,功能强大,解决方案多;清晰度上不需要处理
- 缺点:还原度问题;有配置负担;2.96 MB
关于 foreignObject:
html-to-image
因为在实现上用了 foreignObject
,所以必须要浏览器支持这个属性。看了下支持程度,除了IE外, chrome safari 支持的都挺好。所以不用因为这个担心不能使用html-to-image。
html2canvas无法解决的问题【开发时候规避】:
重要的有两点问题需要关注:
- 图片问题:支持img标签图片,背景图, 不支持边框图
- 字体问题: 不支持用FontFace的定义的字体
html2canvas中的解释:
总结
- 保守的方案:直接使用html2canvas,因为它功能强大,兼容性好
- 极致追求:可以优先使用html-to-image ,因为它非常小&可以傻瓜式调用而不用担心配置&还原度高。兜底使用html2canvas。
场景一: 截图内容不能直接复用页面内容
在主页面底部有个分分享按钮,点击之后可以分享一张图片到朋友圈,图片底部有一个携带小程序路径参数的小程序二维码,图片的内容和展示给用户的页面内容不一致。
方案设计
由于图片内容是动态的,由数据组成,所以需要我们手动绘制。能想到的简单的方法就是把海报内容创建到一个div里,把div 进行隐藏。之后就可以对这个div进行整体操作。
几种元素不可见方案
display: none ❎
visibility: hidden 【黑屏】
margin-left / nargin-top: -1000px 【黑屏】
position:absolute; left/top: -1000px 【黑屏】
z-index: -1000 ✅
除了元素不可见,为了实现隐藏效果而不影响页面正常显示,还需要处理让分享元素内容不占文档流位置。
具体代码
html:
<div id="app">
<div class="wrapper">
...
<div id="share" class="share-screen">
...
</div>
</div>
</div>
// style
.wrapper{
position:relative;
}
.share-screen{
width: 100%;
z-index: -100; // 用户不可见
position: absolute;
// 定位
top: 0;
left:0;
background-color: #fff;
}
js:
// 获取分享区域内容图片
private getShareImg() {
const node = document.getElementById('share');
return html2canvas(node, {
useCORS: true,
x: 0,
y: 0,
logging: false,
}).then(canvas => {
return this.getPosterUrl(canvas);
}).catch();
}
// 转成一张图片base64
public getPosterUrl(canvas): string {
return canvas.toDataURL('image/jpeg');
// return canvas.toDataURL('image/jpeg').replace(/data:image\/\w+;base64,/, '');
}
问题参考
图片跨域问题
useCORS
设置为true
图片清晰度问题
经测试,html2canvas v1.0.01-rc7
之后 不存在清晰度的问题
v1.0.01-rc5
之前参考:
/*
* 图片跨域及截图模糊处理
* https://www.cnblogs.com/padding1015/p/9225517.html
*/
let shareContent = domObj,//需要截图的包裹的(原生的)DOM 对象
width = shareContent.clientWidth,//shareContent.offsetWidth; //获取dom 宽度
height = shareContent.clientHeight,//shareContent.offsetHeight; //获取dom 高度
canvas = document.createElement("canvas"), //创建一个canvas节点
scale = 2; //定义任意放大倍数 支持小数
canvas.width = width * scale; //定义canvas 宽度 * 缩放
canvas.height = height * scale; //定义canvas高度 *缩放
canvas.style.width = shareContent.clientWidth * scale + "px";
canvas.style.height = shareContent.clientHeight * scale + "px";
canvas.getContext("2d").scale(scale, scale); //获取context,设置scale
let opts = {
scale: scale, // 添加的scale 参数
canvas: canvas, //自定义 canvas
logging: false, //日志开关,便于查看html2canvas的内部执行流程
width: width, //dom 原始宽度
height: height,
useCORS: true // 【重要】开启跨域配置
};
html2canvas(shareContent,opts).then()
截图的内容区域在可视区外,html2canvas 截图空白的问题
需要确定你的截图区域。截图区域样式设置:
// html
<div id="app">
<div class="wrapper">
...
<div id="share" class="share-screen">
...
</div>
</div>
</div>
// style
.wrapper{
position:relative;
}
.share-screen{
width: 100%;
z-index: -100; // 用户不可见
position: absolute;
// 定位
top: 0;
left:0;
background-color: #fff;
}
html2canvas 配置:
html2canvas(node, {
useCORS: true,
x: 0, // x-offset:Crop canvas x-coordinate
y: 0, // y-offset:Crop canvas y-coordinate
logging: false,
}).then();
使用html-to-image 报错
安卓上会报错: Error: Failed to execute 'toDataURL' on 'HTMLCanvasElement'......
请换成html2canvas
。
html2canvas 图片大小适配问题
这里主要是dpr的问题,库会自动匹配设备window.devicePixelRatio
。经过上述配置就不会有问题。
html2canvas 远程图片【允许跨域】偶尔不展示问题
html2canvas
截图原理是节点克隆和样式克隆,所以即使页面上的图片加载出来了也不能保证截图里的图片是正常的。远程图片加载需要时间,这个时间是不确定的。如果在图片还没加载回来就转成图片,会有图片不显示的问题。
处理的办法就是:
- 加载本地图片不会有这个问题
- 远程图片不要返回http地址,可以返回base64编码直接被浏览器渲染,这样就相当于直接返回图片内容。或者手动请求图片地址,将二进制流转成base64。
可能遇到的难点
页面内容中有特别多的图片,由于截图的过程中要截图图片内容需要对图片发起请求,大量的图片请求不一定能保证截图的完整性。即使图片请求没有问题,html2canvas
在截图处理上也可能会丢失部分图片。(参考:https://zhuanlan.zhihu.com/p/...)
所以使用远程图片地址是有风险的。经过真机测试发现,出错的概率确实很高(50% 是有的)。
如何解决?
- 转成base64
- 转成blob,在用URL.createBlobURL 存在内存中
转成base64
这个就需要讨论base64的优缺点了。
优点:
- 使用base64资源可以减少网络请求数量(通常是远程图片)
图片内容直出,不会出现图片加载耗时或失败导致截图问题 - toDataURL()的兼容性强,除了IE<=8 safari<=3 之外基本其他浏览器都支持(https://caniuse.com/?search=c...)
缺点: - 大小比原始图片大1/3
- 要注意转换时候的格式,png → jpeg 透明区域会变黑色背景(解决方案:https://evestorm.github.io/po...)
转换:
动态创建一个图片标签,将图片绘制到画布上,使用canvas.toDataURL
导出base64
function toDataURL(url) {
// 跳过base64图片
const base64Pattern = /data:image\/(.)+;base64/i;
if (base64Pattern.test(url)) {
return Promise.resolve(url);
}
const fileType = getFileType(url) || 'png'; // 默认是png png支持透明
return new Promise((resolve, reject) => {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
const img = document.createElement('img');
img.crossOrigin = 'Anonymous'; // 解决跨域图片问题,
img.src = url;
img.onload = () => {
canvas.width = img.width;
canvas.height = img.height;
ctx.drawImage(img, 0, 0);
const dataUrl = canvas.toDataURL(`image/${fileType}`);
resolve(dataUrl);
};
img.onerror = e => {
reject(e);
};
});
}
// 获取图片文件格式
function getFileType(url) {
const pattern = /.*\.(png|jpeg|gif)$/i; // Data URI scheme支持的image类型
return (url.match(pattern) || [undefined, undefined])[1];
}
转成blob
优点:
- 支持转换的格式丰富,除了image之外还可以支持其他类型,无需关心文件类型转换问题
缺点:
- 兼容性还是有一定问题, IE6~9 safari<=10 都是不支持的(https://caniuse.com/?search=c...)
- 需要调用revokeObjectURL 释放内存
转换:
function toBlobURL(url) {
return new Promise((resolve, reject) => {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
const img = document.createElement('img');
img.crossOrigin = 'Anonymous'; // 解决跨域图片问题,
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);
// 释放 blobURL
URL.revokeObjectURL(blobURL)
});;
};
img.onerror = e => {
reject(e);
};
});
}
实现页面全部图片src使用base64
const urlmap = {};
// 获取图片文件格式
function getFileType(url) {
const pattern = /.*\.(png|jpeg|gif)$/i; // Data URI scheme支持的image类型
return (url.match(pattern) || [undefined, undefined])[1];
}
// remoteURL转base64
function toDataURL(url) {
// 过滤重复值
if (urlMap[url]) {
return Promise.resolve(urlMap[url]);
}
// 跳过base64图片
const base64Pattern = /data:image\/(.)+;base64/i;
if (base64Pattern.test(url)) {
return Promise.resolve(url);
}
const fileType = getFileType(url) || 'png'; // 默认是png png支持透明
return new Promise((resolve, reject) => {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
const img = document.createElement('img');
img.crossOrigin = 'Anonymous'; // 解决跨域图片问题,
img.src = url;
img.onload = () => {
canvas.width = img.width;
canvas.height = img.height;
ctx.drawImage(img, 0, 0);
const dataUrl = canvas.toDataURL(`image/${fileType}`);
resolve(dataUrl);
};
img.onerror = e => {
reject(e);
};
});
}
// 批量转base64
function convertToBlobImage(targetNode) {
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 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 = toDataURL(v.src).then(blob => {
v.src = blob;
});
}
p.finally(() => {
if (++count === nodeList.length) {
return resolve(true);
}
});
}
});
}
注意事项:
- 远程图片是png的时候要转成png格式(
toDataURL(image/png
)),因为png图片是支持透明的,在转成jpeg文件的时候会关闭alpha通道,导致透明区域变成黑色背景。推荐自适应文件类型,默认设置成png - Data URI scheme支持的image类型: png | jpeg | gif
- 在canvas 上绘制图片的时候,可能会报错:
"Failed to execute 'toBlob' on 'HTMLCanvasElement': Tainted canvases may not be exported"
,原因是CORS图片会污染画布,导致无法读取其数据。加上后面这段代码就可以解决跨域图片问题:img.crossOrigin = 'Anonymous'
扩展知识
<img /> src
下支持三种: url/base64/blob
。
1. 聊一聊几种常用web图片格式:gif、jpg、png、webp
2. 处理arrayBuffer 文件
// buffer -> base64
function getData(){
let binary = '';
const bytes = new Uint8Array(buffer);
const len = bytes.byteLength;
for (let i = 0; i < len; i++) {
binary += String.fromCharCode(bytes[i]);
}
return 'data:image/png;base64,' + btoa(binary); // window.btoa() 方法用于创建一个 base-64 编码的字符串。
}
3. base64转blob:
// 原理:利用URL.createObjectURL为blob对象创建临时的URL
function base64ToBlob ({b64data = '', contentType = '', sliceSize = 512} = {}) {
return new Promise((resolve, reject) => {
// 使用 atob() 方法将数据解码
let byteCharacters = atob(b64data);
let byteArrays = [];
for (let offset = 0; offset < byteCharacters.length; offset += sliceSize) {
let slice = byteCharacters.slice(offset, offset + sliceSize);
let byteNumbers = [];
for (let i = 0; i < slice.length; i++) {
byteNumbers.push(slice.charCodeAt(i));
}
// 8 位无符号整数值的类型化数组。内容将初始化为 0。
// 如果无法分配请求数目的字节,则将引发异常。
byteArrays.push(newUint8Array(byteNumbers));
}
let result = new Blob(byteArrays, {
type: contentType
})
result = Object.assign(result,{
// jartto: 这里一定要处理一下 URL.createObjectURL
preview: URL.createObjectURL(result),
name: `图片示例.png`
});
resolve(result)
})
}
4. blob转base64:
兼容性:IE>9 safari>5 https://caniuse.com/?search=r...
// 原理:利用fileReader的readAsDataURL,将blob转为base64
function blobToBase64(blob) {
return new Promise((resolve, reject) => {
const fileReader = new FileReader();
fileReader.onload = (e) => {
resolve(e.target.result);
};
// readAsDataURL
fileReader.readAsDataURL(blob);
fileReader.onerror = () => {
reject(newError('文件流异常'));
};
});
}
场景二:截图内容是页面内容上的扩展
截图的主要内容就是页面展示的内容,底部拼接导流banner。
如何实现动态拼接
可以使用html2canvas
的onclone
属性,实现修改复制出来的节点内容。在onclone里面修改元素,不会影响页面原始元素。
将动态显示内容脱离文档流并且置为不可见:
<!-- html -->
<div class="wrapper">
<!-- 主要内容 -->
<div class="content" id="share"></div>
<!-- 动态添加的内容 -->
<div class="dynamic-contetn" id="share-footer"></div>
</div>
// css
.wrapper{
position:relative;
}
.dynamic-content{
position: absolute;
top:0;
left:0;
right: 0;
z-index:-100;
}
截图的时候,在onclone里面修改让动态内容出现在正常位置上并且置为可见:
const node = document.getElementById('share'); // 分享海报的主要内容区域
const
shareFooter = document.getElementById('share-footer'); // 海报底部内容
html2canvas(node,{
onclone: (doc: Document) => {
// 将海报底部内容区域显示出来,用于和内容一起截屏
shareFooterNode.style.position = 'relative';
shareFooterNode.style.display = 'flex';
shareFooterNode.style.zIndex = '1';
}
}).then();
控制截图大小:
因为截图的高度默认是Node元素的offsetHeight,现在这个高度要变成 Node高度 + 动态区域高度:
const node = document.getElementById('share'); // 分享海报的主要内容区域
const shareFooter = document.getElementById('share-footer'); // 海报底部内容
const { height: footerHeight} = shareFooter.getBoundingClientRect();
html2canvas(node, {
useCORS: true,
logging: false,
height: node.offsetHeight + footerHeight,
}).then();
截取可视区域内容的另一种实践
在body的子元素加一层壳子,让网页内容在壳子里滚动。
html:
<html>
<body>
<div class="wrapper">
<div id="share" class="content"></div>
</div>
</body>
</html>
// css
.wrapper{
position:fixed;
left:0;
right:0;
top:0;
bottom:0;
}
javascript:
const wrapper = document.getElementsByClassName('wrapper')[0]; // wrapper 用于监听scroll
const node = document.getElementById('share'); // 分享海报的主要内容区域
const { scrollTop } = wrapper;
const { height: footerHeight} = shareFooter.getBoundingClientRect();
return this.convertToBlobImage(node).then(res => {
return html2canvas(node, {
useCORS: true,
logging: false,
x: 0,
y: -scrollTop, // 重要
height: node.offsetHeight, // 重要
}).then();
这样也能正常截图。
所以x/y的设置和你的布局有关系,可以几种都试试,使用截图正常的那种方案。
两种场景下总结
截图内容与页面内容 完全不一样:
- 准备一份截图区域节点内容并隐藏
- 使用
x:0 y:0
来解决截图区域问题
截图内容与页面大内容部分一致:
- 复用页面节点内容
- 使用body下的壳子和
y:-scrollTop
解决截图区域问题 - 如果有动态内容,使用
height: node.offsetHeight + dynamicHeight
图片要转换成base64内容,保障截图的完整性
在特殊场景下出现的两大问题
一 canvas
尺寸过大导致canvas.toDataURL()
返回data:/
的问题
解决办法:
检测是否合法,不合法则降低html2canvas scale(会影响清晰度)。 降到1还不合法那就没办法了。
每个浏览器限制不通,没有直接用于检测的API,推荐一个检测库canvas-size
。从设备当前的dpr开始测试,向下兼容:
private async getAvailableScale(node) {
let scale = 1;
const {offsetWidth, offsetHeight} = node;
const nodeWidth = Math.ceil(offsetWidth);
const nodeHeight = Math.ceil(offsetHeight);
const dpr = window.devicePixelRatio;
const sizes = [];
for (let i = dpr; i >= 1; i--) {
sizes.push([nodeWidth * dpr, nodeHeight * dpr]);
}
try {
const { width, height, benchmark } = await canvasSize.test({
sizes,
usePromise: true,
useWorker : true,
});
console.log(`Success: ${width} x ${height} (${benchmark} ms)`);
scale = width / nodeWidth;
} catch ({ width, height, benchmark }) {
console.log(`Error: ${width} x ${height} (${benchmark} ms)`);
}
return scale;
}
二 微信SDK分享朋友圈大小512K限制问题
解决方案:
使用 canvas.toDataURL(type,quality)第二个参数,压缩图片,会减小图片大小(但同时图片质量也会下降)。quality: (0,1)
,默认是0.92
public getPosterUrl(canvas): string {
const img = canvas.toDataURL('image/jpeg');
const size = Math.ceil(getImgSize(img) / 1024); // 获取base64图片大小
console.log('海报图片大小(kb):', size);
console.log('海报图片质量:', 400 / size); // 超出取值范围0-1,将会使用默认值 0.92
// HuaWei mate20 / android10 长图下不压缩会分享失败
// 400kb是基于512kb经过测试算出来的一个比较好的参考值
return canvas.toDataURL('image/jpeg', 400 / size).replace(/data:image\/\w+;base64,/, '');
}
dom-to-image 原理
可能某天会存在(或许已经存在?)将HTML的一部分导出到图像的简单、标准的方式(然后,这段脚本就成了我为了实现这样的目标而经历的所有问题的证据),但到目前为止我还没有找到。
该库使用SVG的功能,该功能允许在<foreignObject>
标记内包含任意HTML内容。因此,为了呈现该DOM
节点,会执行以下步骤:
- 递归克隆原始DOM节点
计算节点和每个子节点的样式,然后将其复制到相应的克隆中
- 并且不要忘记重新创建伪元素,因为它们当然不会以任何方式克隆
嵌入网络字体
- 查找所有可能表示网络字体的
@font-face
声明 - 解析文件URL,下载相应的文件
base64
编码并将内联内容作为dataURLs
- 连接所有已处理的CSS规则,并将它们放入一个<style>元素中,然后将其附加到克隆中
- 查找所有可能表示网络字体的
嵌入图片
- 将图像URL嵌入到<img>元素中
- 背景CSS属性中使用的嵌入式图像,其方式类似于字体
- 将克隆的节点序列化为XML
- 将XML包装到
<foreignObject>
标记中,然后包装到SVG中,然后使其成为数据URL - (可选)要以Uint8Array的形式获取PNG内容或原始像素数据,请创建一个以
SVG
为源的Image
元素,并将其呈现在您还创建的屏幕外画布上,然后从画布中读取内容 - 完毕
参考资料
- 原理实现: https://zhuanlan.zhihu.com/p/...
海报方案:前端海报生成的不同方案和优劣图片
- dom-to-image踩坑指南: https://segmentfault.com/a/11...
- html2canvas 踩坑1: https://www.cnblogs.com/paddi...
- html2canvas 踩坑2: https://somelou.xyz/p/3871219...
- 前端快照方案: https://zhuanlan.zhihu.com/p/...
- canvas尺寸过大问题:https://stackoverflow.com/que...
- 微信分享图片尺寸限制 https://developers.weixin.qq....
- https://developer.mozilla.org...
后记
后续有新的场景和问题积累会持续补充。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。