對于一個 height 設(shè)置為 auto 的元素,當它的高度發(fā)生了不由樣式引起的改變時,并不會觸發(fā) transition 過渡動畫。
容器元素的高度往往是由其內(nèi)容決定的,如果一個容器元素的內(nèi)容高度突然發(fā)生了改變,而無法進行過渡動畫,有時會顯得比較生硬,比如下面的登錄框組件:

那么這種非樣式引起的變化如何實現(xiàn)過渡效果呢?可以借助 FLIP 技術(shù)。
FLIP 是什么
FLIP 是 First,Last,Invert,Play 的縮寫,其含義是:
-
First- 獲取元素變化之前的狀態(tài) -
Last- 獲取元素變化后的最終狀態(tài) -
Invert- 將元素從Last狀態(tài)反轉(zhuǎn)到First狀態(tài),比如通過添加transform屬性,使得元素變化后,看起來仍像是處于First狀態(tài)一樣 -
Play- 此時添加過渡動畫,再移除Invert效果(取消transform),動畫就會開始生效,使得元素看起來從First過渡到了Last
需要用到的 Web API
要實現(xiàn)一個基本的 FLIP 過渡動畫,需要使用到以下一些 Web API
Element.getBoundingClientRect() - Web API 接口參考 | MDN (mozilla.org)
Window:requestAnimationFrame() 方法 - Web API 接口參考 | MDN (mozilla.org)
基本過渡效果實現(xiàn)
使用以上 API,就可以初步實現(xiàn)一個監(jiān)聽元素尺寸變化,并對其應(yīng)用 FLIP 動畫的函數(shù) useBoxTransition,代碼如下:
/**
*
* @param {HTMLElement} el 要實現(xiàn)過渡的元素 DOM
* @param {number} duration 過渡動畫持續(xù)時間,單位 ms
* @returns 返回一個函數(shù),調(diào)用后取消對過渡元素尺寸變化的監(jiān)聽
*/
export default function useBoxTransition(el: HTMLElement, duration: number) {
// boxSize 用于記錄元素處于 First 狀態(tài)時的尺寸大小
let boxSize: {
width: number
height: number
} | null = null
const elStyle = el.style // el 的 CSSStyleDeclaration 對象
const resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
// 被觀察的 box 發(fā)生尺寸變化時要進行的操作
// 獲取當前回調(diào)調(diào)用時,box 的寬高
const borderBoxSize = entry.borderBoxSize[0]
const writtingMode = elStyle.getPropertyValue('writing-mode')
const isHorizontal =
writtingMode === 'vertical-rl' ||
writtingMode === 'vertical-lr' ||
writtingMode === 'sideways-rl' ||
writtingMode === 'sideways-lr'
? false
: true
const width = isHorizontal
? borderBoxSize.inlineSize
: borderBoxSize.blockSize
const height = isHorizontal
? borderBoxSize.blockSize
: borderBoxSize.inlineSize
// 當 box 尺寸發(fā)生變化時,使用 FLIP 動畫技術(shù)產(chǎn)生過渡動畫,使用過渡效果的是 scale 形變
// 根據(jù) First 和 Last 計算出 Inverse 所需的 scale 大小
// box 首次被觀察時會觸發(fā)一次回調(diào),此時 boxSize 為 null,scale 應(yīng)為 1
const scaleX = boxSize ? boxSize.width / width : 1
const scaleY = boxSize ? boxSize.height / height : 1
// 尺寸發(fā)生變化的瞬間,要使用 scale 變形將其保持變化前的尺寸,要先將 transition 去除
elStyle.setProperty('transition', 'none')
elStyle.setProperty('transform', `scale(${scaleX}, ${scaleY})`)
// 將 scale 移除,并應(yīng)用 transition 以實現(xiàn)過渡效果
setTimeout(() => {
elStyle.setProperty('transform', 'none')
elStyle.setProperty('transition', `transform ${duration}ms`)
})
// 記錄變化后的 boxSize
boxSize = { width, height }
}
})
resizeObserver.observe(el)
const cancelBoxTransition = () => {
resizeObserver.unobserve(el)
}
return cancelBoxTransition
}
效果如下所示:

效果改進
目前已經(jīng)實現(xiàn)了初步的過渡效果,但在一些場景下會有些瑕疵:
- 如果在過渡動畫完成前,元素有了新的狀態(tài)變化,則動畫被打斷,無法平滑過渡到新的狀態(tài)
-
FLIP動畫過渡過程中,實際上發(fā)生變化的是transform屬性,并不影響元素在文檔流中占據(jù)的位置,如果需要該元素影響周圍的元素,那么周圍元素無法實現(xiàn)平滑過渡
如下所示:

對于動畫打斷問題的優(yōu)化思路
- 使用
Window.requestAnimationFrame()方法在每一幀中獲取元素的尺寸 - 這樣做可以實時地獲取到元素的尺寸,實時地更新
First狀態(tài)
對于元素在文檔流中問題的優(yōu)化思路
- 應(yīng)用過渡的元素外可以套一個
.outer元素,其定位為relative,過渡元素的定位為absolute,且居中于.outer元素 - 當過渡元素尺寸發(fā)生變化時,通過
resizeObserver獲取其最終的尺寸,將其寬高設(shè)置給.outer元素(實例代碼運行于Vue 3中,因此使用的是Vue提供的ref api將其寬高暴露出來,可以方便地監(jiān)聽其變化;如果在React中則可以將設(shè)置.outer元素寬高的方法作為參數(shù)傳入useBoxTransition中,在需要的時候調(diào)用),并給.outer元素設(shè)置寬高的過渡效果,使其在文檔流中所占的位置與過渡元素的尺寸同步 - 但是也要注意,這樣做可能會引起瀏覽器高頻率的重排,在復(fù)雜布局中慎用!
改進后的useBoxTransition 函數(shù)如下:
import throttle from 'lodash/throttle'
import { ref } from 'vue'
type BoxSize = {
width: number
height: number
}
type BoxSizeRef = globalThis.Ref<BoxSize>
/**
*
* @param {HTMLElement} el 要實現(xiàn)過渡的元素 DOM
* @param {number} duration 過渡動畫持續(xù)時間,單位 ms
* @param {string} mode 過渡動畫緩動速率,同 CSS transition-timing-function 可選值
* @returns 返回一個有兩個項的元組:第一項為 keyBoxSizeRef,當元素大小發(fā)生變化時會將變化后的目標尺寸發(fā)送給 keyBoxSizeRef.value;第二項為一個函數(shù),調(diào)用后取消對過渡元素尺寸變化的監(jiān)聽
*/
export default function useBoxTransition(
el: HTMLElement,
duration: number,
mode?: string
) {
let boxSizeList: BoxSize[] = [] // boxSizeList 表示對 box 的尺寸的記錄數(shù)組;為什么是使用列表:因為當 box 尺寸變化的一瞬間,box 的 transform 效果無法及時移除,此時 box 的尺寸可能是非預(yù)期的,因此使用列表來記錄 box 的尺寸,在必要的時候盡可能地將非預(yù)期的尺寸移除
const keyBoxSizeRef: BoxSizeRef = ref({ width: 0, height: 0 }) // keyBoxSizeRef 是
let isObserved = false // box 是否已經(jīng)開始被觀察
let frameId = 0 // 當前 animationFrame 的 id
let isTransforming = false // 當前是否處于變形過渡中
const elStyle = el.style // el 的 CSSStyleDeclaration 對象
const elComputedStyle = getComputedStyle(el) // el 的只讀動態(tài) CSSStyleDeclaration 對象
// 獲取當前 boxSize 的函數(shù)
function getBoxSize() {
const rect = el.getBoundingClientRect() // el 的 DOMRect 對象
return { width: rect.width, height: rect.height }
}
// 同步更新 boxSizeList
function updateBoxsize(boxSize: BoxSize) {
boxSizeList.push(boxSize)
// 只保留前最新的 4 條記錄
boxSizeList = boxSizeList.slice(-4)
}
// 定義 animationFrame 的回調(diào)函數(shù),使得當 box 變形時可以更新 boxSize 記錄
const animationFrameCallback = throttle(() => {
// 為避免使用了函數(shù)節(jié)流后,導(dǎo)致回調(diào)函數(shù)延遲觸發(fā)使得 cancelAnimationFrame 失敗,因此使用 isTransforming 變量控制回調(diào)函數(shù)中的操作是否執(zhí)行
if (isTransforming) {
const boxSize = getBoxSize()
updateBoxsize(boxSize)
frameId = requestAnimationFrame(animationFrameCallback)
}
}, 20)
// 過渡事件的回調(diào)函數(shù),在過渡過程中實時更新 boxSize
function onTransitionStart(e: Event) {
if (e.target !== el) return
// 變形中斷的一瞬間,boxSize 的尺寸可能是非預(yù)期的,因此在變形開始時,將最新的幾個可能是非預(yù)期的 boxSize 移除,只留下最早的那個
if (boxSizeList.length > 1) {
boxSizeList = boxSizeList.slice(0, 1)
}
isTransforming = true
frameId = requestAnimationFrame(animationFrameCallback)
// console.log('過渡開始')
}
function onTransitionCancel(e: Event) {
if (e.target !== el) return
isTransforming = false
cancelAnimationFrame(frameId)
// console.log('過渡中斷')
}
function onTransitionEnd(e: Event) {
if (e.target !== el) return
isTransforming = false
cancelAnimationFrame(frameId)
// console.log('過渡完成')
}
el.addEventListener('transitionstart', onTransitionStart)
el.addEventListener('transitioncancel', onTransitionCancel)
el.addEventListener('transitionend', onTransitionEnd)
const resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
// 被觀察的 box 發(fā)生尺寸變化時要進行的操作
// 獲取當前回調(diào)調(diào)用時,box 的寬高
const borderBoxSize = entry.borderBoxSize[0]
const writtingMode = elStyle.getPropertyValue('writing-mode')
const isHorizontal =
writtingMode === 'vertical-rl' ||
writtingMode === 'vertical-lr' ||
writtingMode === 'sideways-rl' ||
writtingMode === 'sideways-lr'
? false
: true
const width = isHorizontal
? borderBoxSize.inlineSize
: borderBoxSize.blockSize
const height = isHorizontal
? borderBoxSize.blockSize
: borderBoxSize.inlineSize
// box 首次被觀察時會觸發(fā)一次回調(diào),此時不需要應(yīng)用過渡,只需將當前尺寸記錄到 boxSizeList 中
if (!isObserved) {
isObserved = true
const boxSize = { width, height }
boxSizeList.push(boxSize)
keyBoxSizeRef.value = boxSize
return
}
// 當 box 尺寸發(fā)生變化時,將此刻 box 的目標尺寸暴露給 keyBoxSizeRef
keyBoxSizeRef.value = {
width,
height
}
// 當 box 尺寸發(fā)生變化時,使用 FLIP 動畫技術(shù)產(chǎn)生過渡動畫,使用過渡效果的是 scale 形變
// 根據(jù) First 和 Last 計算出 Inverse 所需的 scale 大小
const scaleX = boxSizeList[0].width / width
const scaleY = boxSizeList[0].height / height
// 尺寸發(fā)生變化的瞬間,要使用 scale 變形將其保持變化前的尺寸,要先將 transition 去除
elStyle.setProperty('transition', 'none')
const originalTransform =
elStyle.transform || elComputedStyle.getPropertyValue('--transform')
elStyle.setProperty(
'transform',
`${originalTransform} scale(${scaleX}, ${scaleY})`
)
// 將 scale 移除,并應(yīng)用 transition 以實現(xiàn)過渡效果
setTimeout(() => {
elStyle.setProperty('transform', originalTransform)
elStyle.setProperty('transition', `transform ${duration}ms ${mode}`)
})
}
})
resizeObserver.observe(el)
const cancelBoxTransition = () => {
resizeObserver.unobserve(el)
cancelAnimationFrame(frameId)
}
const result: [BoxSizeRef, () => void] = [keyBoxSizeRef, cancelBoxTransition]
return result
}
相應(yīng)的 vue 組件代碼如下:
<template>
<div class="outer" ref="outerRef">
<div class="card-container" ref="cardRef">
<div class="card-content">
<slot></slot>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import useBoxTransition from '@/utils/useBoxTransition'
type Props = {
transition?: boolean
duration?: number
mode?: string
}
const props = defineProps<Props>()
const { transition, duration = 200, mode = 'ease' } = props
const cardRef = ref<HTMLElement | null>(null)
const outerRef = ref<HTMLElement | null>(null)
let cancelBoxTransition = () => {} // 取消 boxTransition 效果
onMounted(() => {
if (cardRef.value) {
const cardEl = cardRef.value as HTMLElement
const outerEl = outerRef.value as HTMLElement
if (transition) {
const boxTransition = useBoxTransition(cardEl, duration, mode)
const keyBoxSizeRef = boxTransition[0]
cancelBoxTransition = boxTransition[1]
outerEl.style.setProperty(
'--transition',
`weight ${duration}ms ${mode}, height ${duration}ms ${mode}`
)
watch(keyBoxSizeRef, () => {
outerEl.style.setProperty('--height', keyBoxSizeRef.value.height + 'px')
outerEl.style.setProperty('--width', keyBoxSizeRef.value.width + 'px')
})
}
}
})
onUnmounted(() => {
cancelBoxTransition()
})
</script>
<style scoped lang="less">
.outer {
position: relative;
&::before {
content: '';
display: block;
width: var(--width);
height: var(--height);
transition: var(--transition);
}
.card-container {
position: absolute;
top: 50%;
left: 50%;
width: 100%;
--transform: translate(-50%, -50%);
transform: var(--transform);
box-sizing: border-box;
background-color: rgba(255, 255, 255, 0.7);
border-radius: var(--border-radius, 20px);
overflow: hidden;
backdrop-filter: blur(10px);
padding: 30px;
box-shadow: var(--box-shadow, 0 0 15px 0 rgba(0, 0, 0, 0.3));
}
}
</style>
優(yōu)化后的效果如下:


注意點
過渡元素本身的 transform 樣式屬性
useBoxTransition 函數(shù)中會覆蓋應(yīng)用過渡的元素的 transform 屬性,如果需要額外為元素設(shè)置其它的 transform 效果,需要使用 css 變量 --transform 設(shè)置,或使用內(nèi)聯(lián)樣式設(shè)置。
這是因為,useBoxTransition 函數(shù)中對另外設(shè)置的 transform 效果和過渡所需的 transform 效果做了合并。
然而通過 getComputedStyle(Element) 讀取到的 transform 的屬性值總是會被轉(zhuǎn)化為 matrix() 的形式,使得 transform 屬性值無法正常合并;而 CSS 變量和使用 Element.style 獲取到的內(nèi)聯(lián)樣式中 transform 的值是原始的,可以正常合并。
如何選擇獲取元素寬高的方式
Element.getBoundingClientRect() 獲取到的 DOMRect 的寬高包含了 transform 變化,而 Element.offsetWidth / Element.offsetHeight 以及 ResizeObserverEntry 對象獲取到的寬高是元素本身的占位大小。
因此在需要獲取 transition 過程中,包含 transform 效果的元素大小時,使用 Element.getBoundingClientRect(),否則可以使用 Element.offsetWidth / Element.offsetHeight 或 ResizeObserverEntry 對象。
獲取元素高度時遇到的 bug
測試案例中使用了 elementPlus UI 庫的 el-tabs 組件,當元素包含該組件時,無論是使用 Element.getBoundingClientRect()、Element.offsetHeight 還是使用 Element.Style、getComputedStyle(Element) 獲取到的元素高度均缺少了 40px;而使用 ResizeObserverEntry 對象獲取到的高度則是正確的,但是它無法脫離 ResizeObserver API 獨立使用。
經(jīng)過測試驗證,缺少的 40px 高度來自于 el-tabs 組件中 .el-tabs__header 元素的高度,也就是說,在獲取元素高度時,將 .el-tabs__header 元素的高度忽略了。
測試后找出的解決方法是,手動將 .el-tabs__header 元素樣式(注意不要寫在帶 scoped 屬性的 style 標簽中,會被判定為布局樣式而無法生效)的 height 屬性指定為 calc(var(--el-tabs-header-height) - 1px),即可恢復(fù)正常的高度計算。
至于為什么這樣會造成高度計算錯誤,希望有大神能解惑。