canvas模块 version 1.0.1 思路速记

630 阅读16分钟

概要

项目UI改版,对于炫酷展示内容作出了一定的要求,所以自己尝试的编写了一次canvas的绘制模块的内容,在这里做个总结和回顾。当然本次绘制模块完全是使用canvas的一个实现,并没有涉及到svg或者css。只是我这个菜鸟的尝试,大神请忽视之。

实现

需求内容

canvas模块的需求主要是如下:

1.复杂图形的绘制:canvas为我们提供了很多的基础模型的内容,但是有的时候UI大大的设计稿简直是要把人往绝路上面逼(类目ING)。简单一点的例如,有弧度的矩形,弯曲的三角形等等,我们常常需要对这一类的内容进行脑洞大开式的开发。所以我们需要有对复杂会图形的绘制能力,同时也是希望可以对这些复杂的图形进行复用。

2.图形动画展示:动画的展示对于前段来说必不可少的,炫酷的前端不可能总是一成不变的静态内容吧。但是动画的主要难题在于,可能对于同一帧的屏幕变动之中,不同的组件的变化是不相同的,如何同调,如何组合这些内容的变化。

3.图形组件之间的数据交互:同一个展示内容,需要不同的绘制模块来相互合作,有的时候不同的组件之间需要有一定的数据交互,例如某位移动画,其他组件的移动位置与速度,需要当前选中的组件的速度与位置偏移来进行计算。

4.图形组件的事件:最后,也是最终要的一点,图形是由许多的组件内容来进行结合从而展示的,但是用户在操作的时候,不同的方式对于不同的组件来说可能需要有不一样的交互。并且此时也十分考验组建的交互。

基础设计

1.组件化绘制:

考虑到上面这些需求,我们将单个的操作点作为一个组建的话,例如绘制的长方形,圆形,甚至是线条。那其实复杂的内容,实际上都是由许许多多的组件进行组合而来的,所以我们可以考虑将图形设计成为组件的形式。并且复杂组件绘制的时候,可以将其拆分成为自定义绘制部分以及可复用组件的部分。父组件同时需要对子组件进行内容的管理,如此也方便了父子组件之间的信息传递。

组件的形式解剖绘制的话,单个组件的内容应该是怎么样的呢?应该如何的去进行内容的处理呢?还有很重要的一点就是,父组件绘制的时候,我们应该如何去绘制呢,因为canvas的内容展示实际上是图层的形式一层一层叠加的,当绘制的顺序不对的话,最后的呈现效果也将会完全不同。

2.动画配置

针对某一组件的动画,canvas之中使用的方式还是清除 - 重绘。之中我们可以通过使用imageData针对一些可以不变的内容进行一定的存储。来加速当前的绘制。问题在于什么时候对内容进行缓存,如何获取相应的缓存内容。

3.数据交互

组件之间的数据交互,可以通过数值传递的方式来进行的,prop的存在很好的帮助子组件的定制化,和可重用。那么应该如何进行数据的传递,父组件之中的参数,有些需要计算之后才能传递给子组件,这一步应该如何,或者说怎么去实现。

4.图形事件

事件是交互的基础,如一个绘制组件,我们不可能将所有的交互同时绑定在这个组件之上,针对于同一个组件的不同的子组件,可能会针对同一类型的交互产生不同效果,同时这些效果之间可能也有相关性。如何达到关联性,这点很重要。

实现内容

1.生命周期

些这个模块的时候实际上我是借用了一部分vue的思想。首先我们确定的是,组建之中拥有的特定属性。

  1. canvas:表示的是当前的绘制函数进行绘制的画板内容。
  2. state:当前组建所拥有的的属性内容。
  3. events:相关的事件信息和内容,记录为当前组件绑定的事件。
  4. animations:组件动画事件记录。
  5. here:判定某一坐标是否在当前的组件之中。
  6. clear:当前组件的清除方法,清除方法是我们的达到动效绘制展示的基础。

接下来需要确定的实际上是一个组件的运行流程,就是我们所说的生命周期。生命周期表明了一个组件整体的运行轨迹,确定好生命周期可以更好的明确组建当前的状态,以及组件能处理的事情,还有处理方式。例如当组件在初始化的环节的时候,我们可以确定当前组建的状态是initing状态,并且他需要处理的工作和反馈实际上就是加载好当前给定的配置内容。

首先我们可以确定环节性质的内容:

初始化环节:

  1. 初始化函数是必不可少的,初始化的时候主要处理的是开启当前组件的生命周期,然后处理一部分当前给定参数内容。因为参数的变动我们需要有捕捉,并针对当前的参数修改进行组件重绘,所以这我们需要借鉴一下vue的数据绑架原理。在初始化之后我们可以对当前的数据进行一步份自定义的操作了,所以我们可以为流程之中添加一个afterInit的钩子。

  2. 在初始化之后,考虑到需要有相关的视图,先可视,再有动效和事件交互。所有的一切都是以可视为前提,所以这里我们在初始化之后的一步确定为绘制。由于绘制的特殊性,其实际上是依赖于属性内容的计算得出相关的绘制结果的,所以这里并不好确立任何的钩子函数来改变内容,绘制前钩子,可以直接定义afterInit内容,绘制完成之后,在调用其他的钩子函数也实属没有什么必要,所以决定这里放弃钩子。

  3. 在绘制之后实际上我们应该对于其相关的动效进行添加了,因为动效也是属于视觉的一部分。动效的内容实际上是一份针对state内容修改的逻辑,并且需要依据传递的时间内容,来做一个定时运行,具体的实现我们将会在之后更完整的说明。

  4. 最后我们需要添加的是当前组件的交互内容,事件依据类型将会有专门的数组内容存储所有绑定到当前类型的数据,并且依据先进先运行的方式,来运行这些回调函数。

更新重绘环节:

  1. 当我们更新了state之中数据的时候,会触发更新机制,开始更新周期,首先是直接更改当前的state之中的值,之后会调用afterUpdate的钩子函数,用户可以在这里记录一些相关的变化信息,但是拒绝更改当前的state内容。

  2. 调用完成钩子函数之后,组件的状态将会变成可更新的状态,然后模块将会重画当前的模块内容。

销毁周期:

  1. 模块的内容可以用户手动的进行销毁,调用destroy函数之后,组件进入销毁周期。调用beforeDestory钩子函数内容。在这个钩子函数之中可以决定某些保留信息。从而对当前的信息内容进行一处理。
  2. 销毁过程将会解绑组件内容,删除相关的状态信息和用户自定义的内容函数。
  3. 组件生命周期结束。

生命周期流程图

2. 组件数据参数处理模块

由于需要针对动画还有事件内容做特殊的处理,同时为了拥有相对的向下文环境,所以我们这里采用的是将几个不同的模块内容拆分到不同的文件之中去相应实现。通过es6的模块来引入需要时使用的内容。这样我们将会有单独的环境,防止其之间的互不侵染。


2.1. 生命周期的初始化:

生命周期我们上面说明过了,但是在初始化一个组件的时候我们需要对其生命周期进行一些初始化操作,包括一私有数据以及相关的调用方法。可以看下面这一段代码内容:

Layer.prototype._startLifecycle = function () {
    const layer = this
    
    // 表示当前组件内容是否有相关的更新。
    // 当state之中的内容被改动的时候我们会改变这个值。
    // 并依据当前值内容的来确定是否重绘
    layer.$updated = false

    // 表示当前组件是否已经被销毁。这个参数主要是因为,
    // 当数据内容被销毁的之后实际上是还有一段时间停留在内存之中
    // 我们只是取消了相关的指向,有待垃圾清理机制来回收。
    // destoryed表示的是已经清理完成了相关的指向。
    layer.$destroyed = false
}

之后的话我们还需要对生命周期之中的一些函数内容进行封装,比如我们的afterUpdate实际上他不仅仅是为了方便用户调用的hook函数,同时他也承接了之后的重绘等等工作内容的衔接。所以我们可以编写为如下的形式。

Layer.prototype.$afterUpdate = function () {
    const layer = this
    // 调用用户自定义的afterUpdate方法
    layer.afterUpdate && layer.afterUpdate(layer.state)
    
    // 需要当前的组件的父组件及上层组件作出重绘更新操作。
    layer.$willUpdate()
    
    // 需要当前组件及其子组件作出强制的重绘更新操作。
    // 强制是应为,当前修改的参数不一定是我们传递给子组件的参数
    // 但是有的时候,改变单一参数的话实际上还是会对全组件展示有比较大的影响。
    // 所以为了更为精准的展示组件内容,所以子组件也需要强制更新。
    layer.$forceUpdate()
}

2.2. state内容初始化

state值的相关的组件属性内容,我们可以放在单独的js之中进行初始化,包括其他一些可以简单赋值的参数内容,例如clear(组件清除)函数或者here(位置判别)函数。我们单独的将关于当前绘制内容的参数给定到state之中,并且为每一个参数设置相关的描述,已达到参数变化监控的效果,速写方式如下:

function initState (layer, options) {
  layer.state = layer.state ? layer.state : {}
  // add origin value
  for (let key in options) {
    layer.state['$' + key] = options[key]
  }
  // set properties
  for (let key in options) {
    (function () {
      let ok = '$' + key
      let k = key
      let o = layer.state
      
      // 描述符的创建
      Object.defineProperty(o, k, {
        enumerable: true,
        configurable: true,
        get: function getState () {
        
          // 获取值的时候并不做特殊的操作
          return o[ok]
        },
        set: function setState (newValue) {
        
          // 当时值有变动的时候我们需要判别,并调用更新后钩子方法。
          // afterUpdate的内容,我们上面已经说明了。
          if (newValue !== o[ok]) {
            o['$' + k] = newValue
            callhook(layer, 'afterUpdate')
          }
        }
      })
    })()
  }
}

之后我们还可以为state内容的设置作出相关的便捷方法,例如多内容同时设置,多参数同时获取等等。


2.3. 其他相关自定义方法和参数:

我们上面有简单提到clear,here等函数的内容,这里我们在着重说明一下,这些相关的参数内容,包括:

  • clear:请求展示,用于清除当前的内容的展示效果,其实我们主要的清除方法在canvas之中的还是clearRect,所以要在用的时候十分注意,我们应该只传递最上层组件的清除方法,因为重绘的时候,我们可以依据上面的afterUpdate之中的逻辑来看,是一定会重绘最上层组件内容的,这是因为,清除长方形区域,有的时候往往会对旁边的其他组件造成绘制影响,并且这种影响往往是不可辨别的。所以我们需要对内容进行完全的重新绘制。
  • here:表示当前的内容的区域判别,判断某一位置是不是在组件之中,或者相对组建的什么方向,这一个事件往往会用在交互之中,自定义组建的时候,此函数的返回内容可以完全的定制化,但会的内容将会以参数的形式给到相关的事件回调函数之中,当然用户也可以使用$here自主的调用当前的方法。
  • imageData:传递的参数内容是一个对象,对象之中有get和put的方法,这个方法主要是用于imageData内容的操作,帮助我们进行内容的缓存,实际上我们的回顾一下afterData的方法我们可以看到实际上对父组件的内容更新是因为子组件的变化,但是同级的其他子实际上是没有变化的,有一些并不会影响到的组件内容,还是可以考虑使用imageData的方式来进行像素内容的缓存的,并且在未有改变更新的情况下重绘的话,我们是可以考虑使用imageData的内容的,但是这个内容需要自定义组件的时候进行考量。
  • path*:这是一个方法内容,也是必传参数,表示的是当前组件的绘制方法,我们绘制的时候实际上就是对当前的方法进行运行的,在这个函数之中我们可以有自定义绘制的部分,或者通过函数调用其他组件的内容。
  • $parent:这个组件实际上是绘制父组件的时候,模块自动传递给子组件的内容,表示的就是父组件本省,在使用组建的时候是不需要编写进去的。
  • delay:表示当前组件将会被延迟绘制。考虑到图层展示的问题有一些内容将会需要绘制在其他的组件上方。所以拥有了当前参数,所以此处使用delay作为标识。之后的话我是比较想要改成以z-index参数内容作为基础的绘制形式,来确定图层的绘制高低。
  • z_index:图层参数,之后的版本会被引入(version - 2)

暂时我们确定的就是上面这些需要组件传递到内用,用*表明的是必填字段内容。代码也是很简单的,我们只是需要对参数进行复制就好了,使用$开头。


1.2.4. 绘制方法模块

绘制是所有内容之中的重头戏,我们单独写在一个js文件之中。绘制中包括自主绘制内容,子组件绘制内容,我们需要分开来进行处理的。自主的内容绘制需要完全绘制完成之后在调用子组件,否则的话并不保证样式不会乱窜。(这也是很让我头痛的一点)。 应为考量到相关的绘制延迟,所以我现在写的代码如下:

//主要是用于记录需要延迟绘制的组件内容
const delays = []

//记录最上层的绘制组件对象
let drawingOrigin = null

function drawingPath (layer, path) {
  // 当前为空的情况我们可以确定当前绘制的是最上层组件的内容。
  if (!drawingOrigin) {
    drawingOrigin = layer
  }
  let brush = path || layer.path
  let used = false
  let i = 0
  const drawing = function () {
    // 判断当前是不是有相关的imageData内容的处理函数。
    // 如果有并且当前组件并没有更新的话,这直接使用imageData
    if (!layer.$update && layer.$imageData && layer.$putImageData) {
      layer.$putImageData(layer.context, layer.state, layer.$imageData)
    } else {
    
      // 调用path绘制方法。
      let autoDraw = brush.call(layer, layer.context, layer.state)
      
      // 下面这里纯属为了便捷,自主绘制而已。
      if (autoDraw) {
        if (layer.state.fill) {
          setBrushStyle(layer.context, layer.state.fill)
          layer.context.fill()
          used = true
        }
        if (layer.state.stroke) {
          if (used) {
            brush.call(layer, layer.context, layer.state)
          }
          setBrushStyle(layer.context, layer.state.stroke)
          layer.context.stroke()
        }
      }
      
      // 如果有getImageData方法的话(就是之前的imageData参数之中的get方法)
      // 则获取新的imageData对象。
      if (layer.$getImageData) {
        layer.$imageData = layer.$getImageData(layer.context, layer.state)
      }
    }
  }
  
  // 探测到delay的话
  if (layer.$delay) {
  
    // 遍历当前的delays内容,如果有当前的对象,则拿出来进行绘制。
    // 并从延迟队列之中删除它
    for (i = 0; i < delays.length; i++) {
      if (delays[i] === layer) {
        drawing()
        break
      }
    }
    if (i === delays.length) {
      delays.push(layer)
    } else {
      delays.splice(i, 1)
    }
  } else {
    drawing()
  }
  
  // 绘制完path之中的内容最终将会再次的检测到最上层的组件元素.
  // 这个时候再讲延迟元素拿出来绘制。
  if (drawingOrigin === layer) {
    drawingOrigin = null
    for (const val of delays) {
      drawingPath(val)
    }
  }
}

所以上面的代码实际的绘制顺序是,有限没有延迟的组件和内容,延迟组件会进行存储,在最后绘制,但是如果延时组件之中还有延时组件的话,会再一次的存储到当前的延时组件队列之中,在第一级延时组件绘制完成之后,在进行绘制,以此类推。


2.5. 动画模块

动画模块主要是对当前的内容进行循环的绘制,所以市场需要使用定时器,我们需要对当前的定时器做一个兼容的操作,代码如下:

function animateCompatible () {
  if (!window.requestAnimationFrame) {
    window.requestAnimationFrame =
      window.webkitRequestAnimationFrame ||
      function (callback) {
        return setTimeout(callback, 1000 / 60)
      }
  }
}

如上代码所示,可以解决我们很多的不兼容的问题,以为有的内容不支持当前新的requestAnimationFrame,当然这个函数将会让我们的绘制更为的顺畅。

animation之中严重影响我们运行速率的是大量的定时器内容,以及每一个定时器到时运行的时候的canvas的重绘,如果我们针对动画内容,每一个动画都给出一个定时器的话,实际上会浪费很多的动画开销的,因为相同时间间隔的动画实际上是可以进行统一的绘制操作的,这样不仅减少了定时器的数量,也同时减少了canvas内容的重绘次数。会比较好的提升当前的动画的效果。当然这是针对大量的定时以及绘制的情况,少量的当前并没有那么多影响的。

那么我们可以通过什么方式来进行实现呢。将相同的动画归类到指定的时间间隔队列之中,动画间隔单次调用所有的当前时间间隔队列之中的内容,统一的进行更新,但是针对不同时间的动画内容,我并没有确定好更好的方式,现在能想到的是统一的心跳钟,但是这样的话,间隔心跳时长应该是多少ms,性能是不是真的能更近一步有待考较。这里就不贴代码了,因为方法很简单,主要是动画的开始和移除,还有添加等等操作,主要的内容就是时间间隔队列内容。


2.6.事件交互模块。

最后我们需要处理的是事件交互模块内容。事件也是canvas用来于用户交互的关键所在,用户在对canvas进行操作的时候我们可以获取到相关的事件内容,然后判定当前的位置信息,来确定当前内容之中的组件信息。并触发组件之中记录的相同类型事件。事件的基础处理函数内容是包括添加删除事件还有触发,添加删除好理解,给定相关的事件类型,还有回调函数内容,以及用户需要传递回的meta参数(用户需要在事件之中使用的参数,模块只做存储以及传递),添加到事件对象之中,或者在事件对象之中删除掉某一类型的事件,或者事件类型下的某个回调函数。

事件的执行我们是按照添加的顺序进行执行的,实际上每一个事件类型对应的都是一个回调函数队列。emit函数内容我们需要考量的主要是,出发事件的时候传递的内容,应该包括,之前的meta自定义参数,here的判断结果,pos事件位置内容,以及最后的组件state参数内容。较全的参数数据将会使得事件内容更为的灵活。代码如下:

Layer.prototype.emit = function emit (type, pos) {
    const layer = this
    if (layer.$events[type]) {
      let list = layer.$events[type]
      for (let call of list) {
        let check = layer.$here && layer.$here(layer.state, pos.x, pos.y, layer.context)
        
        // 调用事件回调
        call.callback.call(layer, call.meta, check, pos, layer.state)
      }
    }
 }

最后还有一点,如果子组件添加事件的话,父组件需要添加相关的触发函数,从而达到统一联动的效果,所以在添加事件的时候需要判定父组件之中是否有子组件当前时间类型的触发函数,没有的话需要加上。


总结

上述是我编写的canvas模块内容的version 1内容。只是一些粗浅的理解和想法。

version 2 之中我希望可以做到: 1.动画的统一心跳,或者再度优化。 2.imageData的可重用的扩展。 3.基础组件元素的编写集成。 4.事件这一块内容可能需要更为谨慎的对待,但是暂时还没有具体想法(思考状ING) 5.配置化组件绘制,配置模块将会提上日程。

希望还会有后续进度。 希望。。。。