【小貼士】關於transitionEnd/animate的一個有趣故事

範大腳腳發表於2017-12-21

前言

在很久之前,我們專案有一個動畫功能,功能本身很簡單,便是典型的右進左出,並且帶動畫功能

以當時來說,雖然很簡單,但是受限於框架本身的難度,就直接使用了CSS3的方式完成了功能

當時主要使用transform與animation實現功能,並且用了一個settimeout執行回撥,然後此事便不了了之了

但是出來混總是要還的,這不,最近相似的東西又提了出來,我們當然可以將原來的那套東西拿來用,但是看著那個settimeout總是不是滋味,因為這樣捕捉回撥的效果以及可能引起的BUG大家都懂,於是就想使用transitionEnd監控動畫結束再執行相關回撥,於是便有了一個有趣的想法

當時的心聲

嗯,不行,這次我要寫一個通用的東西,他至少有這些功能:

① 我可以給他一個CSS變化屬性

② 我可以給他一個時間長度

③ 我可以給他一個動畫曲線引數

有了以上東西我就可以讓一個元素觸發動畫,並且對其註冊transitionEnd事件,最後執行我們的回撥,於是我基本就陷進去了

但是,我想著想著突然感覺不對,感覺以上東西好像在哪裡見過,於是一個叫animate的東西冒了出來

突然一剎那,我有一個不妙的感覺,搞出來一看:

animate
animate(properties, [duration, [easing, [function(){ ... }]]])   ⇒ self
      animate(properties, { duration: msec, easing: type, complete: fn })   ⇒ self
      animate(animationName, { ... })   ⇒ self
  
對當前Zepto集合物件中元素進行css transition屬性平滑過渡。

properties: 一個物件,該物件包含了css動畫的值,或者css幀動畫的名稱。
duration (預設 400):以毫秒為單位的時間,或者一個字串。
fast (200 ms)
slow (600 ms)
任何$.fx.speeds自定義屬性
easing (預設 linear):指定動畫的緩動型別,使用以下一個:
ease
linear
ease-in / ease-out
ease-in-out
cubic-bezier(...)
complete:動畫完成時的回撥函式

於是,我自己的想法就只能呵呵了,這個就是我要的嘛......

而且zepto裡面便是監聽transitionEnd這個事件觸發回撥,所以,我們今天就來學習這個animate即可!!!

transitionEnd

transitionEnd是CSS3動畫transition唯一的事件,我之前還去找個transitionStart,米有找到......

介紹他之前,我們先來個簡單的例子,W3C上面的例子:

<!DOCTYPE html>
<html>
<head>
    <style>
        div { width: 100px; height: 100px; background: blue; transition: width 2s; -moz-transition: width 2s; /* Firefox 4 */ -webkit-transition: width 2s; /* Safari and Chrome */ -o-transition: width 2s; /* Opera */ }
        
        div:hover { width: 300px; }
    </style>
</head>
<body>
    <div>
    </div>
    <p>
        請把滑鼠指標移動到藍色的 div 元素上,就可以看到過渡效果。</p>
    <p>
        <b>註釋:</b>本例在 Internet Explorer 中無效。</p>
</body>
</html>

好了,現在若是我們要在動畫結束時候加一個事件該怎麼辦呢? 

<!DOCTYPE html>
<html>
<head>
    <style>
        div { width: 100px; height: 100px; background: blue; transition: width 1s; -moz-transition: width 1s; /* Firefox 4 */ -webkit-transition: width 1s; /* Safari and Chrome */ -o-transition: width 1s; /* Opera */ }
        
        div:hover { width: 300px; }
    </style>
</head>
<body>
    <div id="demo">
    </div>
    <br />
    <span id="msg"></span>
    <p>
        請把滑鼠指標移動到藍色的 div 元素上,就可以看到過渡效果。</p>
    <p>
        <b>註釋:</b>本例在 Internet Explorer 中無效。</p>
    <script type="text/javascript">
        var demo = document.getElementById('demo');
        var msg = document.getElementById('msg');

//        eventType(this.scroller, 'transitionend', this);
//        eventType(this.scroller, 'webkitTransitionEnd', this);
//        eventType(this.scroller, 'oTransitionEnd', this);
//        eventType(this.scroller, 'MSTransitionEnd', this);

        demo.addEventListener('webkitTransitionEnd', function () {
            msg.innerHTML = '事件回撥,當前原始寬度:' + window.getComputedStyle(demo).width;
        });
        
    </script>
</body>
</html>

這個例子雖然簡單卻很好的說明了一些問題,現在我們就來簡單模擬一下animate

簡單模擬animate

既然zepto已經很好的實現了該功能,我們這裡就簡單的模擬下即可,然後看看zepto原始碼

var demo = document.getElementById('demo');
var msg = document.getElementById('msg');

//簡單模擬animate,引數問題就不管他了,暫時只考慮width吧
function animate(el, css, time, fn) {
      
  if (!el) return;

  var callback = function () {
    fn(arguments);
    el.removeEventListener('webkitTransitionEnd', callback);
  };

  el.addEventListener('webkitTransitionEnd', callback);

  for (var k in css) {
    //這裡暫時只考慮webkit核心
    el.style['-webkit-transition'] = k + ' ' + time + 's';
  }

  for (var k in css) {
    //這裡暫時只考慮webkit核心
    el.style[k] = css[k];
  }
}

demo.addEventListener('mouseenter', function () {
  animate(demo, { width: '300px' }, 1, fn);
});

demo.addEventListener('mouseout', function () {
  animate(demo, { width: '100px' }, 2, fn);
});

var fn = function () {
  msg.innerHTML = '事件回撥,當前原始寬度:' + window.getComputedStyle(demo).width;
}

這是一個簡單的實現,每次執行animate的時候,先會執行一次transitionEnd的事件註冊,並且執行一次後就銷燬

第二步為其設定transition屬性,如果可以的話,這裡最好是可以消除

最後一步就是為其設定css屬性即可整個邏輯很簡單,大概原理就是這樣,我接下來來看看zepto高大上的實現!!!

zepto高大上的animate

zepto要實現以上程式碼的話,這樣搞:

var demo = $('#demo');
var msg = $('#msg');

var fn = function () {
  msg.html('事件回撥,當前原始寬度:' + demo.width());
};

demo.on('mouseenter', function () {
  demo.animate({ 'width': '300px' }, 1000, 'ease-out', fn);
});

demo.on('mouseout', function () {
  demo.animate({ 'width': '100px' }, 2000, 'ease-out', fn);
});

然後我們現在來看看原始碼:

;(function($, undefined){
  var prefix = '', eventPrefix, endEventName, endAnimationName,
    vendors = { Webkit: 'webkit', Moz: '', O: 'o' },
    document = window.document, testEl = document.createElement('div'),
    supportedTransforms = /^((translate|rotate|scale)(X|Y|Z|3d)?|matrix(3d)?|perspective|skew(X|Y)?)$/i,
    transform,
    transitionProperty, transitionDuration, transitionTiming, transitionDelay,
    animationName, animationDuration, animationTiming, animationDelay,
    cssReset = {}

  function dasherize(str) { return str.replace(/([a-z])([A-Z])/, '$1-$2').toLowerCase() }
  function normalizeEvent(name) { return eventPrefix ? eventPrefix + name : name.toLowerCase() }

  $.each(vendors, function(vendor, event){
    if (testEl.style[vendor + 'TransitionProperty'] !== undefined) {
      prefix = '-' + vendor.toLowerCase() + '-'
      eventPrefix = event
      return false
    }
  })

  transform = prefix + 'transform'
  cssReset[transitionProperty = prefix + 'transition-property'] =
  cssReset[transitionDuration = prefix + 'transition-duration'] =
  cssReset[transitionDelay    = prefix + 'transition-delay'] =
  cssReset[transitionTiming   = prefix + 'transition-timing-function'] =
  cssReset[animationName      = prefix + 'animation-name'] =
  cssReset[animationDuration  = prefix + 'animation-duration'] =
  cssReset[animationDelay     = prefix + 'animation-delay'] =
  cssReset[animationTiming    = prefix + 'animation-timing-function'] = ''

  $.fx = {
    off: (eventPrefix === undefined && testEl.style.transitionProperty === undefined),
    speeds: { _default: 400, fast: 200, slow: 600 },
    cssPrefix: prefix,
    transitionEnd: normalizeEvent('TransitionEnd'),
    animationEnd: normalizeEvent('AnimationEnd')
  }

  $.fn.animate = function(properties, duration, ease, callback, delay){
    if ($.isFunction(duration))
      callback = duration, ease = undefined, duration = undefined
    if ($.isFunction(ease))
      callback = ease, ease = undefined
    if ($.isPlainObject(duration))
      ease = duration.easing, callback = duration.complete, delay = duration.delay, duration = duration.duration
    if (duration) duration = (typeof duration == 'number' ? duration :
                    ($.fx.speeds[duration] || $.fx.speeds._default)) / 1000
    if (delay) delay = parseFloat(delay) / 1000
    return this.anim(properties, duration, ease, callback, delay)
  }

  $.fn.anim = function(properties, duration, ease, callback, delay){
    var key, cssValues = {}, cssProperties, transforms = '',
        that = this, wrappedCallback, endEvent = $.fx.transitionEnd,
        fired = false

    if (duration === undefined) duration = $.fx.speeds._default / 1000
    if (delay === undefined) delay = 0
    if ($.fx.off) duration = 0

    if (typeof properties == 'string') {
      // keyframe animation
      cssValues[animationName] = properties
      cssValues[animationDuration] = duration + 's'
      cssValues[animationDelay] = delay + 's'
      cssValues[animationTiming] = (ease || 'linear')
      endEvent = $.fx.animationEnd
    } else {
      cssProperties = []
      // CSS transitions
      for (key in properties)
        if (supportedTransforms.test(key)) transforms += key + '(' + properties[key] + ') '
        else cssValues[key] = properties[key], cssProperties.push(dasherize(key))

      if (transforms) cssValues[transform] = transforms, cssProperties.push(transform)
      if (duration > 0 && typeof properties === 'object') {
        cssValues[transitionProperty] = cssProperties.join(', ')
        cssValues[transitionDuration] = duration + 's'
        cssValues[transitionDelay] = delay + 's'
        cssValues[transitionTiming] = (ease || 'linear')
      }
    }

    wrappedCallback = function(event){
      if (typeof event !== 'undefined') {
        if (event.target !== event.currentTarget) return // makes sure the event didn't bubble from "below"
        $(event.target).unbind(endEvent, wrappedCallback)
      } else
        $(this).unbind(endEvent, wrappedCallback) // triggered by setTimeout

      fired = true
      $(this).css(cssReset)
      callback && callback.call(this)
    }
    if (duration > 0){
      this.bind(endEvent, wrappedCallback)
      // transitionEnd is not always firing on older Android phones
      // so make sure it gets fired
      setTimeout(function(){
        if (fired) return
        wrappedCallback.call(that)
      }, (duration * 1000) + 25)
    }

    // trigger page reflow so new elements can animate
    this.size() && this.get(0).clientLeft

    this.css(cssValues)

    if (duration <= 0) setTimeout(function() {
      that.each(function(){ wrappedCallback.call(this) })
    }, 0)

    return this
  }

  testEl = null
})(Zepto)
View Code

看程式碼首先還是看入口,我們這裡的入口就是animate

demo.animate({ 'width': '300px' }, 1000, 'ease-out', fn);
 1 $.fn.animate = function(properties, duration, ease, callback, delay){
 2   if ($.isFunction(duration))
 3     callback = duration, ease = undefined, duration = undefined
 4   if ($.isFunction(ease))
 5     callback = ease, ease = undefined
 6   if ($.isPlainObject(duration))
 7     ease = duration.easing, callback = duration.complete, delay = duration.delay, duration = duration.duration
 8   if (duration) duration = (typeof duration == 'number' ? duration :
 9                   ($.fx.speeds[duration] || $.fx.speeds._default)) / 1000
10   if (delay) delay = parseFloat(delay) / 1000
11   return this.anim(properties, duration, ease, callback, delay)
12 }

他首先這裡做了一些預設處理,因為我們傳遞的引數是不定的,所以第二個引數極有可能是回撥

所以他第一句就是做一個簡單的判斷,第二句也不例外

其實他整個animate都是做一些屬性處理,並未做實際的事情,具體的實現還是在anim中

 1 $.fn.anim = function(properties, duration, ease, callback, delay){
 2   var key, cssValues = {}, cssProperties, transforms = '',
 3       that = this, wrappedCallback, endEvent = $.fx.transitionEnd,
 4       fired = false
 5 
 6   if (duration === undefined) duration = $.fx.speeds._default / 1000
 7   if (delay === undefined) delay = 0
 8   if ($.fx.off) duration = 0
 9 
10   if (typeof properties == 'string') {
11     // keyframe animation
12     cssValues[animationName] = properties
13     cssValues[animationDuration] = duration + 's'
14     cssValues[animationDelay] = delay + 's'
15     cssValues[animationTiming] = (ease || 'linear')
16     endEvent = $.fx.animationEnd
17   } else {
18     cssProperties = []
19     // CSS transitions
20     for (key in properties)
21       if (supportedTransforms.test(key)) transforms += key + '(' + properties[key] + ') '
22       else cssValues[key] = properties[key], cssProperties.push(dasherize(key))
23 
24     if (transforms) cssValues[transform] = transforms, cssProperties.push(transform)
25     if (duration > 0 && typeof properties === 'object') {
26       cssValues[transitionProperty] = cssProperties.join(', ')
27       cssValues[transitionDuration] = duration + 's'
28       cssValues[transitionDelay] = delay + 's'
29       cssValues[transitionTiming] = (ease || 'linear')
30     }
31   }
32 
33   wrappedCallback = function(event){
34     if (typeof event !== 'undefined') {
35       if (event.target !== event.currentTarget) return // makes sure the event didn't bubble from "below"
36       $(event.target).unbind(endEvent, wrappedCallback)
37     } else
38       $(this).unbind(endEvent, wrappedCallback) // triggered by setTimeout
39 
40     fired = true
41     $(this).css(cssReset)
42     callback && callback.call(this)
43   }
44   if (duration > 0){
45     this.bind(endEvent, wrappedCallback)
46     // transitionEnd is not always firing on older Android phones
47     // so make sure it gets fired
48     setTimeout(function(){
49       if (fired) return
50       wrappedCallback.call(that)
51     }, (duration * 1000) + 25)
52   }
53 
54   // trigger page reflow so new elements can animate
55   this.size() && this.get(0).clientLeft
56 
57   this.css(cssValues)
58 
59   if (duration <= 0) setTimeout(function() {
60     that.each(function(){ wrappedCallback.call(this) })
61   }, 0)
62 
63   return this
64 }

傳入anim的引數真的就沒有什麼問題了

第一個是css屬性

第二個是動畫執行時間

第三個是動畫曲線,這個很神奇,沒事不要去搞他

第四個是回撥函式

第五個是什麼就暫時不知道是什麼了

進入後,10行之前還是在做容錯性處理,這裡我們最主要關注點放在endEvent上面

這個東西由前面的fx物件獲取:

$.fx = {
  off: (eventPrefix === undefined && testEl.style.transitionProperty === undefined),
  speeds: { _default: 400, fast: 200, slow: 600 },
  cssPrefix: prefix,
  transitionEnd: normalizeEvent('TransitionEnd'),
  animationEnd: normalizeEvent('AnimationEnd')
}

而我們要做的chrome、firefox等相容全部被normalizeEvent做了,這裡

vendors = { Webkit: 'webkit', Moz: '', O: 'o' }
testEl = document.createElement('div') $.each(vendors, function(vendor, event){ if (testEl.style[vendor + 'TransitionProperty'] !== undefined) { prefix = '-' + vendor.toLowerCase() + '-' eventPrefix = event return false } })

這裡根據這種方式得出了相容事件的字首,webkit的話會返回webkit字首:

$.fx.transitionEnd => "webkitTransitionEnd"

然後下一步簡單仍然是先設定transition相關的屬性,並且指定事件結束事件回撥:

cssValues[animationName] = properties
cssValues[animationDuration] = duration + 's'
cssValues[animationDelay] = delay + 's'
cssValues[animationTiming] = (ease || 'linear')
endEvent = $.fx.animationEnd

當然,如果我們傳入的CSS不止一個的話,下面的處理會相對複雜點

cssProperties = []
// CSS transitions
for (key in properties)
  if (supportedTransforms.test(key)) transforms += key + '(' + properties[key] + ') '
  else cssValues[key] = properties[key], cssProperties.push(dasherize(key))

if (transforms) cssValues[transform] = transforms, cssProperties.push(transform)
if (duration > 0 && typeof properties === 'object') {
  cssValues[transitionProperty] = cssProperties.join(', ')
  cssValues[transitionDuration] = duration + 's'
  cssValues[transitionDelay] = delay + 's'
  cssValues[transitionTiming] = (ease || 'linear')
}

這裡先放下處理Transform等新屬性之外,與上面的操作無他

然後關鍵步驟又來了,

 1 wrappedCallback = function(event){
 2   if (typeof event !== 'undefined') {
 3     if (event.target !== event.currentTarget) return // makes sure the event didn't bubble from "below"
 4     $(event.target).unbind(endEvent, wrappedCallback)
 5   } else
 6     $(this).unbind(endEvent, wrappedCallback) // triggered by setTimeout
 7 
 8   fired = true
 9   $(this).css(cssReset)
10   callback && callback.call(this)
11 }
12 if (duration > 0){
13   this.bind(endEvent, wrappedCallback)
14   // transitionEnd is not always firing on older Android phones
15   // so make sure it gets fired
16   setTimeout(function(){
17     if (fired) return
18     wrappedCallback.call(that)
19   }, (duration * 1000) + 25)
20 }

他這裡首先宣告瞭回撥函式wrappedCallback,這個函式首先乾的事情是登出事件

然後執行傳入的回撥,這裡將this指向了呼叫者,也就是繫結的標籤

後面便是真實的事件繫結操作,裡面仍然有一個延時函式執行

其中有一個狀態機fired,來記錄該事件是否觸發

然後就為css複製了,這個時候動畫執行結束便會觸發transitionEnd事件了

最後,程式碼結束.......

結語

今天,我們簡單的說了下zepto的animate方法,希望對各位有幫助,若是文中有任何問題請提出

相關文章