h5活动页经常会有生成海报分享到朋友圈的需求,虽然后端可以合成,但是会并发量大的时候会给服务器带来压力,并且比较耗时,这个业务放到前端做更合适,下面梳理几个实现方案

比如这么一张海报,上顶部是一张固定的背景图,下方是用户头像和根据接口获取的动态文案

Canvas绘制方案

画布大小

为了保证绘制出来的图片足够清晰,我们需要使用两倍的像素来绘制,比如设计的海报是320x484,那么需要设置canvas大小为640x968,当然3倍图也没问题。

放置背景图

由于图片加载是异步的,需要用promise来保证,在图片加载完成以后再进行其他处理

function imgPromise (img) {
  return new Promise((resolve, reject) => {
    img.onload = () => {
      resolve(img.src)
    }
    img.onerror = (e) => {
      reject(e)
    }
  })
}

由于图片通常托管在CDN上,在使用canvas绘制图片的时候可能会出现跨域问题,因此需要设置图片的跨域属性,这里要注意的是必须先设置跨域属性。

let queue = []
let bg = new Image()
bg.setAttribute('crossOrigin', 'anonymous')
bg.src = posterBgUrl
queue.push(this.imgPromise(bg))

出于安全考虑,跨域的的图片如果在canvas中绘制后,会导致canvas被污染,如果再想从canvas中通过如下三个API取得数据就会因为安全问题报错。

  • getImageData
  • toBlob
  • toDataURL

设置crossOrigin属性等于告诉浏览器将要加载一张跨域的图片,同时如果图片服务器返回的跨域头,图片就能被正常使用,canvas不会被污染。 crossOrigin有两个值anonymous和use-credentials,通常从CDN加载图片不需要凭证使用anonymous 即可,如果图片服务器设置了需要cookie之类的凭证就需要使用use-credentials。

绘制圆角矩形

function roundRect (oCtx, x, y, w, h, r, borderColor) {
    if (w < 2 * r) r = w / 2
    if (h < 2 * r) r = h / 2
    oCtx.beginPath()
    oCtx.strokeStyle = borderColor
    oCtx.moveTo(x + r, y)
    oCtx.arcTo(x + w, y, x + w, y + h, r)
    oCtx.arcTo(x + w, y + h, x, y + h, r)
    oCtx.arcTo(x, y + h, x, y, r)
    oCtx.arcTo(x, y, x + w, y, r)
    oCtx.closePath()
    oCtx.lineWidth = 1
    oCtx.stroke()
    ctx.save()
}

绘制图片与文字

ctx.drawImage(bg, 26, 26, 586, 692)
ctx.fillStyle = '#979797'
ctx.font = 'normal 24px Arial'
ctx.fillText('欢迎使用', 368, 820)
ctx.save()

绘制文字的时候有个问题需要注意,文字默认的baseline是alphabetic,即英文字母的baseline,如果绘制字母a的纵坐标设置为0,那么等于是a的底部的纵坐标是0,上面一部分超出了画布,因此需要设置文字的baseline为top

ctx.textBaseline = 'top'

转图片

canvas.toDataURL('image/png')

HTML2canvas方案

html2canvas是一个生成网页截屏的库,实现方案有两种方式foreignObject与canvas绘制

foreignObject

foreignObject是svg的一个标签。它使得SVG可以嵌入不同命名空间的XML元素,比如在svg内嵌入HTML代码。 借助这个特性,可以在svg内部嵌入海报的HTML模版,然后通过canvas将svg转成图片

<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
  <style>
    div {
      color: white;
      font: 18px serif;
      height: 100%;
      overflow: auto;
    }
  </style>
 
  <polygon points="5,5 195,10 185,185 10,195" />
  <foreignObject x="20" y="20" width="160" height="160">
    <div xmlns="http://www.w3.org/1999/xhtml">
      This is <code>code</code>.
          <img src="https://a.com/b.png" width="100px" height="50px" alt="">
    </div>
  </foreignObject>
</svg>

canvas绘制DOM

此方案比较复杂,需要解析每个节点的宽高,背景色,边框等信息,然后递归绘制,这里不展开讨论

nodejs截图

浏览器原生有截图的功能,例如chrome中通过命令面板可以调出截图功能

相比上面的方案,某种程度上这可以说是最能精准还原页面的方案,那么如何通过nodejs来调用浏览器呢?

Puppeteer

Puppeteer是一个服务端的无头浏览器API库,与此相似得还有phantomjs,不过它太老了而且不在开发。

Puppeteer原生支持截图

const puppeteer = require('puppeteer');

(async () => {
  const browser = await puppeteer.launch();
  const page = await browser.newPage();
  await page.setViewport({
      width: 640,
      height: 480,
      deviceScaleFactor: 1,
  });
  await page.goto('https://example.com');
  await page.screenshot({path: 'example.png'});

  await browser.close();
})();

然后通过page.setContent这个api可以将html字符串加载到页面中, 那么前端只需要拼接好html字符串和相应的样式,然后通过调用node服务截图生成图片返回即可 梳理流程如下:

  • 前端根据设计稿拼接写好html模版
  • 将html字符串传给nodejs截图服务
  • nodejs生成截图 并上传图片到cdn返回图片CDN地址,或者返回图片的base64码

对比

复杂度

nodejs的复杂度最高
除了需要nodejs服务端,还有服务器要求。由于puppeteer依赖的gtk3库只能在centos7才有,所以必须使用centos7的服务器,另外还有一大堆的依赖库需要安装

另外为了保证渲染的精确,还需要安装字体。

纯canvas方案麻烦的地方在与需要算图片和文本的坐标,另外还有文本换行的问题,需要和设计和交互方讨论

兼容性

纯Canvas绘制最好,nodejs方案次之,html2canvas方案的兼容性问题比较大

最终选择

  • 活动页统一使用了纯canvas绘制的方案
  • 开发了一个海报生成的nodejs服务,用来给某些并发量不大的业务使用
  • html2canvas方案由于兼容性差被放弃