一個不安分的箭頭引起的思考(JS動畫實現方式對比)

產品組的小仙女爲了表示數據的流向提出向作一個動態的箭頭,可是她沒有想好要怎麼作。因而,我給了她2套效果。啦啦啦啦~html

哈哈哈

普通箭頭的實現

普通箭頭咱們經過一個正方形的div,顯示 div 的上邊框和右邊框,同時旋轉 45 度就能夠實現一個直角的箭頭。web

<style>
        .wrap{
            width: 10px;
            height: 10px;
            border-top: 2px solid red;
            border-right: 2px solid red;
            transform: rotate(45deg);
        }
    </style>
    <div class="wrap"></div>
複製代碼

而咱們的產品想要的是鈍角(大於90度)的多個箭頭額。因而面試

鈍角箭頭

苦思冥想,屢次嘗試以後,決定簡簡單單用兩個線絕對定位造成一個鈍角。promise

<style>
    .top{
        width: 4px;
        height: 10px;
        transform: rotate(23deg);
        position: relative;
        top: -1px;
        background-color: #FFBD1D;
    }
    .bottom{
        width: 4px;
        height: 10px;
        transform: rotate(-23deg);
        position: relative;
        bottom: -1px;
        background-color: rgb(255, 189, 29);
    }
    .arrow-wrap{
        font-size: 0;
    }
</style>
<div class="wrap">
    <div class="arrow-wrap move">
        <div class="arrow">
            <div class="bottom"></div>
            <div class="top"></div>
        </div>
    </div>   
</div>
複製代碼

複製 5 個事後就是完整的箭頭了,有了完整的箭頭咱們就能夠開始寫動畫啦。
瀏覽器

動畫一

5個箭頭一塊兒動。markdown

//關鍵代碼
.move{
    animation: my-animation 2s;

}
@keyframes my-animation{
    0%{transform: translate(0px)}
    25%{transform: translate(13px)}
    50%{transform: translate(0px)}
    75%{transform: translate(13px)}
    100%{transform: translate(0px)}
}
複製代碼

總體箭頭能夠旋轉一下
異步

.arrow-wrap{
      display: inline-block;
      font-size: 0;
      transform: rotate(45deg)
  }
  .move{
      animation: my-animation 2s;

  }
  @keyframes my-animation{
      0%{transform: translate(0px) rotate(45deg);}
      25%{transform: translate(13px, 13px) rotate(45deg);}
      50%{transform: translate(0px) rotate(45deg);}
      75%{transform: translate(13px, 13px) rotate(45deg);}
      100%{transform: translate(0px) rotate(45deg);}
  }
複製代碼

動畫二

5個箭頭分別出現再消失造成一種移動的錯覺。頁面初始的時候5個箭頭 opacity:0; 每過固定時間如 90ms 依次顯示(opacity:1)箭頭,就能夠產生箭頭移動的效果。咱們能夠利用 animation 動畫的延遲依次讓各個箭頭顯示。咱們也能夠利用 js 控制好時間間隔給 5 個小箭頭添加樣式實現。async

純 CSS 實現

不嫌繁瑣,咱們爲每一個小箭頭定義了 animation 的屬性值,經過爲 .arrow-wrap 類下面的 5 個 .arrow 類依次添加動畫屬性實現。缺點是 CSS 動畫只能第一知足咱們順序執行的需求。函數

  • .arrow-wrap .arrow:nth-child(3){}; 表示的是 .arrow-wrap 類下面的,第 3 個子元素同時是 arrow class 的元素的樣式;
  • animation-fill-mode: forwards; 控制動畫結束後保持最後一幀的狀態,也就是 opacity: 1;
  • 經過 animation-delay 屬性設置了不一樣箭頭的延遲執行。
  • 延遲執行是一次性的,只有動畫的第一次有效。那麼經過 CSS 實現的箭頭也只能是一次性的,只能執行一次動畫。
<style>
    .arrow{
        display: inline-block;
        margin-left: 7px;
        opacity: 0;
    }
    .top{
        width: 4px;
        height: 10px;
        transform: rotate(23deg);
        position: relative;
        top: -1px;
        background-color: rgb(255, 189, 29);
    }
    .bottom{
        width: 4px;
        height: 10px;
        transform: rotate(-23deg);
        position: relative;
        bottom: -1px;
        background-color: rgb(255, 189, 29);
    }
    .arrow-wrap{
        display: inline-block;
        min-width: 40px;
        font-size: 0;
    }
    .arrow-wrap .arrow:first-child{
        animation-name: my-animation;
        animation-duration: 0.1s;
        animation-fill-mode: forwards;

    }
    .arrow-wrap .arrow:nth-child(2){
        animation-name: my-animation;
        animation-delay: 0.08s;
        animation-duration: 0.1s;
        animation-fill-mode: forwards;
    }
    .arrow-wrap .arrow:nth-child(3){
        animation-name: my-animation;
        animation-delay: 0.18s;
        animation-duration: 0.1s;
        animation-fill-mode: forwards;
    }
    .arrow-wrap .arrow:nth-child(4){
        animation-name: my-animation;
        animation-delay: 0.28s;
        animation-duration: 0.1s;
        animation-fill-mode: forwards;
    }
    .arrow-wrap .arrow:last-child{
        animation-name: my-animation;
        animation-delay: 0.38s;
        animation-duration: 0.1s;
        animation-fill-mode: forwards;
    }

    @keyframes my-animation{
        100%{opacity: 1;}
    }
</style>
<div class="wrap">
    <div class="arrow-wrap">
        <div class="arrow">
            <div class="bottom"></div>
            <div class="top"></div>
        </div>
        <div class="arrow">
            <div class="bottom"></div>
            <div class="top"></div>
        </div>
        <div class="arrow">
            <div class="bottom"></div>
            <div class="top"></div>
        </div>
        <div class="arrow">
            <div class="bottom"></div>
            <div class="top"></div>
        </div>
        <div class="arrow">
            <div class="bottom"></div>
            <div class="top"></div>
        </div>
    </div>   
</div>
複製代碼
setInterval

固定時間執行的函數咱們很容易想到 setTimeout 和 setInterval 兩個函數。 setTimeout 表示延遲多久執行;setInterval 表示每隔固定時間週期性的執行。固然咱們其實能夠在使用setTimeout 函數中調用自身實現每隔固定時間週期性的執行的效果。oop

setTimeout 的返回值 timeoutID 是一個正整數,表示定時器的編號。這個值能夠傳遞給clearTimeout()來取消該定時器。

setInterval 的返回值 intervalID 是一個非零數值,用來標識經過setInterval()建立的計時器,這個值能夠用來做爲clearInterval()的參數來清除對應的計時器 。

使用 setInterval 實現動畫的代碼會簡潔一些:

<style>
    .ease-in{
        animation-name: my-aimation;
        animation-duration: 0.09s;
        animation-fill-mode: forwards;
    }

    @keyframes my-aimation{
        100%{opacity: 1;}
    }
</style>
<script>
    const markers = document.getElementsByClassName('arrow');
    let index = 0;
    //先爲第一個小箭頭添加動畫,每隔 0.09s 依次爲每一個小箭頭添加動畫。
    markers[index].setAttribute("class", "arrow ease-in");
    let shrinkTimer = setInterval(()=>{
        index++;
        if(index == markers.length){
            clearInterval(shrinkTimer);
            return;
        }
        markers[index].setAttribute("class", "arrow ease-in");
    }, 90);
</script>
複製代碼

咱們能夠經過修改 setInterval 函數裏面的邏輯,實現動畫的循環播放:

.ease-in{
        animation-name: my-aimation;
        animation-duration: 0.2s;
        animation-fill-mode: forwards;
    }
    @keyframes my-aimation{
        100%{opacity: 1;}
    }
  <script>
      const markers = document.getElementsByClassName('arrow');
      let index = 0;
      markers[index].setAttribute("class", "arrow ease-in");
      let shrinkTimer = setInterval(()=>{
          index++;
          if(index == markers.length){
              index = 0;
              [...markers].forEach(item=>{
                  item.setAttribute("class", "arrow");
              });
          }
          markers[index].setAttribute("class", "arrow ease-in");
      }, 200);
  </script>
複製代碼
requestAnimationFrame

大多數 電腦顯示器的刷新頻率是60Hz,大概至關於每秒鐘重繪60次。大多數瀏覽器都會對重繪操做加以限制,不超過顯示器的重繪頻率,由於即便超過那個頻率用戶體驗也不會有提高。所以,最平滑動畫的最佳循環間隔是1000ms/60,約等於16.7ms。requestAnimationFrame 是 HTML5新增的定時器。由系統來決定回調函數的執行時機。具體一點講就是,系統每次繪製以前會主動調用 requestAnimationFrame 中的回調函數。

  • window.requestAnimationFrame(fn) 告訴瀏覽器你但願在瀏覽器下一次重繪以前執行 fn 函數中的邏輯。與 window.setTimeout(fn, duration) 相似,自己只能執行一次。若是想實現相似於 setInterval 週期性執行函數 fn 的話,須要在 fn 中再次調用window.requestAnimationFrame()

  • window.requestAnimationFrame(fn) 中 fn 函數自己是由一個默認的參數的 timestamp ,該參數 timestamp 與performance.now()的返回值相同,它表示requestAnimationFrame() 開始去執行回調函數的時刻。咱們能夠理解爲與 Date.now() 相似的表示時間,timestamp 以浮點數的形式表示時間,精度最高可達微秒級。

  • requestAnimationFrame 的返回值是一個 long 整數,請求 ID ,是回調列表中惟一的標識。是個非零值,沒別的意義。你能夠傳這個值給 window.cancelAnimationFrame() 以取消回調函數。

  • 在頁面 A 中使用了 requestAnimationFrame 函數循環執行動畫的時候,切換到頁面 B,這個時候 A 頁面的 requestAnimationFrame 是不會執行,由於這個時候自己 A 頁面也沒有內容要重繪到屏幕上。

利用 requestAnimationFrame 的動畫代碼以下:

<script>
    let index = 0;
    const markers = document.getElementsByClassName('arrow');
    let count = 0;
    let myReq;
    const times = 5;
    function loop(){
        myReq = window.requestAnimationFrame(function(){
            count++;
            if(count%times === 0){
                markers[index].setAttribute("class", "arrow ease-in");
                index++;
            }
            loop();
        });
        if(count > times * markers.length){
            window.cancelAnimationFrame(myReq);
        }

    }
    loop();
</script>
複製代碼

思考

既生瑜何生亮呢?已經有 setInterval 和 setTimeout 這些定時器來幫助咱們完成 CSS 不能完成的動畫了,爲何還要有 window.requestAnimationFrame(fn) 的出現呢?讓咱們來分析一下 setInterval、setTimeout 存在的問題。

setInterval 的問題分析:

  1. setInterval 設置的時間間隔 duration 表明的是按照 duration 的間隔執行必定的邏輯。 duration 不是 16.7ms, 好比說是 10ms。那麼 10ms 後到了執行了必定的邏輯,可是不會渲染到頁面上,得等到 16.7ms 的時候纔會渲染。渲染流程以下:

    • 10ms 執行移動 1px 的函數;16.7ms 的時候渲染
    • 20ms 執行移動 2px 的函數;不會渲染
    • 30ms 執行移動 3px 的函數;33.4ms 的時候渲染...

    能夠看到 20ms 的移動沒有被渲染,會出現 丟幀 的問題。頁面上給人一種頓頓頓的卡頓。致使這個問題的緣由是咱們執行函數的渲染頻率跟頁面實際的渲染頻率沒有保持一致。若是咱們直接使用 requestAnimationFrame 來控制動畫效果,顯然就沒有這個問題。

  2. 既然是頻率不一致致使的,那咱們使得它們一致不就能夠了嗎?是的,這樣是能夠的。咱們儘可能讓咱們 setInterval 執行頻率與頁面渲染頻率保持一致(或者間隔時間是屏幕渲染時間間隔的倍數)。可是須要注意兩個方面。一方面,屏幕刷新頻率受 屏幕分辨率屏幕尺寸 的影響,不一樣設備的屏幕繪製頻率可能會不一樣,咱們不能直接固定死 setInterval 的執行頻率;另外一方面,setInterval 自己執行時間和間隔實際上是有不肯定性的。爲何這麼說呢?緣由有三點:

    • 事件循環機制
    • setInterval 重複定時器的問題。
    • tab 頁面切換的時候 setInterval 仍然執行,頁面並無渲染。
事件循環

咱們都知道 JavaScript 是一門單線程且非阻塞的腳本語言,這意味着 JavaScript 代碼在執行的時候都只有一個主線程來處理全部任務。而非阻塞是指當代碼須要處理異步任務時,主線程會掛起(pending)這個任務,當異步任務處理完畢後,主線程再根據必定規則去執行相應回調。

事實上,當任務處理完畢後,JavaScript 會將這個事件加入到一個隊列中,咱們稱這個隊列爲 事件隊列。被放入事件隊列中的事件不會當即執行其回調,而是等待當前執行棧中的全部任務執行完畢後,主線程會去查找事件隊列中是否有任務。

異步任務有兩種類型:微任務(microtask)和宏任務(macrotask)。不一樣類型的任務會被分配到不一樣的任務隊列中。

當執行棧中的全部任務都執行完畢後(同步代碼執行完畢後),會去檢查微任務隊列中是否有事件存在,若是存在,則會依次執行微任務隊列中事件對應的回調,直到爲空。而後去宏任務隊列中取出一個事件,把對應的回調加入當前執行棧,當執行棧中的全部任務都執行完畢後,檢查微任務隊列是否有事件存在。無限重複此過程,就造成了一個無限循環。這個循環就叫做 事件循環

屬於微任務的事件包括但不限於:

  • Promsie.then
  • MutationObserver
  • Object.observe
  • process.nextTick

屬於宏任務的事件包括但不限於:

  • setTimeout
  • setInterval
  • setImmediate
  • MessageChannel
  • requestAnimationFrame
  • I/O
  • UI 交互事件

setInterval 和 setTimeout 都屬於宏任務。對於比較複雜的 JavaScript 業務代碼裏面,setInterval 和 setTimeout 的執行時間是不肯定的。setTimeout(fn, duration); 瀏覽器只是在 duration 時間後,將 fn 加入到宏任務隊列中,具體執行的時間要看事件循環執行宏任務的時間了。

插播一道面試題目,說明的更詳細一些:

console.log('script start')

  async function async1() {
    await async2()
    console.log('async1 end')
  }
  async function async2() {
    console.log('async2 end')
  }
  async1()

  setTimeout(function() {
    console.log('setTimeout')
  }, 0)

  new Promise(resolve => {
    console.log('Promise')
    resolve()
  })
    .then(function() {
      console.log('promise1')
    })
    .then(function() {
      console.log('promise2')
    })

  console.log('script end')
複製代碼
上述代碼執行順序:   
  script start  
  async2 end   
  Promise   
  script end  
  async1 end  
  promise1  
  promise2  
  setTimeout  
複製代碼

事件循環的執行順序是:同步代碼—> 微任務(要所有執行)—>宏任務(執行一個)—>微任務(所有執行)—>宏任務(執行一個) 說明:async function async1(){...} 函數體內的同步代碼其實至關於 new Promise(resolve=>{...; resolve()}) 的代碼。是同步代碼。遇到 await 至關於 new Promise().then(res=>{...}); 是 微任務,會被放入微任務隊列中,等待執行。這個和個人另外一篇博文中解釋的是一致的 juejin.cn/post/688367…

細心的你必定會發現 requestAnimationFrame 也屬於宏任務。是的,requestAnimationFrame 也屬於宏任務。跟 setTimeout 和 setInterval 不一樣的是咱們不須要考慮動畫執行頻率和屏幕渲染頻率是否一致的問題。使用 requestAnimationFrame 實現的動畫會更加絲滑。

setInterval 重複定時器的問題

在《JavaScript高級程序設計》這本書中有介紹。咱們已經瞭解到其實 setInterval 每次執行的時間實際上是待定的。那就存在屢次函數都未執行的狀況。使用 setInterval()建立的定時器確保了定時器代碼規則地插入隊列中。這個方式的問題在於,定時器代碼可能在代碼再次被添加到隊列以前尚未完成執行,結果致使定時器代碼連續運行好幾回,而之間沒有任何停頓。幸虧 JavaScript 引擎夠聰明,能避免這個問題。當使用 setInterval()時,僅當沒有該定時器的任何其餘代碼實例時,纔將定時器代碼添加到隊列中。 這確保了定時器代碼加入到隊列中的最小時間間隔爲指定間隔。

這種重複定時器的規則有兩個問題: (1) 某些間隔會被跳過; (2) 多個定時器的代碼執行之間的間隔可能會比預期的小。

《JavaScript高級程序設計》也介紹了怎麼解決這個問題,那就是使用 setTimeout 調用自身的方式實現:

setTimeout(function(){
  //處理中
  setTimeout(arguments.callee, interval);
}, interval);
複製代碼

這個模式鏈式調用了 setTimeout(),每次函數執行的時候都會建立一個新的定時器。第二個 setTimeout()調用使用了 arguments.callee 來獲取對當前執行的函數的引用,併爲其設置另一個定時器。這樣作的好處是,在前一個定時器代碼執行完以前,不會向隊列插入新的定時器代碼,確保不會有任何缺失的間隔。並且,它能夠保證在下一次定時器代碼執行以前,至少要等待指定的間隔,避免了連續的運行。這個模式主要用於重複定時器。

這樣就完美了嗎?其實不是的, setTimeout() 屬於宏任務一樣具備執行時間不肯定的問題的。setTimeout(fn, duration); 瀏覽器也只是在 duration 時間後,將 fn 加入到宏任務隊列中,具體執行的時間要看事件循環執行宏任務的時間了。利用 setTimeout()調用自身能夠實現代碼重複被執行,優於直接使用 setInterval 實現的方式。

tab 頁面切換的問題

使用 setTimeout 或者 setInterval 函數實現的動畫在克服了渲染頻率不一致的問題後,看起來還能夠,但當咱們切換了頁面,等待一下子後,再返回動畫頁面會發現出現有些詭異的現象。

好比咱們使用 setTimeout 或者 setInterval 實現了輪播圖;切換頁面後,其實 setTimeout、 setInterval 函數仍然在執行,可是頁面並無繼續渲染保留的是切換前的位置。當咱們切換回頁面的時候,setTimeout、 setInterval 函數執行的位置確定跟以前是不一致的。這就致使了動畫看起來是不連貫的。

這個問題也是有破解方法的,那就是監聽頁面被隱藏和激活的事件。在頁面被隱藏的時候清除動畫,保留動畫當前的狀態;頁面被激活的時候從新開始動畫。代碼能夠參考 juejin.cn/post/688361… 博客。

還有一點,我看不少博客沒有介紹。那就是 requestAnimationFrame 在頁面切換的時候不會執行,可是若是咱們的代碼利用了 requestAnimationFrame 回調函數中的時間值 timestamp 的話要注意了,timestamp 是隨着時間增加的,表示每次回調執行的時間。看下面的例子:

const element = document.getElementById('myDiv');
  let start;
  function step(timestamp) {
      if (start === undefined)
          start = timestamp;
      const elapsed = timestamp - start;
      element.style.transform = 'translateX(' + 0.1 * elapsed + 'px)';
      window.requestAnimationFrame(step);
  }
  window.requestAnimationFrame(step);
複製代碼

頁面不切換的話, myDiv 絲滑般在頁面滑動,可是當咱們切換了頁面。雖然 requestAnimationFrame 函數並不執行,當咱們再切回來的時候,myDiv 的位置並非咱們切換頁面前的位置了,由於每次執行 requestAnimationFrame 的回調函數中 timestamp 的值表示的是一個客觀值,是隨着時間增加的。

總結

setInterval:

  • setInterval 是宏任務,受事件循環機制的影響可能不會按照咱們指望的時間順序執行;
  • 同時,setInterval 還有重複定時器的問題:僅當沒有該定時器的任何其餘代碼實例時,纔將定時器代碼添加到隊列中。這就致使了2個問題(1) 某些間隔會被跳過; (2) 多個定時器的代碼執行之間的間隔可能會比預期的小。
  • 最後一點是 setInterval 在頁面切換的時候也會執行,致使了動畫的不連貫問題。解決方法是監聽頁面激活和隱藏事件。

setTimeout:

  • setTimeout 也是宏任務,會受事件循環機制的影響可能不會按照咱們指望的時間執行;
  • 利用 setTimeout 調用自身的方式能夠實現函數按固定時間間隔重複執行,實現效果優於直接使用 setInterval。
  • 同 setInterval 同樣,頁面切換後仍然在執行,存在動畫的不連貫問題。解決方法是監聽頁面激活和隱藏事件。

requestAnimationFrame:

  • requestAnimationFrame 的出現解決了 setTimeout 和 setInterval 實現動畫頻率和刷新頻率不一致致使頁面不夠絲滑的問題。
  • requestAnimationFrame 自己在頁面切換後不會執行,是優勢也是一個小坑。使用時須要根據具體動畫效果考慮。
  • 最後一點, requestAnimationFrame 畢竟是 HTML5 才新增的定時器,須要經過 setTimeout 進行pollfy。
let lastTime = 0
const prefixes = 'webkit moz ms o'.split(' ') // 各瀏覽器前綴

let requestAnimationFrame
let cancelAnimationFrame

const isServer = typeof window === 'undefined'
if (isServer) {
  requestAnimationFrame = function() {
    return
  }
  cancelAnimationFrame = function() {
    return
  }
} else {
  requestAnimationFrame = window.requestAnimationFrame
  cancelAnimationFrame = window.cancelAnimationFrame
  let prefix
    // 經過遍歷各瀏覽器前綴,來獲得requestAnimationFrame和cancelAnimationFrame在當前瀏覽器的實現形式
  for (let i = 0; i < prefixes.length; i++) {
    if (requestAnimationFrame && cancelAnimationFrame) { break }
    prefix = prefixes[i]
    requestAnimationFrame = requestAnimationFrame || window[prefix + 'RequestAnimationFrame']
    cancelAnimationFrame = cancelAnimationFrame || window[prefix + 'CancelAnimationFrame'] || window[prefix + 'CancelRequestAnimationFrame']
  }

  // 若是當前瀏覽器不支持requestAnimationFrame和cancelAnimationFrame,則會退到setTimeout
  if (!requestAnimationFrame || !cancelAnimationFrame) {
    requestAnimationFrame = function(callback) {
      const currTime = new Date().getTime()
      // 爲了使setTimteout的儘量的接近每秒60幀的效果
      const timeToCall = Math.max(0, 16 - (currTime - lastTime))
      const id = window.setTimeout(() => {
        callback(currTime + timeToCall)
      }, timeToCall)
      lastTime = currTime + timeToCall
      return id
    }

    cancelAnimationFrame = function(id) {
      window.clearTimeout(id)
    }
  }
}

export { requestAnimationFrame, cancelAnimationFrame }

複製代碼

參考:
www.cnblogs.com/onepixel/p/…
www.cnblogs.com/xiaohuochai…

感謝

若是本文有幫助到你的地方,記得點贊哦,這將是我持續不斷創做的動力~

You want to see a miracle, son? Be the miracle. 年輕人,想要看到奇蹟,那就去成爲奇蹟。

相關文章
相關標籤/搜索