2024 年 1 月 20 日

JavaScript 動畫

JavaScript 動畫可以處理 CSS 無法處理的事情。

例如,沿著複雜路徑移動,使用與貝茲曲線不同的計時函數,或在畫布上進行動畫。

使用 setInterval

動畫可以實作為一系列的畫面,通常是對 HTML/CSS 屬性的微小變更。

例如,將 style.left0px 變更為 100px 會移動元素。如果我們在 setInterval 中增加它,以微小的延遲(例如每秒 50 次)變更 2px,那麼它看起來就會很順暢。這與電影中的原理相同:每秒 24 個畫面就足以讓它看起來很順暢。

偽程式碼可以如下所示

let timer = setInterval(function() {
  if (animation complete) clearInterval(timer);
  else increase style.left by 2px
}, 20); // change by 2px every 20ms, about 50 frames per second

更完整的動畫範例

let start = Date.now(); // remember start time

let timer = setInterval(function() {
  // how much time passed from the start?
  let timePassed = Date.now() - start;

  if (timePassed >= 2000) {
    clearInterval(timer); // finish the animation after 2 seconds
    return;
  }

  // draw the animation at the moment timePassed
  draw(timePassed);

}, 20);

// as timePassed goes from 0 to 2000
// left gets values from 0px to 400px
function draw(timePassed) {
  train.style.left = timePassed / 5 + 'px';
}

按一下以查看示範

結果
index.html
<!DOCTYPE HTML>
<html>

<head>
  <style>
    #train {
      position: relative;
      cursor: pointer;
    }
  </style>
</head>

<body>

  <img id="train" src="https://js.cx/clipart/train.gif">


  <script>
    train.onclick = function() {
      let start = Date.now();

      let timer = setInterval(function() {
        let timePassed = Date.now() - start;

        train.style.left = timePassed / 5 + 'px';

        if (timePassed > 2000) clearInterval(timer);

      }, 20);
    }
  </script>


</body>

</html>

使用 requestAnimationFrame

想像一下我們同時執行多個動畫。

如果我們分別執行它們,那麼即使每個動畫都有 setInterval(..., 20),瀏覽器仍必須比每 20ms 重新繪製更頻繁。

這是因為它們有不同的開始時間,所以「每 20ms」在不同的動畫之間有所不同。間隔沒有對齊。因此,我們將在 20ms 內執行多個獨立執行。

換句話說,這

setInterval(function() {
  animate1();
  animate2();
  animate3();
}, 20)

…比三個獨立呼叫更輕量

setInterval(animate1, 20); // independent animations
setInterval(animate2, 20); // in different places of the script
setInterval(animate3, 20);

這些多個獨立的重新繪製應該組合在一起,以使瀏覽器更容易重新繪製,從而減少 CPU 負載並看起來更順暢。

還有一件事需要記住。有時 CPU 會過載,或者有其他原因導致重新繪製的頻率較低(例如當瀏覽器標籤隱藏時),因此我們真的不應該每 20ms 執行一次。

但是我們如何在 JavaScript 中知道這一點?有一個規範 動畫時序 提供了 requestAnimationFrame 函數。它解決了所有這些問題,甚至更多。

語法

let requestId = requestAnimationFrame(callback)

這將安排 callback 函數在瀏覽器想要執行動畫時最接近的時間執行。

如果我們在 callback 中對元素進行更改,則它們將與其他 requestAnimationFrame 回呼和 CSS 動畫組合在一起。因此,將進行一次幾何重新計算和重新繪製,而不是多次。

返回的值 requestId 可用於取消呼叫

// cancel the scheduled execution of callback
cancelAnimationFrame(requestId);

callback 獲得一個參數 - 從頁面載入開始經過的時間(以毫秒為單位)。也可以通過呼叫 performance.now() 來獲取此時間。

通常 callback 會很快執行,除非 CPU 過載或筆電電池幾乎耗盡,或者有其他原因。

以下程式碼顯示了 requestAnimationFrame 前 10 次執行的時間。通常為 10-20ms

<script>
  let prev = performance.now();
  let times = 0;

  requestAnimationFrame(function measure(time) {
    document.body.insertAdjacentHTML("beforeEnd", Math.floor(time - prev) + " ");
    prev = time;

    if (times++ < 10) requestAnimationFrame(measure);
  })
</script>

結構化動畫

現在,我們可以根據 requestAnimationFrame 製作一個更通用的動畫函數

function animate({timing, draw, duration}) {

  let start = performance.now();

  requestAnimationFrame(function animate(time) {
    // timeFraction goes from 0 to 1
    let timeFraction = (time - start) / duration;
    if (timeFraction > 1) timeFraction = 1;

    // calculate the current animation state
    let progress = timing(timeFraction)

    draw(progress); // draw it

    if (timeFraction < 1) {
      requestAnimationFrame(animate);
    }

  });
}

函數 animate 接受 3 個參數,這些參數基本上描述了動畫

持續時間

動畫的總時間。例如,1000

timing(timeFraction)

計時函數,例如 CSS 屬性 transition-timing-function,它會取得已過時間的分數(開始時為 0,結束時為 1),並傳回動畫完成度(例如貝茲曲線上的 y)。

例如,線性函數表示動畫會以相同的速度均勻進行

function linear(timeFraction) {
  return timeFraction;
}

其圖表:

這就像 transition-timing-function: linear。下方顯示更多有趣的變體。

draw(progress)

取得動畫完成狀態並繪製的函數。值 progress=0 表示動畫開始狀態,而 progress=1 表示結束狀態。

這是實際繪製動畫的函數。

它可以移動元素

function draw(progress) {
  train.style.left = progress + 'px';
}

…或執行其他任何動作,我們可以以任何方式動畫化任何事物。

讓我們使用我們的函數,將元素 width0 動畫化到 100%

按一下元素以查看示範

結果
animate.js
index.html
function animate({duration, draw, timing}) {

  let start = performance.now();

  requestAnimationFrame(function animate(time) {
    let timeFraction = (time - start) / duration;
    if (timeFraction > 1) timeFraction = 1;

    let progress = timing(timeFraction)

    draw(progress);

    if (timeFraction < 1) {
      requestAnimationFrame(animate);
    }

  });
}
<!DOCTYPE HTML>
<html>

<head>
  <meta charset="utf-8">
  <style>
    progress {
      width: 5%;
    }
  </style>
  <script src="animate.js"></script>
</head>

<body>


  <progress id="elem"></progress>

  <script>
    elem.onclick = function() {
      animate({
        duration: 1000,
        timing: function(timeFraction) {
          return timeFraction;
        },
        draw: function(progress) {
          elem.style.width = progress * 100 + '%';
        }
      });
    };
  </script>


</body>

</html>

其程式碼

animate({
  duration: 1000,
  timing(timeFraction) {
    return timeFraction;
  },
  draw(progress) {
    elem.style.width = progress * 100 + '%';
  }
});

與 CSS 動畫不同,我們可以在這裡建立任何計時函數和任何繪製函數。計時函數不受貝茲曲線限制。而且 draw 可以超越屬性,建立新元素,例如煙火動畫或類似的東西。

計時函數

我們在上面看到了最簡單的線性計時函數。

讓我們看看更多。我們將嘗試使用不同的計時函數進行移動動畫,以了解它們如何運作。

n 的次方

如果我們想要加速動畫,我們可以在次方 n 中使用 progress

例如,拋物線

function quad(timeFraction) {
  return Math.pow(timeFraction, 2)
}

圖表

實際操作(按一下以啟用)

…或三次曲線,甚至更大的 n。增加次方會讓它加速更快。

以下是次方 5progress 的圖表

實際操作

弧線

函數

function circ(timeFraction) {
  return 1 - Math.sin(Math.acos(timeFraction));
}

圖表

後退:射箭

此函數會執行「射箭」。我們先「拉弓弦」,然後「射擊」。

與先前的函數不同,它取決於額外的參數 x,即「彈性係數」。「拉弓弦」的距離由它定義。

程式碼

function back(x, timeFraction) {
  return Math.pow(timeFraction, 2) * ((x + 1) * timeFraction - x)
}

x = 1.5 的圖表

對於動畫,我們使用它與 x 的特定值。x = 1.5 的範例

彈跳

想像我們正在掉一個球。它掉下來,然後彈跳幾次並停止。

bounce 函數執行相同的動作,但順序相反:「彈跳」會立即開始。它使用幾個特殊係數來執行此動作

function bounce(timeFraction) {
  for (let a = 0, b = 1; 1; a += b, b /= 2) {
    if (timeFraction >= (7 - 4 * a) / 11) {
      return -Math.pow((11 - 6 * a - 11 * timeFraction) / 4, 2) + Math.pow(b, 2)
    }
  }
}

實際操作

彈性動畫

另一個「彈性」函數,它接受一個額外的參數 x 作為「初始範圍」。

function elastic(x, timeFraction) {
  return Math.pow(2, 10 * (timeFraction - 1)) * Math.cos(20 * Math.PI * x / 3 * timeFraction)
}

x=1.5 的圖形:

x=1.5 的動作

反轉:ease*

因此,我們有一組計時函數。它們的直接應用稱為「easeIn」。

有時我們需要以相反的順序顯示動畫。這是使用「easeOut」轉換來完成的。

easeOut

在「easeOut」模式中,timing 函數會放入包裝函數 timingEaseOut

timingEaseOut(timeFraction) = 1 - timing(1 - timeFraction)

換句話說,我們有一個「轉換」函數 makeEaseOut,它會取得一個「常規」計時函數並回傳其周圍的包裝函數

// accepts a timing function, returns the transformed variant
function makeEaseOut(timing) {
  return function(timeFraction) {
    return 1 - timing(1 - timeFraction);
  }
}

例如,我們可以取得上面描述的 bounce 函數並套用它

let bounceEaseOut = makeEaseOut(bounce);

然後彈跳不會在動畫的開始,而是在動畫的結尾。看起來更好

結果
style.css
index.html
#brick {
  width: 40px;
  height: 20px;
  background: #EE6B47;
  position: relative;
  cursor: pointer;
}

#path {
  outline: 1px solid #E8C48E;
  width: 540px;
  height: 20px;
}
<!DOCTYPE HTML>
<html>

<head>
  <meta charset="utf-8">
  <link rel="stylesheet" href="style.css">
  <script src="https://js.cx/libs/animate.js"></script>
</head>

<body>


  <div id="path">
    <div id="brick"></div>
  </div>

  <script>
    function makeEaseOut(timing) {
      return function(timeFraction) {
        return 1 - timing(1 - timeFraction);
      }
    }

    function bounce(timeFraction) {
      for (let a = 0, b = 1; 1; a += b, b /= 2) {
        if (timeFraction >= (7 - 4 * a) / 11) {
          return -Math.pow((11 - 6 * a - 11 * timeFraction) / 4, 2) + Math.pow(b, 2)
        }
      }
    }

    let bounceEaseOut = makeEaseOut(bounce);

    brick.onclick = function() {
      animate({
        duration: 3000,
        timing: bounceEaseOut,
        draw: function(progress) {
          brick.style.left = progress * 500 + 'px';
        }
      });
    };
  </script>


</body>

</html>

在這裡,我們可以看到轉換如何改變函數的行為

如果一開始就有動畫效果,例如彈跳,它將會在結尾顯示。

在上面的圖表中,常規彈跳 為紅色,easeOut 彈跳 為藍色。

  • 常規彈跳 - 物件在底部彈跳,然後在結尾急劇跳到頂部。
  • easeOut 之後 - 它會先跳到頂部,然後在那裡彈跳。

easeInOut

我們也可以在動畫的開始和結尾顯示效果。轉換稱為「easeInOut」。

給定計時函數,我們會這樣計算動畫狀態

if (timeFraction <= 0.5) { // first half of the animation
  return timing(2 * timeFraction) / 2;
} else { // second half of the animation
  return (2 - timing(2 * (1 - timeFraction))) / 2;
}

包裝函數程式碼

function makeEaseInOut(timing) {
  return function(timeFraction) {
    if (timeFraction < .5)
      return timing(2 * timeFraction) / 2;
    else
      return (2 - timing(2 * (1 - timeFraction))) / 2;
  }
}

bounceEaseInOut = makeEaseInOut(bounce);

在動作中,bounceEaseInOut

結果
style.css
index.html
#brick {
  width: 40px;
  height: 20px;
  background: #EE6B47;
  position: relative;
  cursor: pointer;
}

#path {
  outline: 1px solid #E8C48E;
  width: 540px;
  height: 20px;
}
<!DOCTYPE HTML>
<html>

<head>
  <meta charset="utf-8">
  <link rel="stylesheet" href="style.css">
  <script src="https://js.cx/libs/animate.js"></script>
</head>

<body>


  <div id="path">
    <div id="brick"></div>
  </div>

  <script>
    function makeEaseInOut(timing) {
      return function(timeFraction) {
        if (timeFraction < .5)
          return timing(2 * timeFraction) / 2;
        else
          return (2 - timing(2 * (1 - timeFraction))) / 2;
      }
    }


    function bounce(timeFraction) {
      for (let a = 0, b = 1; 1; a += b, b /= 2) {
        if (timeFraction >= (7 - 4 * a) / 11) {
          return -Math.pow((11 - 6 * a - 11 * timeFraction) / 4, 2) + Math.pow(b, 2)
        }
      }
    }

    let bounceEaseInOut = makeEaseInOut(bounce);

    brick.onclick = function() {
      animate({
        duration: 3000,
        timing: bounceEaseInOut,
        draw: function(progress) {
          brick.style.left = progress * 500 + 'px';
        }
      });
    };
  </script>


</body>

</html>

「easeInOut」轉換將兩個圖表合併為一個:easeIn(常規)用於動畫的前半段,easeOut(反轉)用於後半段。

如果我們比較 circ 計時函數的 easeIneaseOuteaseInOut 的圖表,效果會很明顯

  • 紅色circeaseIn)的常規變體。
  • 綠色easeOut
  • 藍色easeInOut

正如我們所見,動畫前半段的圖形是縮小的 easeIn,而後半段是縮小的 easeOut。因此,動畫以相同的效果開始和結束。

更有趣的「繪製」

我們可以做一些事,而不是移動元素。我們只需要撰寫適當的 draw

以下是動畫「彈跳」文字輸入

結果
style.css
index.html
textarea {
  display: block;
  border: 1px solid #BBB;
  color: #444;
  font-size: 110%;
}

button {
  margin-top: 10px;
}
<!DOCTYPE HTML>
<html>

<head>
  <meta charset="utf-8">
  <link rel="stylesheet" href="style.css">
  <script src="https://js.cx/libs/animate.js"></script>
</head>

<body>


  <textarea id="textExample" rows="5" cols="60">He took his vorpal sword in hand:
Long time the manxome foe he sought—
So rested he by the Tumtum tree,
And stood awhile in thought.
  </textarea>

  <button onclick="animateText(textExample)">Run the animated typing!</button>

  <script>
    function animateText(textArea) {
      let text = textArea.value;
      let to = text.length,
        from = 0;

      animate({
        duration: 5000,
        timing: bounce,
        draw: function(progress) {
          let result = (to - from) * progress + from;
          textArea.value = text.slice(0, Math.ceil(result))
        }
      });
    }


    function bounce(timeFraction) {
      for (let a = 0, b = 1; 1; a += b, b /= 2) {
        if (timeFraction >= (7 - 4 * a) / 11) {
          return -Math.pow((11 - 6 * a - 11 * timeFraction) / 4, 2) + Math.pow(b, 2)
        }
      }
    }
  </script>


</body>

</html>

摘要

對於 CSS 無法妥善處理的動畫,或需要嚴格控制的動畫,JavaScript 可以提供協助。JavaScript 動畫應透過 requestAnimationFrame 實作。內建方法允許設定一個回呼函式,在瀏覽器準備重新繪製時執行。通常會很快,但確切時間取決於瀏覽器。

當頁面在背景中時,完全沒有重新繪製,因此回呼不會執行:動畫將暫停,並且不會消耗資源。這很棒。

以下是設定大多數動畫的輔助函式 animate

function animate({timing, draw, duration}) {

  let start = performance.now();

  requestAnimationFrame(function animate(time) {
    // timeFraction goes from 0 to 1
    let timeFraction = (time - start) / duration;
    if (timeFraction > 1) timeFraction = 1;

    // calculate the current animation state
    let progress = timing(timeFraction);

    draw(progress); // draw it

    if (timeFraction < 1) {
      requestAnimationFrame(animate);
    }

  });
}

選項

  • duration – 總動畫時間(以毫秒為單位)。
  • timing – 計算動畫進度的函式。取得從 0 到 1 的時間分數,傳回動畫進度,通常從 0 到 1。
  • draw – 繪製動畫的函式。

我們當然可以改善它,增加更多花俏的功能,但 JavaScript 動畫並非每天都會應用。它們用於執行一些有趣且非標準的事情。因此,您可以在需要時新增所需的功能。

JavaScript 動畫可以使用任何計時函式。我們介紹了許多範例和轉換,讓它們更具通用性。與 CSS 不同,我們在此不受貝茲曲線的限制。

draw 也是如此:我們可以動畫任何東西,而不仅仅是 CSS 屬性。

任務

重要性:5

製作一個彈跳球。按一下以查看它應有的外觀

為任務開啟沙盒。

要彈跳,我們可以使用 CSS 屬性 topposition:absolute,讓球在具有 position:relative 的場地內。

場地的底部座標是 field.clientHeight。CSS top 屬性是指球的上緣。因此,它應從 0field.clientHeight - ball.clientHeight,這是球的上緣的最後最低位置。

要取得「彈跳」效果,我們可以在 easeOut 模式中使用計時函式 bounce

以下是動畫的最終程式碼

let to = field.clientHeight - ball.clientHeight;

animate({
  duration: 2000,
  timing: makeEaseOut(bounce),
  draw(progress) {
    ball.style.top = to * progress + 'px'
  }
});

在沙盒中開啟解決方案。

重要性:5

讓球向右彈跳。像這樣

撰寫動畫程式碼。向左的距離為 100px

以先前任務 讓彈跳球動起來 的解決方案作為來源。

在任務 讓彈跳球動起來 中,我們只有一個要動畫化的屬性。現在我們需要再一個:elem.style.left

水平座標會依照另一條定律改變:它不會「彈跳」,而是逐漸增加,將球向右移動。

我們可以為它再寫一個 animate

我們可以使用 linear 作為時間函數,但類似 makeEaseOut(quad) 的東西看起來好多了。

程式碼

let height = field.clientHeight - ball.clientHeight;
let width = 100;

// animate top (bouncing)
animate({
  duration: 2000,
  timing: makeEaseOut(bounce),
  draw: function(progress) {
    ball.style.top = height * progress + 'px'
  }
});

// animate left (moving to the right)
animate({
  duration: 2000,
  timing: makeEaseOut(quad),
  draw: function(progress) {
    ball.style.left = width * progress + "px"
  }
});

在沙盒中開啟解決方案。

教學課程地圖

留言

留言前請先閱讀這段文字…
  • 如果您有改善建議,請 提交 GitHub 問題 或發起拉取請求,而非留言。
  • 如果您無法理解文章中的某些內容,請詳細說明。
  • 若要插入幾行程式碼,請使用 <code> 標籤,若要插入多行,請將它們包在 <pre> 標籤中,若要插入超過 10 行,請使用沙盒 (plnkrjsbincodepen…)