學任何東西的第一步:從踩地雷開始。
簡述
如果要用 React 18 來使用 Gsap 的話 ,一定要先認識 context
與 revert
的用途,不然你可能會像我一樣踩到很多雷 QQ
首先 React 18 的 <React.StrictMode>
有個很常見的問題,就是 useEffect
會 render 兩次,例如說:
1 2 3
| useEffect(() => { console.log('effect') }, [])
|
執行後你會看到 effect
被 log 兩次,這個是 React 18 以後都會出現的預期行為。可是這樣子會引發的問題是「每一次在 useEffect
中設置的動畫其實會被執行兩次」,接著就會產生一些靈異現象,舉幾個我蠻常碰到的:
- 每次 hot reload 的時後動畫都會變得怪怪的
- 在使用 scroll 類的 plugin 時會直接跑版
- etc…
而解決這個問題的方法就是在 clean functoin 中做一些處理。打個比方來說,如果你在 useEffect
中設置了像 setInterval
這類的計時器,那你應該會想到要在 clean function 中用 clearInterval
把這個計數器給清掉,免得在一開始的時候設定到兩次計時器,對吧?
所以接下來會介紹如何用 revert 解決執行兩次的問題。
revert
其實動畫也是一樣的,既然它會被 call 兩次,那我就要在它被 call 第二次以前把第一次的動畫給清除掉,所以會這樣子寫:
1 2 3 4 5 6 7 8 9 10 11 12 13
| useEffect(() => { const tween = gsap.to('.square', { background: '#28a92b', duration: 2, rotation: 360, xPercent: '100', ease: 'none', repeat: -1, yoyo: true })
return () => tween.revert() }, [])
|
revert
的作用是把動畫取消並清除所有 gsap 加上去的 inline style,這跟另一個叫做 kill
的 method 有點類似,但還是有差異,就是 kill
並不會把 inline-style 給清除,不懂的話可以到 這個範例 參考看看。
context
接著來介紹 context 的用途,不過在那之前先回憶一下剛剛的範例:
1 2 3 4 5 6 7 8 9 10 11 12 13
| useEffect(() => { const tween = gsap.to('.square', { background: '#28a92b', duration: 2, rotation: 360, xPercent: '100', ease: 'none', repeat: -1, yoyo: true })
return () => tween.revert() }, [])
|
我們在前面提過在 useEffect 中執行動畫時,要記得在 clean function 中用 revert
來避免動畫被執行兩次的問題。可是另一個問題來了:
我的動畫很多耶,難道也得一個一個 revert 哦?
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| useEffect(() => { const tween1 = gsap.to('.square1', {...}) const tween2 = gsap.to('.square2', {...}) const tween3 = gsap.to('.square3', {...}) const tween4 = gsap.to('.square4', {...}) const tween5 = gsap.to('.square5', {...}) const tween6 = gsap.to('.square6', {...})
return () => { tween1.revert() tween2.revert() tween3.revert() tween4.revert() tween5.revert() tween6.revert() } }, [])
|
如果不使用 context
的話確實只能這樣子做,但是用了 context
以後可以輕輕鬆鬆用一行來搞定:
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| useEffect(() => { const ctx = gsap.context(() => { const tween1 = gsap.to('.square1', {...}) const tween2 = gsap.to('.square2', {...}) const tween3 = gsap.to('.square3', {...}) const tween4 = gsap.to('.square4', {...}) const tween5 = gsap.to('.square5', {...}) const tween6 = gsap.to('.square6', {...}) })
return () => ctx.revert() }, [])
|
之所以可以這麼輕鬆是因為 context
的本質是用來「記錄 tween 或 timeline」,只要是出現在 context
裡面的動畫都會被記錄下來,所以當你對整個 context
執行 revert
時,就好像是在說「幫我把所有記錄給重置」的感覺,所以所有記錄都會被重置。
使用 context
的好處還有很多,但我覺得這種 revert
的功能可以算是寫 React 的起手式了,所以這個功能一定要學起來,接下來會介紹幾個 context
的其他功能。
在 context 中設定 scope
既然我們都寫 React 了,那就一定會把一些東西抽出去寫成「元件」,來達到重複使用的便利性。可是這時候會碰到的另一個問題是,如果「不同的元件中有相同的 class 名稱怎麼辦?」,舉個例子:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25
| function App() { return ( <> <div className='App'> <div className='square'>square1</div> <B /> </div> </> ) }
function B() { useEffect(() => { const ctx = gsap.context(() => { gsap.to('.square', { xPercent: '-50', duration: 2 }) }) return () => ctx.revert() }, [])
return ( <div> <div className='square'>square2</div> </div> ) }
|
App
裡面有一個子元件 B
,而 B
裡面對 .square
設置了一個動畫,那結果會怎麼樣?
這時候你就會發現「不對啊,我只想對 B
裡面的 .square
設動畫而已,怎麼也動到 A
的 .square
了」。不過仔細想想這也挺合理的,畢竟都叫做 .square
嘛,誰知道你指的是哪一個 .square
呢?
總而言之,為了避免這種問題我們可以在 context
中傳入一個值來限制 scope,例如把剛剛的範例改寫成這樣:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27
| function App() { return ( <> <div className='App'> <div className='square'>square1</div> <B /> </div> </> ) }
function B() { const component = useRef < HTMLDivElement > null useEffect(() => { const ctx = gsap.context(() => { gsap.to('.square', { xPercent: '-50', duration: 2 }) }, component) return () => ctx.revert() }, [])
return ( <div ref={component}> <div className='square'>square2</div> </div> ) }
|
加上 scope 以後,就代表只有出現在 B
裡面的 .square
才會被設置動畫:
在 context 中使用事件
有些時候你可能會想要在點擊的時候才觸發動畫,這時候你可以這樣做:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54
| function App() { const component = useRef(null) const btn = useRef < HTMLButtonElement > null const square = useRef < HTMLDivElement > null
useEffect(() => { const ctx = gsap.context((context) => { context.add('onSquareClick', () => { gsap.to('.square', { background: '#28a92b', duration: 2, rotation: 360, xPercent: '100', ease: 'none', repeat: -1, yoyo: true }) }) }, component)
square.current?.addEventListener('click', onTriggerAnimate) btn.current?.addEventListener('click', onRevert)
return () => { ctx.revert() square.current?.removeEventListener('click', onTriggerAnimate) btn.current?.removeEventListener('click', onRevert) }
function onTriggerAnimate() { ctx.onSquareClick() } function onRevert() { ctx.revert() } }, [])
return ( <> <div ref={component} className='App'> <button className='btn' ref={btn}> Kill the animate </button> <div className='square one' ref={square}> One </div> </div> </> ) }
|
或者也可以寫成這樣子:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54
| function App() { const component = useRef(null) const btn = useRef < HTMLButtonElement > null const square = useRef < HTMLDivElement > null
useEffect(() => { const ctx = gsap.context(() => {}, component)
ctx.add('onSquareClick', () => { gsap.to('.square', { background: '#28a92b', duration: 2, rotation: 360, xPercent: '100', ease: 'none', repeat: -1, yoyo: true }) })
square.current?.addEventListener('click', onTriggerAnimate) btn.current?.addEventListener('click', onRevert)
return () => { ctx.revert() square.current?.removeEventListener('click', onTriggerAnimate) btn.current?.removeEventListener('click', onRevert) }
function onTriggerAnimate() { ctx.onSquareClick() } function onRevert() { ctx.revert() } }, [])
return ( <> <div ref={component} className='App'> <button className='btn' ref={btn}> Kill the animate </button> <div className='square one' ref={square}> One </div> </div> </> ) }
|
我懶的貼圖了,所以 範例一、範例二 就直接附在這了。
順便解釋一下為什麼要用這種方式來實作?你可能會想說「不能直接把 event 寫在 context 中來處理嗎」,像這樣:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| useEffect(() => { const ctx = gsap.context(() => { square.current?.addEventListener('click', () => { gsap.to('.square', { background: '#28a92b', duration: 2, rotation: 360, xPercent: '100', ease: 'none', repeat: -1, yoyo: true }) }) }, component)
btn.current?.addEventListener('click', () => ctx.revert()) }, [])
|
這個就要回憶一下「同步 / 非同步」的觀念了,不過簡單來說 ctx
從執行到結束時,gsap.to
裡面的內容其實是不會被記錄下來的,因為那個當下根本還沒被點擊,而沒被點擊就代表還沒觸發動畫。
所以接下來當你想透過 ctx.revert()
來重置時,就會因為找不到該筆紀錄所以沒辦法正確的重置,這就是為什麼不能這樣寫的原因。