从零到一实现一个原生js帧动画库(中)

47 阅读4分钟

携手创作,共同成长!这是我参与「掘金日新计划 · 8 月更文挑战」的第13天,点击查看活动详情

在上一篇文章中,我们详细描述了如何链式的执行数组中的任务,主要是通过next函数调用来改变任务链的索引值,然后执行任务。本章主要是解释如何定时的去改变的changePosition,从而形成动画。

requestAnimationFrame

requestAnimationFrame字面意思是请求动画帧,也称 帧循环

requestAnimationFrame 告诉浏览器——你希望执行一个动画,并且要求浏览器在下次重绘之前调用指定的回调函数更新动画。该方法需要传入一个回调函数作为参数,该回调函数会在浏览器下一次重绘之前执行。

我们先初步认识一下它,我们给它传递一个回调函数 test 。

<body> 
    <h1>requestAnimationFrame API</h1> 
    <button id='begin' class="begin">开始</button> 
    <button id='end' class="end">停止</button> 
</body> 

// js 
(() => { 
    function test() { 
      console.log('hello ~ requestAnimationFrame'); 
    } 
    requestAnimationFrame(test) 
})()

可以看到,控制台成功的输出了一次 log 。

但是它只执行了一次,怎么做动画呢?别急,再看看 MDN 怎么说。

若你想在浏览器下次重绘之前继续更新下一帧动画,那么回调函数自身必须再次调用window.requestAnimationFrame()

(() => { 
    let n = 0 
    function test() { 
      n++ 
      console.log(`hello ~ requestAnimationFrame ${n}`)
      requestAnimationFrame(test) 
    } 
    requestAnimationFrame(test) 
})()

可以看到,test函数会隔一段时间执行一次,它执行次数通常是每秒 60 次,但在大多数遵循 W3C 建议的浏览器中,回调函数执行次数通常与 浏览器屏幕刷新次数 相匹配。

这回就知道了,原来它根本就不用手动设置执行间隔时间,而是根据 浏览器屏幕刷新次数 自动调整了,也就是说浏览器屏幕刷新多少次,它就执行多少次。

终止执行

那如果我想要在特定的条件下终止 requestAnimationFrame 怎么办呢,官方也给出了答案,那就是 cancelAnimationFrame API 。 只需要把 requestAnimationFrame 的返回值作为参数传递给 cancelAnimationFrame 就可以了。

动画的代码实现

  1. 定义一个timeline的类
var DEFAULT_INTERVAL = 1000 / 60
// 初始化状态
var STATE_INITIAL = 0
// 开始状态
var STATE_START = 1
// 停止状态
var STATE_STOP = 2

function Timeline() {
  this.animationHandler = 0
  this.state = STATE_INITIAL
}
  1. 动画开始逻辑

参数interval表示动画执行的事件间隔,如果你想动画快一点可以把interval设置的更小,如果你想让动画慢一点可以设置的更大,如果不设置就默认是浏览器的刷新频率。

/**
 * 动画开始
 * @param {*} interval 每一次回调的间隔时间,并不一定要求是17ms
 */
Timeline.prototype.start = function (interval) {
  if (this.state === STATE_START) {
    return
  }
  this.state = STATE_START
  // 默认是1秒中刷新60次 1000/60
  this.interval = interval || DEFAULT_INTERVAL
  startTimeline(this, +new Date())
}

function startTimeline(timeline, startTime) {
  timeline.startTime = startTime
  // 记录上一次回调的函数
  var lastTick = +new Date()
  nextTick.interval = timeline.interval
  nextTick()
  /**
   * 每一帧执行的函数
   */
  function nextTick() {
    var now = +new Date()
    timeline.animationHandler = requestAnimationFrame(nextTick)

    // 当前时间和上一次动画回调的时间差大于设置的事件间隔interval,表示这次可以执行回调函数onenterframe
    if (now - lastTick >= timeline.interval) {
      timeline.onenterframe(now - startTime)
      lastTick = now
    }
  }
}

下面代码保证nextTick会循环执行:

nextTick()
function nextTick() {
    timeline.animationHandler = requestAnimationFrame(nextTick)
}

下面代码保证了,在我们规定的时间间隔内执行一次nextTick

if (now - lastTick >= timeline.interval) {
      // 动画的具体逻辑,由外部定义
      timeline.onenterframe(now - startTime)
      lastTick = now
}

下面的代码保证了外部可以定义onenterframe函数逻辑:

Timeline.prototype.onenterframe = function (time) {}

onenterframe具体实现如下

Animation.prototype._asyncTask = function (task) {
  var me = this
  // 定义每一帧执行的回调函数
  var enterFrame = function (time) {
    var taskFn = task.taskFn
    var next = function () {
      // 停止当前任务
      me.timeline.stop()
      // 执行下一个任务
      me._next(task)
    }
    taskFn(next, time)
  }
  this.timeline.onenterframe = enterFrame
  this.timeline.start(this.interval)
}

也就是如果满足interval的时间间隔,那么就不断的执行的enterFrame函数,在这个动画中enterFrame函数的具体逻辑就是不断改变background-position:

// time是动画从开始到目前的时间,通过这个时间和interval来计算index
taskFn = function (next, time) {
  if (imgUrl) {
    ele.style.backgroundImage = 'url(' + imgUrl + ')'
  }
  // 获得当前背景图片的位置索引
  // | 0 等于Math.floor
  var index = Math.min((time / me.interval) | 0, len)
  var position = positions[index - 1].split(' ')
  // 改变dom对象的背景图片位置
  ele.style.backgroundPosition = position[0] + 'px ' + position[1] + 'px'
  if (index === len) {
    next()
  }
}

总结

timeline类的主要功能是利用requestAnimationFrame来实现一个不断循环执行一段逻辑的功能,执行的时间间隔是外界传入的,只要达到now - lastTick >= timeline.interval的条件就去执行动画的逻辑。

然后在animation类中定义动画执行的逻辑,比如changePosition