1 引言
随着裂变营销策略的兴起,定制化海报分享的需求不断增加。作为开发者,一张背景图+一个二维码的海报合成的需求便会出现在我们的工作中,如下图。
本文给大家介绍海报生成相关知识以及使用中常见的问题。希望能够抛砖引玉,为遇到类似需求或问题的伙伴们提供参考。
2 实现方式
2.1 生成步骤
在用户视角,海报生成像是“截图”,点击生成海报按钮之后,定制化海报便会呈现在屏幕上,再点击保存按钮,海报便会保存在手机相册里。
而在程序内部,还需要开发者做一些其他工作。这里的客户端包含原生和前端,两者在实现原理上类似——首先用画布绘制海报,然后将海报转成图片。在服务端,常用的方案是开启一个无头浏览器,先在浏览器上渲染出海报,然后截图生成图片,将生成的图片下发给前端。
2.2 各端类库
如下图,服务端不同语言、客户端不同系统都有可供直接上手的类库。
简单对三端进行下对比
端 | 生成效率 | 海报效果 | 兼容性 | 其他 |
---|---|---|---|---|
服务端 | 与服务器性能成正比 | 中等 | 好 | 开发成本高 |
客户端 | 高 | 好 | 较差 | 维护成本高 |
前端 | 中等 | 较好 | 好 | 复杂排版受限 |
服务端:在服务端生成海报大部分会选择使用puppeteer等插件,模拟一个浏览器然后渲染海报并截图。服务端的生成效率以及质量强依赖于服务器性能,不过与之对应的客户端的压力也会变小。另外,在查阅资料时,发现一个有趣的实现方式--图片水印。如果是由一个背景和一个二维码拼成的这种简单场景,可以把背景当作图片,二维码当作水印,直接调用第三方的图片水印能力,就能便捷实现海报生成能力。 客户端:客户端可以直接利用设备的CPU和GPU,所以在生成海报的效率上有着天然优势。但是端的维护成本较高,需要各端分别去实现。 前端:前端海报生成的类库多种多样,普通的海报生成需求都能满足,但也有一些跨域、复杂排版受限等问题。笔者作为前端开发,深入学习了前端实现的方案,下面讲一下前端实现及遇到的问题。
2.3 前端实现
前端的实现方案除使用Canvas API纯手写外,还有三类可参考的js库。Canvas API较为底层,上手成本高,海报生成需求真正用此实现的极少,下文一笔带过,重点展开讲一下现有的js库。
2.3.1 Canvas API
Canvas API(画布)用于在网页实时生成图像,并且可以操作图像内容。这种方案是开发者直接在画布上进行海报绘制,然后使用canvas.toDataURL
将画布转为图片,如果绘制一些简单的图像还是可以使用的。
// html中创建Canvas元素
<canvas id="canvas"></canvas>
// 获取Canvas元素
const canvasEl = document.getElementById('canvas');
// 获取上下文
const ctx = canvasEl.getContext('2d');
// 设置填充颜色
ctx.fillStyle = 'red';
ctx.fillRect(100, 100, 20, 20);
2.3.2 JS库
常用可以实现海报生成的JS库有三种类型。第一种是以Fabric.js为代表的,通过直接封装底层API实现的。第二种是重写渲染引擎的,代表类库是html2canvas。第三种使用了SVG的foreignObject
,常用的库是dom-to-image。
类型 | 实现思路 | 代表类库 | 优点 | 缺点 |
---|---|---|---|---|
直接封装Canvas API | 封装底层API | Fabric.js | 海报可定制 | 使用门槛高 |
DOM->Canvas | 重写一套新的渲染引擎 | html2canvas | 使用门槛低、内置跨域方案 | 部分css不兼容 |
DOM->SVG->Canvas | 使用了SVG的foreignObject | dom-to-image | 使用门槛低、还原度高 | 不支持跨域 |
下面依次详细讲一下提及到的代表类库。
Fabric.js
Fabric.js是活跃在github上的明星项目,这个库对canvas进行封装,提供更丰富的图形支持以及事件处理。下面是Fabric.js的用法。
// html中创建Canvas元素
<canvas id="canvas"></canvas>
// 创建一个fabric实例
let canvas = new fabric.Canvas("canvas");
// 创建一个矩形对象
let rect = new fabric.Rect({
left: 100, //距离左边的距离
top: 100, //距离上边的距离
fill: "red", //填充的颜色
width: 20, //矩形宽度
height: 20, //矩形高度
});
// 将矩形添加到canvas画布上
canvas.add(rect);
// 在画布上绘制一张图片
fabric.Image.fromURL('imagePath.jpg', function(img) {
img.set({
left: 400,
top: 200,
});
canvas.add(img);
});
通过对比不难看出,Fabric.js的简单用法是和原生语法类似的。但当需要海报样式可DIY时,就体现出了Fabric.js的强大之处。如下图,用户可以对图形使用拖动、缩放、旋转、改变大小和形状等操作,可以实现高度定制化的海报。
html2canvas
html2canvas官方是这样介绍的——该脚本允许您直接在用户浏览器上对网页或部分网页进行“截图”。截图基于 DOM,因此可能与实际表示不完全一致,因为它不会制作实际的截图,而是基于页面上可用的信息构建截图。此外,截止到2024年8月,html2canvas库在github已经有30.3k
star。
html2canvas使用
html2canvas不同于前两种方式,无需调用绘制API,只需将DOM传入js库提供的方法,便可得到对应的图片。
// html中创建需要绘制的元素
<div id="绘制div">
<img src="海报图片路径"/>
<p>海报文本</p>
</div>
import html2canvas from 'html2canvas'
// 获取绘制元素
const el = document.getElementById('绘制div');
// 调用html2canvas方法进行绘制
html2canvas(el).then(function(canvas) {
// 使用toDataURL处理Canvas即可
})
html2canvas原理
虽然使用简单,但是这个库的底层实现是极其复杂的,可以大致理解为参考浏览器渲染原理又实现了一套新的渲染引擎,使用Canvas API将HTML+CSS画出来。大体实现流程如下
重点步骤一:获取节点树
获取节点树用到的方法是parseTree。parseTree的入参就是一个普通的DOM元素,返回值是一个ElementContainer对象,该对象主要包含DOM元素的位置信息(bounds
: width
|height
|left
|top
)、样式数据、文本节点数据等(只是节点树的相关信息,不包含层叠数据,层叠数据在parseStackingContexts方法中取得)。
解析的方法就是递归整个DOM树,并取得每一层节点的数据。ElementContainer对象大致如下:
{
bounds: {height: 260, left: 6, top: -100, width: 1440},
elements: [
{
bounds: {left: 6, top: -100, width: 1440, height: 240},
elements: [
{
bounds: {left: 6, top: -100, width: 1440, height: 240},
elements: [
{styles: CSSParsedDeclaration, textNodes: Array(1), elements: Array(0), bounds: Bounds, flags: 0},
{styles: CSSParsedDeclaration, textNodes: Array(1), elements: Array(0), bounds: Bounds, flags: 0},
...
],
flags: 0,
styles: {backgroundClip: Array(1), backgroundColor: 0, backgroundImage: Array(0), backgroundOrigin: Array(1), backgroundPosition: Array(1), …},
textNodes: []
}
],
flags: 0,
styles: CSSParsedDeclaration {backgroundClip: Array(1), backgroundColor: 0, backgroundImage: Array(0), backgroundOrigin: Array(1), backgroundPosition: Array(1), …},
textNodes: []
}
],
flags: 4,
styles: CSSParsedDeclaration {backgroundClip: Array(1), backgroundColor: 0, backgroundImage: Array(0), backgroundOrigin: Array(1), backgroundPosition: Array(1), …},
textNodes: []
}
重点步骤二:渲染离屏Canvas
将节点树遍历得到层叠数据后,将层叠数据渲染到离屏Canvas的过程,是html2canvas最核心的事情,这件事由renderStackContent方法来实现,为了避免渲染过程中流式布局被浮动或定位元素打破布局,renderStackContent使用了CSS层叠布局规则,如下图。
默认情况下,CSS是流式布局,按顺序渲染即可,但如果遇到浮动或定位时,原有的简单布局就会被打破,脱离正常文档流的元素会形成层叠上下文,可以理解为PS中的图层,将这些图层叠在一起,最终绘制出看到的海报。下面的源码可以理解为html2canvas是对CSS层叠布局规则的一个实现。
async renderStackContent(stack: StackingContext) {
// 1. 最底层是background/border
await this.renderNodeBackgroundAndBorders(stack.element);
// 2. 第二层是负z-index
for (const child of stack.negativeZIndex) {
await this.renderStack(child);
}
// 3. 第三层是block块状盒子
await this.renderNodeContent(stack.element);
for (const child of stack.nonInlineLevel) {
await this.renderNode(child);
}
// 4. 第四层是float浮动盒子
for (const child of stack.nonPositionedFloats) {
await this.renderStack(child);
}
// 5. 第五层是inline/inline-block水平盒子
for (const child of stack.nonPositionedInlineLevel) {
await this.renderStack(child);
}
for (const child of stack.inlineLevel) {
await this.renderNode(child);
}
// 6. 第六层是以下三种:
// (1) ‘z-index: auto’或‘z-index: 0’。
// (2) ‘transform: none’
// (3) opacity小于1
for (const child of stack.zeroOrAutoZIndexOrTransformedOrOpacity) {
await this.renderStack(child);
}
// 7. 第七层是正z-index
for (const child of stack.positiveZIndex) {
await this.renderStack(child);
}
}
正因为html2canvas重写了渲染引擎,所以对CSS的支持并不是很友好,如果有较为复杂的样式,需要进行充分的调试。即便如此,html2canvas仍保持每周150w+的下载量,是DOM直接绘制图片领域的霸主。
dom-to-image
dom-to-image是另外一种类型的“截图”工具,同样适用于海报绘制。使用方式和html2canvas大同小异,传入DOM即可生成对应的图片。
dom-to-image使用
// 引入dom-to-image库
import domtoimage from 'dom-to-image';
// 需要转换成图像的DOM节点
const node = document.getElementById('绘制div');
// 使用domtoimage.toPng将DOM节点转换成PNG图像
domtoimage.toPng(node)
.then(function (dataUrl) {
// 创建一个图片元素并设置src属性为转换后的图像数据URL
var img = new Image();
img.src = dataUrl;
// 将图片添加到文档中
document.body.appendChild(img);
})
dom-to-image原理
dom-to-image实现原理要比html2canvas简单的多,直接使用SVG的foreignObject
,只需要把DOM放在这个方法里,便可以在SVG绘制出对应的图片,因为SVG是浏览器的标准,所以不用担心此类方法对CSS的支持不友好问题。
需要注意的是,dom-to-image在将DOM绘制成SVG后,也使用了Canvas进行重新绘制。SVG已经是图片,为什么还要再使用Canvas呢,因为SVG方案生成的图片体积很大,包含很多冗余信息,使用Canvas进行重新绘制,可以大大降低图片体积,还能导出想要的图片格式。
其他
dom-to-image-more 基于dom-to-image,解决了跨域问题 html-to-image 基于dom-to-image,增加了typescript支持 modern-screenshot 基于dom-to-image,整合了以上的优化,是个理想的选择 Painter.js 适用于小程序端
3 常见问题
3.1 跨域
问题原因
不论用Canvas还是SVG生成海报时,海报中的图片会重新加载再进行绘制,虽然img标签本身不会跨域,但用于绘制时会触发浏览器的限制。
解决方案
请求图片时增加属性img.crossOrigin = 'anonymous',但是这样使用会有一个风险,如果需要canvas绘制的图片在页面中已经加载过一次,图片会被浏览器缓存,当绘制时,设置过的crossOrigin便会失效。
使用html2canvas、dom-to-image-more等库中封装好的内置跨域能力,大多实现原理也比较简单,为了避免浏览器缓存,会在资源请求时附带时间戳。
3.2 图片白屏(html2canvas)
问题原因
当使用html2canvas时,如果先将生成页滑动到底部再生成海报,就会出现图片白屏问题,排除跨域问题、资源加载问题,发现是触发了html2canvas天然bug。
正常情况下,html2canvas会从顶部开始绘制传入的DOM,但当同时满足以下三点时便会出现保存在本地的海报有白屏情况。
海报生成页超出一屏,也就是y轴有滚动条 滚动条发生滚动 预期绘制的海报是通过弹窗形式展现在屏幕中间的
产生的原因在源码中找到了答案,在renderCanvas方法中进行了下面操作:
this.ctx.translate(-options.x + options.scrollX, -options.y + options.scrollY);
在绘制时,画布的宽度高度默认为DOM的宽度高度,问题就出现在y轴的坐标上。
y轴起始坐标=-options.y + options.scrollY
,其中的y默认值为0,scrollY默认值是window.pageYOffset
,也就是默认绘制的y轴坐标为已经滚出视窗的y轴的高度。所以实际截图时便会出现下面这种情况。
解决方案
直接使用window.scrollTo(0, 0),但底部页面会发生滚动,如果在没有过高体验要求的前提下可以解决白屏问题。
html2canvas内置的兼容此问题的方案,如下代码
const scrollTop = document.documentElement.scrollTop||document.body.scrollTop;//得到滚动条高度
const domObj = document.querySelector("#canvas")
html2canvas(domObj, {
y: scrollTop,//解决有滚动条时,生成海报顶部有空白问题
})
3.3 海报中图片比例不正确(html2canvas)
问题原因
在海报实现过程中,大部分需要对图片进行保留原始比例的剪切、缩放或者直接进行拉伸,这也就用到了object-fit属性。前文提到过html2canvas有一套自己的渲染引擎,对CSS、尤其是新属性支持不太友好,这也就导致再使用html2canvas生成海报时object-fit不生效,与浏览器渲染的海报图片不一致的情况。
解决方案
如果使用html2canvas,可以将<img>标签转为背景图模式,背景图的几个属性html2canvas是支持的。
如果必须使用<img>标签,可以使用SVG生成的js库,如modern-screenshot、dom-to-image等来规避这个问题。
4 总结
现有的技术已经满足服务端、客户端(原生)、前端去实现海报的绘制工作。上文提及到的类库大多底层还是通过Canvas API实现的,在实际使用过程中,可以根据自己的需求及现状选择不同的技术。下面是选型指南:
如服务器性能稳定且排版复杂,推荐使用服务端生成方式。 如需要复杂排版的完美呈现或者有用户交互的场景,推荐使用客户端生成。 如普通的排版或者是较大并发的场景,使用前端生成即可。 前端推荐使用html2canvas或modern-screenshot,两者各有小缺陷,实践中可以替换使用,会规避大部分问题;如果有操作海报元素、高度DIY海报的需求推荐使用Fabric.js。
5 参考文章
https://www.npmjs.com/package/html2canvas http://fabricjs.com/ https://juejin.cn/post/7339671825646338057 https://zhuanlan.zhihu.com/p/701919912 https://zhuanlan.zhihu.com/p/338265679