Day17 提過,看到舊專案用 Lottie 時忘記銷毀實例,結果是讓網頁吃掉超多記憶體。當時只是一筆帶過,今天來好好看一看,問題是怎麼發生的、在新專案又該怎麼避免。
舊專案的 Lottie 動畫是一個元件一份手寫,這樣分散在專案的各個地方,本來就很容易丟三落四。而在新專案中,我選擇把它封裝成共用元件,順便改成「用到才載入套件」。今天就先來看舊專案的問題,再介紹新專案封裝的做法,最後補充一些細節。那麼我們開始吧!
先回頭盤點舊專案。專案裡共有 10 個元件、11 處 loadAnimation,有載入畫面、提示彈窗、錄音播放、倒數結束等,每個元件都自己 import 一次、自己建立並呼叫 loadAnimation。寫法大概都長這樣:
<script setup>
import { ref, onMounted } from 'vue'
import lottie from 'lottie-web/build/player/lottie_light.js'
const el = ref(null)
onMounted(() => {
lottie.loadAnimation({
container: el.value,
renderer: 'svg',
loop: true,
autoplay: true,
path: '/animation/coffee.json',
})
})
// ! 沒有在 onBeforeUnmount 呼叫 destroy()
</script>
<template>
<div ref="el" style="width: 90%; height: 90%"></div>
</template>
畫面看起來沒什麼問題,動畫也順利播放。但 loadAnimation 會把這個動畫實例登記到 lottie 內部,元件被卸載、DOM 被移除之後,這份登記依然在,必須呼叫 destroy() 才會被移除。
用 destroy 和 onBeforeUnmount 搜尋了整個專案,發現 11 處裡只有 1 處(一個開開關關的彈窗)離開時一定會清理。頂多一處有在手動重新載入時呼叫 destroy(),其他所有的卸載行為都沒有用 destroy() 清除動畫實例。
最烙賽的是主要功能頁面切換用的 loading 動畫,它出現得特別頻繁,卻也最容易被漏掉。產品用一陣子之後,就能明顯發現記憶體占用愈來愈大,使用者也反應在行動裝置上操作,耗電量很快。
💡
destroy()做了什麼?
- 清空容器裡的 SVG,並銷毀各個圖層元素。
- 把這個實例從 lottie 內部的登記表移除;如果它正在播放,也會一併扣掉播放計數,計數歸零時
requestAnimationFrame的更新迴圈就會停下來。- 丟掉用
addEventListener掛上去的回呼,並把renderer等內部參考設為null。
問題點一目了然,就是該做的收尾沒有做。而分散式的寫法讓這個問題更雪上加霜,就算現在一個個補上,難保後續都會記得,更何況是多人協作的專案。
既然每個地方都要做一樣的事,不如把它收進一個元件。新專案的做法分成以下幾個步驟。
動畫路徑、尺寸、是否循環、是否自動播放,這些是每次使用時會不同的東西:
const props = defineProps({
src: { type: String, required: true },
width: { type: [Number, String], default: undefined },
height: { type: [Number, String], default: undefined },
autoplay: { type: Boolean, default: true },
loop: { type: Boolean, default: true },
// 可切換播放/暫停,第三點會提到
playing: { type: Boolean, default: true },
})
因為專案有些不同單位的需求,所以尺寸同時接受數字與字串,數字就自動補上 px,字串(例如 '16vw')則原樣使用:
const toCssSize = (value) => {
if (value === undefined || value === null) return undefined
return typeof value === 'number' ? `${value}px` : value
}
「建立動畫」和「離開時銷毀」是每次都一樣的流程,統一寫在元件裡。先看建立的部分,把 props 帶進 loadAnimation:
import lottie from 'lottie-web/build/player/lottie_light.js'
const containerRef = ref(null)
let animation = null // 把實例存起來,離開時才有東西可以銷毀
const initLottie = () => {
animation = lottie.loadAnimation({
container: containerRef.value,
renderer: 'svg',
loop: props.loop,
autoplay: props.autoplay && props.playing,
path: props.src,
rendererSettings: {
preserveAspectRatio: 'xMidYMid meet', // 縮放時維持比例、置中
progressiveLoad: true, // 邊載入邊繪製
},
})
}
onMounted(initLottie)
loadAnimation 的回傳值就是動畫實例,所以一定要用變數存起來,不然離開的時候就沒有東西可以呼叫 destroy()。接著補上離開時的銷毀:
onBeforeUnmount(() => {
animation?.destroy()
animation = null
})
這樣使用的人就不用重複寫同樣的流程,只需要關注父層要傳什麼資料進去,動畫的建立與卸載由元件的生命週期管理。
有些動畫需要隨著使用者操作播放、暫停,以錄音為例,錄音的時候波形要動,暫停的時候要停止動畫。所以用 playing 這個 prop 來控制,元件內用 watch 監聽變動,去呼叫對應的 play() / pause() 函式:
const applyPlayState = () => {
if (!animation) return
if (props.playing) {
animation.play()
} else {
animation.pause()
}
}
watch(() => props.playing, applyPlayState)
還有一個小細節,動畫資料是非同步載入的,載入完成時 lottie 會依照「建立當下」的 autoplay 自動開始播放。如果在載入期間 playing 已經變成 false,動畫還是會自己動起來。所以在 loadAnimation 建立動畫之後,要等 DOMLoaded 事件(lottie 定義的事件,意指動畫的 DOM 都建立完成),再把「當下」的播放狀態套用一次:
const initLottie = () => {
animation = lottie.loadAnimation({ /* ... */ })
animation.addEventListener('DOMLoaded', applyPlayState)
}
以上,寫完後只需要到父層引用元件,再傳入對應的 props 即可!
<LazyLottie
src="/animation/coffee.json"
width="16vw"
height="5vw"
:playing="!isPaused"
/>
| 手寫 | 封裝後 | |
|---|---|---|
| 每處要寫 | import、容器 ref、loadAnimation、還要記得 destroy |
一行 <LazyLottie> |
| 會不會漏掉卸載 | 11 處只有 1 處做到 | 統一套用元件內卸載邏輯 |
| 要調整 renderer 等設定 | 改 11 處 | 只需要改元件內的設定 |
其實專案的頁面本來就是路由懶載入,就算用靜態 import,lottie 也只會跟著用到它的頁面載入,不會進首頁。但是專案目前的使用情境,可能連進入頁面後也不會用到。同樣以錄音動畫為例,因為錄音不是編輯表單的預設選項,使用者如果選擇其他形式,完全不會顯示錄音 UI。
也就是說,就算進了頁面,這包套件對不少使用者仍然是白載的。所以我們把它再往後延一層:第一層是路由懶載入,讓其他頁面不用背;第二層是動態 import(),等到波形真的要出現才下載。
const lottie = (await import('lottie-web/build/player/lottie_light')).default
代價是按下開始錄音後,要先下載套件、再載入動畫檔,波形可能比按鈕晚一點點出現,錄音本身不受影響。目前覺得這個取捨可以接受。
💡 小提醒
- 動態
import()不是一定比較好。如果元件一進頁面就會顯示,或是頁面本身已經是懶載入、元件又幾乎一定會用到,直接靜態 import 更簡單,也不用處理後面第 3 點的卸載時機。- 專案選用的是
lottie_light。不單單因為相較完整版lottie.min.js更加輕量,也因為 light build 不含 eval/表達式引擎,可以避免 CSP 的unsafe-eval問題。
既然都要「用到才載入」,那麼「用到」的時機就交給 Day27 的 IntersectionObserver:元件進入畫面,才載入套件和動畫檔。
<template>
<!-- 拆成外層(被觀察)和內層(放動畫) -->
<div ref="containerRef" class="lottie-animation" :style="containerStyle">
<div v-if="isVisible" ref="animRef" class="w-100 h-100" />
</div>
</template>
onMounted(() => {
observer = new IntersectionObserver(([entry]) => {
if (!entry.isIntersecting) return
isVisible.value = true // 讓動畫容器長出來
observer.disconnect() // 只需要觸發一次
observer = null
initLottie()
}, { threshold: 0.1 })
observer.observe(containerRef.value)
})
initLottie 的流程是:
isVisible 設為 true。await nextTick(),等 v-if 的容器真的出現在 DOM。await import() 載入套件。loadAnimation() 建立動畫。把流程攤開來看,會發現動態 import() 讓流程多了好幾個 await。不出意外的話就要出意外了,如果使用者按下開始錄音、元件開始載入套件,但套件還沒下載完,使用者就馬上結束錄音或切換頁面,元件被卸載了。
這時候 onBeforeUnmount 會執行 animation?.destroy(),但 animation 要等 import() 回來之後才會被賦值,這時候還是 null,等於什麼都沒清到。而 initLottie 並不知道元件已經被卸載,import() 回來後照樣往下執行 loadAnimation,留下一個沒人管的實例。
補強的寫法,可以加上一個旗標:
let disposed = false
const initLottie = async () => {
// ...
const lottie = (await import('lottie-web/build/player/lottie_light')).default
if (disposed || !animRef.value) return // await 回來後,先確認自己還在
animation = lottie.loadAnimation({ /* ... */ })
}
onBeforeUnmount(() => {
disposed = true // 標記已卸載
observer?.disconnect()
animation?.destroy()
animation = null
})
其實寫到這邊,浮出了一個新想法:現階段的 lottie 動畫,只是簡單的視覺效果,或許可以跟 UI/UX 討論看看,有沒有用 CSS 做的可能?就不用為了一碟醋,包一盤餃子。或者確認後續新增的頁面會不會也用到 lottie,如果未來有更多地方會用到,不如就改成靜態 import?🤔
.
.
.
今天藉著 Lottie 動畫的皮,思考了該如何處理那些「重複出現、又容易漏掉的步驟」。怎樣封裝元件真的是一門大學問,我自己是會參考常用套件是如何挖 props、設計元件責任,過程蠻有趣的。而且做出實用性高的元件也蠻有成就感。
Day28 Clear!倒數剩下 2 天! (∩^o^)⊃━☆゚.*・。