前端知库

前端怎样生成截图或分享海报?html2canvas 有哪些限制?

html2canvas 读取 DOM 和样式,在 canvas 上重新画一遍,不是真的截屏,所以 box-shadow、filter 等部分 CSS 画不出来。版式固定的海报更适合直接用 Canvas 绘制。跨域图片要带 crossorigin 且服务端返回 CORS 头,否则画布被污染,toBlob 会抛 SecurityError。

参考答案

简答

比如活动页要生成一张带头像、昵称和二维码的分享海报,让用户长按保存。常见做法有两种:用 html2canvas 把一段 DOM 转成图片,或者直接用 Canvas 一笔一笔画出来。

html2canvas 并不是真的截屏。它遍历 DOM,读出每个元素的样式,再用 Canvas 重新画一遍,所以结果取决于它支持哪些 CSS。官方文档列出 box-shadow、filter、object-fit 等属性目前不支持,跨域图片默认也读不到。

版式固定的海报,直接用 Canvas 画更可控:背景、头像、文字、二维码的位置都由自己算,画完用 toBlob 导出成图片。为了在高清屏上不糊,画布的像素尺寸要乘以 devicePixelRatio。

最常见的坑是跨域图片。没有经过 CORS 允许的图片画进画布后,画布就被“污染”了,再调用 toBlob、toDataURL、getImageData 都会抛 SecurityError。解决办法是给图片设置 crossOrigin = "anonymous",同时图片服务器返回 Access-Control-Allow-Origin。

html2canvas 是怎样工作的

html2canvas 的官方文档说得很直白:它遍历所在页面的 DOM,根据读到的属性构建页面的一份“表示”,再画到 canvas 上,并不是去截取屏幕上真实的像素。

所以它能画出来的样子,取决于它实现了哪些 CSS。官方 Features 页面列出了目前不支持的属性,包括 background-blend-mode、border-image、box-decoration-break、box-shadow、filter、font-variant-ligatures、mix-blend-mode、object-fit、repeating-linear-gradient()、writing-mode 和 zoom。设计稿里用了阴影、滤镜,生成的图片就会和页面上看到的不一样。另外它不渲染 Flash、Java Applet 这类插件内容。

它的常用配置也和这些限制有关,下面的默认值来自官方 Configuration 页面:

JavaScript
const canvas = await html2canvas(posterElement, {
  useCORS: true, // 默认 false,尝试用 CORS 加载跨域图片
  scale: window.devicePixelRatio, // 默认值就是设备像素比
  backgroundColor: '#ffffff', // 默认白色,传 null 得到透明背景
  imageTimeout: 15000, // 默认 15 秒,单张图片加载超时
});

跨域图片还有 proxy 选项,可以填一个代理地址来加载跨域图片,留空时跨域图片不会被加载。allowTaint 默认是 false,即使打开,画布被污染以后也导不出图片,所以一般不开。

直接用 Canvas 画海报

海报的版式通常是固定的,位置、字号都写在设计稿上,这种情况直接用 Canvas 画更可靠,不受 html2canvas 的 CSS 支持范围影响。

Canvas 画文字有一个麻烦:fillText 不会自动换行,长昵称或者长标题要自己拆行。思路是逐个字符往当前行里加,用 measureText 量宽度,超过最大宽度就另起一行。下面的例子把量宽度的函数换成“每个字 20 像素”,方便直接运行看结果:

JavaScript
function wrapText(text, maxWidth, measure) {
  const lines = [];
  let line = '';
  for (const char of text) {
    if (line && measure(line + char) > maxWidth) {
      lines.push(line);
      line = char;
    } else {
      line += char;
    }
  }
  if (line) lines.push(line);
  return lines;
}

const measure = (value) => value.length * 20;
const lines = wrapText('新人专享限时优惠快来扫码', 100, measure);

console.log(lines.length); // 3
console.log(lines[0]); // 新人专享限
console.log(lines[2]); // 扫码

const dpr = 2;
console.log(375 * dpr); // 750

最大宽度 100、每个字 20 像素,一行最多放 5 个字,所以 12 个字被拆成 3 行,最后一行只剩“扫码”。在浏览器里,把 measure 换成 (value) => ctx.measureText(value).width 就行,每行再用 fillText 按行高往下画。

最后一行的 750 是画布的实际像素宽度。海报在页面上显示 375 像素宽,可是在设备像素比为 2 的屏幕上,一个 CSS 像素对应 2 个物理像素。MDN 的做法是让画布的 width、height 乘以 devicePixelRatio,CSS 尺寸保持不变,再用 ctx.scale(dpr, dpr) 把坐标系换回 CSS 像素,这样后面的绘制代码仍然按 375 宽来写,导出的图片却是清晰的:

JavaScript
const dpr = window.devicePixelRatio;
canvas.style.width = '375px';
canvas.style.height = '667px';
canvas.width = Math.floor(375 * dpr);
canvas.height = Math.floor(667 * dpr);
ctx.scale(dpr, dpr);

跨域图片为什么会让导出失败

头像、商品图往往放在 CDN 上,和页面不同源。MDN 解释,只要往画布里画了从其他源加载、又没有经过 CORS 允许的数据,画布就会变成“被污染”(tainted)的状态。之后调用 getImageData()、toBlob()、toDataURL() 或 captureStream(),都会抛出 SecurityError。

浏览器这样做是为了防止网页借画布读出别的网站的图片内容。HTML 规范里,画布有一个 origin-clean 标记,画入 CORS 跨域的图片后标记变成 false,toBlob 第一步就检查它。

解决要前后端配合。前端在设置 src 之前给图片设置 crossOrigin,让浏览器以 CORS 方式请求;图片服务器在响应里返回 Access-Control-Allow-Origin:

JavaScript
function loadImage(url) {
  return new Promise((resolve, reject) => {
    const img = new Image();
    img.crossOrigin = 'anonymous'; // 不带 Cookie 的 CORS 请求
    img.onload = () => resolve(img);
    img.onerror = reject;
    img.src = url; // 最后设置 src,这一步开始下载
  });
}

服务器不配合时,前端设置了 crossOrigin 也没用,请求会被 CORS 拦下,图片加载失败。这时只能让同源的服务端代理这张图片,这也是 html2canvas 提供 proxy 选项的原因。CORS 的原理见《什么是跨域?CORS 为什么需要服务端配合?》。

怎样导出图片

画完以后用 toBlob 导出。它不直接返回结果,而是通过回调拿到 Blob;不传类型时默认导出 PNG,第三个参数是 0 到 1 之间的质量,只对 JPEG、WebP 这类有损格式有效:

JavaScript
function canvasToBlob(canvas, type = 'image/png', quality) {
  return new Promise((resolve, reject) => {
    canvas.toBlob((blob) => {
      // 图片因为任何原因生成不了时,回调拿到的是 null
      if (blob) resolve(blob);
      else reject(new Error('导出失败'));
    }, type, quality);
  });
}

const blob = await canvasToBlob(canvas, 'image/jpeg', 0.9);
const url = URL.createObjectURL(blob);

为什么要判断 null?MDN 写明,图片因为任何原因无法生成时,回调会收到 null;按 HTML 规范,宽或高为 0 的画布就会得到 null。拿到 Blob 以后可以上传,或者生成 object URL 放进 <img> 里让用户长按保存,用完调用 URL.revokeObjectURL 释放,见《File、Blob 和 object URL 怎样用于文件预览?》。

常见追问

为什么 html2canvas 生成的图片和页面不一样?

因为它是读样式重新画,不是截屏。它不支持的 CSS,比如 box-shadow、filter、object-fit,在图片里就会丢掉或者变样;跨域图片没配 CORS 也会画不出来。遇到这种情况,要么换成它支持的写法,要么改成直接用 Canvas 绘制。

toDataURL 和 toBlob 选哪个?

两者都能导出图片,被污染的画布上都会抛 SecurityError。区别是 toDataURL 同步返回一个 data URL 字符串,toBlob 通过回调给出 Blob。要上传或者生成 object URL 时,用 toBlob 拿到的 Blob 可以直接用,不用再从字符串转换。

图片明明配了 CORS,画布还是被污染了,可能是什么原因?

先检查 crossOrigin 是不是在设置 src 之前就设置了,再看这张图片的响应里有没有 Access-Control-Allow-Origin。如果前端没设置 crossOrigin,浏览器就不会发 CORS 请求,即使服务器配了响应头,这张图片对画布来说仍然是不可读的跨域数据。