如何做一個支持轉場動畫的路由?

寫在前面: 本文章的一部分內容涉及筆者在公司(阿里巴巴菜鳥網路)實習所做的一部分內容,由於該項目尚未對外開源(正在往開源方向發展),基於保密性考慮,暫不提供實例代碼。

寫著寫著發現有點亂,由於不能發出代碼參考,可能讀起來還是不明,如果有相關技術交流,直接私戳我吧

首先,我們從題目入手,「轉場動畫」,想必大家都還記得Microsoft PowerPoint提供了各種複雜的轉場動畫效果,實際上,這些效果大多數也可以通過html/css/js在網頁中實現,對於常用的web開發框架react/vue,官網或第三方也提供了一些轉場動畫/過渡動畫,但是我體驗下來發現有如下問題:

  • 轉場動畫需要自己寫css類的樣式,如果細節考慮不到位等,則會出現諸如動畫組件堆疊、轉場動畫不連續等,甚至有時候需要自己在animation屬性中手動設置延遲時間,甚至即使如此也不能解決問題。
  • 配置繁瑣,我們需要在希望有轉場動畫的組件單獨進行組件包裹參數配置等,不能像路由那樣,在一個文件中進行「宏觀配置」,也沒有辦法將轉場動畫和路由進行有機的結合
  • 無法通過簡單代碼支持非確定性動畫,所謂非確定性動畫:
    • 比如A、B、C三個兄弟頁面頁面,假設在空間上,A在最左邊,C在最右邊,B在中間,網頁一次只能顯示一個頁面(想像一下左推右推的那種banner),如果當前處於B頁面,我們希望動畫中能體現優先順序,那麼從B切換到A,A從左側推入,那麼此時B是右側推出,如果從B切換到C,那麼C從右側推入,B從左側推出,對於B來說,它的動畫不僅僅和自己有關係,也和之後出現的組件以及它們之間的優先順序有關係,這個時候,僅僅通過幾個類,是無法完成這樣複雜的需求的。

當然,在實際使用過程中還會有一些其他的問題,總之體驗是非常不好,也因此,我們需要自己造輪子。

經過綜合考慮,最後的產出並不是一個組件,也不是一個react-router的插件,而是一個用來代替react-router並且支持轉場動畫的框架/模塊,為什麼要這樣,主要有以下幾個原因:

  • 團隊自己有一套基於react的框架(這裡稱之為XXX,也支持react組件),框架僅僅用了react暴露出的render等等,自己要做的這一部分需要被整合進XXX-router中,所以不能採用react-router插件方式。
  • 如果單獨使用一個組件,那麼無法和路由切換進行有機的結合,並且基於組件的話很多轉場實現起來並不是特別容易(比如需要組件父節點掛載點的參與等),需要從框架級別考慮這個問題。

當然,自己實際上也做了一個純react版本的可以代替react-router並且支持組件動畫的router模塊,不過目前還有些地方沒有完善。

路由動畫的設計原則

在開始之前,我們首先應該明確指導原則,我們的動畫是基於路由的,而路由可以是一個樹形結構或者是一個列表結構,比如一個簡單的路由如下:

——a—————————bn |-----| |-----|n a1 a2 b1 b2n

這個路由有六個頁面,其中a和b是同級別頁面,下面的a1、a2是a頁面的子頁面,b1、b2是b頁面的子頁面,我們規定:

  • 從同級頁面切換,採用左滑或者右滑。(比如a切換到b採用左滑,從a1切換到a2採用左滑,從a2切換到b1也採用左滑(可以看成是1.2和2.1,1.2排在前面))
  • 從非同級頁面切換,採用上滑或者下滑,其中從父輩頁面切換到子級頁面向上滑,從子級頁面切換到父輩頁面向下滑。(比如從a1切換到a下滑,從a1切換到b也採用下滑)
  • 以上兩點均是在配置動畫為「有」的情況下並且沒有設置具體動畫的情況下所採取的動作,我們可以通過配置把「左滑」,「右滑」等變成自己喜歡的動畫樣式。
  • 我們的動畫基於路由,也就是說,當路由變化並且配置了轉場動畫時才會觸發轉場動畫,如果沒有配置路由(地址欄URI沒有變化),是不會觸發路由動畫的,因為我認為這個時候是在組件內部,而在組件內部用戶是有充分的自主權的,沒有必要做到解決方案層面。

路由動畫的配置原則

我們的路由動畫在使用上,應該是方便的,最好就在一個文件里配置好了,由於我們已經默認在框架層級對動畫的「方向」進行了判斷,並且也有默認的動畫(推入),實際上具體動畫內容也是可以忽略不配的,當然,框架還提供了其他的一些動畫效果,比如淡入淡出、旋轉飛入飛出等,需要另行指定。

一個簡單的配置例子如下:

animationRouter:{ n animationTime:200,n routerMap:[n {n path: /search/basic-searchn },n {n path: /search/complex-search,n children: [n {n path: /detail/basicn },n {n path: /detail/complexn }]n }, {n path: /search/card-searchn }n ]n}n

在該例子中,我們給五個路由配置了動畫,頂層有三個同級別路由:/search/basic-search/search/complex-search/search/card-search,其中第二個路由/search/complex-search還有兩個子級路由分別是/detail/basic/detail/complex

經過這樣的簡單配置,路由切換到這幾個頁面的時候就可以產生動畫效果(產生動畫效果需要當前頁面和之後的頁面都配置了路由動畫)。

開發時遇到的問題

在開發的過程中,遇到了不少問題,接下來把問題一一列舉。

動畫和組件生命周期衝突問題

如果我們在路由切換的時候不做什麼,這個時候別說路由動畫了,實際上舊的組件就被可能卸載掉了,就更沒有動畫了,所以我們要保證在路由動畫的過程中,舊的組件還沒有卸載,實際上這個時候新舊組件是共存的,等到動畫完成之後再將舊組件卸載掉。

所以實際上我們的流程為:

  • 路由變化 -> 渲染新組件 -> 新舊組件動畫(掛載點同時有新舊組件) -> 舊組件生命周期結束(掛載點只有新組件)

這裡的整個過程,包括組件render,都是在框架中進行控制的。

動畫運行策略問題

在做動畫控制部分的時候有兩種選擇,一種是css animation動畫,另外一種是js控制css屬性實現動畫,經過考慮,主要採用了後者,原因如下:

  • css animation在開始後無法中斷,假設用戶從A切換到B並且快速從B切換回A,無法做到從當前運行位置(比如此時B僅僅左推了40%)返回。
  • css animation的動畫時間控制不便,需要通過js控制,並不是十分方便。
  • css animation在調度上不方便調度(比如由A切換到B並且在動畫沒有結束的時候由B切換到C,這個時候B有一個動畫隊列,需要執行完入場動畫之後執行出場動畫)。
  • 用js控制css並且配合requestAnimationFrame的使用,效果尚可。

容器組件問題

在沒有路由動畫的時候,對於主體內容我們只有一個dom掛載點即可,當需要切換內容的時候直接調用react.render渲染進去,但是現在在動畫期間,新舊組件的內容需要同時存在,並且這個時候這兩個組件需要被同一個容器元素包裹(css樣式需要)

所以實際上在動畫沒有進行的時候:

<div className="content-container">n <CONTNET_ELEMENT/>n</div>n

在有動畫的時候:

<div className="content-container">n <CONTNET_ELEMENT_OLD/>n <CONTNET_ELEMENT_NEW/>n</div>n

這個className="content-container"的外層包裹組件是框架提供的,對於開發者來說是無感知的。

【重點】包裹的container,如果沒有overflow:hidden,就會在上下移動的過程中覆蓋上下的元素,如果有overflow:hidden,這個時候就有高度塌陷之後被隱藏的問題, 需要通過某種方式指定min-height 或者 height (因為我們的組件要指定position為absolute,否則就會存在內容垂直鋪排等問題) 。實際上我認為這個需求是很難採用純粹的css滿足的:

  • 因為 BFC 的高度計算包含浮動元素,同時清除浮動元素也會讓父元素撐開,所以可以實現父元素被子元素撐開的需求。但是 position:absolute 或者 position:fixed 就不一樣,它是脫離文檔流的,而且不會計算被父元素計算在內,同時也沒有辦法像浮動一樣被清除。所以你的需求應該是無法滿足的。
  • 後來採取的方案是直接通過js獲取offset數值等直接計算出了一個動畫區域,然後直接在動畫區域內運動,這樣就不涉及撐開高度的問題了

動畫阻塞問題

框架的控制策略是,在ReactDOM.render的callback中調用動畫控制函數,然後通過requestAnimationFrame和函數遞歸來執行每一幀動畫。

但是目前有一個問題是如果直接在callback中開啟動畫並且沒有延遲的話,動畫會有一些卡頓,並且總有一幀動畫時間在100ms以及以上,要知道總共給的動畫運行時間也只有200ms,所以這是基本不能接受的。

經過查閱資料,可能的原因有以下兩條(第二條是重點):

  • eact的Render的Callback調用的時候並沒有完全渲染完畢,如果需要完全渲染完畢之後再調用則需要把邏輯寫在ComponentDidMount中,但由於我們是在做框架,所以這種方案肯定不可行。(參考鏈接 : stackoverflow.com/quest )
  • 組件在enter的過程(constructor過程的一部分,walle生命周期函數)會調用ajax,等待ajax返回數據後react會執行diff操作和排版上屏,這個時間是比較長的,累計js會阻塞100-200ms,所以導致現在動畫跑不了幾幀。

考慮到第二個原因是主要原因,目前的做法是延後了enter(enter是XXX框架自帶的生命周期,在react生命周期的componentDidMount之前執行)的執行,讓enter的執行時機在路由動畫之後,可以基本解決這個問題,但是這樣的做法簡單粗暴,當時自己一點都不明白react搞了那套fiber到底能真正起到什麼幫助,但是當現在自己真正參與到框架開發中時,才發現fiber這套機制真是太牛逼了(可惜本框架還沒法用)。

連續點擊過快動畫錯亂問題

如果我們不考慮連續點擊情況,由於我們的動畫運行默認時間是200ms,: 用戶在快速連續點擊(間隔幾十ms)的時候,由於這個時候沒有對動畫觸發以及管理進行節流,還是會導致樣式錯亂。

我們用一個全局變數(window.isRending)來表示一定時間內正在渲染和動畫的元素,並且windw.isRending為1的當前元素就是當前頁面上正在展示(準備動畫離去)的元素。

  • 注意:windw.isRending為1(包括之後的序列號)是有兩個元素的,一個是當前元素(出場元素),另外一個是入場元素。

每當路由切換被觸發,我們給window.isRending加1,並且設置setTimeout(300)之後減去1(300ms大約是用戶雙擊一次的時間)

如果當windw.isRending>2的時候路由處理函數被觸發,這個時候說明動畫隊列裡面已經有兩對動畫了,這個時候我們就要開始清理動畫隊列:

假設我們的動畫隊列如下

  • 1.in 1.out 2.in 2.out 3.in 3.out 我們經過刪除之後如下:
  • 1.out 3.in

這個時候實際上的效果就是 3.in 和 1.out 可以銜接的比較順暢

代碼解耦問題

雖然路由動畫這一部分是直接服務於框架的,是在XXX-router的基礎上進行開發改造,而XXX-router是框架的一個默認組件,但是出於可維護性考慮,筆者還是希望路由動畫這部分代碼可插拔,和原本的路由部分充分解除耦合,但是又考慮到」不維護兩套代碼「的原則,對原來的一些類增加了模版方法,並在路由組件中繼承了這些類,寫入自己的邏輯。

最終的路由動畫模塊由三部分組成:

  • AnimationController: 動畫調度管理與動畫實現類
  • UIView: 繼承原來XXX-router中的render,填充模版方法,調用AnimationController相關函數
  • Utils:工具函數類

最後,立一個flag吧,希望我能完善純react版本的路由動畫,最好能夠無縫替換react-router,雖然我知道事情似乎要被鴿掉的樣子。


推薦閱讀:

redux-saga 實踐總結
現在有個項目表單很多,用什麼技術框架合適?

TAG:React | 前端开发 | reactrouter |