Gsap-context 與 revert(寫 React 的必備知識)

學任何東西的第一步:從踩地雷開始。

簡述

如果要用 React 18 來使用 Gsap 的話 ,一定要先認識 contextrevert 的用途,不然你可能會像我一樣踩到很多雷 QQ

首先 React 18 的 <React.StrictMode> 有個很常見的問題,就是 useEffectrender 兩次,例如說:

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(() => {
// 把所有動畫都包在 context 裡
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 設置了一個動畫,那結果會怎麼樣?

example1-context-without-scope

這時候你就會發現「不對啊,我只想對 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 })
// 把 scope 限制在 B 的 <div> 中
}, component)
return () => ctx.revert()
}, [])

return (
<div ref={component}>
<div className='square'>square2</div>
</div>
)
}

加上 scope 以後,就代表只有出現在 B 裡面的 .square 才會被設置動畫:

example1-context-with-scope

在 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) => {
// 新增一個 custom event(名字自己取)
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 呼叫 custom event
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 來新增 custom event
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() 來重置時,就會因為找不到該筆紀錄所以沒辦法正確的重置,這就是為什麼不能這樣寫的原因。

Gsap-常見的基本方法 Echarts Note
Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×