canvas合成图片的踩坑之旅

需求

需求效果: 获取数据,渲染到页面的指定位置上,并且支持将该区域的图片高清保存。

需求细节:

  1. 图片局部元素有灰色滤镜的状态。
  2. 图片根据不同分类 id 动态获取,尺寸不一。
  3. 页面展示区域固定,等比例缩放,输出图片为高清图。

html2canvas

经过调研,不再重复造轮子,确定采用 html2canvas 库去实现。

原理

1. foreignObject 到 canvas 模式

对应 html2canvas 的foreignObjectRendering选项

  1. 把要截图的 dom 克隆一份,过程中把 getComputedStyle 附上 style

  2. 放到 svg 的 foreignObject 中

  3. 把 svg 序列化成 img 的 src(SVG 直接内联):

1
img.src = "data:image/svg+xml;charset=utf-8," + encodeURIComponent(new XMLSerializer().serializeToString(svg));
  1. ctx.drawImage(img, ….)

纯 canvas 模式

1
2
3
4
5
6
7
1.把要截图的dom克隆一份,过程中把getComputedStyle附上style

2.把克隆的dom转成类似VirtualDom的对象

3.递归这个对象,根据父子关系、层叠关系来计算出一个renderQueue

4.每个renderQueue Item都是一个虚拟dom对象,根据之前getComputedStyle得到的style信息,调用ctx的各种方法

粗略查看了html2canvas的文档,foreignObjectRendering属性默认为false,对于 foreignObject 这种实现方法,隐约感觉是有坑的, 这里先不做过多调研,抓紧时间上需求。

坑 1:图片跨域问题

很快,笔者就遇到一个大家经常碰到的问题,对,就是跨域图片问题。报错类似下图

如果是 canvas.toDataURL()方法,则会报

Failed to execute ‘toDataURL’ on  ’HTMLCanvasElement’: Tainted canvased may not be exported

为什么报错

这种错误 很容易让笔者联想到 cors,查阅文档

尽管不通过 CORS 就可以在 <canvas> 中使用其他来源的图片,但是这会污染画布,并且不再认为是安全的画布,这将可能在 <canvas> 检索数据过程中引发异常。

被污染的画布在调用getImageData(),toBlob(),toDataURL()都会抛出安全错误。

这种机制可以避免未经许可拉取远程网站信息而导致的用户隐私泄露。

其实 CORS 规则在其他场景也很常见

在 HTML5 中,一些 HTML 元素提供了对 CORS 的支持, 例如 <audio>、<img>、<link>、<script> 和 <video> 均有一个跨域属性 (crossOrigin property),它允许你配置元素获取数据的 CORS 请求。

例如常常用到的<script>标签,如果不遵从 CORS 规则,引入异源的脚本,一般只会捕捉到如Script Error的错误。

同理,通过 JS 可以获取到图片的EXIF等敏感信息, CORS 规则确保了这些隐私数据不被利用。

解决方法

  1. img 标签的crossorigin属性为anonymous

    这个属性相当于告诉服务器不携带credential如 cookie 等去请求

    这个枚举属性表明是否必须使用 CORS 完成相关图像的抓取。启用 CORS 的图像 可以在 <canvas>元素中重复使用,而不会被污染(tainted)。

    当用户并未显式使用本属性时,默认不使用 CORS 发起请求

    IE11+(IE Edge),Safari,Chrome,Firefox 浏览器均支持,IE9 和 IE10 会报 SecurityError 安全错误

  2. 后端对 CORS 的图片跨域请求要做处理

    Access-Control-Allow-Origin “*“, 该配置为允许跨域访问图片。

  3. html2canvas 打开allowTaintuseCORS属性, 以便告知插件需要开启该特性。

至此,我也以为这个坑就这样平躺过去了,然而,报错依旧,重新检查了一下前后端,确实都符合 cors 的规则,问题出在哪里呢?

最后,stackoverflow 等等讨论区中找到了原因:浏览器使用缓存中的图片,导致 CORS 请求没有触发。
观察控制台,图片确实是from memory cache,所以解决方法如下:

1
<img src={url + `?t=${Number(new Date())}`} />

对于图片缓存了 CORS 导致问题的这个情况,没有比较官方的解释,社区有人表示 safari 没有这个问题,说是 Chrome 的问题,找了半天没有比较官方的解释,那么就过了吧~

坑 2:图片清晰度问题

简单查阅了文档,使用 html2canvas 的scale属性,其默认值是window.devicePixelRatio

需求中, 页面的大小为输出图的 0.7 倍

需要注意的是,布局元素布局的时候要按照设计图的标注等比例放大响应倍数(1 / 0.7 ≈ 1.4),再利用transform: scale(0.7)进行缩放,这样就能够保证输出原图时的清晰度。

坑 3: CSS3 属性支持

库的支持

基于 html2canvas 中实现原理可知,
使用纯 canvas 方法,对于每个 css 属性 都必须 在 canvas 实现模拟,对于 css 这种语言,举个例子吧,就相当于实现了一趟 babel 吧,其中的工作量是可想而知的。

所以,html2canvas 给出了一下不支持的属性如下

1
2
3
4
5
6
7
8
9
10
11
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

模拟 text-overflow: ellipsis

这个属性输出图片是有问题的, 只能够用 js 对字符串做处理了,这里常规要注意中英文的占位长度问题, 简单处理如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function textOverflow(str = '', maxLen = 14) {
let len = 0
for (let i = 0; i < str.length; i++) {
if (str.charCodeAt(i) > 127 || str.charCodeAt(i) == 94) {
len += 2
} else {
len++
}
if (len >= maxLen) {
return str.substr(0, i + 1) + '...'
}
}
return str
}

模拟 CSS3 filter: grayscale()

常规操作,还是到 issue 讨论区看看,果然半年前是有人提过 pr,至今还没有处理

此时笔者内心还是晾了一把,逛了一圈,笔者用简单的 function 实现了,复杂度 O(xy), 原理是提取出 ImageData,操作 ImageData.data 属性,修改每个像素点的色值。

只读的 ImageData.data 属性,返回 Uint8ClampedArray ,描述一个一维数组,包含以 RGBA 顺序的数据,数据使用 0 至 255(包含)的整数表示。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
import dataURLtoBlob from 'blueimp-canvas-to-blob' // 使用polyfill

export function grayImage(imgObj: HTMLImageElement) {
const canvas = document.createElement('canvas')
const canvasContext = canvas.getContext('2d')

const imgW = imgObj.naturalWidth // 这里使用naturalWidth/Height属性,保持原图的清晰度
const imgH = imgObj.naturalHeight
canvas.width = imgW
canvas.height = imgH
if (!canvasContext) return
canvasContext.drawImage(imgObj, 0, 0)
const imgPixels = canvasContext.getImageData(0, 0, imgW, imgH)

for (let y = 0; y < imgPixels.height; y++) {
for (let x = 0; x < imgPixels.width; x++) {
const i = y * 4 * imgPixels.width + x * 4
const avg =
(imgPixels.data[i] + imgPixels.data[i + 1] + imgPixels.data[i + 2]) / 3
imgPixels.data[i] = avg
imgPixels.data[i + 1] = avg
imgPixels.data[i + 2] = avg
}
}
canvasContext.putImageData(
imgPixels,
0,
0,
0,
0,
imgPixels.width,
imgPixels.height
)
// base64 => blob => blobUrl
const base64 = canvas.toDataURL()
const blobUrl = URL.createObjectURL(dataURLtoBlob(base64))
return blobUrl
}

注意,canvas.toBlob 在一些浏览器上的兼容性是不太好的,查阅如下:

然而,我是怎么知道的呢? 因为笔者刚好是基于 Chorme49 内核的 webview 下开发,浏览器抛出了异常,看了下 chrome 的支持,也是刚好栽在了 49 版本之下,卒~

那么 用 base64 可以吗? 我觉得是不行的,因为众所周知 base64 的长度体积是很大的,往往比原图要大,不仅如此,base64 生成后是直接更新到虚拟 dom 和真实 dom 的,整个过程造成了性能浪费,而且 base64 过大就会造成了文档体积过大,显然这个方案是不合理的。

幸好,我们可以简单引入一下 polyfill 解决该问题, 如下

1
2
3
4
import dataURLtoBlob from 'blueimp-canvas-to-blob' // 使用polyfill
// base64 => blob => blobUrl
const base64 = canvas.toDataURL()
const blobUrl = URL.createObjectURL(dataURLtoBlob(base64))

通过dataURLtoBlob的 polyfill,这步是会有一些耗时,但实测下来性能没有问题,至此,又安全地过了这个坑~

坑 4:内存问题

接着上面,我们发现使用过程中,在不断地打开带有灰度图片的页面中, 发现了莫名的卡顿问题.

分析

初步猜测是内存泄漏的问题,使用 菜单 => 更多工具 => 任务管理,观察内存占用

笔者发现,只要触发一次上面提到的模拟灰度处理grayImage(),内存就上涨 2MB 左右,由此可见,处理后的图片占用内存并没有被回收或者释放。

定位问题

回顾一下grayImage的逻辑,看上去是一个纯函数,这时候我们通过经验,可以观察到一些可能存在副作用的代码,例如URL.createObjectURL

查看 MDN 文档, 果然,官方给予了明确的提示:

在每次调用 createObjectURL() 方法时,都会创建一个新的 URL 对象,即使你已经用相同的对象作为参数创建过。当不再需要这些 URL 对象时,每个对象必须通过调用 URL.revokeObjectURL() 方法来释放。

浏览器在 document 卸载的时候,会自动释放它们,但是为了获得最佳性能和内存使用状况,你应该在安全的时机主动释放掉它们。

为了便于逻辑的复用,笔者简单地封装了一下<img />作为 ImgContainer 组件 ,在组件中,加上卸载组件的逻辑, 问题得以解决。

1
2
3
4
5
6
7
useEffect(() => {
return () => {
blobUrlQueue.forEach((url) => {
URL.revokeObjectURL(url)
})
}
}, [])

最后

以上,由于开发时间紧迫,一些问题没有继续深入地分析,仅仅是以笔记的形式做记录,如有错误或者其他意见,欢迎讨论和指正。

参考