猪小花1号

个人签名

282篇博客

wap页生成海报功能踩坑总结(上篇)

猪小花1号2018-09-03 13:56

作者:李新新


随着移动端用户的增加,各个产品在wap页的提供的功能也越来越多,尤其是微信中,因为支持“识别图片中的二维码”,所以诞生了许多新玩法。比如许多产品都有的: 在微信wap页点击某个按钮后,生成一张带二维码的海报,用户可以保存海报,并分享给他人。

本文介绍了开发云课堂在微信wap页分享海报功能的经历,分享了使用html2canvas.js所遇到的问题和解决方案,希望能对大家有所帮助。

本文适用人群

  1. 需要在微信wap页开发分享海报功能的前端程序员们
  2. 想要了解html2canvas库的吃瓜群众
  3. 挣扎在html2canvas库中的开发者们

背景

产品大大的需求: 做一个生成海报的功能。在微信wap页使用。把html元素生成一张宣传海报,方便用户分享出去。 注意点:

  1. 在使用海报的地方,并不会显示海报来源的html,只显示海报的图片。 所以要求了html节点是隐藏的。
  2. 海报的数据来源于当前登录用户、当前课程有关(根据这些生成二维码),课程图片也是动态的。 所以图片不能写死保存在文件夹下,而是放在nos上。

思路

首先梳理需求。所谓的生成海报,简化后其实就是: 根据html元素生成图片。其中,html元素包括图片(背景图、课程图片、二维码)和文字(推广语、课程名)。

那么第一个问题就是,由前端实现还是后端实现?

首先看后端实现:云课堂工程的后端使用的语言是java,java是有比较成熟的库来实现html转成image这个功能的,并且我们之前也实现过一个类似的功能。但是后端实现这个功能有一些不足: 1. 比较慢,平均生成一张图需要2-10s ,2. 不适合需要实时的场景。而我们的需求明显是不能接受等待这么久的。3. 会产生白图或者黑图或者图片不完整的情况。

如果由前端实现呢,我们很容易想到用canvas,而在wap页,浏览器对canvas的支持还是比较好的。另外,这么有挑战性的任务,身为一个前端,怎么能不试一试呢?

所以经过权衡,最终的决定是由前端来实现。

下一个问题,怎么实现?

实现

于是开始我调研用canvas实现html转图片。

方案一

在张鑫旭大大的博客里发现了SVG 简介与截图等应用 这篇文章。用svg的foreignObject实现截图功能。文章的主要思路是:

  1. 先写一个svg,用foreignObject包围要写的html元素。

  2. 用canvas的drawImage方法把svg转成canvas。

  3. 用canvas的toDataURL方法把canvas转成图片。

简单说来,就是: html-->svg-->canvas-->img。

嗯,看起来很简单。看完DEMO后,年幼无知的我一头就扎进了实现业务逻辑的大坑里,开始按照这个思路实现第一版的生成海报功能。

第一版是简化版,复制了下zxx的DEMO,图片用本地图片代替,写完后在本地用Chrome尝试没有问题。 然鹅,事情怎么会这么简单……当把图片换成nos图片后——

报错"Tainted canvases may not be exported",这是因为: 当引用外域的图片,并且该图片并没有CORS认证时,canvas被“污染了”,而被污染的canvas不能使用toBlob(), toDataURL(), getImageData()方法。见CORS enabled image

Although you can use images without CORS approval in your canvas, doing so taints the canvas. Once a canvas has been tainted, you can no longer pull data back out of the canvas. For example, you can no longer use the canvas toBlob(), toDataURL(), or getImageData() methods; doing so will throw a security error.

解决方法很简单,给img设置crossOrigin属性为anonymous


<img crossOrigin="anonymous" >

或者在js中指定


if (dom.tagName.toLowerCase() == 'img') {
  dom.crossOrigin = "anonymous";
}

同时img的服务器也要有正确的Access-Control-Allow-Origin 响应头即可。


到此Chrome的问题解决了,然而在Safari下……


一切就是这么残忍。果断给我报错了。问题出在最后一步: canvas-->img,报错的是canvas.toDataURL方法。这是我遇到的第一个坑:


svg的foreignObject里面有外域的图片时,尽管指定了crossOrigin,在safari中,canvas.toDataURL方法仍然会有安全性问题。


这个坑应该和CORS有关,但是搜索了一番,没有找到更深层的原因和解决方案。(有人知道的话欢迎告诉我)


报错导致画不出来图,这也就意味着,这个方案拒绝了Safari。而拒绝了Safari,就等于拒绝了所有的苹果手机……如果你去跟策划小哥哥说:我们能不能不兼容iphone,相信我,他们一定会提着刀来见你的。


本方案,卒。


方案二


看来foreignObject的路子行不通了,我只好继续寻觅~


就在这时,我发现了一个js库: html2canvas。


那么下面给大家介绍一下html2canvas这个库。


html2canvas库


一个用js进行“截屏”操作的库。因为是基于dom元素的,并不是真的截屏,所以可能会有一些不准确。

This script allows you to take "screenshots" of webpages or parts of it, directly on the users browser. The screenshot is based on the DOM and as such may not be 100% accurate to the real representation as it does not make an actual screenshot, but builds the screenshot based on the information available on the page.

使用方式:


html2canvas(document.body, {
  onrendered: function(canvas) {
    document.body.appendChild(canvas);
  }
});

文档地址: https://html2canvas.hertzen.com/documentation.html


原理


html2canvas的基本原理是,把dom树拉出来,挨个画到canvas上。比如div就取backgroud-color等,画一个长方形。最后返回这个画布。

The script renders the current page as a canvas image, by reading the DOM and the different styles applied to the elements.

所以我们基本可以猜想到html2canvas画图的整个流程:


  1. 递归处理每个节点,记录这个节点应该怎么画。(比如div就画边框和背景,文字就画文字等等)
  2. 考虑节点的层级问题。比如z-index,float, position等样式的影响。
  3. 从低层级开始画到canvas上,逐渐向上画。层级高的覆盖层级低的。


试了一下DEMO,基本可行。于是,就是你了!(毕竟调研+开发只有两天时间,我不想再寻找了)


踩坑经验及解决方案


决定了使用html2canvas后,还要再决定一个问题: 用哪个版本的?


目前html2canvas有最新版5.0beta4和正式版4.1。5.0beta版使用了promise等新技术;4.1作为正式版,社区里有更多的解决方案。而我,两个版本都试了……至于为什么,后面会告诉你们……


好,到这里方向是确定了,但是道路是艰难的,下面我分享一下在使用过程中遇到的问题们。


问题一,怎么画出不显示的元素


从文章最开始的需求背景,大家应该就知道了,我们的html元素是隐藏的,页面上并不会显示,只需要显示根据html元素画出来的图片。然而,html2canvas本质上是一个“截屏”工具,屏幕上有什么,它就画什么,而隐藏的元素,它不会画出来。


怎么解决呢?别急,本宝宝分别告诉大家5.0版的和4.1版的


5.0版


5.0版本,传入的options里面,有一个onclone参数,这个参数是做什么的呢?看一下源码,在这个版本中,会先复制传入的dom元素,然后再画出复制后的dom元素,onclone就是复制之后的回调。所以我们在clone dom后,给clone的dom节点加上display:block,就可以解决画不出display:none的问题了。


html2canvas(p, {
  useCORS: true,
  onrendered: function(canvas) {
    $img.src = canvas.toDataURL('image/png');
  },
  onclone: function(doc){
    hiddenDiv = doc.getElementById('parent');
    hiddenDiv.style.display = 'block'; //  这里,设置display为block
  }
});

4.1版


4.1版本的没有onclone回调了,稍微有点麻烦,因为我们只能改源码了。


源码中有一个isElementVisible方法,是判断元素是否显示的。那么我们修改这个方法为:


function isElementVisible(element) {
    return (getCSS(element, 'display') !== "none");
    // return (getCSS(element, 'display') !== "none" && getCSS(element, 'visibility') !== "hidden" && !element.hasAttribute("data-html2canvas-ignore"));
  }

配合父元素的类:


.parent{
    visibility: hidden;
    position:fixed;
    z-index: -1;
    top:0;    
}

这样就可以达到显示隐藏的元素的目的了。


问题二,图片模糊怎么办


最初,我们发现,生成的图片在Mac上看总是糊的。如下图:

canvas模糊的话,很容易想到像素点的原因。

于是有思路:我们尝试把canvas的width和height放大。

给canvas的宽高比canvas样式的宽高大,比如把200x200的画缩放到100x100,这样画出来的图点就更多,清晰度就更好。

放大多少呢?——根据window.devicePixelRatio来。

5.0

5.0版本支持自定义canvas并传进去。所以我们在调用html2canvas的时候,先创建好一个尺寸合适的canvas,作为参数传进去。

var p = document.getElementById(domId);
var scaleBy = backingScale();
var box = window.getComputedStyle(p);
var w = parsePixelValue(box.width, 10);
var h = parsePixelValue(box.height, 10);
var canvas = document.createElement('canvas');

function backingScale () {
    if (window.devicePixelRatio && window.devicePixelRatio > 1) {
        return window.devicePixelRatio;
    }
    return 1;
};
function parsePixelValue(value) {
    return parseInt(value, 10);
};
// 就是这里了
canvas.width = w * scaleBy;
canvas.height = h * scaleBy;
canvas.style.width = w + 'px';
canvas.style.height = h + 'px';

var context = canvas.getContext('2d');
context.scale(scaleBy, scaleBy);

html2canvas(p, {
    useCORS: true,
    canvas: canvas,  // 把canvas传进去
    onrendered: function(canvas) {
        cb(canvas.toDataURL('image/png', 1));
    },
    logging: true,
    onclone: function(doc) {
        hiddenDiv = doc.getElementById(domId);
        hiddenDiv.style.display = 'block';
    }
});

4.1

4.1版本尽管也支持自定义传canvas进去,但是在最后画图的时候,会改写canvas的width和height,

return function(parsedData, options, document, queue, _html2canvas) {
...
canvas.width = canvas.style.width =  options.width || zStack.ctx.width;
canvas.height = canvas.style.height = options.height || zStack.ctx.height;
}

所以想要像用5.0版本一样传canvas参数进去的话,就要失望了,还是会一样的糊(废话,宽高都被改了,我还传进去干啥)。

所以,我们又要改源码了……翻到源码最后,首先加一个backingScale方法,根据window.devicePixelRatio计算缩放倍数。然后重写canvas的width和style.width

function backingScale () {
      if (window.devicePixelRatio && window.devicePixelRatio > 1) {
          return window.devicePixelRatio;
      }
  };
return function(parsedData, options, document, queue, _html2canvas) {
...
     // 改成下面的
    canvas.width = Math.ceil(options.width ||zStack.ctx.width)*scaleBy;
    canvas.height = Math.ceil(options.height || zStack.ctx.height)*scaleBy;
    canvas.style.width =  options.width || zStack.ctx.width;
    canvas.style.height = options.height || zStack.ctx.height;
...
if (options.elements.length === 1) {
      if (typeof options.elements[0] === "object" && options.elements[0].nodeName !== "BODY") {
        // 如果传入的element是一个dom元素的话,把图片里面的这个dom元素切出来,否则可能会有白边。
        bounds = _html2canvas.Util.Bounds(options.elements[0]);
        newCanvas = document.createElement('canvas');
        // 这两句是原来的,注释掉
        // newCanvas.width = Math.ceil(bounds.width);
        // newCanvas.height = Math.ceil(bounds.height);
        // 改成下面的
        newCanvas.width = bounds.width*scaleBy;
        newCanvas.height = bounds.height*scaleBy;
        newCanvas.style.width = bounds.width+ 'px';
        newCanvas.style.height = bounds.height+'px';

        newctx = newCanvas.getContext("2d");
         // 原来的
         // newctx.drawImage(canvas, bounds.left, bounds.top, bounds.width, bounds.height, 0, 0, bounds.width, bounds.height);
        // 同样改成下面的
        newctx.drawImage(canvas, bounds.left, bounds.top, bounds.width, bounds.height, 0, 0, newCanvas.width, newCanvas.height);        

        // newctx.scale(4, 4);
        canvas = null;
        return newCanvas;
      }
    }
}

这样之后,图片显然清晰了许多:



网易云大礼包:https://www.163yun.com/gift

本文来自网易实践者社区,经作者李新新授权发布。