我是如何封装生成海报组件来取代html2canvas( objToPoster:新一代更快更清晰的海报组件)

5,348 阅读10分钟

1.背景

由于近期较多用户反馈我们的分享海报存在着一些小问题,我便归纳总结了一下,总共有以下几点问题:

  • 海报不清晰

  • 海报生成速度太慢

  • 长按海报无法保存

  • 海报文字重叠

于是便深入研究了一番,发现都是和html2canvas插件有关(这是一个非常非常优秀的插件),发现通过原生api去绘制海报这些问题都能大大避免掉,于是便基于原生api自己来封装一个插件,objToPoster海报生成组件就诞生了。

github地址:github.com/6sy/crerate…

上面有完整的演示示例,喜欢的觉得还不错的记得star一下哦~

2.objToPoster VS html2canvas

2.1 生成速度对比:

html2canvas插件效果:

bandicam 2021-09-15 12-59-49-309.gif

objToPoster插件效果:

bandicam 2021-09-15 13-01-35-666.gif

通过生成效果明显我们的封装的组件速度要快于html2canvas的,再通过js代码来看具体的生成时间:

html2canvas生成时间:耗时2005ms

image.png

objToPoster生成时间:耗时777ms

image.png

2.2 清晰度对比:

html2canvas:

image.png

objToPoster:

image.png

放在一起比对:

image.png

3.基础绘制功能的讲解

在学习组件之前,我们需要学习一下canvas几个基础功能。

首先呢,我们先获取canvas元素,并且拿到ctx对象。

function initCanvas() {
   canvas = document.getElementsByTagName("canvas");
   ctx = canvas[0].getContext("2d");
   console.log("ctx", ctx);
   canvas[0].style.width=300+'px';
   canvas[0].style.height=300+'px';
}

3.1 绘制文字 ctx.fillText()

// 画文字
function drawText() {
   // 开始画画路径
   ctx.beginPath();
   // 文字颜色
   ctx.fillStyle = "red";
   // 文字样式
   ctx.font = "22px  Arial,sans-serif";
   // 文字对齐方式
   // 默认为alphabetic, top, hanging, middle, ideographic, bottom
   ctx.textBaseline = "hanging";
   ctx.fillText("hello world", 250, 10, 1300);
   // 结束这段路径
   ctx.closePath();
}

效果:

image.png 我们再试试增加文字的长度:

image.png

会发现,canvas绘制文字的api并不会自动换行,超出canvas范围就会自动被截取了。好在canvas给我们提供了另外一个api

let TextMetrics = ctx.measureText('hello world');
console.log(TextMetrics)

打印结果:

image.png 这个width就是返回了我传入字符串所占的长度。我们就可以从另一个方向来实现换行,只要给我最大宽度,我们遍历所有字符串来控制就可以了。下面便是实现换行的函数,我先将它挂在到CanvasRenderingContext2D的原型上:

/**
*处理canvas文字展示情况的函数 换行和对齐方式
@param {*}text:文字值
@param {*}x:首个字的x位置
@param {*}y:首个字的y位置
@param {*}maxWidth:单行最多展示多少个字
@param {*}lineHeight:文字的行高
*/
CanvasRenderingContext2D.prototype.wrapText = function (
    text,
    x,
    y,
    maxWidth = 300,
    lineHeight = 12,
) {
    if (typeof text !== "string" || typeof x !== "number" || typeof y !== "number") {
        return;
    }
    let context = this;
    // 字符串分割成数组
    let arrText = text.split("");
    // 每行的字符串内容
    let lineText = "";
    for (let i = 0; i < arrText.length; i++) {
        // 零时的字符串 用于判断是否超出规定宽度
        let tempText = lineText + arrText[i];
        let TextMetrics = context.measureText(tempText);
        // 判断是否需要换行
        if (TextMetrics.width > maxWidth && i > 0) {
            // 将已经成一行的文字画上去
            context.fillText(lineText, x, y);
            // 因为加上这个 arrText[i]文字 导致超出我们的限制 所以这个字 就成为下一行的第一个字了
            lineText = arrText[i];
            // y的高度加上我们给的lineHeight高度
            y += lineHeight;
        }else{
            lineText = lineText + arrText[i];
        }
    }
    context.fillText(lineText, x, y)
};

这个时候我们就可以通过ctx.wrapText的方式来调用了:

// 画文字
function drawText() {
    // 开始画画路径
    ctx.beginPath();
    // 文字颜色
    ctx.fillStyle = "red";
    // 文字样式
    ctx.font = "22px  Arial,sans-serif";
    // 文字对齐方式
    // 默认为alphabetic, top, hanging, middle, ideographic, bottom
    ctx.textBaseline = "hanging";
    ctx.fillText("hello world hello world", 250, 10, 1300);
    let TextMetrics = ctx.measureText("hello world");
    console.log(TextMetrics)
   //  新增 
    ctx.wrapText("hello world hello world", 10, 10, 120, 23);
    // 结束这段路径
    ctx.closePath();
}

效果:

image.png

3.2 绘制图片 ctx.drawImage

// 画图片
function drawImage() {
    
    let img= new Image();
    img.src =
        "http://wechatapppro-1252524126.file.myqcloud.com/apppcXiaoeT6666/file/YXBwbW9kdWxlZGVmYXVsdC13ZXdvcmstdXNlci1hdmF0YXItdV8xMjM0NTY3ODkxMDExXzEyMzQ1Njc4OTEtZTNVYnJ1YWs.jpg";
    img.onload=function(){
        // 开始画画路径
        ctx.beginPath();
        ctx.drawImage(img, 0, 0, 100, 100);
        // 结束这段路径
        ctx.closePath();
    }
}

效果:

image.png

我们必须要等待图片加载完成之后才能进行绘制的,所以要在onload里面进行canvas的绘制,同时这张图片的域名需要配置可跨域的。

3.3 绘制状态 ctx.save()和 ctx.restore();

function restoreState() {
   ctx.beginPath();
   ctx.font = "30px  Arial,sans-serif";
   ctx.fillStyle = "red";
   ctx.fillText("123", 30, 30);
   // 保存此刻的状态
   ctx.save();
   ctx.closePath();
   //
   ctx.beginPath();
   ctx.fillStyle = "black";
   ctx.fillText("456", 65, 65);
   // 恢复之前保存的状态
   ctx.restore();
   ctx.fillText("789", 100, 100);
   ctx.closePath();
}

效果:

image.png

ctx.save()保存的状态时绘制颜色为红色,当我们调用ctx.resetore()时,此刻的绘制的颜色就能恢复到之前的红色,所以123 789是红色 而456为黑色。

3.4 裁剪 ctx.clip()

function clipImage(){
   ctx.beginPath();
   // 画一个裁剪范围
   ctx.arc(50, 50, 50, 0, Math.PI * 2, true);
   // 进行裁剪 只有裁剪范围内的地方才能展示
   ctx.clip();
   drawImage();
   // 测试裁剪范围
   // setTimeout(() => {
   //     ctx.beginPath();
   //     ctx.font = "30px  Arial,sans-serif";
   //     ctx.fillStyle = "red";
   //     ctx.fillText("1234567", 50, 50);
   // }, 1000);
   
}

效果:

image.png

很明显,正方形的图片已经被裁剪成圆形了,此时我在测试一下圆形以外的区域,看看能不能绘制出来。

// 裁剪图片
function clipImage(){
    ctx.beginPath();
    // 画一个裁剪范围
    ctx.arc(50, 50, 50, 0, Math.PI * 2, true);
    // 进行裁剪 只有裁剪范围内的地方才能展示
    ctx.clip();
    drawImage();
    // 新增 测试裁剪范围
    setTimeout(() => {
        ctx.beginPath();
        ctx.font = "30px  Arial,sans-serif";
        ctx.fillStyle = "red";
        ctx.fillText("1234567", 50, 50);
    }, 1000);
    
}

效果:

image.png

很明显,裁剪区域只能展示123,4567在裁剪范围之外,就不能绘制上去。

4.封装组件思路及部分问题梳理

4.1 分析海报的元素

首先,我们先观察一张海报,发现里面的元素其实很少,只有背景图,头像,昵称,标题,二维码等,且要表达的信息也只是位置和大小而已。

通过技术角度而将,我们很容易将这些元素封装成2种类型:图片和文字。
但是我们都知道,绘制海报第一个动作肯定先要绘制海报的背景图,为了技术更好的实现,我们将背景图也封装成一种类型。所以一共有三种类型:背景图,图片及文字。

基于图片类型,我们可以这样设置参数:

'resource-img':{
    type:'img', // 类型
    src:'https://wechatapppro-1252524126.file.myqcloud.com/123.jpeg, // 地址
    x:10, // x轴方向
    y:100, // y轴方向
    width:120, // 图片长
    height:120 // 图片宽
    radius:60 // 裁剪半径
},

文字类型:

'user-name':{
    size:10, // 文字大小
    color:'red', // 文字类型
    family:'sans-serif', // 文字样式
    x:10, // x轴方向
    y:10 // y轴方向
}

4.2 图片加载问题

通过上面绘制图片我们得知,必须要在img.onload里面进行绘制,但是海报里面有多个图片需要进行绘制,所以我们可以先把所有图片都加载成功后,再来绘制,使用promise.all。

4.3 绘制顺序

canvas的绘制其实和js一样,都是单线程,一次只能做一件事,所以控制海报绘制顺序就非常重要了。首先,就是要将图片全部加载完成,之后再根据每个元素的顺序依次画出来。作为背景图的元素优先级肯定是最高的,肯定是第一个画出来的。而文字是要呈现在图片之上的,所以优先级是最低的。剩下的就是头像二维码资源图片等了,这些顺序是没有讲究的。我们可以根据用户传入的顺序来绘制了。大概流程如下:

image.png

5.组件讲解

5.1 调用

<script>
    let img=document.querySelector('.img')
    let obj={
            "background-img":{
                type:'background-img',
                src:'https://wechatapppro-1252524126.file.myqcloud.com/appAKLWLitn7978/image/a95789e8626cd3d428ecb85c823d525c.png',
                x:0,
                y:0,
                width:250,
                height:450,
            },
            'user-avatar':{
                type:'img',
                src:'https://wechatavator-1252524126.file.myqcloud.com/appAKLWLitn7978/image/compress/u_5f6b0990cac40_vSh2X7BAc2.png',
                x:88,
                y:28,
                width:68,
                height:68,
                radius:34,
            },
            'resource-img':{
                type:'img',
                src:'https://wechatapppro-1252524126.file.myqcloud.com/appAKLWLitn7978/image/b_u_5b2225aa46488_oGKN7IvA/ktb3nze709jx.jpeg?imageView2/2/w/640/h/480/q/100/format/webp',
                x:10,
                y:100,
                width:230,
                height:120
            },
            'qr-code':{
                type:'img',
                src:'data:image/png;base64,iVBOR...', // 省略
                x:100,
                y:300,
                width:44,
                height:44,
            },
            'user-name':{
                type:'font',
                x:10,
                y:20,
                value:'好好学习',
                size:20
            }
        }
     // 这段代码我们下面重点分析一下
    let ddd=new objToPoster(obj,250,450,10).convertToImg().then(res=>{
        console.log(res) // res就是生成海报的base64图片
        img.src=res
    });
</script>

首先我们知道一个海报内有许多元素,那么我们怎么把他们组合起来呢,可以通过数组或者对象的方式。在这里的话我选择用对象的方式,因为有属性名可以更清晰的标记出每个元素,所以第一个参数就是obj对象。

第二个和第三个参数其实就是我们在编辑海报时的那个盒子的长和宽。因为我们所有元素的x和y都是基于盒子来定位的。

第三个参数就是决定我们生成海报的质量的,首先我们是在canvas里面进行绘制的。当canvas元素越大,绘制出来的质量就越高,这个参数就是控制canvas大小,从而控制生成图片的质量。

5.2 看看new objToPoster发生了什么

constructor(posterObj, imgWidth = 300, imgHeight = 700, proportion = 1) {
        this.ctx = null;
        this.canvas = null;
        // 生成的海报
        this.img = null;
        this.proportion = proportion;
        // 绘制的所有图片 文字默认再图片上面 == 所以先画图片再画文字的
        this.posterImages = [];
        // 绘制所有的文字
        this.posterFonts = [];
        // 初始化canvas元素
        this.initCanvas(imgWidth * proportion, imgHeight * proportion);
        // 初始化传入的对象    为什么要做这一步:绘图的先后顺序
        this.initPosterObj(posterObj);
    }

在constructor里面主要做了两件事,一是通过initCanvas方法初始化canvas元素 .第二就是初始化我们传入的元素对象,分别存进posterImages和posterFonts数组里面。
initCanvas函数:

initCanvas(width, height) {
        let canvas = document.createElement("canvas");
        this.canvas = canvas;
        canvas.style["position"] = "absolute";
        canvas.style["z-index"] = "-1";
        canvas.style["top"] = 0;
        canvas.style["left"] = 0;
        canvas.style["display"] = "none";
        canvas.width = width;
        canvas.height = height;
        document.body.appendChild(canvas);
        this.ctx = canvas.getContext("2d");
        console.log("init-canvas is ok");
    }

initPosterObj函数:

// 将对象转换成一个数组按照绘画顺序的
    initPosterObj(posterObj) {
        let objNames = Object.keys(posterObj);
        for (let i = 0; i < objNames.length; i++) {
            // 背景图片
            if (posterObj[objNames[i]].type === TYPES[0]) {
                this.posterImages.unshift(posterObj[objNames[i]]);
            }
            // 普通图片
            if (posterObj[objNames[i]].type === TYPES[1]) {
                this.posterImages.push(posterObj[objNames[i]]);
            }
            // 文字
            if (posterObj[objNames[i]].type === TYPES[2]) {
                this.posterFonts.push(posterObj[objNames[i]]);
            }
        }
    }

把这些元素存放在数组里到时候循环绘制出来就好了,这里就有一个很巧妙的地方,当时ackground-img类型,用unshift进行插入数组,所以背景图永远是第一个绘制出来的。

5.3进入convertToImg方法:

convertToImg() {
    return new Promise(async (res, rej) => {
        try {
            await this.initImages(this.posterImages); //加载出所有图片
            this.draw(); // 逐一绘制
            this.img = this.canvas.toDataURL("image/jpeg"); // 生成base64图片
            this.clearCanvas(); // 清除canvas dom对象
            res(this.img); // 返回图片
        } catch (e) {
            console.error(e);
        }
    });
}

解析:这个函数做了三件事情,首先通过await初始化出所有图片再进行绘制,绘制完成后生成图片返回出去。

5.3 initImages 初始化所有图片:

// 初始化所有的图片资源
    initImages(arr) {
        let imgLoadPromise = [];
        for (let j = 0; j < arr.length; j++) {
            let p = new Promise((resolve, reject) => {
                let img = new Image();
                img.crossOrigin = "anonymous";
                img.src = arr[j].src;
                img.onload = function () {
                    console.log("img-onload");
                    arr[j].imgOnload = img;
                    resolve(true);
                };
            });
            imgLoadPromise.push(p);
        }
        return Promise.all(imgLoadPromise);
  }

这个时候每个元素对象的imgOnload就存放着img对象,于是我们遍历posterImages和posterFonts数组就能绘制了。

5.4 drawImage绘制图片的方法:

function drawImage(ctx, obj, proportion) {
    ctx.beginPath();
    // 恢复裁剪前的状态
    ctx.restore();
    ctx.save();
    // 裁剪
    if (obj.radius) {
        console.log(obj);
        // 正方形
        if (obj.width === obj.height) {
            ctx.arc(
                (obj.width * proportion) / 2 + obj.x * proportion,
                (obj.width * proportion) / 2 + obj.y * proportion,
                obj.border * proportion,
                0,
                Math.PI * 2,
                true
            );
            ctx.clip();
            ctx.drawImage(
                obj.imgOnload,
                obj.x * proportion,
                obj.y * proportion,
                obj.width * proportion,
                obj.height * proportion
            );

            ctx.closePath();
        }
        // 矩形
        else {
        }
    } else {
        ctx.drawImage(
            obj.imgOnload,
            obj.x * proportion,
            obj.y * proportion,
            obj.width * proportion,
            obj.height * proportion
        );
        ctx.closePath();
    }
}

我们通过之前的例子知道,裁剪之后区域外的地方就不能绘制了,所以每次画图片时我们就调用ctx.restore()和 ctx.save(),这样就能避免裁剪之后无法绘制的问题了。

5.5 class objToPoster 完整代码:

// obj img font shape
import { drawImage, drawFont } from "./utils/drawImage.js";
let TYPES = ["background-img", "img", "font"];
class objToPoster {
   // posterObj-元素对象 imgWidth-图片宽(单位默认px) imgHeight-图片长(单位默认px) proportion(图片精度-越大精度越高)
   constructor(posterObj, imgWidth = 300, imgHeight = 700, proportion = 1) {
       this.ctx = null;
       this.canvas = null;
       // 生成的海报
       this.img = null;
       this.proportion = proportion;
       // 绘制的所有图片 文字默认再图片上面 == 所以先画图片再画文字的
       this.posterImages = [];
       // 绘制所有的文字
       this.posterFonts = [];
       // 初始化canvas元素
       this.initCanvas(imgWidth * proportion, imgHeight * proportion);
       // 初始化传入的对象    为什么要做这一步:绘图的先后顺序
       this.initPosterObj(posterObj);
   }
   initCanvas(width, height) {
       let canvas = document.createElement("canvas");
       this.canvas = canvas;
       canvas.style["position"] = "absolute";
       canvas.style["z-index"] = "-1";
       canvas.style["top"] = 0;
       canvas.style["left"] = 0;
       canvas.style["display"] = "none";
       canvas.width = width;
       canvas.height = height;
       document.body.appendChild(canvas);
       this.ctx = canvas.getContext("2d");
       console.log("init-canvas is ok");
   }
   // 将对象转换成一个数组按照绘画顺序的
   initPosterObj(posterObj) {
       verfiyObjEmpty(posterObj);
       let objNames = Object.keys(posterObj);
       for (let i = 0; i < objNames.length; i++) {
           // 背景图片
           if (posterObj[objNames[i]].type === TYPES[0]) {
               this.posterImages.unshift(posterObj[objNames[i]]);
           }
           // 普通图片
           if (posterObj[objNames[i]].type === TYPES[1]) {
               this.posterImages.push(posterObj[objNames[i]]);
           }
           // 文字
           if (posterObj[objNames[i]].type === TYPES[2]) {
               this.posterFonts.push(posterObj[objNames[i]]);
           }
       }
   }
   // 初始化所有的图片资源
   initImages(arr) {
       let imgLoadPromise = [];
       for (let j = 0; j < arr.length; j++) {
           let p = new Promise((resolve, reject) => {
               let img = new Image();
               img.crossOrigin = "anonymous";
               img.src = arr[j].src;
               img.onload = function () {
                   console.log("img-onload");
                   arr[j].imgOnload = img;
                   resolve(true);
               };
           });
           imgLoadPromise.push(p);
       }
       return Promise.all(imgLoadPromise);
   }
   // 开始画画
   draw(ctx, arr) {
       this.drawImgs();
       this.drawFonts();
   }
   drawImgs() {
       for (let i = 0; i < this.posterImages.length; i++) {
           drawImage(this.ctx, this.posterImages[i], this.proportion);
       }
   }
   drawFonts() {
       for (let i = 0; i < this.posterFonts.length; i++) {
           drawFont(this.ctx, this.posterFonts[i], this.proportion);
       }
   }
   convertToImg() {
       return new Promise(async (res, rej) => {
           try {
               await this.initImages(this.posterImages);
               this.draw();
               this.img = this.canvas.toDataURL("image/jpeg");
               this.clearCanvas();
               res(this.img);
           } catch (e) {
               console.error(e);
           }
       });
   }
   clearCanvas() {
       this.canvas.remove();
   }
}

export default objToPoster;
window.objToPoster = objToPoster

5.6绘制的两个方法:

export function drawImage(ctx, obj, proportion) {
   ctx.beginPath();
   // 恢复裁剪前的状态
   ctx.restore();
   ctx.save();
   // 裁剪
   if (obj.radius) {
       console.log(obj);
       // 正方形
       if (obj.width === obj.height) {
           ctx.arc(
               (obj.width * proportion) / 2 + obj.x * proportion,
               (obj.width * proportion) / 2 + obj.y * proportion,
               obj.radius * proportion,
               0,
               Math.PI * 2,
               true
           );
           ctx.clip();
           ctx.drawImage(
               obj.imgOnload,
               obj.x * proportion,
               obj.y * proportion,
               obj.width * proportion,
               obj.height * proportion
           );

           ctx.closePath();
       }
       // 矩形
       else {
       }
   } else {
       ctx.drawImage(
           obj.imgOnload,
           obj.x * proportion,
           obj.y * proportion,
           obj.width * proportion,
           obj.height * proportion
       );
       ctx.closePath();
   }
}

export function drawFont(ctx, obj, proportion) {
   // 大小
   let size = obj.size || 10;
   size = size * proportion + "px";
   // 颜色
   let color = obj.color || "black";
   // 样式
   let family = obj.family || "Arial,sans-serif";
   // 文字对齐方式
   let textBaseline = obj.textBaseline || "hanging";

   ctx.beginPath();
   ctx.fillStyle = color;
   ctx.font = `${size} ${family}`;
   ctx.textBaseline = textBaseline;
   ctx.fillText(obj.value, obj.x * proportion, obj.y * proportion, 133 * proportion);
   ctx.closePath();
}
export function drawBlobImg(ctx, obj, proportion, Blob) {
   let img = new Image();
   img.src = Blob;
   obj.imgOnload = img;
   drawImage(ctx, obj, proportion);
}

6.后续迭代功能:

  • 支持标题文字换行
  • 支持文字背景(类似标签那种形式)

7 结尾

大家在使用的时候有任何问题都可以反馈给我哈,有那些不足需要改进的地方都可以指出来纠正我,希望这个组件能帮助到大家~