JavaScript 動(dòng)畫

2023-02-17 10:58 更新

JavaScript 動(dòng)畫可以處理 CSS 無法處理的事情。

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

使用 setInterval

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

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

偽代碼如下:

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

更完整的動(dòng)畫示例:

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

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

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

  // 在 timePassed 時(shí)刻繪制動(dòng)畫
  draw(timePassed);

}, 20);

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

完整示例

使用 requestAnimationFrame

假設(shè)我們有幾個(gè)同時(shí)運(yùn)行的動(dòng)畫。

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

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

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

換句話說,像下面這樣:

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

……比這樣更好:

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

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

有一個(gè)標(biāo)準(zhǔn)動(dòng)畫時(shí)序提供了 requestAnimationFrame 函數(shù)。

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

語法:

let requestId = requestAnimationFrame(callback);

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

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

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

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

callback 得到一個(gè)參數(shù) —— 從頁面加載開始經(jīng)過的毫秒數(shù)。這個(gè)時(shí)間也可通過調(diào)用 performance.now() 得到。

通常 callback 很快就會(huì)運(yùn)行,除非 CPU 過載或筆記本電量消耗殆盡,或者其他原因。

下面的代碼顯示了 requestAnimationFrame 的前 10 次運(yùn)行之間的時(shí)間間隔。通常是 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)化動(dòng)畫

現(xiàn)在我們可以在 requestAnimationFrame 基礎(chǔ)上創(chuàng)建一個(gè)更通用的動(dò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;

    // 計(jì)算當(dāng)前動(dòng)畫狀態(tài)
    let progress = timing(timeFraction);

    draw(progress); // 繪制

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

  });
}

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

?duration ?

動(dòng)畫總時(shí)間,比如 ?1000?。

?timing(timeFraction)?

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

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

function linear(timeFraction) {
  return timeFraction;
}

圖像如下:


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

?draw(progress)?

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

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

它可以移動(dòng)元素:

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

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

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

完整示例

它的代碼如下:

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

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

時(shí)序函數(shù)

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

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

n 次冪

如果我們想加速動(dòng)畫,我們可以讓 progress 為 n 次冪。

例如,拋物線:

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

圖像如下:


實(shí)際效果

……或者三次曲線甚至使用更大的 n。增大冪會(huì)讓動(dòng)畫加速得更快。

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


實(shí)際效果

圓弧

函數(shù):

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

圖像:


實(shí)際效果

反彈:弓箭射擊

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

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

代碼如下:

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

x = 1.5 時(shí)的圖像:


在動(dòng)畫中我們使用特定的 x 值。下面是 x = 1.5 時(shí)的例子:

完整示例

彈跳

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

bounce 函數(shù)也是如此,但順序相反:“bouncing”立即啟動(dòng)。它使用了幾個(gè)特殊的系數(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)
    }
  }
}

完整示例

伸縮動(dòng)畫

另一個(gè)“伸縮”函數(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 時(shí)的圖像:


x=1.5 時(shí)的演示

完整示例

逆轉(zhuǎn):ease*

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

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

easeOut

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

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

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

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

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

let bounceEaseOut = makeEaseOut(bounce);

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

完整示例

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


如果在開始時(shí)有動(dòng)畫效果,比如彈跳 —— 那么它將在最后顯示。

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

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

easeInOut

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

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

if (timeFraction <= 0.5) { // 動(dòng)畫前半部分
  return timing(2 * timeFraction) / 2;
} else { // 動(dòng)畫后半部分
  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” 變換將兩個(gè)圖像連接成一個(gè):動(dòng)畫的前半部分為“easeIn”(常規(guī)),后半部分為“easeOut”(反向)。

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


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

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

更有趣的 “draw”

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

這是動(dòng)畫形式的“彈跳”文字輸入:

完整示例

總結(jié)

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

當(dāng)頁面在后臺(tái)時(shí),根本沒有重繪,因此回調(diào)將不會(huì)運(yùn)行:動(dòng)畫將被暫停并且不會(huì)消耗資源。那很棒。

這是設(shè)置大多數(shù)動(dòng)畫的 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;

    // 計(jì)算當(dāng)前動(dòng)畫狀態(tài)
    let progress = timing(timeFraction);

    draw(progress); // 繪制

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

  });
}

參數(shù):

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

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

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

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

任務(wù)


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

重要程度: 5

做一個(gè)彈跳的球。

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


解決方案

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

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

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

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

這是動(dòng)畫的最終代碼:

let to = field.clientHeight - ball.clientHeight;

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

使用沙箱打開解決方案。


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

重要程度: 5

讓球向右移動(dòng)。

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

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


解決方案

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

水平坐標(biāo)由另一個(gè)定律改變:它不會(huì)“反彈”,而是逐漸增加使球逐漸向右移動(dòng)。

我們可以為它多寫一個(gè) animate。

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

代碼:

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

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

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

使用沙箱打開解決方案。


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

掃描二維碼

下載編程獅App

公眾號(hào)
微信公眾號(hào)

編程獅公眾號(hào)