本文由 简悦 SimpRead 转码, 原文地址 mp.weixin.qq.com
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,可以将
标签转为背景图模式,背景图的几个属性 html2canvas 是支持的。
如果必须使用
标签,可以使用 SVG 生成的 js 库,如 modern-screenshot、dom-to-image 等来规避这个问题。
4 总结
现有的技术已经满足服务端、客户端 (原生)、前端去实现海报的绘制工作。上文提及到的类库大多底层还是通过 Canvas API 实现的,在实际使用过程中,可以根据自己的需求及现状选择不同的技术。下面是选型指南:
如服务器性能稳定且排版复杂,推荐使用服务端生成方式。
如需要复杂排版的完美呈现或者有用户交互的场景,推荐使用客户端生成。
如普通的排版或者是较大并发的场景,使用前端生成即可。
前端推荐使用 html2canvas 或 modern-screenshot,两者各有小缺陷,实践中可以替换使用,会规避大部分问题;如果有操作海报元素、高度 DIY 海报的需求推荐使用 Fabric.js。
5 参考文章
想了解更多转转公司的业务实践,点击关注下方的公众号吧!