一起動手實現一個js幀動畫庫

xue_發表於2018-08-02

GitHub上有原始碼和dome,自己可以下載和檢視效果 GitHub地址

js中什麼是幀動畫?

就是通過一張圖片切換background Position,或者通過多張圖片的切換src來進行的動畫效果

相當一部分的瀏覽器的顯示頻率是16.7ms,所以在進行幀動畫的時候選用16.7ms的頻率或者是16.7ms的倍數,避免丟幀。所以我們採用requestAnimationFrame來執行動畫的操作。

動畫庫的介面設計


animation(imgList) // animation 類


對外暴露介面:

changePosition(ele, positions, imageUrl) // 改變元素的 background-position 實現幀動畫

changeUrl(ele, imgList) // 通過改變圖片元素的URL 實現幀動畫

repeat(times) // 動畫執行的次數 times為空時無限迴圈

wait(time) // 動畫的等待時間

then(callback) // 自定義執行任務

start(interval) // 動畫開始執行,interval 表示動畫執行的間隔 預設為16.7ms

pause() // 動畫暫停

restart() // 動畫從上一次暫停處重新執行


私有介面:

_loadImage(imgList) // 預載入圖片

_add(taskFn, type) // 方法新增到任務佇列中,taskFn為任務函式,type為任務型別

_runTask() // 執行任務

_next() // 當前任務結束後執行下一個任務

_dispose() // 釋放資源

將所有的介面先定義完成

 var TIMING = 1000 / 60 // 一秒60幀的執行速度和瀏覽器的顯示速度相同
/**
 * 幀動畫類
 */
function Animation (imgList) {
  /**
   * 0為初始狀態
   * 1為運動狀態
   * 2為暫停狀態
   */
  this._state = 0
  // 任務佇列,所有事件都被載入這個任務佇列中,在start後被依次執行
  this._taskQuery = []
  // 執行當前任務的索引
  this._index = 0
  // 執行任務前要先載入好img後才能執行下一個任,避免動畫執行中img還沒載入出來
  this._loadImage(imgList)
}

/**
 * 改變元素的 background-position 實現幀動畫
 * @param ele dom物件
 * @param positions 背景位置陣列 示例: ['20 0', '40 0']
 * @param imageUrl 背景圖片url
 */
Animation.prototype.changePosition = function (ele, positions, imageUrl) {
  return this
}

/**
 * 通過改變圖片元素的URL 實現幀動畫
 * @param ele dom物件
 * @param imglist 圖片url陣列
 */
Animation.prototype.changeUrl = function (ele, imglist) {
  return this
}

/**
 * 迴圈執行
 * @param times 迴圈執行上一個任務的次數
 */
Animation.prototype.repeat = function (times) {
  return this
}

/**
 * 執行下一個任務的等待時長
 * @param time 等待的時長
 */
Animation.prototype.wait = function (time) {
  return this
}

/**
 * 自定義執行任務
 * @param calback 自定義執行任務
 */
Animation.prototype.then = function (calback) {
  return this
}

/**
 * 動畫開始執行
 * @param interval 動畫執行的頻率
 */
Animation.prototype.start = function (interval) {
  this.interval = interval || TIMING
  return this
}

/**
 * 動畫暫停
 */
Animation.prototype.pause = function () {
  return this
}

/**
 * 動畫從上一次暫停處重新執行
 */
Animation.prototype.restart = function() {
  return this
}


/**
 * 圖片預載入
 * @param imgList 預載入的圖片陣列
 */
Animation.prototype._loadImage = function (imgList) {
}

/**
 * 新增任務到任務佇列
 * @param taskFn 執行的任務
 * @param type 任務型別
 */
Animation.prototype._add = function(taskFn, type) {

}

/**
 * 執行當前任務
 */
Animation.prototype._runTask = function () {

}

/**
 * 切換到下一個任務
 */
Animation.prototype._next = function () {

}

/**
 * 釋放資源
 */
Animation.prototype._dispose = function () {

}
複製程式碼

接下來我們來實現_loadImage

Animation.prototype._loadImage = function (imgList) {
// 每個taskFn都會接受一個next引數,在_runTask中執行taskFn的時候會傳入,用來在任務執行完成後進行下一個操作
  var taskFn = function (next) {
    // 圖片載入事件
    loadImage(imgList, next)
  }
  /**
   * 0為非動畫任務
   * 1為動畫任務 比如 changePosition 和 changeUrl 事件
   */
  var type = 0

  this._add(taskFn, type)
}
複製程式碼

1、這裡出現了我們未曾定義的事件loadImage,我們新建一個loadimage.js, 把loadinimage引入當前js中吧 var loadImage = require('./imageLoad')。 不過這個事件要待會來實現,我們先把簡單的事先做了吧。

2、我們先吧這裡用到的_add先實現把,順便把_next也實現一下把

/**
 * 新增任務到任務佇列
 * @param taskFn 執行的任務
 * @param type 任務型別
 */
Animation.prototype._add = function(taskFn, type) {
  this._taskQuery.push({
    taskFn: taskFn,
    type: type
  })
}
/**
 * 切換到下一個任務
 */
Animation.prototype._next = function () {
  this._index++
  this._runTask()
}
複製程式碼

是不是很簡單。看這裡又用到了_runTask,接下來我們分析一下這個函式吧。

3、任務中分為兩種任務,一個是非動畫任務,一個是動畫任務。 那麼我們就需要先建立兩個方法。在_runTask中判斷任務型別來執行對應的方法。 _syncTask和_asyncTask方法。

/**
 * 動畫任務
 * @param task 任務物件 {taskFn, type}
 */
Animation.prototype._asyncTask = function (task) {

}

/**
 * 非動畫任務
 * @param task 任務物件 {taskFn, type}
 */
Animation.prototype._syncTask = function (task) {

}
複製程式碼

4、好了我們接下來先看看_runTask的邏輯吧

/**
 * 執行當前任務
 */
Animation.prototype._runTask = function () {
  // 當任務佇列沒有任務時或者當前不是處於運動狀態時就不做任何操作
  if (!this._taskQuery || this._state !== 1) {
    return
  }

  // 當任務全部完成時釋放資源
  if (this._index === this._taskQuery.length) {
    this._dispose()
    return
  }

  var task = this._taskQuery[this._index]
  var type = task.type
  if (type === 0) {
    this._syncTask(task)
  } else if (type === 1) {
    this._asyncTask(task)
  }
}
複製程式碼

5、我們這裡用到了_dispose釋放資源,這個當然是最好才寫的一個方法,_asyncTask和_syncTask當然那最簡單的先來練練手。 我們先來看看_syncTask非動畫任務來試試

/**
 * 非動畫任務
 * @param task 任務物件 {taskFn, type}
 */
Animation.prototype._syncTask = function (task) {
  var _this = this
  var next = function () {
    _this._next()
  }
  var taskFn = task.taskFn
  taskFn(next)
}
複製程式碼

6、現在寫了那麼多next有些小夥伴可能有點蒙圈了,怎麼都沒用過next呀,我有點跑不通邏輯呀。那麼我們先來寫一個非動畫任務then方法來試試

/**
 * 自定義執行任務
 * @param calback 自定義執行任務
 */
Animation.prototype.then = function (calback) {
// 這裡的taskFn接受一個next引數
  var taskFn = function (next) {
    calback()
    next() //taskFn在_runTask中被呼叫了,這裡的next就是在_runTask方法中傳入的_next方法中
  }
  var type = 0
  // 在_add方法中我們會接受taskFn方法
  this._add(taskFn, type)
  return this
}
複製程式碼

看到我們的我們的then方法了嗎。我來給大家來理一下next吧。其實在_add方法中我們會接受taskFn方法,這裡的taskFn又接受一個next引數,taskFn會在_runTask方法中被呼叫就會傳入_next方法那就是這裡的next

7、那麼_next方法講明白了,那我們就開始講repeat(times)方法吧 // 重複執行上一個任務

/**
 * 迴圈執行
 * @param times 迴圈執行上一個任務的次數
 */
Animation.prototype.repeat = function (times) {
  var _this = this
  var taskFn = function (next) {
    // times為空 無限迴圈上一個任務
    if (typeof times === 'undefined') {
      _this._index--
      _this._runTask()
      return
    }
    // times 有數值時
    if (times) {
      times--
      _this._index--
      _this._runTask()
      return
    } else {
      next()
    }
  }
  var type = 0
  this._add(taskFn, type)
  return this
}
複製程式碼

8、接下來我們在來看看wait方法和start方法吧

/**
 * 執行下一個任務的等待時長
 * @param time 等待的時長
 */
Animation.prototype.wait = function (time) {
  var taskFn = function (next) {
    setTimeout(function () {
      next()
    }, time);
  }
  var type = 0
  this._add(taskFn, type)
  return this
}

/**
 * 動畫開始執行
 * @param interval 動畫執行的頻率
 */
Animation.prototype.start = function (interval) {
  // 本身已經是執行狀態或者任務佇列裡沒有任務時不進行操作
  if (this._state === 1 || !this.taskQuery.length) {
    return this
  }
  
  this.interval = interval || TIMING
  this._state = 1
  this._runTask()
  return this
}
複製程式碼

loadImag

9、現在基本簡單的任務也快寫完了,那麼我們就開始回到第一步中的loadImage方法,那麼我們接下來開啟loadImage.js來一起完成loadImage方法

/**
 * 圖片預載入
 * @param imglist 需要載入的圖片陣列
 * @param next 載入完成後進入下一個任務
 * @param timeout 圖片載入超時時長
 */
function loadImage (imglist, next, timeout) {
  // 全部圖片載入完成後的狀態,state_array的長度等於imglist的長度時,進入next下一個任務
  var state_array = []
  // 是否超時
  var isTimeout = false

  for (var key in imglist) {
    // 過濾property上的屬性
    if (!imglist.hasOwnProperty(key)) {
      continue
    }

    var item = imglist[key]

    if(typeof item === 'string') {
      item = {
        src: item
      }
    }
    // imglist[key]不存在或者typeof item !== 'string'時跳過這條資料
    if (!item || !item.src) {
      continue
    }

    item.image = new Image()
    // 載入圖片
    doimg(item)
    
  }
  function (item) {}
}
複製程式碼

10、那麼我們接下來看看doimg方法該怎麼寫

// 載入圖片
  function doimg(item) {
    var img = item.image
    img.src = item.src
    // 載入是否超時
    item.isTimeout = false

    img.onload = function () {
      // 非常重要,如果不列印img,切換圖片url來執行的幀動畫會請求新的圖片資源
      console.log(img)
      item.status = 'loaded'
      done()
    }

    img.onerror = function () {
      item.status = 'error'
      done()
    }
    // 是否超時
    if (timeout) {
      // 超時定時器
      item.timeoutId = 0
      item.timeoutId = setTimeout(onTimeout, timeout)
    }
    // 載入完成後的回撥
    function done() {
      img.onload = img.onerror = null
      // 如果沒有超時執行,因為超時的時候已經執行過一次了
      if (!item.isTimeout) {
        state_array.push(item.status)
        if (state_array.length === imglist.length) {
          next()
        }
      }
    }

    // 載入超時
    function onTimeout() {
      item.isTimeout = true
      state_array.push('error')
      if (state_array.length === imglist.length) {
        next()
      }
    }
  }
複製程式碼

當然最後記得把loadImage暴露出來module.exports = loadImage

11、我們loadImage方法已經完成了,那麼接下來就是Timeline類,對動畫任務進行執行的方法,我們新建一個timeline.js吧。將他進入animation.js中var Timeline = require('./timeline')

Timeline類

Timeline類用來執行動畫方法

對外暴露介面

start(interval) // 動畫開始,interval 每一次回撥的間隔時間

stop() // 動畫暫停

restart() // 繼續播放

onenterframe(time) // 每一幀執行的函式,該方法不定義內容,給外部重寫。time 從動畫開始到當前執行的時間

1、首先我們要先定義requestAnimationFrame,這個用來反覆執行動畫方法。至於為什麼用這個文章開頭已經解釋過了

// 一秒60幀與螢幕重新整理頻率同步
var TIMEOUT = 1000 / 60

var requestAnimationFrame = (function () {
  return window.requestAnimationFrame ||
    window.webkitRequestAnimationFrame ||
    function (callback) {
      return window.setTimeout(callback, TIMEOUT);
    }
})()

var cancelAnimationFrame = (function () {
  return window.cancelAnimationFrame ||
    window.webkitCancelAnimationFrame ||
    function (id) {
      return window.clearTimeout(id);
    }
})()
複製程式碼

2、我們來書寫所有方法

/**
 * 時間軸類 Timeline
 */

function Timeline() {
    /**
    * 0表示初始狀態
    * 1表示播放狀態
    * 2表示暫停狀態
    */
  this._state = 0
  // 定時器id
  this.animationHandle = 0
}

/**
 * 時間軸上每一次回撥執行的函式
 * @param time 從動畫開始到當前執行的時間
 */
Timeline.prototype.onenterframe = function (time) {
}

/**
 * 動畫開始
 * @param interval 每一次回撥的間隔時間
 */
Timeline.prototype.start = function (interval) {
}

/**
 * 動畫停止
 */
Timeline.prototype.stop = function () {
}
/**
 * 重新執行
 */
Timeline.prototype.restart = function () {
}

/**
 * 節流函式 對節流函式不理解的朋友可以去看我的防抖和節流,就知道什麼是節流了
 * @param timeline timeline物件
 * @param startTime  動畫開始的時間
 */
function startTimeline(timeline, startTime) {
}



module.exports = Timeline

複製程式碼

3、我們先來完成start方法吧

/**
 * 動畫開始
 * @param interval 每一次回撥的間隔時間
 */
Timeline.prototype.start = function (interval) {
  if (this._state === 1) {
    return this
  }
  this._state = 1
  this.interval = interval || TIMEOUT
  startTimeline(this, +new Date())
}
複製程式碼

4、接下來我們看看startTimeline幹了什麼

/**
 * 節流函式 對節流函式不理解的朋友可以去看我的防抖和節流,就知道什麼是節流了
 * @param timeline timeline物件
 * @param startTime  動畫開始的時間
 */
function startTimeline(timeline, startTime) {
  // 記錄這一次開始的時間
  timeline.startTime = startTime

  // 記錄節流函式上一次執行的時間
  var lastTime = +new Date()
  nextTask()

  function nextTask(){
    var now = +new Date()
    timeline.animationHandle = requestAnimationFrame(nextTask)
    if (now - lastTime >= timeline.interval) {
      lastTime = now
      // 執行動畫的總時長
      timeline.onenterframe(now - startTime)
    }
  }
}
複製程式碼

5、我們該讓動畫停下來了

/**
 * 動畫停止
 */
Timeline.prototype.stop = function () {
  if (this._state !== 1) {
    return this
  }
  this._state = 2
  if (this.startTime) {
    // dur 總執行的時長
    this.dur = +new Date() - this.startTime
    cancelAnimationFrame(this.animationHandle)
  }
}
複製程式碼

6、讓我們的動畫接著上一幀重新跑起來吧

/**
 * 重新執行
 */
Timeline.prototype.restart = function () {
  if (this._state === 1) {
    return this
  }
  if (!this.dur) {
    return this
  }
  this._state = 1
  //startTimeline中會對傳入的值進行 +new Date() - startTime 拿到執行總時長,也就是要加上之前的總時長this.dur = +new Date() - (+new Date() - this.dur)
  startTimeline(this, +new Date() - this.dur)
}
複製程式碼

我們的Timeline類就這樣完成了。把Timeline類引入到animation.js中把var Timeline = require('./timeline')同時在Animation類中例項化一下把this.timeline = new Timeline()

7、接下來我們看看怎麼寫一下animation中的動畫執行方法_asyncTask

/**
 * 動畫任務
 * @param task 任務物件 {taskFn, type}
 */
Animation.prototype._asyncTask = function (task) {
  var _this = this
  
  function enterframe(time) {
    var taskFn = task.taskFn
    function next () {
      _this.timeline.stop()
      _this._next()
    }
    taskFn(next, time)
  }

  this.timeline.onenterframe = enterframe
  this.timeline.start(this.interval)
}
複製程式碼

8、接下來我們定義一個動畫方法試試吧changePosition

/**
 * 通過改變圖片背景位置,實現幀動畫
 * @param {Element} ele 
 * @param {Array} positions 
 * @param {String} imageUrl 
 */
Animation.prototype.changePosition = function (ele, positions, imageUrl) {
  var len = positions.length
  var taskFn
  var type
  var _this = this
  if (len) {
    taskFn = function (next, time) {
      if (imageUrl) {
        ele.style.backgroundImage = 'url(' + imageUrl + ')'
      }
      var index = Math.min(time / _this.interval | 0, len)
      var position = positions[index - 1].split(' ')

      ele.style.backgroundPosition = position[0] + 'px ' + position[1] + 'px'

      if (index === len) {
        next()
      }
    }
    type = 1
    this._add(taskFn, type)
  }

  return this
}
複製程式碼

10、我們先試試能不能跑起來後再進行寫下面的幾個介面吧 webpack.config.js

module.exports = {
  entry: {
    animation: './src/animation.js'
  },
  output: {
    path: __dirname + '/build',
    filename: '[name].js',
    library: 'animation',
    libraryTarget: 'umd'
  }
}
複製程式碼

執行命令

npm -g webpack

webpack

11、新建一個dome資料夾index.html。圖片行尋找

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <title>Document</title>
  <style>
    #rabbit{
      width: 600px;
      height: 100px;
    }
  </style>
</head>
<body>
  <div id="rabbit"></div>
  <script src="../build/animation.js"></script>
  <script>
    var positions = ['120 0', '240 0', '360 0']
    var rabbitEle = document.querySelector('#rabbit')
    var rabbit = animation(['./timg.jpg'])
    .changePosition(rabbitEle, positions, './timg.jpg')
    .repeat(10)

    rabbit.start(100)
  </script>
</body>
</html>
複製程式碼

12、changeUrl利用多張圖片切換的動畫

/**
 * 通過改變圖片元素的URL 實現幀動畫
 * @param ele dom物件
 * @param imglist 圖片url陣列
 */
Animation.prototype.changeUrl = function (ele, imglist) {
  var len = imglist.length
  var taskFn
  var type
  var _this = this
  if (len) {
    taskFn = function (next, time) {
      
      var index = Math.min(time / _this.interval | 0, len - 1)
      var imageUrl = imglist[index]
      ele.style.backgroundImage = 'url(' + imageUrl + ')'

      if (index === len - 1) {
        next()
      }
    }
    type = 1
    this._add(taskFn, type)
  }
  return this
}
複製程式碼

13、pause、restart、_dispose三個最後的方法

/**
 * 動畫暫停
 */
Animation.prototype.pause = function () {
  if (this._state !== 1) {
    return this
  }
  this._state = 2
  this.timeline.stop()
  return this
}

/**
 * 動畫從上一次暫停處重新執行
 */
Animation.prototype.restart = function () {
  if (this._state !== 2) {
    return this
  }
  this._state = 1
  this.timeline.restart()
  return this
}
/**
 * 釋放資源
 */
Animation.prototype._dispose = function () {
  if (this._state !== STATE_INITTAL) {
    this._state = STATE_INITTAL
    this.taskQuery = null
    this.timeline.stop()
    this.timeline = null
  }
}
複製程式碼

我們完成了

相關文章