更优雅地基于 canvas 在前端画海报

2019-06-20  本文已影响0人  Web前端学习营

我们的业务涉及电商、教育行业,出于营销以及功能需要,会有很多卡片展示(长按保存)的需求,或者分享长图的需求。以及我们有面向商家的PC端,商家端又能编辑、实时预览卡片的样式。

同样的卡片内容我们需要在两端以两种框架(vue react)分别维护。

考虑到依赖太大(ungzipped 160kb+)、稳定性、可维护性、可拓展性等因素,我们没有采用html2canvas 这个第三方转换库。而是采用抽离一系列 canvas-utils 的方式进行 canvas 画图。

因为 canvas 原生的绘图 api 都是以绝对定位的像素点,再辅以尺寸信息进行绘制。

哦对了,我这里有一套Web前端从入门到精通的全套资料,价值4980元,现免费送给大家,但是要加我的Web前端学习Q群:983840410才可以免费领取,因为我在里面会私发给大家,欢迎你前来领取,

比如:

ctx.rect(x, y, width, height);// 画矩形ctx.drawImage(img, destx, desty, destWidth, destHeight);// 画图片复制代码

所以我们定义的 canvas-utils 入参也必须包含这些位置、尺寸信息。

/** * 绘制圆角矩形 * *@param{*} ctx 画布 *@param{Number} radius 半径 *@param{Number} x 左上角 *@param{Number} y 左上角 *@param{Number} width 宽度 *@param{Number} height 高度 *@param{String} color 颜色 *@param{String} mode 填充模式 *@param{Function} fn 回调函数 */exportfunctiondrawRoundedRectangle(){}/** * 绘制图片(方、圆角、圆) * *@param{*} ctx 画布 *@param{*} img load好的img对象 *@param{Number} x 左上角定点 x 轴坐标 *@param{Number} y 左上角定点 y 轴坐标 *@param{Number} w 宽 *@param{Number} h 高 *@param{Number} radius 圆角半径 */exportfunctiondrawImage(){}/** * 绘制多行片段 * *@param{*} ctx        画布 *@param{*} content    内容 *@param{*} x          绘制左下角原点 x 坐标 *@param{*} y          绘制左下角原点 y 坐标 *@param{*} maxWidth    最大宽度 *@param{*} fontSize    字体大小 *@param{*} fontFamily  字体家族 *@param{*} color      字体颜色 *@param{*} textAlign  字体排布 *@param{*} lineHeight  设置行高 *@param{*} maxLine 最大行数 */exportfunctiondrawParagraph(){}/** * 创建一个画布 * *@param{*} width 宽 *@param{*} height 高 *@return{*} canvasAndCtx 画布相关信息 */exportfunctioninitCanvasContext(width, height){return[canvas, ctx];}复制代码

这四个核心方法涵盖了几乎所有海报画图类需求,图片、段落文字、背景容器、画布创建。并且已经把 canvas 相关的 api 收拢了,开发者无需关注恼人的 canvas api,只需要在设计稿上量好尺寸以及位置,就能将对应的元素绝对定位到画布上。

大概业务中的实现(伪代码):

Promise.all([      canvasUtils.loadUrlImage(mainCoverImg),      canvasUtils.loadBase64Image(cardInfo.qrCode),    ])      .then(([cover, qrCode, shopnameIcon, titleIcon]) =>{const[canvas, ctx] = canvasUtils.initCanvasContext(325,564);// 绘制底框canvasUtils.drawRoundedRectangle(ctx, ...sizeMapValue.base);// 绘制封面图canvasUtils.drawImage(ctx, ...sizeMapValue.cover);// 绘制标题canvasUtils.drawParagraph(ctx, ...sizeMapValue.title);// 绘制题数canvasUtils.drawImage(ctx, ...sizeMapValue.titleIcon);// ...returncanvas.toDataURL('image/png');      })复制代码

因为图片的入参是个 img 对象,需要先 load 图片链接,这里就有个异步的过程,所以设计之初就规定先 Promise.all 所有图片拿到 img 再进行画图操作。

采用这种方式画海报能实现基本需求,但也有一定局限性。

比如:

draw***

那么,如何改善这些问题,在前端更优雅地画海报呢?

如何定义 schema

不使用 html2canvas 还有个原因是该库基于 htmlElement,公司现状下 jsx 和 vue 模板语法不兼容,无法复用代码片段,还有个更重要的原因是小程序没法用,那么采用什么类型的 schema 去收敛 api,以及最大化在不同平台兼容?

这里采用了 json 的形式去配置化参数生成图片。

基础 schema:

{type:'',css: {},custom: null,// 自定义回调}复制代码

之前的核心 drawImage drawParagraph drawRoundedRectangle 方法目的就是绘制 图片、文字、容器,对于这三个类型分别有不同的额外配置,需要不同的更具语义化的 schema。

图片:

{type:'image',css: {},url:'',mode:'fill | contain',custom: null,};复制代码

文字:

{type:'text',css: {},text:'',custom: null,};复制代码

容器:

{type:'div',css: {},mode:'div | line',children: [],custom: null,}复制代码

type 为 div 类型的 schema 相当于是个容器,具有 children 字段,与 html 中的 div 概念也类似,div 可以嵌套承载更多的 div、text、image,共同构建一颗完整的节点树。

用 json schema 去描述一张卡片的伪代码:

{type:'div',css:{},    children: [      {type:'div',css:{},        children: [          {type:'text',css:{},            text:'文字一'},          {type:'image',css:{},            url:'cdn.image.com/test1',mode:'contain'}        ]      },      {type:'text',css:{},        text:'好多文字 好多文字 好多文字'},    ]  }复制代码

使用 json schema 去描述视图,已经解决了之前 canvas-utils 方案的几个局限性。

画图前需要先 load 图片地址,涉及异步,这是比较冗余的操作

传入给 image 的是 url 地址或者是 base64字符串,load 图片的操作会在内部实现,外部无需关心。

一直调 draw*** 方法,传相似的参数,这也是冗余操作,采用 json 配置参数会不会更好?

所有的方法调用被 type 替代,原先必传的 尺寸、位置信息

canvasUtils.drawParagraph(ctx, cardInfo.title, 14, 380, 285, 14, undefined, undefined, undefined, 20, 2);

被 css 字段代替:

{type:'text',css: {width:'285px',height:'14px',x:'14px',y:'380px',    ...  },text: cardInfo.title,custom: null,};复制代码

绝对定位的布局系统的缺陷

现在的 schema 定义在实现的功能上跟之前的 canvas-utils 本质上没什么区别,只是简化了使用姿势,所有的节点都是按照绝对定位,我们需要手动传入所有节点的尺寸信息(width height)以及位置信息(x y),现在市面是几乎所有类似 jsonToCanvas 的类库都是这样设计,但这样并不能解决我们提到的几个局限性。

如果生成图片的高度需要自适应多个子元素的高度?这需要写很多额外逻辑。

如果两种不同样式的文字横向居中显示?又要疯狂的计算再传入 x y 定位,总之涉及到自适应样式的需求我们就得在逻辑中频繁的计算。

比如说下图的样式,横向布局,有不同的文字大小以及样式,而且文字的个数还是自定义的:

这三个节点我们都要实时计算 width height x y ,再传入 css 字段,工作量还是巨大的。

既然我们的 schema 在描述图片结构上(嵌套)的向 html 靠齐,那么我们 css 字段 的 schema 为什么不向真实的 css 靠齐?

借助 margin 块状流式布局,借助 inline-block 横向布局,将之前的绝对定位改成 css 默认的 相对定位,模拟 css 的能力。

更重要的是模拟实现 css属性 的强大继承能力,这样我们在定义某个节点的 css 属性时,就不用把各种属性再写一遍,直接依赖父节点css属性的继承。

暴露给用户使用的 schema 需要足够智能,把需求计算的需求在组件内部吃掉。

原本的定义:

{"type":"div","css": {"width":"200px","height":"200px","x":"0px","y":"0px",  },"children": [    {"type":"text","css": {"width":"动态计算","height":"动态计算","x":"动态计算","y":"动态计算","fontSize":"12px"},"text":"自定义文案:"},    {"type":"text","css": {"width":"动态计算","height":"动态计算","x":"动态计算","y":"动态计算","fontSize":"16px","color":"red"},"text":"我后面跟这张图片"},    {"type":"image","css": {"width":"15px","height":"15px",      },"url":"https://su.yzcdn.cn/public_files/2018/12/14/61d0dad50c5b2789a0232c120ae5f7fa.jpg","mode":"contain"}  ]}复制代码

更智能的定义:

{"type":"div","css": {"width":"200px","height":"200px",  },"children": [    {"type":"text","css": {"display":"inline-block","marginTop":"3px",      },"text":"自定义文案:"},    {"type":"text","css": {"display":"inline-block","fontSize":"16px","color":"red"},"text":"我后面跟这张图片"},    {"type":"image","css": {"width":"15px","height":"15px","display":"inline-block"},"url":"https://su.yzcdn.cn/public_files/2018/12/14/61d0dad50c5b2789a0232c120ae5f7fa.jpg","mode":"contain"}  ]}复制代码

我们可以看到优化后的版本并不需要指定文字的宽度高度,也不用指定图片的位置信息,就跟写原生 css html 一致。

优化 css schema 来处理动态尺寸的需求

既然要靠齐 css 的能力,那 css schema 的定义也就要参照css2.1 规范进行,我们定义的 css schema 是 css2.1 规范的子集。

那我们去寻找规范中有哪几个集合是适用我们的 case。

box model

www.w3.org/TR/CSS2/box…

涉及到盒模型相关的 css 属性

exportinterfaceIBoxModel{marginLeft: string;marginRight: string;marginTop: string;marginBottom: string;borderWidth: string;borderColor: string;borderStyle:'solid'|'dashed';borderRadius: string | undefined;boxShadow: string | undefined;customVerticalAlign:'down'|'top'|'center';customAlign:'left'|'right'|'center';}复制代码

visual formatting model

www.w3.org/TR/CSS2/vis…

可视格式化模型也是 css 规范中除了 盒模型(box model)外最为重要的模型,他描述了基于盒模型的元素是如何排列在可视化窗口中的,比如 position 来描述是绝对定位还是相对定位。display: block | inline-block 用来描述纵向排列还是横向排列。

摘取部分需要的属性:

exportinterfaceIVisFormatModel{width: string;height: string;maxWidth: string | undefined;maxHeight: string | undefined;minWidth: string;minHeight: string;position:'absolute'|'relative';top: string | undefined;left: string | undefined;right: string | undefined;bottom: string | undefined;display:'block'|'inline-block';}复制代码

Colors and Backgrounds

www.w3.org/TR/CSS2/col…

用来描述颜色和背景

exportinterfaceIColorAndBg{color: string;backgroundColor: string;}复制代码

Fonts

www.w3.org/TR/CSS2/fon…

用来描述单个文字的具体样式,大小、字体等。

exportinterfaceIFonts {  lineHeight:string|undefined;// line-height 应该属于 visual formatting model,但与传统的 css 不太一样,我们规定在无法在 div 中写文字fontStyle:string;  fontFamily:string;  fontWeight:number;  fontSize:string;}复制代码

Text

www.w3.org/TR/CSS2/tex…

与 Fonts 不同,这个规范是为了描述文字之前的排列行为,比如对其方式,是否有中划线等。

exportinterfaceIText {  textAlign:'left'|'right'|'center';  lineClamp:number|undefined;// 不在 css2.1 规范内,方便描述几行文字拦截展示 【...】textDecoration:'line-through'|undefined;}复制代码

画图库的实现过程,计算盒模型

不管我们的 css schema 定义的如何对用户友好,在组件内部最终调用 canvas api 的时候我们还是需要传入绝对定位的尺寸以及位置。

定义好了元素类型的 schema 以及 css 的 schema,需要实现的就是在组件内部根据节点的 css属性 计算各个节点的盒模型尺寸,再由最终的盒模型数据,绘制出最终的 canvas。

整体流程:

根据 css 计算得到盒模型数据,是画图库代码量最大的步骤。以下就是计算盒模型的计算流程。

constdefaultConfig = canvasWrap.setDefault(copyConfig);constinlineBlockConfig = canvasWrap.setInlineBlock(defaultConfig);constwidthConfig = canvasWrap.addWidth(inlineBlockConfig);constheightConfig = canvasWrap.addHeight(widthConfig);constoriginConfig = canvasWrap.addOrigin(heightConfig);复制代码

setDefault 设置默认值

因为 schema 允许部分字段不传,所以第一步递归遍历传入的数据源,将默认值赋值给入参。

setInlineBlock 将 inline-block 的元素修改结构

如图所示,setInlineBlock 方法会将连续排列的 inline-block 节点聚合,新建一个空白的 div 插入原先的位置,然后将这些 inline-block 节点作为 children 插入其中,这样做的目的在于方便后面的 width height 计算。

addWidth 计算所有节点的宽

遍历所有节点,如果发现是有 children 的 div,则继续递归遍历。

模拟原生 css 特性,如果当前节点设置了 width,则取当前宽,否则取父节点计算完的宽。

当然还有许多 css 属性会影响到 width 最终的计算,比如 minWidth maxWidth,又比如子节点元素是否都是 inline-block。

再比如当前的 type 为 text,而且又没有设置 width,这里就得调用 canvas 提供的 ctx.measureText(content).width; 去获取 width。

计算完的 width 会结合 margin,border 等 css 属性再次计算各种盒模型宽。

const sumWidth = calRealdemension(sumWidth, [css.minWidth, css.maxWidth]);const layerWidth = sumPixels(sumWidth, marginWidth);const contentWidth = minusPixels(sumWidth, addedBorderWidth);addBoxWidth(element, sumWidth);addLayerWidth(element, layerWidth);addContentWidth(element, contentWidth);复制代码

这里会将计算完的数据直接赋值给当前 config 对象,这样在递归到下一层 children 时就可以直接使用父节点 width 了。

addHeight 计算所有节点的高

与计算宽度大同小异,这里不再赘述。

addOrigin 计算所有节点的位置

既然已经计算得出所有节点的尺寸信息,同样递归遍历所有的节点,以父节点为基准就能计算得到所有子节点的位置信息。

绘制 canvas 图片

constimages = canvasWrap.getImages(originConfig);images.then(imgMap=>{    resolve(canvasWrap.drawCanvas(originConfig, imgMap));})复制代码

得到所有节点的位置、尺寸信息,再结合统一 load 的图片信息,最后就可以使用 canvas-utils 中的绘制方法,进行图片绘制了。

自定义插槽 custom

最后再提一下定义 schema 时预留的 custom 字段,可以传回调函数进去,暴露出来的参数为 ctx,用来调用 canvas 绘制 api,以及该节点的盒模型数据,这样用户就能知道当前节点的范围。

custom(canvas, ctx, config) {ctx.beginPath();ctx.moveTo(config.origin.x, config.origin.y);ctx.lineTo(50,40);ctx.stroke();},复制代码

canvas 绘图的注意点

生成图片模糊问题

当我们直接给 canvas 设定 width,height 时,比如

复制代码

这实际告诉浏览器的是以位图(bitmap)的形式生成一张 200x200 物理像素点的画布,我们可以直接看成是一张图片。

如果没有人为的用 css 指定这张画布的逻辑宽高,那么浏览器默认会设置成 200px x 200px。

我们可以直接想象成将一张 200x200 的位图,以 css 200x200 设置。这就相当于前端工程师熟知的高分辨率下 2 倍图优化问题。

解决方式也就类似解决 2 倍图问题,将 canvas 的宽高放大 n 倍(n 取决于 window.devicePixelRatio ),css 设置成原宽高。

functioninitCanvasContext(width:number, height:number): [HTMLCanvasElement,CanvasRenderingContext2D]{  canvas.width = width *window.devicePixelRatio;  canvas.height = height *window.devicePixelRatio;  canvas.style.width =`${width}px`;  canvas.style.height =`${height}px`;  ctx.setTransform(ratio,0,0, ratio,0,0);return[canvas, ctx];};复制代码

如何用 canvas 绘制文字段落

使用 ctx.fillText(content, x, y); 绘制段落时,y 的定位并不在文字的下方。

比如我们绘制两条 y 分别为 10 24 的直线,再绘制 y 为 24 的文字:

原因是 canvas 绘制文字有自己的基准规则

默认文字的基准线就是偏下,这里做过实验,在不同系统设备上各个基准都不太一样,包括 bottom ideographic ,唯独 middel 的样式在各个平台上表现是一致的。

所以这里有个取巧的方法,可以使文字是上下居中的。

ctx.textBaseline ='middle';//适配安卓 ios 下的文字居中问题ctx.save();ctx.translate(0, -(fontSize /2));//适配安卓 ios 下的文字居中问题ctx.fillText(content, x, y);ctx.restore();复制代码

先将文字基准线居中,再在绘制文字的时刻改变坐标系,画完后改变成原来的坐标系。

上一篇下一篇

猜你喜欢

热点阅读