使用render函数在canvas中布局生成海报图

1,827 阅读6分钟

demo mobile

demo pc

在codepen中尝试

项目地址easy-canvas

vue组件版本vue-easy-canvas

easyCanvas实现思路解析

背景

一个常见的需求,在开发微信小程序时,前端需要生成海报图分享,目前常见解决方案如下:

  1. 使用htmlCanvas库,利用dom来生成图片
  2. 前端使用ctx的api一个一个的画出来,或者借助一些绘图工具
  3. 利用puppeteer后端服务,打开相应界面截图

痛点:

  1. 这个库本身并不能在小程序使用,因为涉及到dom,在web端也有各种兼容性问题比如某个属性不支持
  2. 这个方案,额。。。可能这就是程序员头发少的原因吧。费尽千辛万苦画好,万一视觉调整一下。。这个方案开发费时费力,不好维护。虽然web端有react-canvas,小程序也有一些工具,但目前都只是封装了绘制矩形、文字等方法,对于布局来说还是需要手动计算宽高以及位置,没有完全解决痛点。
  3. 这种方案对前端来说是最完美的,也推荐大家有条件用这个方案,前端写好页面放到服务上,然后再挂一个服务访问这个页面来截图,因为开发和截图的都是chromium,基本不存在兼容性问题。但是这种方案会非常耗费服务器资源,每次截图都要打开一个新的浏览器tab,并且截图耗时比较长,对于一些公司来说可能无法接受。

简介

easy-canvas实现了在canvas中创建文档流,api极易上手基本没有学习成本,可以很轻松的支持组件化开发,并且没有第三方依赖,只要支持标准的canvas就可以使用,在实现基本功能的基础上添加了事件、scroll-view等支持。基础版支持小程序、web。

如果使用过render函数的肯定很熟悉使用方式了,相关属性在项目里以及示例里都有介绍,本篇文章就不过多介绍,基本使用如下:

npm install easy-canvas-layout --save
    import easyCanvas from 'easy-canvas-layout'

    // 首先绑定图层
    const layer = easyCanvas.createLayer(ctx, {
      dpr: 2,
      width: 300,
      height: 600,
      canvas   // 小程序环境必传
    })

    // 创建node 
    // c(tag,options,children)
    const node = easyCanvas.createElement((c) => {
      return c('view', { 
        styles: { backgroundColor:'#000' }, // 样式
        attrs:{},                           // 属性 比如src
        on:{}                               // 事件 如click load 
      }, 
      [
        c('text',{color:'#fff'},'Hello World')
      ])
    })

    // mount
    node.mount(layer)

vue中使用

另外在基础版本上,封装了相应的vue组件,相比render函数,要简洁易懂很多,基本使用如下:

npm install vue-easy-canvas --save
import easyCanvas from 'vue-easy-canvas'
Vue.use(easyCanvas)
<ec-canvas :width="300" :height="600">
    <ec-scroll-view :styles="{height:600}">

    <ec-view :styles="styles.imageWrapper">
        <ec-image 
            src="https://tse1-mm.cn.bing.net/th/id/OIP.Dkj8fnK1SsPHIBmAN9XnUAHaNK?pid=Api&rs=1" 
            :styles="styles.image" 
            mode="aspectFill"></ec-image>
        <ec-view :styles="styles.homeTitleWrapper">
        <ec-text>easyCanvas</ec-text>
        </ec-view>
    </ec-view>

    <ec-view :styles="styles.itemWrapper" 
        v-for="(item,index) in examples" 
        :key="index"
        :on="{
        click(e){
            window.location.href = host + item.url
        }
        }">
        <ec-view :styles="styles.title">
        <ec-text>{{item.title}}</ec-text>
        </ec-view>
        <ec-view :styles="styles.desc">
        <ec-text>{{item.desc}}</ec-text>
        </ec-view>
    </ec-view>

    </ec-scroll-view>
</ec-canvas>

支持元素

  • view 基本元素,类似div
  • text 文本 支持自动换行以及超过省略等功能,目前text实现为inline-block
  • image 图片 src mode支持aspectFit以及aspectFill,其他css特性同web 支持load事件监听图片加载并且绘制完成
  • scroll-view 滚动容器,需要在样式里设置direction 支持x、y、xy,并且设置具体尺寸 设置renderOnDemand只绘制可见部分

支持属性

属性使用像素的地方统一使用数字

  • display block | inline-block | flex, text默认是inline-block的
  • width auto 100% Number 这里盒模型使用border-box,不可修改
  • height
  • flex flex不支持auto,固定宽度直接使用width
  • minWidth maxWidth minHeight maxHeight 如果设置了具体宽度高度不生效
  • margin marginLeft,marginRight,marginTop,marginBottom margin支持数组缩写例如 [10,20] [10,20,10,20]
  • paddingLeft,paddingRight,paddingTop,paddingBottom 同上
  • backgroundColor
  • borderRadius
  • borderWidth borderTopWidth ... 细边框直接设置0.5
  • borderColor
  • lineHeight 字体相关的只在text内有效
  • color
  • fontSize
  • textAlign left right center
  • textIndent Number
  • verticalAlign top middle bottom
  • justifyContent flex-start center flex-end flex布局 水平方向对其
  • alignItems flex-start center flex-end flex布局 垂直方向对其
  • maxLine 最大行数,超出自动省略号,只支持在text中使用
  • whiteSpace normal nowrap 控制换行,不能控制字体,只能控制inline-block
  • overflow hidden 如果添加了圆角,会自动加上 hidden
  • flexDirection
  • borderStyle dash Array 详见ctx.setLineDash()
  • shadowBlur 设置了阴影会自动加上 overflow:hidden;
  • shadowColor
  • shadowOffsetX
  • shadowOffsetY
  • position static absolute
  • opacity Number

例如这个组件库里的button组件

正常来说我们写一个按钮

.button{
    display:inline-block;
    background:green;
    color:#fff;
    font-size:14px;
    padding:4px 12px;
    text-align:center;
    border-radius:4px;
}

在easyCanvas中的写法

function Button(c){
    return c('view',{
        styles:{
            display:'inline-block',
            backgroundColor:'green',
            color:'#fff',
            fontSize:14,
            padding:[4,12],
            textAlign:'center',
            borderRadius:4
        }
    },[
        c('text',{},'按钮')
    ])
}

是不是觉得很熟悉很简单,让我们来写一个可以接受参数的按钮

function Button(c, { attrs, styles, on }, content) {
  const size = attrs.size || 'medium'
  const nums = SIZE[size]
  let _styles = Object.assign({
    backgroundColor: THEME[attrs.type.toUpperCase() || 'info'],
    display: 'inline-block',
    borderRadius: 2,
    color: '#fff',
    lineHeight: nums.lineHeight,
    padding: nums.padding,
    fontSize: nums.fontSize
  }, styles || {})

  if (attrs.plain) {
    _styles.color = THEME[attrs.type.toUpperCase()]
    _styles.borderWidth = 0.5
    _styles.borderColor = THEME[attrs.type.toUpperCase()]
    _styles.backgroundColor = PLAIN_THEME[attrs.type.toUpperCase() || 'info']
  }

  if (attrs.round) {
    _styles.borderRadius = nums.borderRadius
  }

  return c('view', {
    attrs: Object.assign({

    }, attrs || {}),
    styles: _styles,
    on: on || {},
  }, typeof content === 'string' ? [c('text', {}, content)] : content)
}

这样在使用的地方可以传入参数,像这样,也就是大家在demo里看到的

Button(c, {
    attrs: { type: 'primary', plain: true },
}, '主要按钮'),
Button(c, {
    attrs: { type: 'success', plain: true },
}, '成功按钮'),
Button(c, {
    attrs: { type: 'info', plain: true },
}, '信息按钮'),
Button(c, {
    attrs: { type: 'warning', plain: true },
}, '警告按钮'),
Button(c, {
    attrs: { type: 'error', plain: true },
}, '危险按钮'),

并且,easyCanvas支持注册全局组件,方便调用,其他参数请看项目使用文档

// 注册全局组件
easyCanvas.component('button',Button)

// 使用全局组件
function Page(c){
    return c('button',{
        attrs: { type: 'warning', plain: true },
    }, '警告按钮')
}

另外easyCanvas内置了事件管理器,可以支持类似web中的事件,从父级向子级执行捕获,子级再向父级冒泡。

首先需要让canvas元素接管事件


// canvas元素监听鼠标事件
canvas.ontouchstart = ontouchstart
canvas.ontouchmove = ontouchmove
canvas.ontouchend = ontouchend
canvas.onmousedown = ontouchstart
canvas.onmousemove = ontouchmove
canvas.onmouseup = ontouchend
canvas.onmousewheel = onmousewheel


// 将事件交给事件管理器接管 需要注意的是,这里的坐标是相对于canvas元素的坐标,而不是屏幕
function ontouchstart(e) {
  e.preventDefault()
  layer.eventManager.touchstart(e.pageX || e.touches[0].pageX || 0, e.pageY || e.touches[0].pageY || 0)
}
function ontouchmove(e) {
  e.preventDefault()
  layer.eventManager.touchmove(e.pageX || e.touches[0].pageX || 0, e.pageY || e.touches[0].pageY || 0)
}
function ontouchend(e) {
  e.preventDefault()
  layer.eventManager.touchend(
    e.pageX || e.changedTouches[0].pageX || 0,
    e.pageY || e.changedTouches[0].pageY || 0
  )
}
function onClick(e) {
  e.preventDefault()
  layer.eventManager.click(e.pageX, e.pageY)
}
function onmousewheel(e){
  e.preventDefault()
  layer.eventManager.mousewheel(e.pageX,e.pageY,-e.deltaX,-e.deltaY)
}

接管到事件后,我们就可以在元素内监听事件了

c('button',{
    id:'测试按钮',
    on:{
        click(e){
            // 阻止冒泡到父级
            e.stopPropagation()
            alert(e.currentTarget.id) // alert 测试按钮
        }
    }
},'点我点我')

目前支持的鼠标事件有: click、touchstart、touchmove、touchend、mousewheel。

图片支持 load、error事件

另外,支持在layer中监听所有图片请求完成,比如我们需要在图片加载完成,reflow布局并且重新渲染后立即生成图片:

easyCanvas.createLayer(ctx, {
    dpr,
    width,
    height,
    lifecycle: {
        onEffectSuccess(res) {
            // 所有图片加载成功
        },
        onEffectFail(res) {
            // 有图片加载失败
        },
        onEffectComplete(){
            // 只要加载结束就会调用
            // 生成图片...
        }
    }
})

easyCanvas还支持在初始渲染后对元素进行操作

// 获取元素 key为attrs中定义的
el.getElementBy(key,value)

// 增加元素
el.appendChild(element)
el.prependChild(element)
el.append(element) // 加在当前元素后
el.prepend(element)

// 删除元素
el.removeChild(element)
el.remove()

// 修改样式 内部会根据样式判断是否需要reflow还是仅仅repaint就足够
el.setStyles(styles)

demo中点击左侧右侧定位代码

c('view', {
    on: {
        click(e) {
            const target = layer.getElementBy('id', item.en)[0]
            if (!target || e.currentTarget === lastSelect) return
            const scrollView = layer.getElementBy('id', 'main')[1]
            scrollView.scrollTo({ y: target.y })
            e.currentTarget.setStyles({ backgroundColor: '#f1f1f1', color: '#333' })
            if (lastSelect) lastSelect.setStyles({ backgroundColor: '' })
            lastSelect = e.currentTarget
        }
    },
    styles: {
        padding: 10,
        color: '#666',
        fontSize: 16
    }
}, [c('text', {}, item.en + ' ' + item.zh)]))

Ending

本篇文章主要介绍项目背景以及基本使用,也是为了给自己打个广告吧:) 后面会写实现原理以及一些坑,欢迎各位交流,感谢阅读!