JavaScript 動畫

2023-02-17 10:58 更新

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

例如,沿著具有與 Bezier 曲線不同的時序函數(shù)的復(fù)雜路徑移動,或者實現(xiàn)畫布上的動畫。

使用 setInterval

從 HTML/CSS 的角度來看,動畫是 style 屬性的逐漸變化。例如,將 style.left 從 0px 變化到 100px 可以移動元素。

如果我們用 setInterval 每秒做 50 次小變化,看起來會更流暢。電影也是這樣的原理:每秒 24 幀或更多幀足以使其看起來流暢。

偽代碼如下:

let delay = 1000 / 50; // 每秒 50 幀
let timer = setInterval(function() {
  if (animation complete) clearInterval(timer);
  else increase style.left
}, delay)

更完整的動畫示例:

let start = Date.now(); // 保存開始時間

let timer = setInterval(function() {
  // 距開始過了多長時間
  let timePassed = Date.now() - start;

  if (timePassed >= 2000) {
    clearInterval(timer); // 2 秒后結(jié)束動畫
    return;
  }

  // 在 timePassed 時刻繪制動畫
  draw(timePassed);

}, 20);

// 隨著 timePassed 從 0 增加到 2000
// 將 left 的值從 0px 增加到 400px
function draw(timePassed) {
  train.style.left = timePassed / 5 + 'px';
}

完整示例

使用 requestAnimationFrame

假設(shè)我們有幾個同時運行的動畫。

如果我們單獨運行它們,每個都有自己的 setInterval(..., 20),那么瀏覽器必須以比 20ms 更頻繁的速度重繪。

每個 setInterval 每 20ms 觸發(fā)一次,但它們相互獨立,因此 20ms 內(nèi)將有多個獨立運行的重繪。

這幾個獨立的重繪應(yīng)該組合在一起,以使瀏覽器更加容易處理。

換句話說,像下面這樣:

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

……比這樣更好:

setInterval(animate1, 20);
setInterval(animate2, 20);
setInterval(animate3, 20);

還有一件事需要記住。有時當 CPU 過載時,或者有其他原因需要降低重繪頻率。例如,如果瀏覽器選項卡被隱藏,那么繪圖完全沒有意義。

有一個標準動畫時序提供了 requestAnimationFrame 函數(shù)。

它解決了所有這些問題,甚至更多其它的問題。

語法:

let requestId = requestAnimationFrame(callback);

這會讓 callback 函數(shù)在瀏覽器每次重繪的最近時間運行。

如果我們對 callback 中的元素進行變化,這些變化將與其他 requestAnimationFrame 回調(diào)和 CSS 動畫組合在一起。因此,只會有一次幾何重新計算和重繪,而不是多次。

返回值 requestId 可用來取消回調(diào):

// 取消回調(diào)的周期執(zhí)行
cancelAnimationFrame(requestId);

callback 得到一個參數(shù) —— 從頁面加載開始經(jīng)過的毫秒數(shù)。這個時間也可通過調(diào)用 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>

結(jié)構(gòu)化動畫

現(xiàn)在我們可以在 requestAnimationFrame 基礎(chǔ)上創(chuàng)建一個更通用的動畫函數(shù):

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

  let start = performance.now();

  requestAnimationFrame(function animate(time) {
    // timeFraction 從 0 增加到 1
    let timeFraction = (time - start) / duration;
    if (timeFraction > 1) timeFraction = 1;

    // 計算當前動畫狀態(tài)
    let progress = timing(timeFraction);

    draw(progress); // 繪制

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

  });
}

animate 函數(shù)接受 3 個描述動畫的基本參數(shù):

?duration ?

動畫總時間,比如 ?1000?。

?timing(timeFraction)?

時序函數(shù),類似 CSS 屬性 transition-timing-function,傳入一個已過去的時間與總時間之比的小數(shù)(0 代表開始,1 代表結(jié)束),返回動畫完成度(類似 Bezier 曲線中的 y)。

例如,線性函數(shù)意味著動畫以相同的速度均勻地進行:

function linear(timeFraction) {
  return timeFraction;
}

圖像如下:


它類似于 transition-timing-function: linear。后文有更多有趣的變體。

?draw(progress)?

獲取動畫完成狀態(tài)并繪制的函數(shù)。值 progress = 0 表示開始動畫狀態(tài),progress = 1 表示結(jié)束狀態(tài)。

這是實際繪制動畫的函數(shù)。

它可以移動元素:

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

……或者做任何其他事情,我們可以以任何方式為任何事物制作動畫。

讓我們使用我們的函數(shù)將元素的 width 從 0 變化為 100%。

完整示例

它的代碼如下:

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

與 CSS 動畫不同,我們可以在這里設(shè)計任何時序函數(shù)和任何繪圖函數(shù)。時序函數(shù)不受 Bezier 曲線的限制。并且 draw 不局限于操作 CSS 屬性,還可以為類似煙花動畫或其他動畫創(chuàng)建新元素。

時序函數(shù)

上文我們看到了最簡單的線性時序函數(shù)。

讓我們看看更多。我們將嘗試使用不同時序函數(shù)的移動動畫來查看它們的工作原理。

n 次冪

如果我們想加速動畫,我們可以讓 progress 為 n 次冪。

例如,拋物線:

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

圖像如下:


實際效果

……或者三次曲線甚至使用更大的 n。增大冪會讓動畫加速得更快。

下面是 progress 為 5 次冪的圖像:


實際效果

圓弧

函數(shù):

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

圖像:


實際效果

反彈:弓箭射擊

此函數(shù)執(zhí)行“弓箭射擊”。首先,我們“拉弓弦”,然后“射擊”。

與以前的函數(shù)不同,它取決于附加參數(shù) x,即“彈性系數(shù)”?!袄摇钡木嚯x由它定義。

代碼如下:

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

x = 1.5 時的圖像:


在動畫中我們使用特定的 x 值。下面是 x = 1.5 時的例子:

完整示例

彈跳

想象一下,我們正在拋球。球落下之后,彈跳幾次然后停下來。

bounce 函數(shù)也是如此,但順序相反:“bouncing”立即啟動。它使用了幾個特殊的系數(shù):

function bounce(timeFraction) {
  for (let a = 0, b = 1, result; 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)
    }
  }
}

完整示例

伸縮動畫

另一個“伸縮”函數(shù)接受附加參數(shù) 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 時的演示

完整示例

逆轉(zhuǎn):ease*

我們有一組時序函數(shù)。它們的直接應(yīng)用稱為“easeIn”。

有時我們需要以相反的順序顯示動畫。這是通過“easeOut”變換完成的。

easeOut

在“easeOut”模式中,我們將 timing 函數(shù)封裝到 timingEaseOut中:

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

換句話說,我們有一個“變換”函數(shù) makeEaseOut,它接受一個“常規(guī)”時序函數(shù) timing 并返回一個封裝器,里面封裝了 timing 函數(shù):

// 接受時序函數(shù),返回變換后的變體
function makeEaseOut(timing) {
  return function(timeFraction) {
    return 1 - timing(1 - timeFraction);
  }
}

例如,我們可以使用上面描述的 bounce 函數(shù):

let bounceEaseOut = makeEaseOut(bounce);

這樣,彈跳不會在動畫開始時執(zhí)行,而是在動畫結(jié)束時。這樣看起來更好:

完整示例

在這里,我們可以看到變換如何改變函數(shù)的行為:


如果在開始時有動畫效果,比如彈跳 —— 那么它將在最后顯示。

上圖中常規(guī)彈跳為紅色,easeOut 彈跳為藍色。

  • 常規(guī)彈跳 —— 物體在底部彈跳,然后突然跳到頂部。
  • ?easeOut? 變換之后 —— 物體跳到頂部之后,在那里彈跳。

easeInOut

我們還可以在動畫的開頭和結(jié)尾都顯示效果。該變換稱為“easeInOut”。

給定時序函數(shù),我們按下面的方式計算動畫狀態(tài):

if (timeFraction <= 0.5) { // 動畫前半部分
  return timing(2 * timeFraction) / 2;
} else { // 動畫后半部分
  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 演示如下:

完整示例

“easeInOut” 變換將兩個圖像連接成一個:動畫的前半部分為“easeIn”(常規(guī)),后半部分為“easeOut”(反向)。

如果我們比較 circ 時序函數(shù)的 easeIn、easeOut 和 easeInOut 的圖像,就可以清楚地看到效果:


  • 紅色是 ?circ?(?easeIn?)的常規(guī)變體。
  • 綠色 —— ?easeOut?。
  • 藍色 —— ?easeInOut?。

正如我們所看到的,動畫前半部分的圖形是縮小的“easeIn”,后半部分是縮小的“easeOut”。結(jié)果是動畫以相同的效果開始和結(jié)束。

更有趣的 “draw”

除了移動元素,我們還可以做其他事情。我們所需要的只是寫出合適的 ?draw?。

這是動畫形式的“彈跳”文字輸入:

完整示例

總結(jié)

JavaScript 動畫應(yīng)該通過 requestAnimationFrame 實現(xiàn)。該內(nèi)建方法允許設(shè)置回調(diào)函數(shù),以便在瀏覽器準備重繪時運行。那通常很快,但確切的時間取決于瀏覽器。

當頁面在后臺時,根本沒有重繪,因此回調(diào)將不會運行:動畫將被暫停并且不會消耗資源。那很棒。

這是設(shè)置大多數(shù)動畫的 helper 函數(shù) animate

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

  let start = performance.now();

  requestAnimationFrame(function animate(time) {
    // timeFraction 從 0 增加到 1
    let timeFraction = (time - start) / duration;
    if (timeFraction > 1) timeFraction = 1;

    // 計算當前動畫狀態(tài)
    let progress = timing(timeFraction);

    draw(progress); // 繪制

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

  });
}

參數(shù):

  • ?duration? —— 動畫運行的總毫秒數(shù)。
  • ?timing? —— 計算動畫進度的函數(shù)。獲取從 0 到 1 的小數(shù)時間,返回動畫進度,通常也是從 0 到 1。
  • ?draw? —— 繪制動畫的函數(shù)。

當然我們可以改進它,增加更多花里胡哨的東西,但 JavaScript 動畫不是經(jīng)常用到。它們用于做一些有趣和不標準的事情。因此,您大可在必要時再添加所需的功能。

JavaScript 動畫可以使用任何時序函數(shù)。我們介紹了很多例子和變換,使它們更加通用。與 CSS 不同,我們不僅限于 Bezier 曲線。

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

任務(wù)


為彈跳的球設(shè)置動畫

重要程度: 5

做一個彈跳的球。

打開一個任務(wù)沙箱。


解決方案

為了達到反彈效果,我們可以在帶有 position:relative 屬性的區(qū)域內(nèi),給小球使用 top 和 position:absolute CSS 屬性。

field 區(qū)域的底部坐標是 field.clientHeight。top 屬性給出了球頂部的坐標,在最底部時達到 field.clientHeight - ball.clientHeight

因此,我們將 top 從 0 變化到 field.clientHeight - ball.clientHeight 來設(shè)置動畫。

現(xiàn)在為了獲得“彈跳”效果,我們可以在 easeOut 模式下使用時序函數(shù) bounce

這是動畫的最終代碼:

let to = field.clientHeight - ball.clientHeight;

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

使用沙箱打開解決方案。


設(shè)置動畫使球向右移動

重要程度: 5

讓球向右移動。

編寫動畫代碼。終止時球到左側(cè)的距離是 100px

從前一個任務(wù) 為彈跳的球設(shè)置動畫 的答案開始。


解決方案

在任務(wù) 為彈跳的球設(shè)置動畫 中,我們只有一個需要添加動畫的屬性?,F(xiàn)在多了一個 elem.style.left

水平坐標由另一個定律改變:它不會“反彈”,而是逐漸增加使球逐漸向右移動。

我們可以為它多寫一個 animate。

至于時序函數(shù),我們可以使用 linear,但像 makeEaseOut(quad) 這樣的函數(shù)看起來要好得多。

代碼:

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

// 設(shè)置 top 動畫(彈跳)
animate({
  duration: 2000,
  timing: makeEaseOut(bounce),
  draw: function(progress) {
    ball.style.top = height * progress + 'px'
  }
});

// 設(shè)置 left 動畫(向右移動)
animate({
  duration: 2000,
  timing: makeEaseOut(quad),
  draw: function(progress) {
    ball.style.left = width * progress + "px"
  }
});

使用沙箱打開解決方案。


以上內(nèi)容是否對您有幫助:
在線筆記
App下載
App下載

掃描二維碼

下載編程獅App

公眾號
微信公眾號

編程獅公眾號