likes
comments
collection
share

深度使用html2canvas的经验总结

作者站长头像
站长
· 阅读数 18

1、背景

移动端业务裂变业务最大的难点就是移动端缺少快速裂变的渠道,仅靠搜索引擎的导流,获得的流量和客户引流是极其优先,这也是SEO优化逐渐被人忽视的根本原因。我们的业务肯定希望在微信、微博、抖音等渠道进行快速裂变,但是这些渠道对移动端等网页的引流做了很强的限制,还有二跳的限制、合规限制,功能受限。

解决方案则是依赖图片分享转发来绕开限制,常见的就是各种海报、截图,形式多种多样。这就开发带来了一些要求,这些分享图必须拟合客户画像,具有很强的诱惑才行,才能吸引客户去分享,这就要求开发必须能够一套代码自动生成丰富多样的截图。

常规的玩法有两大类:前端生成和后台生成。

1、后台生成是指在服务端通过Nodejs服务,调用chrome内核来生成图片等资源,例如使用 puppeteer 生成 pdf,也可以通过一些第三方的 Java 库来实现页面到图片的生成。

对比不同实现方式绘制的图片发现,通过后台服务生产的图片在清晰度和字体显示的平滑程度方面效果是最好的,并且开发方式简单,Nodejs 前端可以自己开发和维护,但是缺点是服务端生成依赖 Nodejs 服务,如果只是针对图片服务能力增加额外的服务器环境维护,为额外的增加前端开发人员的服务器环境运维工作量。

2、前端生成则是指在前端浏览器端生成,一般可以选择 html2canvas 或者直接使用 HTML5 canvas 的API直接绘制。

html2canvas 是很成熟的 JavaScript 库,拥有 2.7W+ 的关注,因此使用比较简单。html2canvas 底层是 canvas API 实现的,因此也可以直接使用 canvas API 来绘制内容,生成定制的图片,有点就是性能高,不必依赖于 DOM 结构,可以直接绘制 canvas;缺点就是实现的难度太大,普通开发难以高效开发。

本文主要关注html2canvas前端生成方案,把在其中学习到思路和踩坑和大家分享下,让大家上线之后不被产品反馈Bug,享受美好的周末。

2、原理和使用

正如html2canvas的名字所提示的,其实现截图的原理实际是将DOM对象进行迭代克隆和解析,按照层叠关系自顶向下逐步将DOM对象绘制到 canvas 对象里,然后利用 canvas 的 API toDataURLtoBlob转换成图片数据,最终可以上传后台生成截图的在线地址。

结合 html2canvas 的使用给大家介绍下内部调用细节。

html2canvas(this.posterRef.current as HTMLElement, options: Options)

html2canvas 返回 Promise 的实例,内部调用renderElement,这个方法主要是构造

new DocumentCloner(context, element, cloneOptions)获取克隆之后的DOM,合并用户自定义配置,初始化各个渲染类所需的配置;调用parseTree解析待渲染树,调用 CanvasRender渲染类。

const renderer = new CanvasRenderer(context, renderOptions);
canvas = await renderer.render(root);
return canvas;

2.1、克隆DOM过程

克隆DOM树采用的自顶向下的方式进行递归遍历,核心的逻辑在cloneNode方法。

第一步,const clone = this.createElementClone(node)创建当前Node的克隆对象。

第二步,getComputedStyle(node/nodeBefore/nodeAfter)获取样式集,并且当前样式经过new CSSParsedCounterDeclaration(this.context, style)封装后压入样式计数器。

第三步,判断是否存在子元素,如果存在则将this.cloneChildNodes(node, clone, copyStyles)进行递归挂载。

第四步,挂载 Before 和 After DOM元素

第五步,样式计数器弹出当前样式集,并且返回克隆后的 root 节点。

在这个过程中,Video、Slot、Canvas、SVG、CustomElement、iframe 等节点类型是单独处理的,这里不做多讨论。

2.2、渲染 Canvas 过程

下面介绍绘制过程-将克隆树渲染到 Canvas。

将克隆树渲染到 Canvas 对象中,核心方法类是CanvasRender

const renderer = new CanvasRenderer(context, renderOptions);
// 声明全局canvas实例,传入渲染的相关参数
canvas = await renderer.render(root);
// 进行具体的DOM渲染到canvas中

内部调用细节:

async render(element: ElementContainer): Promise<HTMLCanvasElement> {
    if (this.options.backgroundColor) {
        this.ctx.fillStyle = asString(this.options.backgroundColor);
        this.ctx.fillRect(this.options.x, this.options.y, this.options.width, this.options.height);
    }

    const stack = parseStackingContexts(element);

    await this.renderStack(stack);
    this.applyEffects([]);
    return this.canvas;
}

最终会返回渲染之后的 canvas 对象,能够使用常规 Canvas API 来导出截图数据。

2.2.1、收集层叠上下文

上面的代码中第一个要关注的核心逻辑是parseStackingContexts ,该方法创建层叠上下文,是决定DOM元素渲染z轴的前后次序的关键。

先来阐明一下什么是层叠上下文。

层叠上下文(stacking content),是HTML中的一种三维概念。如果一个节点含有层叠上下文,那么在下图的Z轴中位置就要做调整。

深度使用html2canvas的经验总结

深度使用html2canvas的经验总结

从源码来看,

1、布局position !== static; 2、透明度opacity < 1 3、样式变换 transformed 4、浮动布局floated

都会产生层叠上下文,放入不同层级的样式堆栈中,仔细观察命名就能发现其层叠上下文分类类似著名的7阶层层叠水平的模型。

深度使用html2canvas的经验总结

虽然不是完全一致,但是大多是能够对应上的。

单独处理的层叠上下文样式堆栈:

  • 1、negativeZIndex;
  • 2、positiveZIndex;
  • 3、zeroOrAutoZIndexOrTransformedOrOpacity;
  • 4、nonPositionedFloats;
  • 5、nonPositionedInlineLevel

无层叠上下文的样式集一般放到inlineLevel/nonInlineLevel这两个堆栈。

2.2.2、按照层叠上下午逐步渲染 Canvas过程

接下来根据层叠上下文逐步渲染,核心函数renderStackContent

async renderStackContent(stack: StackingContext): Promise<void> {
    if (contains(stack.element.container.flags, FLAGS.DEBUG_RENDER)) {
        debugger;
    }
    // https://www.w3.org/TR/css-position-3/#painting-order
    // 1. the background and borders of the element forming the stacking context.
    await this.renderNodeBackgroundAndBorders(stack.element);
    // 2. the child stacking contexts with negative stack levels (most negative first).
    for (const child of stack.negativeZIndex) {
        await this.renderStack(child);
    }
    // 3. For all its in-flow, non-positioned, block-level descendants in tree order:
    await this.renderNodeContent(stack.element);

    for (const child of stack.nonInlineLevel) {
        await this.renderNode(child);
    }
    // 4. All non-positioned floating descendants, in tree order. For each one of these,
    // treat the element as if it created a new stacking context, but any positioned descendants and descendants
    // which actually create a new stacking context should be considered part of the parent stacking context,
    // not this new one.
    for (const child of stack.nonPositionedFloats) {
        await this.renderStack(child);
    }
    // 5. the in-flow, inline-level, non-positioned descendants, including inline tables and inline blocks.
    for (const child of stack.nonPositionedInlineLevel) {
        await this.renderStack(child);
    }
    for (const child of stack.inlineLevel) {
        await this.renderNode(child);
    }
    // 6. All positioned, opacity or transform descendants, in tree order that fall into the following categories:
    //  All positioned descendants with 'z-index: auto' or 'z-index: 0', in tree order.
    //  For those with 'z-index: auto', treat the element as if it created a new stacking context,
    //  but any positioned descendants and descendants which actually create a new stacking context should be
    //  considered part of the parent stacking context, not this new one. For those with 'z-index: 0',
    //  treat the stacking context generated atomically.
    //
    //  All opacity descendants with opacity less than 1
    //
    //  All transform descendants with transform other than none
    for (const child of stack.zeroOrAutoZIndexOrTransformedOrOpacity) {
        await this.renderStack(child);
    }
    // 7. Stacking contexts formed by positioned descendants with z-indices greater than or equal to 1 in z-index
    // order (smallest first) then tree order.
    for (const child of stack.positiveZIndex) {
        await this.renderStack(child);
    }
}

源码这里做了丰富的注释,能够清晰看到不同层叠上下文的渲染顺序。开发中如果有z轴的样式层级出错的情况,可以看下出错的样式所在的堆栈的渲染顺序,自行做下调整即可。

3、生成在线地址

在内存中生成我们所需的 Canvas 的对象,接下来就要将内容上传到服务器生成在线文件地址。

const renderer = new CanvasRenderer(context, renderOptions);
// 声明全局canvas实例,传入渲染的相关参数
canvas = await renderer.render(root);
// 进行具体的DOM渲染到canvas中
const jpgImgBase64 = canvas.toDataURL('image/jpeg'); // 预览图片
canvas.toBlob(
  (blob: any) => {
    const fd = new FormData(); // 构造请求上传
    fd.append('file', blob, 'poster.jpeg');
    dispatch({
      type: 'contentCenter/s3FileUpload',
      payload: fd,
    }).then((result: any) => {
      // 取得在线地址
    });
  },
  'image/jpeg',
  0.5,
);

3、踩坑点

1、图片跨域和缓存

官方提供了下面的配置来解决图片跨域问题,官方建议后端增加跨域头Control-Allow-Origin: *避免破坏canvas的安全规则。

NameDefaultDescription
allowTaintfalseWhether to allow cross-origin images to taint the canvas
proxynullUrl to the proxy which is to be used for loading cross-origin images. If left empty, cross-origin images won't be loaded.
useCORSfalseWhether to attempt to load images from a server using CORS

其次是图片的资源缓存会导致截图和DOM渲染不一致的情况,常见于图片是后台手动维护,并没有交给通用文件服务托管,导致图片文件出现延时更新的问题,因此每次生成的时候图片的地址建议拼接上唯一的版本参数?version=uuidxxxx避免缓存。

2、图片清晰度差

官方提供了两个 API - dpi 和 scale 两个参数来解决。

NameTypeDefaultDescription
scalenumber1Increase the resolution by a scale factor (2=double).
dpinumber96Increase the resolution to a specific DPI (dots per inch).
// Create a canvas with double-resolution.
html2canvas(element, {
  scale: 2,
  onrendered: myRenderFunction
});
// Create a canvas with 144 dpi (1.5x resolution).
html2canvas(element, {
  dpi: 144,
  onrendered: myRenderFunction
});

更进一步的hack方案:

按照设备像素比缩放canvas的画布大小,结合scale进行放大,然后再缩小,提高精度。

function canvasToJPG(canvas, width, height) {
  const w = canvas.width;
  const h = canvas.height;
  const canvas = document.createElement('canvas');
  const ctx = canvas.getContext('2d');
  canvas.width = width;
  canvas.height = height;
  ctx.drawImage(canvas, 0, 0, w, h, 0, 0, width, height);
  return canvas.toDataURL('jpeg');
}
function eleToImage() {
  const elem = document.body;
  const width = elem.offsetWidth;
  const height = elem.offsetHeight;

  const canvas = document.createElement('canvas');
  const scale = window.devicePixelRatio; // 设备像素比
  canvas.width = width * scale; // 定义canvas 宽度 * 缩放
  canvas.height = height * scale; // 定义canvas高度 * 缩放
  // 放大后再缩小提高清晰度
  canvas.getContext('2d').scale(scale, scale); 
  html2canvas(elem, {
    scale: scale, // 添加的scale 参数
    canvas: canvas, // 自定义 canvas
    width: width, // dom 原始宽度
    height: height,
    useCORS: true // 【重要】开启跨域配置
  }).then(canvas => {
    const context = canvas.getContext('2d');
    context.webkitImageSmoothingEnabled = false;
    context.imageSmoothingEnabled = false;
    const img = canvasToJPG(canvas, canvas.width, canvas.height);
  });
}

3、滚动元素截图不全

提前滚动到顶部

document.body.scrollTop = document.documentElement.scrollTop = 0;

4、部分样式丢失

html2canvas 声明:CSS属性无法全部支持,需要检查当前需要截图的页面样式是否包含以下不支持的样式。

html2canvas.hertzen.com/features/

如果确实存在复杂的样式建议使用UI生成的图片来代替,避免DOM整体过于复杂。

5、字体间距、样式与DOM存在不一致

常见的场景有@等特殊字符出现空间隙,"."等半角字符被吞掉等

深度使用html2canvas的经验总结

第一步是给文本标签设置字体,html2canvas 绘制的时候默认会添加下面的

font-family: -apple-system, system-ui, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;

其次是对于密集,大段文字部分,可以进行字符分割,每个文字使用内联元素span标签进行包裹,提供渲染的精确度。

假如移动端采用的 rem 布局,再加上频繁改动根元素的font-size, 可能会导致渲染字体的行高出现问题,导致字体下移或者上移。 深度使用html2canvas的经验总结

这里的解决方案是这里的字体不要采用rem的布局,CSS 样式 LineHeight、font-size 等采用 px 进行显式声明。

6、图片生成时间太长

第一个环节-html2canvas,配置 ignoreElements 函数或者在DOM上增加data-html2canvas-ignore属性,去除冗余的DOM,其次精简需要渲染的DOM范围,

第二个环节-canvas to Blob,提前生成blob,上传到后台,获取在线url,增加缓存在线地址。

7、ios 生成图片失效

简单来说就是假如工程化例如webpack中,对图片转成 base64 的大小不做限制,会导致十分巨大的base64数据被塞入 canvas 中,引起Starting DOM parsing; Added image data:image/png:base64,xxxx的报错。

因此对于老方案中的使用 base64 来绕开跨域,工程化存在url-loader的limit设置的场景中,需要注意控制图片数据的大小,也可以增加noparse文件夹,设置内部的图片不做转换。

{
  test: /.(png|jpe?g|gif)(?.*)?$/,
  loader: 'url-loader',
  include: [resolve('static/noparse')],
  options: {
    // 单位是 Byte ,设置一个很小的值,使得这些图片不会被转成 base64
    limit: 1,
    name: utils.assetsPath('img/[name].[hash:7].[ext]')
  }
},
{
  test: /.(png|jpe?g|gif)(?.*)?$/,
  loader: 'url-loader',
  exclude: [resolve('static/noparse')],
  options: {
    // limit: 1,
    name: utils.assetsPath('img/[name].[hash:7].[ext]')
  }
},

8、模糊元素局部优化

设置模糊元素的width和height为素材原有宽高,然后通过 transform: scale 进行缩放。这里scale的数值由具体需求决定。

.targetElem {
  width: 54px;
  height: 142px;
  margin-top:2px;
  margin-left:17px;
  transform: scale(0.5);
}

这里不推荐使用这种方式,首先是本人尝试了下放大不同倍数,整体对生成的图片效果影响很小,其次一旦改变部分元素的实际大小,后续元素必然要使用position: absolute 进行调整,会引起后续元素最终渲染的位置出错,如下图的文字部分上移了,推荐使用坑点1的官方解决方案。

深度使用html2canvas的经验总结

9、tips

  • 背景色默认是白色,可以设置 options backgroundColor: "transparent"
  • ios <br>换行失败,使用其他块级元素进行换行
  • 视频截图失败,可以升级到v1.4.0,新版本支持
  • 插件内容无法支持 官方文档明确指出了这一点,大家需要注意下。

The script doesn't render plugin content such as Flash or Java applets

4、总结

本文主要介绍了基于 html2canvas 的前端截图生成方案,大家能感受到基于前端 JS 生成方案具有很多限制。

  1. 客户端经常会有些小的兼容性问题,特别是IOS端;
  2. 在终端差异较大时无法保证不同终端生成的图片完全一样;
  3. 性能开销比较大,无法预先生成。

假如想更进一步去解决这些限制的话,我个人考虑借助于作系统底层的能力会是个更好的方案。例如在APP端借助于原生的截图能力;在浏览器里借助人机交互接口进行截图,进而获得真正的用户所见即所得的截图效果。

以上就是本文所有的经验感悟,非常感谢您看到这里。 如果对您有帮助的话,请点赞支持下,这是我坚持下去的动力

转载自:https://juejin.cn/post/7209862607975563301
评论
请登录