這篇文章我們深入學習 Transition
動畫。沒錯,CSS3 Transition
動畫。你可能會問,不是很簡單嗎,這什麼好講的?
確實,Transition
動畫使用起來非常容易。只需要給元素加上 transition-delay
, transition-duration
, transition-property
, transition-timing-function
屬性就可以有過濾效果。更簡單的用法是直接使用簡寫的 transition
屬性:
transition: <property> <duration> <timing-function> <delay>; // transition-delay 預設為 0 // transition-property 預設為 all // transition-timing-function 預設為 ease transition: 0.3s;
由於 transition 動畫用起來幾乎沒有成本,一直以來也沒有太深入學習,最近翻看原始碼和 MDN 檔案之後發現有些知識沒有理解到位,於是乎有了這篇文章,希望對讀者更深入瞭解 Transition 動畫有所幫助。(學習視訊分享:)
為了儘量降低閱讀理解成本,這篇文章會寫得稍微囉嗦一點點,大部分範例都會配圖 ——【多圖預警開始!】
簡單的說就是過渡動畫,通常修改 DOM 節點的樣式都是立即更新在頁面上的,例如修改寬高,修改透明度,修改背景色等等。
例如當滑鼠移動至按鈕上時,為了突出按鈕的可互動,會在 hover 時修改它的樣式,讓使用者注意到它。沒有加 transition 過渡動畫,給使用者的感覺會很僵很生硬。
.button { // ... background-color: #00a8ff; } .button:hover { background-color: #fbc531; transform: scale(1.2); }
加上 transition 一行程式碼之後,變化就會比較順滑。
.button { // ... transition: 1s; } // ...
這個例子中我們修改了 background-color
和 transform
,結合 transition
屬性,瀏覽器就會自動讓屬性值隨著時間變化,從舊值逐步過渡到過渡新值,視覺上就是動畫效果。
區分於
Animation
,Transition
動畫側重於表現一次過渡效果,從開始到結束的變化。而 Animation 不需要變化,可以迴圈播放 ▶️。
需要注意,並不是所有的屬性變化都會有過渡效果
有些 CSS 屬性只支援列舉值,非黑即白,不存在中間狀態,例如 visibility: visible;
被修改成 visibility: hidden;
不會有動畫效果,因為不存在可見又不可見的中間狀態。在瀏覽器上的表現是 duration 到了之後元素立即突變為 hidden。
.button:hover { //... visibility: hidden; }
transition-delay
,transition-duration
都是立即生效,這裡值得補一句由於 transition-*
屬性是即時生效,這行程式碼如果是 hover 時才加上,那麼效果會是 hover 時有動畫,移出時沒有動畫。即使是可過渡的屬性變化,也可能因為無法計算中間狀態而失去過渡效果。例如 box-shadow
屬性雖然支援 transition 的動畫的,但如果從 "outset
" 切換到 inset
,也是突變的。
.button { // ... box-shadow: 0 0 0 1px rgb(0 0 0 / 15%); transition: 1s; } .button:hover { // ... box-shadow: inset 0 0 0 10px rgb(0 0 0 / 15%); }
height: 100px
=> height: auto
是不會有動畫的。以上的內容回顧了 Transition 的基本用法,下面我們來看一個在實際開發場景中會遇到的問題。
場景題:假設我們現在接到一個自定義下拉選擇器的動畫需求,設計師給到的效果圖如下:
這是很常見的出現-消失動畫,在很多元件庫裡面都會出現,點選觸發器(按鈕)時才在頁面上渲染 Popup (下拉內容),並且 Popup 出現的同時需要有漸現和下滑的動畫;展開之後再次點選按鈕,Popup 需要漸隱和上滑。
平時使用的時候並沒有過多注意它的實現,不妨現在讓我們動手試驗一下。
暫時忽略 popup 的內容,用了個 div 來佔位模擬,HTML 結構很簡單。
<div class="wrapper"> <div id="button"></div> <div id="popup"></div> </div>
在點選按鈕的時候,讓 popup 顯示/隱藏,然後切換 popup
的 .active
類名。
const btn = document.querySelector("#button"); const popup = document.querySelector("#popup"); if (!popup.classList.contains("active")) { popup.style.display = "block"; popup.classList.add("active"); } else { popup.style.display = "none"; popup.classList.remove("active"); }
編寫 CSS
樣式,在不 active
時透明度設定為 0,向上偏移,active
時則不偏移且透明度設定為 1。
#popup { display: none; opacity: 0; transform: translateY(-8px); transition: 1s; &.active { opacity: 1; transform: translateY(0%); } }
完整程式碼 在這裡,看起來程式碼沒什麼問題,點選按鈕切換的時候,popup 應該會有動畫過渡效果。然而實際執行效果:
硬邦邦地完全沒有過渡效果,這是為啥?明明已經設定了 transition
,且 opacity
和 translateY
都是可計算可過渡的數值,也產生了變化,瀏覽器為什麼不認呢?
在查檔案之前,我們先嚐試使用萬精油 setTimeout
。
方案一:setTimeout 萬精油
修改 JS
程式碼:
btn.addEventListener("click", () => { if (!popup.classList.contains("active")) { popup.style.display = "block"; setTimeout(() => { popup.classList.add("active"); }, 0); } else { popup.classList.remove("active"); setTimeout(() => { popup.style.display = "none"; }, 600); } });
可以看到新增了 setTimeout
之後,transition
動畫就生效了。
隱藏時的 setTimeout 600ms
對應 CSS 中設定的 transition: 0.6s
,就是動畫完成之後才將 display
設定為 none
。
主要困惑的點在於為什麼顯示的時候也需要加 setTimeout
呢?setTimeout 0
在這裡起到的作用是什麼?帶著問題去翻看規範檔案。
在規範檔案的 Starting of transitions 章節找到下面這段話:
When a style change event occurs, implementations must start transitions based on the computed values that changed in that event. If an element is not in the document during that style change event or was not in the document during the previous style change event, then transitions are not started for that element in that style change event.
翻譯一下,當樣式變更事件發生時,實現(瀏覽器)必須根據變更的屬性執行過渡動畫。但如果樣式變更事件發生時或上一次樣式變更事件期間,元素不在檔案中,則不會為該元素啟動過渡動畫。
結合瀏覽器構建 RenderTree 的過程,我們可以很清晰地定位到問題:當樣式變更時間發生時,display: none
的 DOM 元素並不會出現在 RenderTree 中(style.display='block'
不是同步生效的,要在下一次渲染的時候才會更新到 Render Tree),不滿足 Starting of transitions 的條件。
所以 setTimeout 0
的作用是喚起一次 MacroTask,等到 EventLoop 執行回撥函數時,瀏覽器已經完成了一次渲染,再加上 .active
類名,就有了執行過渡動畫的充分條件。
優化方案二:精準卡位 requestAnimationFrame
既然目的為了讓元素先出現到 RenderTree 中,和渲染相關,很容易想到可以將 setTimeout
替換成 requestAnimationFrame
,這樣會更精準,因為 requestAnimation 執行時機和渲染有關。
if (!popup.classList.contains("active")) { popup.style.display = "block"; requestAnimationFrame(() => { popup.classList.add("active"); }); }
補充一個小插曲:在查詢資料的過程中瞭解到 requestAnimationFrame 的規範是要求其回撥函數在 Style/Layout 等階段之前執行,起初 Chrome 和 Firefox 是遵循規範來實現的。而 Safari 和 Edge 是在執行的時機是在之後。 從現在的表現上來看,Chrome 和 Firefox 也改成了在之後執行,翻看以前的檔案會說需要巢狀兩層 requestAnimationFrame,現在已經不需要了。Is requestAnimationFrame called at the right point?
優化方案三:Force Reflow
在規範檔案中,還留意到以下這句話:
Implementations typically have a style change event to correspond with their desired screen refresh rate, and when up-to-date computed style or layout information is needed for a script API that depends on it.
意思是說,瀏覽器通常還會在兩種情況下會產生樣式變更事件,一是滿足螢幕重新整理頻率(不就是 requestAnimationFrame?),二是當 JS 指令碼需要獲取最新的樣式佈局資訊時。
在 JS 程式碼中,有些 API
被呼叫時,瀏覽器會同步地計算樣式和佈局,頻繁呼叫這些 API(offset*/client*/getBoundingClientRect/scroll*/...等等)通常會成為效能瓶頸。
然而在這個場景卻可以產生奇妙的化學反應:
if (!popup.classList.contains("active")) { popup.style.display = "block"; popup.scrollWidth; popup.classList.add("active"); }
注意看,我們只是 display 和 add class 之間讀取了一下 scrollWidth,甚至沒有賦值,過渡動畫就活過來了。
原因是 scrollWidth
強制同步觸發了重排重繪,再下一行程式碼時,popup 的 display 屬性已經更新到 Render Tree 上了。
優化方案四:過渡完了告訴我 onTransitionEnd
現在【出現】動畫已經搞明白了,在看開源庫的原始碼中發現像 vue, bootstrap, react-transition-group 等庫都是使用了 force reflow 的方法,而 antd 所使用的 css-animte 庫則是通過設定 setTimeout。
【消失】動畫還不夠優雅,前面我們是直接寫死 setTimeout 600
,讓元素在動畫結束時消失的。這樣編碼可複用性差,修改動畫時間還得改兩處地方(JS + CSS),有沒有更優雅的實現?
popup.classList.remove("active");setTimeout(() => { popup.style.display = "none"; }, 600);
檔案中也提到了 Transition Events,包括 transitionrun
,transitionstart
,transitionend
,transitioncancel
,看名字就知道事件代表什麼意思,這裡可以用 transitionend
進行程式碼優化。
if (!popup.classList.contains("active")) { popup.style.display = "block"; popup.scrollWidth; popup.classList.add("active"); } else { popup.classList.remove("active"); popup.addEventListener('transitionend', () => { popup.style.display = "none"; }, { once: true }) }
需要注意 transition events
同樣也有冒泡、捕獲的特性,如果有巢狀 transition 時需要留意 event.target
。
到這裡我們已經用原生 JS 完成了一個出現、消失的動畫實現,完整的程式碼在這裡。文章的最後,我們參照 vue-transition
來開發一個 React Transition 的單個元素動畫過渡的最小實現。
根據動畫過程拆分成幾個過程:
*-enter
類名)*-enter-active
類名)*-enter-active
類名)enter-to 和 leave-to 暫時用不上,leave 階段和 enter 基本一致也不再贅述。
直接看程式碼:
export const CSSTransition = (props: Props) => { const { children, name, active } = props; const nodeRef = useRef<HTMLElement | null>(null); const [renderDOM, setRenderDOM] = useState(active); useEffect(() => { requestAnimationFrame(() => { if (active) { setRenderDOM(true); nodeRef.current?.classList.add(`${name}-enter`); // eslint-disable-next-line @typescript-eslint/no-unused-expressions nodeRef.current?.scrollWidth; nodeRef.current?.classList.remove(`${name}-enter`); nodeRef.current?.classList.add(`${name}-enter-active`); nodeRef.current?.addEventListener("transitionend", (event) => { if (event.target === nodeRef.current) { nodeRef.current?.classList.remove(`${name}-enter-active`); } }); } else { nodeRef.current?.classList.add(`${name}-leave`); // eslint-disable-next-line @typescript-eslint/no-unused-expressions nodeRef.current?.scrollWidth; nodeRef.current?.classList.remove(`${name}-leave`); nodeRef.current?.classList.add(`${name}-leave-active`); nodeRef.current?.addEventListener("transitionend", (event) => { if (event.target === nodeRef.current) { nodeRef.current?.classList.remove(`${name}-leave-active`); setRenderDOM(false); } }); } }); }, [active, name]); if (!renderDOM) { return null; } return cloneElement(Children.only(children), { ref: nodeRef }); };
這個元件接收三個 props,分別是
使用方式:
<CSSTransition name="fade" active={active}> // 一個需要做過渡動畫的 ReactElement </CssTransition>
藉助 transition-delay
,加一點技巧實現 stagger 效果:
完整的範例程式碼在這裡,注意:這只是個快速實現用於演示的範例,有非常多的問題沒有考慮在內,僅可用於學習參考。
原本以為非常基礎簡單的知識點,分分鐘可以寫完這篇文章。沒想到中途查檔案,看資料,製作演示 DEMO 還是花了不少時間。好在整理資料的過程中也理清了很多知識點。希望這篇文章對你熟悉 Transition 動畫有所幫助 。
相關推薦:
以上就是你瞭解 Transition 嗎?一起來深入瞭解下Transition!的詳細內容,更多請關注TW511.COM其它相關文章!