大幅提升 webapp 的動畫效果
重點摘要:Animation Worklet 可讓您編寫以裝置原生影格率執行的命令式動畫,以便提供更流暢的動畫效果,並讓動畫更能抵抗主執行緒的卡頓情形,且可連結至捲動動作,而非時間。動畫 Worklet 已在 Chrome Canary 中推出 (位於「Experimental Web Platform features」標記後方),我們也正規劃 Chrome 71 的來源試用。您可以立即開始使用漸進式強化功能。
其他 Animation API?
事實上,這只是我們現有的工具的延伸,而且有充分的理由! 讓我們從頭開始。如果您想在現今網站上為任何 DOM 元素製作動畫,有 2 1/2 種選擇:CSS 轉場可用於簡單的 A 到 B 轉場;CSS 動畫可用於可能會循環且更複雜的時間軸動畫;Web Animations API (WAAPI) 可用於幾乎任意複雜度的動畫。WAAPI 的支援矩陣看起來相當嚴重,但情況正在改善。在此之前,您可以使用polyfill。
這些方法的共通點是,它們都是無狀態且以時間為驅動。不過,開發人員嘗試的某些效果既不是時間導向,也沒有狀態。舉例來說,眾所皆知的視差捲軸,顧名思義,就是捲動驅動。在現今的網站上實作高效的視差捲動效果,難度相當高。
那麼無狀態呢?舉例來說,想想 Android 版 Chrome 的網址列。如果向下捲動頁面,橫幅就會從畫面上消失,但只要您向上捲動,就會重新顯示,即使您已在該頁面中向下捲動一半也一樣。動畫不僅取決於捲動位置,也取決於您先前的捲動方向。屬性有狀態。
另一個問題是捲軸樣式。它們是出了名的難以設定樣式,或至少不夠容易設定樣式。如果我想將 Nyan Cat 做為捲軸,該怎麼做?無論您選擇哪種方法,建構自訂捲軸的效能都不會太好,而且也不簡單。
但重點是阻礙,難以有效實作。其中大多數都依賴事件和/或 requestAnimationFrame
,因此即使螢幕可以 90fps、120fps 或更高的速度運作,並使用寶貴的主執行緒影格預算的一小部分,仍可能維持 60fps。
Animation Worklet 可擴充網頁動畫堆疊的功能,讓您更輕鬆地製作這類效果。開始深入探討前,請先確認我們對動畫的基本概念瞭若指掌。
動畫和時間軸簡介
WAAPI 和 Animation Worklet 會大量使用時間軸,讓您以所需方式協調動畫和特效。本節將快速重溫或介紹時間軸,以及如何與動畫搭配運作。
每份文件都有 document.timeline
。在建立文件時,這個值會從 0 開始,並計算文件開始存在以來的毫秒數。文件中的所有動畫都會根據這個時間軸運作。
為了讓您更瞭解情況,我們來看看這個 WAAPI 程式碼片段
const animation = new Animation(
new KeyframeEffect(
document.querySelector('#a'),
[
{
transform: 'translateX(0)',
},
{
transform: 'translateX(500px)',
},
{
transform: 'translateY(500px)',
},
],
{
delay: 3000,
duration: 2000,
iterations: 3,
}
),
document.timeline
);
animation.play();
當我們呼叫 animation.play()
時,動畫會使用時間軸的 currentTime
做為開始時間。動畫的延遲時間為 3000 毫秒,也就是說,當時間軸達到 `startTime` 時,動畫就會開始 (或變為「啟用」)。
- 3000
. After that time, the animation engine will animate the given element from the first keyframe (
translateX(0)), through all intermediate keyframes (
translateX(500px)) all the way to the last keyframe (
translateY(500px)) in exactly 2000ms, as prescribed by the
durationoptions. Since we have a duration of 2000ms, we will reach the middle keyframe when the timeline's
currentTimeis
startTime + 3000 + 1000and the last keyframe at
startTime + 3000 + 2000`。重點是,時間軸會控制動畫的播放進度!
動畫播放到最後一個主要畫面格後,就會跳回第一個主要畫面格,並開始動畫的下一個疊代作業。自我們設定 iterations: 3
以來,這個程序總共會重複 3 次。如果希望動畫永遠停止,我們可以寫入 iterations: Number.POSITIVE_INFINITY
。以下是上述程式碼的結果。
WAAPI 功能非常強大,而這個 API 還有許多其他功能,例如加/減速設定、主要畫面格粗細和填滿行為,都會影響本文的討論範圍。如要進一步瞭解,建議您參閱 CSS Animations on CSS Tricks 的這篇文章。
編寫動畫工作區
瞭解時間軸的概念後,我們可以開始認識「動畫 Worklet」,瞭解這項功能如何讓您對時間軸產生混淆!Animation Worklet API 不僅以 WAAPI 為基礎,在可擴充的網頁的角度來看,也是一種低階原始元素,可說明 WAAPI 的運作方式。從語法來看,兩者非常相似:
動畫小程式 | WAAPI |
---|---|
new WorkletAnimation( 'passthrough', new KeyframeEffect( document.querySelector('#a'), [ { transform: 'translateX(0)' }, { transform: 'translateX(500px)' } ], { duration: 2000, iterations: Number.POSITIVE_INFINITY } ), document.timeline ).play(); |
new Animation( new KeyframeEffect( document.querySelector('#a'), [ { transform: 'translateX(0)' }, { transform: 'translateX(500px)' } ], { duration: 2000, iterations: Number.POSITIVE_INFINITY } ), document.timeline ).play(); |
不同之處在於第一個參數,也就是推動動畫播放的 worklet 名稱。
特徵偵測
Chrome 是第一個推出這項功能的瀏覽器,因此您必須確保程式碼不會只預期 AnimationWorklet
會存在。因此,在載入工作區塊之前,我們應透過簡單的檢查,偵測使用者的瀏覽器是否支援 AnimationWorklet
:
if ('animationWorklet' in CSS) {
// AnimationWorklet is supported!
}
載入工作單元
Worklet 是 Houdini 專案小組推出的新概念,可讓許多新的 API 更容易建構及擴充。我們稍後會進一步說明 worklet 的詳細資訊,但為了簡單起見,您可以先將 worklet 視為廉價且輕量級的執行緒 (例如 worker)。
我們需要先確認已載入名為「passthrough」的 worklet,再宣告動畫:
// index.html
await CSS.animationWorklet.addModule('passthrough-aw.js');
// ... WorkletAnimation initialization from above ...
// passthrough-aw.js
registerAnimator(
'passthrough',
class {
animate(currentTime, effect) {
effect.localTime = currentTime;
}
}
);
這到底是怎麼一回事?我們會使用 AnimationWorklet 的 registerAnimator()
呼叫,將類別註冊為動畫師,並將其命名為「passthrough」。與我們在上述 WorkletAnimation()
建構函式中使用的名稱相同。註冊完成後,addModule()
傳回的承諾會解析,我們就可以開始使用該 worklet 建立動畫。
瀏覽器要算繪的每個影格都會呼叫例項的 animate()
方法,並傳遞動畫時間軸的 currentTime
以及目前正在處理的效果。我們只有一個效果,也就是 KeyframeEffect
,並使用 currentTime
設定效果的 localTime
,因此這個動畫稱為「快速導入」。使用這個 Worklet 的程式碼後,上述 WAAPI 和 AnimationWorklet 的行為完全相同,如示範中所示。
時間
animate()
方法的 currentTime
參數是傳遞至 WorkletAnimation()
建構函式的時間軸 currentTime
。在上一個範例中,我們只是將該時間傳遞至效果。但由於這是 JavaScript 程式碼,我們可以扭曲時間 💫?
function remap(minIn, maxIn, minOut, maxOut, v) {
return ((v - minIn) / (maxIn - minIn)) * (maxOut - minOut) + minOut;
}
registerAnimator(
'sin',
class {
animate(currentTime, effect) {
effect.localTime = remap(
-1,
1,
0,
2000,
Math.sin((currentTime * 2 * Math.PI) / 2000)
);
}
}
);
我們要使用 currentTime
的 Math.sin()
,然後將該值重新對應到 [0; 2000] 這個範圍,也就是效果所定義的時間範圍。這樣一來,動畫看起來就會很不一樣,而且不需要變更主要畫面格或動畫選項。工作區塊程式碼可任意複雜,可讓您以程式輔助方式定義要以何種順序和程度播放哪些特效。
選項選項
您可以重複使用練習題並變更數字。因此,WorkletAnimation 建構函式可讓您將選項物件傳遞至 worklet:
registerAnimator(
'factor',
class {
constructor(options = {}) {
this.factor = options.factor || 1;
}
animate(currentTime, effect) {
effect.localTime = currentTime * this.factor;
}
}
);
new WorkletAnimation(
'factor',
new KeyframeEffect(
document.querySelector('#b'),
[
/* ... same keyframes as before ... */
],
{
duration: 2000,
iterations: Number.POSITIVE_INFINITY,
}
),
document.timeline,
{factor: 0.5}
).play();
在這個示例中,兩個動畫都使用相同的程式碼,但有不同的選項。
請告訴我你的本機狀態!
如先前所述,動畫工作流程的主要問題之一,就是有狀態的動畫。動畫工作區塊可保留狀態。不過,工作項的主要功能之一,就是可以遷移至不同的執行緒,甚至可以銷毀以節省資源,這也會銷毀其狀態。為避免狀態遺失,動畫工作單元會提供鉤子,在工作單元遭到銷毀「之前」呼叫,您可以使用該鉤子傳回狀態物件。重新建立工作單元時,系統會將該物件傳遞至建構函式。在初始建立時,該參數會是 undefined
。
registerAnimator(
'randomspin',
class {
constructor(options = {}, state = {}) {
this.direction = state.direction || (Math.random() > 0.5 ? 1 : -1);
}
animate(currentTime, effect) {
// Some math to make sure that `localTime` is always > 0.
effect.localTime = 2000 + this.direction * (currentTime % 2000);
}
destroy() {
return {
direction: this.direction,
};
}
}
);
每次重新整理這個示範時,正方形旋轉的方向有 50/50 的機率。如果瀏覽器要拆解 worklet 並將其遷移至其他執行緒,則會在建立時發出另一個 Math.random()
呼叫,這可能會導致方向突然改變。為避免發生這種情況,我們會將動畫隨機選擇的方向做為「狀態」傳回,並在提供的建構函式中使用該狀態。
鉤掛時空連續體:ScrollTimeline
如上一節所示,AnimationWorklet 可讓我們透過程式輔助定義進階時間軸對動畫效果的影響。不過,目前我們的時間軸一律是 document.timeline
,用於追蹤時間。
ScrollTimeline
開啟了新的可能性,讓您可以透過捲動而非時間來驅動動畫。我們將重複使用第一個「passthrough」工作區,用於這個示範:
new WorkletAnimation(
'passthrough',
new KeyframeEffect(
document.querySelector('#a'),
[
{
transform: 'translateX(0)',
},
{
transform: 'translateX(500px)',
},
],
{
duration: 2000,
fill: 'both',
}
),
new ScrollTimeline({
scrollSource: document.querySelector('main'),
orientation: 'vertical', // "horizontal" or "vertical".
timeRange: 2000,
})
).play();
我們並未傳遞 document.timeline
,而是建立新的 ScrollTimeline
。您可能已經猜到,ScrollTimeline
並未使用時間,而是使用 scrollSource
的捲動位置,在工作區中設定 currentTime
。捲動至頂端 (或左側) 代表 currentTime = 0
,捲動至底部 (或右側) 則會將 currentTime
設為 timeRange
。如果您在這個示範中捲動方塊,就能控制紅色方塊的位置。
如果您建立的 ScrollTimeline
包含無法捲動的元素,時間軸的 currentTime
會是 NaN
。因此,特別是考量到回應式設計,您應隨時準備好將 NaN
設為 currentTime
。通常預設值為 0。
長久以來,我們一直都致力於著連結動畫和捲動位置,但其實從未如此在這樣的保真度下達成 (除了利用 CSS3D 的駭客解決方法以外)。「動畫小程式」能以簡單明瞭的方式實作這些效果,同時兼顧效能。舉例來說,如這個示範所示,視差捲動效果現在只需幾行即可定義捲動導向的動畫。
深入解析
小程式
Worklet 是具有隔離範圍和極小 API 途徑的 JavaScript 情境。小型 API 途徑可讓瀏覽器進行更積極的最佳化,尤其是在低階裝置上。此外,工作項不會繫結至特定事件迴圈,但可視需要在執行緒之間移動。這點對於 AnimationWorklet 來說尤其重要。
合成器 NSync
您可能會發現某些 CSS 屬性能夠快速建立動畫效果,有些屬性則無法。有些屬性只需要在 GPU 上進行一些工作即可製作動畫,而其他屬性則會強制瀏覽器重新排版整份文件。
在 Chrome (以及許多其他瀏覽器) 中,我們有一個稱為合成器的程序,其工作 (我在此處簡化說明) 是安排圖層和紋理,然後利用 GPU 盡可能定期更新螢幕,理想情況下要盡可能以螢幕更新速度 (通常為 60Hz) 更新。視動畫所使用的 CSS 屬性而定,瀏覽器可能只需要讓轉譯器執行其工作,而其他屬性則需要執行版面配置,這是只有主執行緒才能執行的作業。視您要為哪些屬性製作動畫而定,動畫工作區塊會繫結至主執行緒,或是在與轉譯器同步的個別執行緒中執行。
戴在手腕上
由於 GPU 是高度競爭的資源,因此通常只有一個合成器程序可在多個分頁中共用。如果合成器遭到阻斷,整個瀏覽器都會停止執行,無法回應使用者輸入內容。您必須盡全力避免這種情況。如果工作區塊無法在算繪影格時及時提供合成器所需的資料,會發生什麼情況?
發生這種情況時,系統會根據規格允許工作單元「滑動」。這會落後於合成器,而合成器可重複使用上一個影格的資料,以維持影格速率。在視覺上,此程式碼看起來會像是卡頓,但差別在於瀏覽器會持續回應使用者輸入的內容。
結論
AnimationWorklet 有許多面向,可在網路上帶來好處。明顯的好處是,您可以進一步控制動畫,並透過新方法驅動動畫,為網頁帶來更高層級的視覺擬真度。不過,API 設計也讓您可以讓應用程式更能抵抗卡頓情形,同時還能同時存取所有新功能。
Animation Worklet 已在 Canary 中推出,我們預計在 Chrome 71 中進行初期試用。我們期待你分享全新網站的使用體驗,並告訴我們有哪些地方需要改善。另外還有一個polyfill,可提供相同的 API,但不會提供效能隔離功能。
請注意,CSS 轉場效果和 CSS 動畫仍是有效的選項,而且可用於簡單的動畫。但如果您需要更精緻的效果,AnimationWorklet 就是您的最佳選擇!