大幅提升 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 的這篇文章。
編寫動畫工作區
瞭解時間軸的概念後,我們可以開始研究 Animation 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」的工作元件:
// 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
,因此這個動畫器稱為「passthrough」。如您在示範中看到的,使用此工作區程式碼後,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] 範圍,這是效果定義的時間範圍。這樣一來,動畫看起來就會很不一樣,而且不需要變更主要畫面格或動畫選項。工作區塊程式碼可任意複雜,可讓您以程式輔助方式定義要以何種順序和程度播放哪些特效。
選項選項
您可能需要重複使用 worklet 並變更其編號。因此,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
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 就是您的最佳選擇!