[貝聊科技]小動畫大學問

對於移動端的Web單頁應用來講,爲了達到媲美原生應用的效果,頁面過渡動畫是必不可少的。經常使用的頁面過渡動畫包括:javascript

  1. 位移——當前頁向左側或右側水平移出可視區,下一頁由反方向移入可視區。
  2. 不透明度變化——當前頁淡出,下一頁淡入。
  3. 1和2同時進行。

(注意:如下討論和實驗均在 Chrome 68 瀏覽器環境下進行)css

目前大多數設備的屏幕刷新率爲60次/秒,算下來每一個幀的預算時間約爲16.66毫秒(1/60秒)。考慮到瀏覽器還有其餘工做要執行,實際上預算時間只有10毫秒。跟此預算時間的差值越大,用戶就會以爲動畫過程越卡。那麼,在這10毫秒內要完成什麼事情呢?當使用JavaScript實現視覺交互效果時,通常要通過如下流程:html

JavaScript視覺交互執行流程

  1. JavaScript的執行。例如修改元素的樣式,或者給元素添加/刪除樣式類。
  2. 樣式計算。根據樣式規則計算出元素的最終樣式。
  3. 佈局(layout)。根據上一步的結果,計算元素佔據的空間大小及其在屏幕的位置。注意,一個元素佈局上的變化有可能會引起其餘元素的聯動變化。
  4. 繪製(paint)。填充像素的過程,包括元素的每一個可視部分。通常來講,繪製是在多個層上進行的。
  5. 合成(composite)。把各層按正確順序合併成一個層,顯示到屏幕上。

值得注意的是,並不是每一幀都會通過上述每個步驟的處理。若是元素的幾何屬性(尺寸、位置)沒有變化,就不須要進行佈局;若是連元素的外觀都沒有改變,就不須要繪製。因此,實現流暢動畫的關鍵就在於如何減小布局和繪製java

位移

對於位移動畫來講,最直接的實現方式,就是把元素設成絕對定位,而後去改變它的left樣式值。例如:web

<!DOCTYPE html>
<html>
<head>
<style> .page { position: absolute; left: 0; top: 0; width: 100%; min-height: 100%; background: #ddd; transition-duration: 2s; transition-property: left; } .leave { left: -100%; } </style>
</head>

<body>
<div id="page" class="page"></div>
<script> var page = document.getElementById('page'); setTimeout(function() { page.classList.add('leave'); }, 2000); </script>
</body>
</html>
複製代碼

使用Chrome開發者工具中的Performance面板錄製動畫過程的性能日誌,以下圖所示:瀏覽器

left動畫過程性能日誌

可見,元素在移動的過程當中不斷觸發了佈局和繪製。因此,這種實現方式的性能是極低的。網上諸多文獻會推薦以transform的變化代替left的變化,而實際狀況又是怎麼樣呢?把樣式代碼稍做修改:性能優化

.page {
    position: absolute;
    left: 0;
    top: 0;
    width: 100%;
    min-height: 100%;
    background: #ddd;
    transition-duration: 2s;
    transition-property: transform;
}
.leave {
    transform: translateX(-100%);
}
複製代碼

錄製性能日誌以下圖所示:app

transform動畫過程性能日誌

可見,僅僅是在動畫開始和結束兩個時間點觸發了繪製,而佈局則徹底沒有觸發。這樣一來,性能就有了很大的提高。可是,這裏還有兩個疑問:框架

  • 爲何transform動畫過程沒有觸發佈局和繪製?
  • 爲何動畫開始前觸發了兩次繪製,動畫結束以後觸發了一次繪製?

要回答這兩個問題,就得了解合成層。工具

合成層

當知足某些條件的時候,元素在渲染時會被分配到一個獨立的層中進行渲染,只要該層的內容不發生改變,就不會觸發繪製,瀏覽器會直接經過合成造成一個新的幀。常見的提高爲合成層的條件包括:

  • 對opacity或transform應用了animation或transition;
  • 有 3D transform ;
  • will-change設置爲opacity或transform。

很明顯,上一節的transform位移動畫知足了第一個條件。因此整個動畫的渲染過程是這樣的:

  • 動畫開始時,因爲div.page被提高爲獨立的合成層,因此它要從新繪製;而document所在層至關於少了一塊內容,也得從新繪製;
  • 動畫過程當中,div.page沒有其餘變化,因此不觸發佈局和繪製;
  • 動畫結束後,div.page再也不是獨立的合成層,回到了document所在層,因此document又從新繪製了一遍。

若是讓div.page一直在獨立的合成層中渲染,則能夠省掉上述過程當中繪製的環節。在樣式代碼添加「will-change: transform」:

.page {
    position: absolute;
    left: 0;
    top: 0;
    width: 100%;
    min-height: 100%;
    background: #ddd;
    transition-duration: 2s;
    transition-property: transform;
    will-change: transform;
}
複製代碼

錄製性能日誌以下:

合成層transform動畫過程性能日誌

可見,已經不存在繪製的步驟了。

順帶一提,Chrome開發者工具中有一個Layers面板,能夠方便地查看頁面上合成層以及成爲合成層的緣由。

Layers面板

(注意:因爲低版本瀏覽器不支持will-change,因此實際應用中,若是想把元素提高到獨立的合成層中渲染,能夠用「transform: translateZ(0)」)

不透明度

衆所周知,不透明度就是經過opacity樣式來控制的。那麼opacity的變化是否會觸發佈局和繪製呢?把樣式代碼修改以下:

.page {
    position: absolute;
    left: 0;
    top: 0;
    width: 100%;
    min-height: 100%;
    background: #ddd;
    transition-duration: 2s;
    transition-property: opacity;
}
.leave {
    opacity: 0;
}
複製代碼

錄製性能日誌以下圖所示:

opacity動畫過程性能日誌

在常規認知中,opacity的變化並不會致使元素位置和尺寸的變化,理應不會觸發佈局。但上述過程當中確實觸發了一次佈局,表現較爲詭異。接下來給div.page添加「will-change: opacity」使其一直在獨立的合成層中渲染。錄製性能日誌以下:

合成層opacity動畫過程性能日誌

可見,仍是會觸發一次繪製。而針對這「一次的佈局」和「一次的繪製」,我進行了進一步的實驗,得出的結論是:opacity從1(包括未設置的狀況,下同)變動到小於1,以及從小於1變動到1,都會觸發佈局和繪製;即便在獨立的合成層中渲染,也只能省掉佈局,沒法省掉繪製。

因爲在opacity動畫過程當中從1到小於1的變動只會有一次,因此上述的佈局和繪製都只觸發一次。

位移和不透明度

同時使用兩種動畫,修改樣式代碼以下:

.page {
    position: absolute;
    left: 0;
    top: 0;
    width: 100%;
    min-height: 100%;
    background: #ddd;
    transition-duration: 2s;
    transition-property: transform, opacity;
}
.leave {
    transform: translateX(-100%);
    opacity: 0;
}
複製代碼

按照前文的描述,動畫過程會觸發:

  • 一次佈局,在動畫開始時觸發,由opacity引發;
  • 兩次繪製,在動畫開始時觸發,因opacity以及提高爲獨立合成層引發;
  • 由獨立合成層回到document所在層時引發。

假若加上「will-change: transform, opacity」,使div.page一直在獨立的合成層中渲染,則只觸發一次繪製,由opacity引發。

然而,建立一個新的合成層並非免費的,它會致使額外的內存開銷。在單頁應用中,應用頁面過渡動畫的元素是頁面的最外層容器,包含了該頁面全部內容結構。若是讓其長期在獨立的合成層中渲染,那內存的消耗是很是大的。

因此,能夠僅在動畫過程當中讓其在獨立的合成層中渲染,而在其餘狀況下則維持常規狀態。

transform和fixed的衝突

若是用transform實現頁面過渡動畫,想必你們都遇到過一個問題:頁面上固定定位的元素,其位置變得不太正常了。

下面經過一段代碼模擬頁面進入的過程,來演示這個問題:

<!DOCTYPE html>
<html>
<head>
<style> .page { position: absolute; left: 0; top: 0; width: 100%; height: 150%; background: #ddd; transition-duration: 3s; transition-timing-function: cubic-bezier(.55, 0, .1, 1); transition-property: transform, opacity; } .before-enter { transform: translateX(100%); opacity: 0; } .fixed { position: fixed; right: 0; bottom: 0; width: 100%; height: 160px; background: #ffc100; } </style>
</head>

<body>
<div id="page" class="page before-enter">
    <div class="fixed"></div>
</div>
<script> var page = document.getElementById('page'); setTimeout(() => { page.classList.remove('before-enter'); }, 2000); </script>
</body>
</html>
複製代碼

運行效果以下:

transform與fixed的衝突

能夠看到,固定定位的黃色元素是在動畫結束後才忽然出現的。那在這以前它跑到哪去了呢?

若是給一個固定定位元素的任意一個祖先元素設置樣式「transform」或者「will-change: transform」,那麼該元素就會相對於最近的設置了上述樣式的祖先元素定位。

由於div.page的高度設成了150%,因此,在動畫過程當中,黃色元素其實是跑到了頁面的最底下(超出了瀏覽器可視範圍)去了。而在某些比較舊(如 iOS 9 的Safari)的移動端瀏覽器中,問題更爲嚴重,固定定位的元素可能會消失掉不再出現。

網上能查到的解決方案有兩種:

  • 經過絕對定位模擬固定定位。雖然是可行的,可是在移動端瀏覽器內,交互上會有一些細節問題,並且元素內部的滾動很容易與頁面滾動衝突。
  • 把固定定位的元素放到應用transform動畫的元素外。但這對使用「Vue.js」這類框架開發的單頁應用來講可行性較低,由於在這類框架中,一個頁面就是一個組件,單獨把頁面中的某個元素抽離出來是比較麻煩的。

因此,這裏介紹第三種方案——在頁面過渡動畫結束以後(此時transform樣式已被移除,再也不影響fixed),再讓固定定位的元素插入到頁面容器。而且,爲了讓它的出現顯得不那麼忽然,增長緩動動畫。代碼主要修改點以下:

@keyframes kf-move-in {
    0% { transform: translateY(100%); }
    100% { transform: translateY(0); }
}
.move-in {
    animation-name: kf-move-in;
    animation-duration: 0.45s;
}
複製代碼
<div id="page" class="page before-enter"></div>
<script> var page = document.getElementById('page'); setTimeout(function() { // 監聽過渡結束 page.addEventListener('transitionend', function() { // 建立、插入固定定位元素 var div = document.createElement('div'); div.className = 'fixed move-in'; page.appendChild(div); }); page.classList.remove('before-enter'); }, 2000); </script>
複製代碼

運行效果以下:

解決transform與fixed的衝突

這樣一來,整個交互就較爲友好了。這同時也說明:技術上的問題,不必定只能經過技術去解決,也能夠從交互上去尋求解決方案。

參考文獻

本文同時發佈於做者我的博客 mrluo.life/article/det…

相關文章
相關標籤/搜索