Skip to content

本文由 简悦 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封装底层 APIFabric.js海报可定制使用门槛高
DOM->Canvas重写一套新的渲染引擎html2canvas使用门槛低、内置跨域方案部分 css 不兼容
DOM->SVG->Canvas使用了 SVG 的foreignObjectdom-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 参考文章

想了解更多转转公司的业务实践,点击关注下方的公众号吧!