簡介
動畫這個概念非常寬泛,涉及各個領域,這里我們把范圍縮小到前端網頁應用層面上,不用講游戲領域的Animate,一切從最簡單的開始。
目前大部分網頁應用都是基于框架開發的,比如Vue,React等,它們都是基于數據驅動視圖的,那么讓我們來對比一下,還沒有這些框架的時候我們如何實現動畫或者過渡效果,然后使用數據驅動又是如何實現的。
傳統過渡動畫
動畫效果對體驗有著非常重要的效果,但是對于很多開發者來講,可能是個非常薄弱的環節。在css3出現之后,很多初學者最常用的動畫過渡可能就是css3的能力了。
css過渡動畫
css啟動過渡動畫非常簡單,書寫transition屬性就可以了,下面寫一個demo
1
|
< div id = "app" class = "normal" ></ div > |
1
2
3
4
5
6
7
8
9
10
11
|
. normal { width : 100px ; height : 100px ; background-color : red ; transition: all 0.3 s; } .normal:hover { background-color : yellow; width : 200px ; height : 200px ; } |
效果還是很贊的,css3的transition基本滿足了大部分動畫需求,如果不滿足還有真正的css3 animation。
大名鼎鼎的css動畫庫,誰用誰知道。
不管是css3 transition 還是 css3 animation,我們簡單使用都是通過切換class類名,如果要做回調處理,瀏覽器也提供了 ontransitionend , onanimationend等動畫幀事件,通過js接口進行監聽即可。
1
2
3
4
5
6
7
|
var el = document.querySelector( '#app' ) el.addEventListener( 'transitionstart' , () => { console.log( 'transition start' ) }) el.addEventListener( 'transitionend' , () => { console.log( 'transition end' ) }) |
ok,這就是css動畫的基礎了,通過js封裝也可以實現大部分的動畫過渡需求,但是局限性在與只能控制css支持的屬性動畫,相對來說控制力還是稍微弱一點。
js動畫
js畢竟是自定義編碼程序,對于動畫的控制力就很強大了,而且能實現各種css不支持的效果。 那么 js 實現動畫的基礎是什么?
簡單來講,所謂動畫就是在 時間軸上不斷更新某個元素的屬性,然后交給瀏覽器重新繪制,在視覺上就成了動畫。廢話少說,還是先來個栗子:
1
|
< div id = "app" class = "normal" ></ div > |
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
|
// Tween僅僅是個緩動函數 var el = document.querySelector( '#app' ) var time = 0, begin = 0, change = 500, duration = 1000, fps = 1000 / 60; function startSport() { var val = Tween.Elastic.easeInOut(time, begin, change, duration); el.style.transform = 'translateX(' + val + 'px)' ; if (time <= duration) { time += fps } else { console.log( '動畫結束重新開始' ) time = 0; } setTimeout(() => { startSport() }, fps) } startSport() |
在時間軸上不斷更新屬性,可以通過setTimeout或者requestAnimation來實現。至于Tween緩動函數,就是類似于插值的概念,給定一系列變量,然后在區間段上可以獲取任意時刻的值,純數學公式,幾乎所有的動畫框架都會使用,想了解的可以參考張鑫旭的Tween.js
OK,這個極簡demo也是js實現動畫的核心基礎了,可以看到我們通過程序完美的控制了過渡值的生成過程,所有其他復雜的動畫機制都是這個模式。
傳統和Vue/React框架對比
通過前面的例子,無論是css過渡還是js過渡,我們都是直接獲取到 dom元素的,然后對dom元素進行屬性操作。
Vue/React都引入了虛擬dom的概念,數據驅動視圖,我們盡量不去操作dom,只控制數據,那么我們如何在數據層面驅動動畫呢?
Vue框架下的過渡動畫
可以先看一遍文檔
我們就不講如何使用了,我們來分析一下Vue提供的transition組件是如何實現動畫過渡支持的。
transition組件
先看transition組件代碼,路徑 “src/platforms/web/runtime/components/transition.js”
核心代碼如下:
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
55
56
57
58
59
60
61
62
|
// 輔助函數,復制props的數據 export function extractTransitionData (comp: Component): Object { const data = {} const options: ComponentOptions = comp.$options // props for (const key in options.propsData) { data[key] = comp[key] } // events. const listeners: ?Object = options._parentListeners for (const key in listeners) { data[camelize(key)] = listeners[key] } return data } export default { name: 'transition' , props: transitionProps, abstract: true , // 抽象組件,意思是不會真實渲染成dom,輔助開發 render (h: Function) { // 通過slots獲取到真實渲染元素children let children: any = this .$slots. default const mode: string = this .mode const rawChild: VNode = children[0] // 添加唯一key // component instance. This key will be used to remove pending leaving nodes // during entering. const id: string = `__transition-${ this ._uid}-` child.key = getKey(id) : child.key // data上注入transition屬性,保存通過props傳遞的數據 const data: Object = (child.data || (child.data = {})).transition = extractTransitionData( this ) const oldRawChild: VNode = this ._vnode const oldChild: VNode = getRealChild(oldRawChild) // important for dynamic transitions! const oldData: Object = oldChild.data.transition = extend({}, data) // handle transition mode if (mode === 'out-in' ) { // return placeholder node and queue update when leave finishes this ._leaving = true mergeVNodeHook(oldData, 'afterLeave' , () => { this ._leaving = false this .$forceUpdate() }) return placeholder(h, rawChild) } else if (mode === 'in-out' ) { let delayedLeave const performLeave = () => { delayedLeave() } mergeVNodeHook(data, 'afterEnter' , performLeave) mergeVNodeHook(data, 'enterCancelled' , performLeave) mergeVNodeHook(oldData, 'delayLeave' , leave => { delayedLeave = leave }) } return rawChild } } |
可以看到,這個組件本身功能比較簡單,就是通過slots拿到需要渲染的元素children,然后把 transition的props屬性數據copy到data的transtion屬性上,供后續注入生命周期使用,mergeVNodeHook就是做生命周期管理的。
modules/transition
接著往下看生命周期相關,路徑:
src/platforms/web/runtime/modules/transition.js
先看默認導出:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
function _enter (_: any, vnode: VNodeWithData) { if (vnode.data.show !== true ) { enter(vnode) } } export default inBrowser ? { create: _enter, activate: _enter, remove (vnode: VNode, rm: Function) { if (vnode.data.show !== true ) { leave(vnode, rm) } } } : {} |
這里inBrowser就當做true,因為我們分析的是瀏覽器環境。
接著看enter 和 leave函數,先看enter:
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
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
|
export function addTransitionClass (el: any, cls: string) { const transitionClasses = el._transitionClasses || (el._transitionClasses = []) if (transitionClasses.indexOf(cls) < 0) { transitionClasses.push(cls) addClass(el, cls) } } export function removeTransitionClass (el: any, cls: string) { if (el._transitionClasses) { remove(el._transitionClasses, cls) } removeClass(el, cls) } export function enter (vnode: VNodeWithData, toggleDisplay: ?() => void) { const el: any = vnode.elm // call leave callback now if (isDef(el._leaveCb)) { el._leaveCb.cancelled = true el._leaveCb() } // 上一步注入data的transition數據 const data = resolveTransition(vnode.data.transition) if (isUndef(data)) { return } /* istanbul ignore if */ if (isDef(el._enterCb) || el.nodeType !== 1) { return } const { css, type, enterClass, enterToClass, enterActiveClass, appearClass, appearToClass, appearActiveClass, beforeEnter, enter, afterEnter, enterCancelled, beforeAppear, appear, afterAppear, appearCancelled, duration } = data let context = activeInstance let transitionNode = activeInstance.$vnode const isAppear = !context._isMounted || !vnode.isRootInsert if (isAppear && !appear && appear !== '' ) { return } // 獲取合適的時機應該注入的className const startClass = isAppear && appearClass ? appearClass : enterClass const activeClass = isAppear && appearActiveClass ? appearActiveClass : enterActiveClass const toClass = isAppear && appearToClass ? appearToClass : enterToClass const beforeEnterHook = isAppear ? (beforeAppear || beforeEnter) : beforeEnter const enterHook = isAppear ? ( typeof appear === 'function' ? appear : enter) : enter const afterEnterHook = isAppear ? (afterAppear || afterEnter) : afterEnter const enterCancelledHook = isAppear ? (appearCancelled || enterCancelled) : enterCancelled const explicitEnterDuration: any = toNumber( isObject(duration) ? duration.enter : duration ) const expectsCSS = css !== false && !isIE9 const userWantsControl = getHookArgumentsLength(enterHook) // 過渡結束之后的回調處理,刪掉進入時的class const cb = el._enterCb = once(() => { if (expectsCSS) { removeTransitionClass(el, toClass) removeTransitionClass(el, activeClass) } if (cb.cancelled) { if (expectsCSS) { removeTransitionClass(el, startClass) } enterCancelledHook && enterCancelledHook(el) } else { afterEnterHook && afterEnterHook(el) } el._enterCb = null }) // dom進入時,添加start class進行過渡 beforeEnterHook && beforeEnterHook(el) if (expectsCSS) { // 設置過渡開始之前的默認樣式 addTransitionClass(el, startClass) addTransitionClass(el, activeClass) // 瀏覽器渲染下一幀 刪除默認樣式,添加toClass // 添加end事件監聽,回調就是上面的cb nextFrame(() => { removeTransitionClass(el, startClass) if (!cb.cancelled) { addTransitionClass(el, toClass) if (!userWantsControl) { if (isValidDuration(explicitEnterDuration)) { setTimeout(cb, explicitEnterDuration) } else { whenTransitionEnds(el, type, cb) } } } }) } if (vnode.data.show) { toggleDisplay && toggleDisplay() enterHook && enterHook(el, cb) } if (!expectsCSS && !userWantsControl) { cb() } } |
enter里使用了一個函數whenTransitionEnds,其實就是監聽過渡或者動畫結束的事件:
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
|
export let transitionEndEvent = 'transitionend' export let animationEndEvent = 'animationend' export function whenTransitionEnds ( el: Element, expectedType: ?string, cb: Function ) { const { type, timeout, propCount } = getTransitionInfo(el, expectedType) if (!type) return cb() const event: string = type === TRANSITION ? transitionEndEvent : animationEndEvent let ended = 0 const end = () => { el.removeEventListener(event, onEnd) cb() } const onEnd = e => { if (e.target === el) { if (++ended >= propCount) { end() } } } setTimeout(() => { if (ended < propCount) { end() } }, timeout + 1) el.addEventListener(event, onEnd) } |
OK,到了這里,根據上面源代碼的注釋分析,我們可以發現:
- Vue先是封裝了一些列操作dom className的輔助方法addClass/removeClass等。
- 然后在生命周期enterHook之后,馬上設置了startClass也就是enterClass的默認初始樣式,還有activeClass
- 緊接著在瀏覽器nextFrame下一幀,移除了startClass,添加了toClass,并且添加了過渡動畫的end事件監聽處理
- 監聽到end事件之后,調動cb,移除了toClass和activeClass
leave的過程和enter的處理過程是一樣,只不過是反向添加移除className
結論:Vue的動畫過渡處理方式和 傳統dom本質上是一樣,只不過融入了Vue的各個生命周期里進行處理,本質上還是在dom 添加刪除的時機進行處理
React里的過渡動畫
噢,我們翻篇了React的文檔,也沒有發現有過渡動畫的處理。嘿,看來官方不原生支持。
但是我們可以自己實現,比如通過useState維護一個狀態,在render里根據狀態進行className的切換,但是復雜的該怎么辦?
所幸在社區找到了一個輪子插件react-transition-group
嗯,直接貼源碼,有了前面Vue的分析,這個非常容易理解,反而更簡單:
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
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
|
class Transition extends React.Component { static contextType = TransitionGroupContext constructor(props, context) { super (props, context) let parentGroup = context let appear = parentGroup && !parentGroup.isMounting ? props.enter : props.appear let initialStatus this .appearStatus = null if (props. in ) { if (appear) { initialStatus = EXITED this .appearStatus = ENTERING } else { initialStatus = ENTERED } } else { if (props.unmountOnExit || props.mountOnEnter) { initialStatus = UNMOUNTED } else { initialStatus = EXITED } } this .state = { status: initialStatus } this .nextCallback = null } // 初始dom的時候,更新默認初始狀態 componentDidMount() { this .updateStatus( true , this .appearStatus) } // data更新的時候,更新對應的狀態 componentDidUpdate(prevProps) { let nextStatus = null if (prevProps !== this .props) { const { status } = this .state if ( this .props. in ) { if (status !== ENTERING && status !== ENTERED) { nextStatus = ENTERING } } else { if (status === ENTERING || status === ENTERED) { nextStatus = EXITING } } } this .updateStatus( false , nextStatus) } updateStatus(mounting = false , nextStatus) { if (nextStatus !== null ) { // nextStatus will always be ENTERING or EXITING. this .cancelNextCallback() if (nextStatus === ENTERING) { this .performEnter(mounting) } else { this .performExit() } } else if ( this .props.unmountOnExit && this .state.status === EXITED) { this .setState({ status: UNMOUNTED }) } } performEnter(mounting) { const { enter } = this .props const appearing = this .context ? this .context.isMounting : mounting const [maybeNode, maybeAppearing] = this .props.nodeRef ? [appearing] : [ReactDOM.findDOMNode( this ), appearing] const timeouts = this .getTimeouts() const enterTimeout = appearing ? timeouts.appear : timeouts.enter // no enter animation skip right to ENTERED // if we are mounting and running this it means appear _must_ be set if ((!mounting && !enter) || config.disabled) { this .safeSetState({ status: ENTERED }, () => { this .props.onEntered(maybeNode) }) return } this .props.onEnter(maybeNode, maybeAppearing) this .safeSetState({ status: ENTERING }, () => { this .props.onEntering(maybeNode, maybeAppearing) this .onTransitionEnd(enterTimeout, () => { this .safeSetState({ status: ENTERED }, () => { this .props.onEntered(maybeNode, maybeAppearing) }) }) }) } performExit() { const { exit } = this .props const timeouts = this .getTimeouts() const maybeNode = this .props.nodeRef ? undefined : ReactDOM.findDOMNode( this ) // no exit animation skip right to EXITED if (!exit || config.disabled) { this .safeSetState({ status: EXITED }, () => { this .props.onExited(maybeNode) }) return } this .props.onExit(maybeNode) this .safeSetState({ status: EXITING }, () => { this .props.onExiting(maybeNode) this .onTransitionEnd(timeouts.exit, () => { this .safeSetState({ status: EXITED }, () => { this .props.onExited(maybeNode) }) }) }) } cancelNextCallback() { if ( this .nextCallback !== null ) { this .nextCallback.cancel() this .nextCallback = null } } safeSetState(nextState, callback) { // This shouldn't be necessary, but there are weird race conditions with // setState callbacks and unmounting in testing, so always make sure that // we can cancel any pending setState callbacks after we unmount. callback = this .setNextCallback(callback) this .setState(nextState, callback) } setNextCallback(callback) { let active = true this .nextCallback = event => { if (active) { active = false this .nextCallback = null callback(event) } } this .nextCallback.cancel = () => { active = false } return this .nextCallback } // 監聽過渡end onTransitionEnd(timeout, handler) { this .setNextCallback(handler) const node = this .props.nodeRef ? this .props.nodeRef.current : ReactDOM.findDOMNode( this ) const doesNotHaveTimeoutOrListener = timeout == null && ! this .props.addEndListener if (!node || doesNotHaveTimeoutOrListener) { setTimeout( this .nextCallback, 0) return } if ( this .props.addEndListener) { const [maybeNode, maybeNextCallback] = this .props.nodeRef ? [ this .nextCallback] : [node, this .nextCallback] this .props.addEndListener(maybeNode, maybeNextCallback) } if (timeout != null ) { setTimeout( this .nextCallback, timeout) } } render() { const status = this .state.status if (status === UNMOUNTED) { return null } const { children, // filter props for `Transition` in : _in, mountOnEnter: _mountOnEnter, unmountOnExit: _unmountOnExit, appear: _appear, enter: _enter, exit: _exit, timeout: _timeout, addEndListener: _addEndListener, onEnter: _onEnter, onEntering: _onEntering, onEntered: _onEntered, onExit: _onExit, onExiting: _onExiting, onExited: _onExited, nodeRef: _nodeRef, ...childProps } = this .props return ( // allows for nested Transitions <TransitionGroupContext.Provider value={ null }> { typeof children === ' function ' ? children(status, childProps) : React.cloneElement(React.Children.only(children), childProps)} </TransitionGroupContext.Provider> ) } } |
可以看到,和Vue是非常相似的,只不過這里變成了在React的各個生命周期函數了進行處理。
到了這里,我們會發現不管是Vue的transiton組件,還是React這個transiton-group組件,著重處理的都是css屬性的動畫。
數據驅動的動畫
而實際場景中總是會遇到css無法處理的動畫,這個時候,可以有兩種解決方案:
通過ref獲取dom,然后采用我們傳統的js方案。
通過state狀態維護繪制dom的數據,不斷通過setState更新state類驅動視圖自動刷新
以上就是前端如何實現動畫過渡效果的詳細內容,更多關于前端實現動畫過渡效果的資料請關注服務器之家其它相關文章!
原文鏈接:https://segmentfault.com/a/1190000039173935