分享商品生成海报的需求技术点总结
需求描述
商品详情页中,点击分享按钮,能生成一张可以保存到手机的商品海报图片,让用户保存到手机之后,可以通过微信或者别的聊天工具,发送这张图片到聊天中,达到分享的效果。
需求分析
整个海报的尺寸比例和外背景由设计提供的背景图来决定,上方的商品图需要根据进入的商品详情不同,而去获取不同的商品主图。右下方放置的二维码,需要根据所选的商品不同,由前端生成不同的分享地址来生成不同的二维码图片。
实现方案
我采用的方案是,先在页面上根据设计的设计图和尺寸比例,渲染出一个海报的 DOM 展现在页面上,然后根据这个 DOM 生成一张图片,再隐藏渲染的 DOM ,展示这张图片。
技术要点
二维码生成
采用的是 vue-qr
这个二维码生成库,生成没有什么太大的问题,唯一的问题在于识别上面。
由于生成的二维码图片在尺寸小于 100px 之后就无法被识别,所以让设计调整了整个海报的尺寸,从而使二维码尽可能的在各个机型上都保证能有 100px 左右的大小。
DOM 转图片
这一部分是整个需求的重点,也是难点,我会着重,细致的来记录整个解决问题,完成需求的过程。
Dom 转图片的这个功能,由于安卓和 iOS 的兼容性不同,所以我选择使用的两个库一个是 domtoimage
还一个是 html2canvas
。其中安卓使用 domtoimage,iOS 使用 html2canvas。
原理:
使用svg
的一个特性,允许在<foreignobject>
标签中包含任意的html
内容。(主要是 XMLSerializer | MDN这个api
将dom
转为svg
)
所以,为了渲染那个dom
节点,你需要采取以下步骤:
- 递归
clone
原始的dom
节点 - 获取 节点以及子节点 上的
computed style
,并将这些样式添加进新建的style标签中(不要忘记了clone 伪元素的样式) - 嵌入网页字体
- 找到所有的
@font-face
- 解析URL资源,并下载对应的资源
- base64编码和内联资源 作为
data:
URLS引用 - 把上面处理完的
css rules
全部都放进<style>
中,并把标签加入到clone的节点中去
- 内嵌图片
- 内联图片src 的url 进
<img>元素
- 背景图片 使用 background css 属性,类似fonts的使用方式
- 序列化 clone 的 dom 节点 为
svg
- 将xml包装到
<foreignobject>
标签中,放入svg
中,然后将其作为data: url
- 将png内容或原始数据作为
uint8array
获取,使用svg作为源创建一个img
标签,并将其渲染到新创建的canvas
上,然后把canvas
转为base64
- 完成
知道原理之后,我们才能更好的知道一些出现在调用库实现中的一些问题是怎么产生的,并且如何去解决它
遇到的问题和解决方案
- DOM 中的图片,在转换之后为空白
由于图片是放在服务端上的,dom 在加载的时候需要一定的时间去下载资源,所以必须等待所有的资源加载完毕后,再进行转换,才不会导致图片资源转换后消失。
解决方法:让转换代码使用 setTimeout 等待一定时间,再进行转换操作。
- 安卓中使用
domtoimage
直接转换成 png 的图,会导致生成的图片被放大
解决方法:使用 toSvg 的方法,转换成 svg 的格式。
- 安卓手机中无法将 svg 格式的图片保存到手机
解决方法:进行 canvas 构造,将 svg 格式的图绘制在 canvas 上,最后再将该 canvas 转换成 png 的图。
- 生成的图片清晰度不够
最终图片的清晰度,其实是取决于第一步中 html 转换成的 canvas 的清晰度
参考 canvas 在高倍屏下变模糊的处理方法和 canvas 绘制图片模糊的解决方法,我们知道其基本原理就是先将 canvas 放大为 设备的物理像素分辨率与CSS像素分辨率的比率(devicePixelRatio)
倍,最后通过 CSS 将 canvas 的 width 和 height 设置为原来的 1 倍大小。
实际在我们的项目中,做出以上优化之后,大颗粒一般的渲染结果还是会显得粗糙,所以我们需要再进行以下的优化
- 关闭 canvas 默认的抗锯齿设置,来实现图像的锐化
- 对于 dom 中的其他元素,也可以使用 css 样式的 scale 来实现同样的缩放
代码
getPoster() {
const self = this;
const toast = self.$toast.loading({
duration: 0,
forbidClick: true,
message: '正在生成海报...',
});
setTimeout(() => {
const width = self.$refs.posterArea.offsetWidth;
const height = self.$refs.posterArea.offsetHeight;
const scale = window.devicePixelRatio;
// eslint-disable-next-line no-console
console.log(self.chargeUserAgent());
if (self.chargeUserAgent() !== 'ios') { // 判断设备
domtoimage.toSvg(self.$refs.posterArea, { cacheBust: true })
.then((dataUrl) => {
const canvas = document.createElement('canvas');
canvas.width = width * scale;
canvas.height = height * scale;
const image = new Image();
image.src = dataUrl;
image.width = width * scale;
image.height = height * scale;
// 将基础图像进行放大
const ctx = canvas.getContext('2d');
image.onload = () => {
ctx.drawImage(image, 0, 0, width * scale, height * scale); // 绘制到画布上
self.posterImage = canvas.toDataURL('image/png');
};
toast.clear();
})
.catch((error) => {
// eslint-disable-next-line no-console
console.error('oops, something went wrong!', error);
});
} else {
const canvas = this.createBaseCanvas(scale, width, height);
html2canvas(self.$refs.posterArea, {
useCORS: true,
scrollY: 0,
scrollX: 0,
width,
height,
logging: false,
scale: 1,
canvas,
}).then((canva) => {
try {
self.posterImage = canva.toDataURL('image/png');
toast.clear();
} catch (err) {
// eslint-disable-next-line no-console
console.log(err);
}
});
}
}, 3000);
},
createBaseCanvas(scale, width, height) { // 构造 canvas
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;
},