從打字機效果的 N 種實現看JS定時器機制和前端動畫

獨釣寒江雪 發表於 2021-01-12

  首先,什麼是打字機效果呢?打字機效果即為文字逐個輸出,實際上就是一種Web動畫。一圖勝千言,諸君請看:

Typed.js

  在Web應用中,實現動畫效果的方法比較多,JavaScript 中可以通過定時器 setTimeout 來實現,css3 可以使用 transition 和 animation 來實現,html5 中的 canvas 也可以實現。除此之外,html5 還提供一個專門用於請求動畫的 API,即 requestAnimationFrame(rAF),顧名思義就是 “請求動畫幀”。接下來,我們一起來看看 打字機效果 的幾種實現。為了便於理解,我會盡量使用簡潔的方式進行實現,有興趣的話,你也可以把這些實現改造的更有逼格、更具藝術氣息一點,因為程式設計,本來就是一門藝術。

打字機效果的 N 種實現

實現一:setTimeout()

  setTimeout版本的實現很簡單,只需把要展示的文字進行切割,使用定時器不斷向DOM元素裡追加文字即可,同時,使用::after偽元素在DOM元素後面產生游標閃爍的效果。程式碼和效果圖如下:

<!-- 樣式 -->
<style type="text/css">
  /* 設定容器樣式 */
  #content {
    height: 400px;
    padding: 10px;
    font-size: 28px;
    border-radius: 20px;
    background-color: antiquewhite;
  }
  /* 產生游標閃爍的效果 */
  #content::after{
      content: '|';
      color:darkgray;
      animation: blink 1s infinite;
  }
  @keyframes blink{
      from{
          opacity: 0;
      }
      to{
          opacity: 1;
      }
  }
</style>

<body>
  <div id='content'></div>
  <script>
    (function () {
    // 獲取容器
    const container = document.getElementById('content')
    // 把需要展示的全部文字進行切割
    const data = '最簡單的打字機效果實現'.split('')
    // 需要追加到容器中的文字下標
    let index = 0
    function writing() {
      if (index < data.length) {
        // 追加文字
        container.innerHTML += data[index ++]
        let timer = setTimeout(writing, 200)
        console.log(timer) // 這裡會依次列印 1 2 3 4 5 6 7 8 9 10
      }
    }
    writing()
  })();
  </script>
</body>

Typed1

  setTimeout()方法的返回值是一個唯一的數值(ID),上面的程式碼中,我們也做了setTimeout()返回值的列印,那麼,這個數值有什麼用呢?
  如果你想要終止setTimeout()方法的執行,那就必須使用 clearTimeout()方法來終止,而使用這個方法的時候,系統必須知道你到底要終止的是哪一個setTimeout()方法(因為你可能同時呼叫了好幾個 setTimeout()方法),這樣clearTimeout()方法就需要一個引數,這個引數就是setTimeout()方法的返回值(數值),用這個數值來唯一確定結束哪一個setTimeout()方法。

實現二:setInterval()

  setInterval實現的打字機效果,其實在MDN window.setInterval 案例三中已經有一個了,而且還實現了播放、暫停以及終止的控制,效果可點選這裡檢視,在此只進行setInterval打字機效果的一個最簡單實現,其實程式碼和前文setTimeout的實現類似,效果也一致。

(function () {
  // 獲取容器
  const container = document.getElementById('content')
  // 把需要展示的全部文字進行切割
  const data = '最簡單的打字機效果實現'.split('')
  // 需要追加到容器中的文字下標
  let index = 0
  let timer = null
  function writing() {
    if (index < data.length) {
      // 追加文字
      container.innerHTML += data[index ++]
      // 沒錯,也可以通過,clearTimeout取消setInterval的執行
      // index === 4 && clearTimeout(timer)
    } else {
      clearInterval(timer)
    }
    console.log(timer) // 這裡會列印出 1 1 1 1 1 ...
  }
  // 使用 setInterval 時,結束後不要忘記進行 clearInterval
  timer = setInterval(writing, 200)
})();

  和setTimeout一樣,setInterval也會返回一個 ID(數字),可以將這個ID傳遞給clearInterval()或者clearTimeout() 以取消定時器的執行

  在此有必要強調一點:定時器指定的時間間隔,表示的是何時將定時器的程式碼新增到訊息佇列,而不是何時執行程式碼。所以真正何時執行程式碼的時間是不能保證的,取決於何時被主執行緒的事件迴圈取到,並執行。

實現三:requestAnimationFrame()

  在動畫的實現上,requestAnimationFrame 比起 setTimeout 和 setInterval來無疑更具優勢。我們先看看打字機效果的requestAnimationFrame實現:

(function () {
    const container = document.getElementById('content')
    const data = '與 setTimeout 相比,requestAnimationFrame 最大的優勢是 由系統來決定回撥函式的執行時機。具體一點講就是,系統每次繪製之前會主動呼叫 requestAnimationFrame 中的回撥函式,如果系統繪製率是 60Hz,那麼回撥函式就每16.7ms 被執行一次,如果繪製頻率是75Hz,那麼這個間隔時間就變成了 1000/75=13.3ms。換句話說就是,requestAnimationFrame 的執行步伐跟著系統的繪製頻率走。它能保證回撥函式在螢幕每一次的繪製間隔中只被執行一次,這樣就不會引起丟幀現象,也不會導致動畫出現卡頓的問題。'.split('')
    let index = 0
    function writing() {
      if (index < data.length) {
        container.innerHTML += data[index ++]
        requestAnimationFrame(writing)
      }
    }
    writing()
  })();

Typed2

  與setTimeout相比,requestAnimationFrame最大的優勢是由系統來決定回撥函式的執行時機。具體一點講,如果螢幕重新整理率是60Hz,那麼回撥函式就每16.7ms被執行一次,如果重新整理率是75Hz,那麼這個時間間隔就變成了1000/75=13.3ms,換句話說就是,requestAnimationFrame的步伐跟著系統的重新整理步伐走。它能保證回撥函式在螢幕每一次的重新整理間隔中只被執行一次,這樣就不會引起丟幀現象,也不會導致動畫出現卡頓的問題

實現四:CSS3

  除了以上三種JS方法之外,其實只用CSS我們也可以實現打字機效果。大概思路是藉助CSS3的@keyframes來不斷改變包含文字的容器的寬度,超出容器部分的文字隱藏不展示。

<style>
  div {
    font-size: 20px;
    /* 初始寬度為0 */
    width: 0;
    height: 30px;
    border-right: 1px solid darkgray;
    /*
    Steps(<number_of_steps>,<direction>)
    steps接收兩個引數:第一個引數指定動畫分割的段數;第二個引數可選,接受 start和 end兩個值,指定在每個間隔的起點或是終點發生階躍變化,預設為 end。
    */
    animation: write 4s steps(14) forwards,
      blink 0.5s steps(1) infinite;
      overflow: hidden;
  }

  @keyframes write {
    0% {
      width: 0;
    }

    100% {
      width: 280px;
    }
  }

  @keyframes blink {
    50% {
      /* transparent是全透明黑色(black)的速記法,即一個類似rgba(0,0,0,0)這樣的值。 */
      border-color: transparent; /* #00000000 */
    }
  }
</style>

<body>
  <div>
    大江東去浪淘盡,千古風流人物
  </div>
</body>

Typed3

  以上CSS打字機效果的原理一目瞭然:

  • 初始文字是全部在頁面上的,只是容器的寬度為0,設定文字超出部分隱藏,然後不斷改變容器的寬度;
  • 設定border-right,並在關鍵幀上改變 border-colortransparent,右邊框就像閃爍的游標了。

實現五:Typed.js

Typed.js is a library that types. Enter in any string, and watch it type at the speed you've set, backspace what it's typed, and begin a new sentence for however many strings you've set.

  Typed.js是一個輕量級的打字動畫庫, 只需要幾行程式碼,就可以在專案中實現炫酷的打字機效果(本文第一張動圖即為Typed.js實現)。原始碼也相對比較簡單,有興趣的話,可以到GitHub進行研讀

<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
  <script src="https://cdn.jsdelivr.net/npm/[email protected]"></script>
</head>

<body>
  <div id="typed-strings">
    <p>Typed.js is a <strong>JavaScript</strong> library.</p>
    <p>It <em>types</em> out sentences.</p>
  </div>
  <span id="typed"></span>
</body>
<script>
  var typed = new Typed('#typed', {
    stringsElement: '#typed-strings',
    typeSpeed: 60
  });
</script>

</html>

Typed4

  使用Typed.js,我們也可以很容易的實現對動畫開始、暫停等的控制:

<body>
  <input type="text" class="content" name="" style="width: 80%;">
  <br>
  <br>
  <button class="start">開始</button>
  <button class="stop">暫停</button>
  <button class="toggle">切換</button>
  <button class="reset">重置</button>
</body>
<script>
const startBtn = document.querySelector('.start');
const stopBtn = document.querySelector('.stop');
const toggleBtn = document.querySelector('.toggle');
const resetBtn = document.querySelector('.reset');
const typed = new Typed('.content',{
  strings: ['雨過白鷺州,留戀銅雀樓,斜陽染幽草,幾度飛紅,搖曳了江上遠帆,回望燈如花,未語人先羞。'],
  typeSpeed: 200,
  startDelay: 100,
  loop: true,
  loopCount: Infinity,
  bindInputFocusEvents:true
});
startBtn.onclick = function () {
  typed.start();
}
stopBtn.onclick = function () {
  typed.stop();
}
toggleBtn.onclick = function () {
  typed.toggle();
}
resetBtn.onclick = function () {
  typed.reset();
}
</script>

Typed5

參考資料:Typed.js官網 | Typed.js GitHub地址

  當然,打字機效果的實現方式,也不僅僅侷限於上面所說的幾種方法,本文的目的,也不在於蒐羅所有打字機效果的實現,如果那樣將毫無意義,接下來,我們將會對CSS3動畫和JS動畫進行一些比較,並對setTimeout、setInterval 和 requestAnimationFrame的一些細節進行總結。

CSS3動畫和JS動畫的比較

  關於CSS動畫和JS動畫,有一種說法是CSS動畫比JS流暢,其實這種流暢是有前提的。藉此機會,我們對CSS3動畫和JS動畫進行一個簡單對比。

JS動畫

  • 優點:

    • JS動畫控制能力強,可以在動畫播放過程中對動畫進行精細控制,如開始、暫停、終止、取消等;
    • JS動畫效果比CSS3動畫豐富,功能涵蓋面廣,比如可以實現曲線運動、衝擊閃爍、視差滾動等CSS難以實現的效果;
    • JS動畫大多數情況下沒有相容性問題,而CSS3動畫有相容性問題;
  • 缺點:

    • JS在瀏覽器的主執行緒中執行,而主執行緒中還有其它需要執行的JS指令碼、樣式計算、佈局、繪製任務等,對其干擾可能導致執行緒出現阻塞,從而造成丟幀的情況;
    • 對於幀速表現不好的低版本瀏覽器,CSS3可以做到自然降級,而JS則需要撰寫額外程式碼;
    • JS動畫往往需要頻繁操作DOM的css屬性來實現視覺上的動畫效果,這個時候瀏覽器要不停地執行重繪和重排,這對於效能的消耗是很大的,尤其是在分配給瀏覽器的記憶體沒那麼寬裕的移動端。

CSS3動畫

  • 優點:

    • 部分情況下瀏覽器可以對動畫進行優化(比如專門新建一個圖層用來跑動畫),為什麼說部分情況下呢,因為是有條件的:

      • 在Chromium基礎上的瀏覽器中
      • 同時CSS動畫不觸發layout或paint,在CSS動畫或JS動畫觸發了paint或layout時,需要main thread進行Layer樹的重計算,這時CSS動畫或JS動畫都會阻塞後續操作。
    • 部分效果可以強制使用硬體加速 (通過 GPU 來提高動畫效能)
  • 缺點:

    • 程式碼冗長。CSS 實現稍微複雜一點動畫,CSS程式碼可能都會變得非常笨重;
    • 執行過程控制較弱。css3動畫只能在某些場景下控制動畫的暫停與繼續,不能在特定的位置新增回撥函式。

main thread(主執行緒)和compositor thread(合成器執行緒)

  • 渲染執行緒分為main thread(主執行緒)和compositor thread(合成器執行緒)。主執行緒中維護了一棵Layer樹(LayerTreeHost),管理了TiledLayer,在compositor thread,維護了同樣一顆LayerTreeHostImpl,管理了LayerImpl,這兩棵樹的內容是拷貝關係。因此可以彼此不干擾,當Javascript在main thread操作LayerTreeHost的同時,compositor thread可以用LayerTreeHostImpl做渲染。當Javascript繁忙導致主執行緒卡住時,合成到螢幕的過程也是流暢的。
  • 為了實現防假死,滑鼠鍵盤訊息會被首先分發到compositor thread,然後再到main thread。這樣,當main thread繁忙時,compositor thread還是能夠響應一部分訊息,例如,滑鼠滾動時,如果main thread繁忙,compositor thread也會處理滾動訊息,滾動已經被提交的頁面部分(未被提交的部分將被刷白)。

CSS動畫比JS動畫流暢的前提

  • CSS動畫比較少或者不觸發pain和layout,即重繪和重排時。例如通過改變如下屬性生成的css動畫,這時整個CSS動畫得以在compositor thread完成(而JS動畫則會在main thread執行,然後觸發compositor進行下一步操作):

    • backface-visibility:該屬性指定當元素背面朝向觀察者時是否可見(3D,實驗中的功能);
    • opacity:設定 div 元素的不透明級別;
    • perspective 設定元素檢視,該屬性隻影響 3D 轉換元素;
    • perspective-origin:該屬性允許您改變 3D 元素的底部位置;
    • transform:該屬性應用於元素的2D或3D轉換。這個屬性允許你將元素旋轉,縮放,移動,傾斜等。
  • JS在執行一些昂貴的任務時,main thread繁忙,CSS動畫由於使用了compositor thread可以保持流暢;
  • 部分屬效能夠啟動3D加速和GPU硬體加速,例如使用transform的translateZ進行3D變換時;
  • 通過設定 will-change 屬性,瀏覽器就可以提前知道哪些元素的屬性將會改變,提前做好準備。待需要改變元素的時機到來時,就可以立刻實現它們,從而避免卡頓等問題。

    • 不要將 will-change 應用到太多元素上,如果過度使用的話,可能導致頁面響應緩慢或者消耗非常多的資源。
    • 例如下面的程式碼就是提前告訴渲染引擎 box 元素將要做幾何變換和透明度變換操作,這時候渲染引擎會將該元素單獨實現一幀,等這些變換髮生時,渲染引擎會通過合成執行緒直接去處理變換,這些變換並沒有涉及到主執行緒,這樣就大大提升了渲染的效率。

      .box {will-change: transform, opacity;}

setTimeout、setInterval 和 requestAnimationFrame 的一些細節

setTimeout 和 setInterval

  • setTimeout 的執行時間並不是確定的。在JavaScript中,setTimeout 任務被放進了非同步佇列中,只有當主執行緒上的任務執行完以後,才會去檢查該佇列裡的任務是否需要開始執行,所以 setTimeout 的實際執行時機一般要比其設定的時間晚一些。
  • 重新整理頻率受 螢幕解析度 和 螢幕尺寸 的影響,不同裝置的螢幕繪製頻率可能會不同,而 setTimeout 只能設定一個固定的時間間隔,這個時間不一定和螢幕的重新整理時間相同。
  • setTimeout 的執行只是在記憶體中對元素屬性進行改變,這個變化必須要等到螢幕下次繪製時才會被更新到螢幕上。如果兩者的步調不一致,就可能會導致中間某一幀的操作被跨越過去,而直接更新下一幀的元素。假設螢幕每隔16.7ms重新整理一次,而setTimeout 每隔10ms設定影像向左移動1px, 就會出現如下繪製過程:

    • 第 0 ms:螢幕未繪製,等待中,setTimeout 也未執行,等待中;
    • 第 10 ms:螢幕未繪製,等待中,setTimeout 開始執行並設定元素屬性 left=1px;
    • 第 16.7 ms:螢幕開始繪製,螢幕上的元素向左移動了 1px, setTimeout 未執行,繼續等待中;
    • 第 20 ms:螢幕未繪製,等待中,setTimeout 開始執行並設定 left=2px;
    • 第 30 ms:螢幕未繪製,等待中,setTimeout 開始執行並設定 left=3px;
    • 第 33.4 ms:螢幕開始繪製,螢幕上的元素向左移動了 3px, setTimeout 未執行,繼續等待中;
    • ...

  從上面的繪製過程中可以看出,螢幕沒有更新 left=2px 的那一幀畫面,元素直接從left=1px 的位置跳到了 left=3px 的的位置,這就是丟幀現象,這種現象就會引起動畫卡頓。

  • setInterval的回撥函式呼叫之間的實際延遲小於程式碼中設定的延遲,因為回撥函式執行所需的時間“消耗”了間隔的一部分,如果回撥函式執行時間長、執行次數多的話,誤差也會越來越大
// repeat with the interval of 2 seconds
let timerId = setInterval(() => console.log('tick', timerId), 2000);
// after 50 seconds stop
setTimeout(() => {
  clearInterval(timerId);
  console.log('stop', timerId);
}, 50000);

setInterval

  • 巢狀的setTimeout可以保證固定的延遲:
let timerId = setTimeout(function tick() {
  console.log('tick', timerId);
  timerId = setTimeout(tick, 2000); // (*)
}, 2000);

setTimeout

requestAnimationFrame

  除了上文提到的requestAnimationFrame的優勢外,requestAnimationFrame還有以下兩個優勢:

  • CPU節能:使用setTimeout實現的動畫,當頁面被隱藏或最小化時,setTimeout 仍然在後臺執行動畫任務,由於此時頁面處於不可見或不可用狀態,重新整理動畫是沒有意義的,完全是浪費CPU資源。而requestAnimationFrame則完全不同,當頁面處於未啟用的狀態下,該頁面的螢幕重新整理任務也會被系統暫停,因此跟著系統步伐走的requestAnimationFrame也會停止渲染,當頁面被啟用時,動畫就從上次停留的地方繼續執行,有效節省了CPU開銷。
  • 函式節流:在高頻率事件(resize,scroll等)中,為了防止在一個重新整理間隔內發生多次函式執行,使用requestAnimationFrame可保證每個重新整理間隔內,函式只被執行一次,這樣既能保證流暢性,也能更好的節省函式執行的開銷。一個重新整理間隔內函式執行多次是沒有意義的,因為顯示器每16.7ms重新整理一次,多次繪製並不會在螢幕上體現出來。

關於最小時間間隔

  • 2011年的標準中是這麼規定的:

    • setTimeout:如果當前正在執行的任務是由setTimeout()方法建立的任務,並且時間間隔小於4ms,則將時間間隔增加到4ms;
    • setInterval:如果時間間隔小於10ms,則將時間間隔增加到10ms。
  • 在最新標準中:如果時間間隔小於0,則將時間間隔設定為0。 如果巢狀級別大於5,並且時間間隔小於4ms,則將時間間隔設定為4ms。

定時器的清除

  • 由於clearTimeout()和clearInterval()清除的是同一列表(活動計時器列表)中的條目,因此可以使用這兩種方法清除setTimeout()或 setInterval()建立的計時器。

參考資料

往期高分合集:

本文首發於個人部落格,歡迎指正和star