淺析react-transition-group源碼
1. react-transition-group 1 版本回顧
react-transition-group 1 版本在子組件創建、刪除過程通過添加 class 控制 css 動效。
介於 css transition 動效在元素添加到頁面過程就會執行,因此這個過程無需調控動效的執行。而當有元素被移除時,我們需要容器組件駐留被刪除的子組件,等到動效執行完成後,才實際刪除該子組件。
針對這一問題,react-transition-group 提供了 TransitionGroup 容器組件。其 state.children 屬性為實際待繪製的組件,當組件更新時,通過比對 state.children, props.children,就可以獲知新增了哪些組件、移除了哪些組件(用戶配置的子組件通過 key 鍵存儲成映射結構)。當子組件被移除時,觸發 performLeave 方法,以調用子組件的 componentWillLeave 方法添加樣式類、執行動效;再通過 componentWillLeave 方法的回調函數,更新 TransitionGroup 容器的 state.children,觸發子組件的實際移除行為。
當採用上述邏輯處理子組件的移除動效時,為著處理邏輯的統一,react-transition-group 針對 componentDidMount 或 componentDidUpdate 時創建的子組件也採用相同的處理邏輯。子組件的鉤子函數 componentWillAppear, componentDidAppear, componentWillEnter, componentDidEnter, componentWillLeave, componentDidLeave 將分別得到執行。當獨立使用 TransitionGroup 容器時,我們可以藉助這些鉤子函數操控節點的樣式以觸發 css 動效,或者組織 js 動效,或者實現懶載入等等。
在 TransitionGroup 容器的基礎上,CSSTransitionGroupChild 組件用於裝飾子組件,在 componentWillAppear 等鉤子函數添加樣式類以執行 css 動效,通過 setTimeout 或 transitionEnd 事件更新 TransitionGroup 容器的 state.children 屬性。CSSTransitionGroup 組件用於將 props 配置上提,而不是通過 CSSTransitionGroupChild 給每個子組件外加一個殼子。
2. react-transition-group 2 版本
2.1 Transition
相比於 1 版本通過判斷子組件的有無添加動效,react-transition-group 2 版本可通過向子組件傳遞狀態值(props.children 以函數形式配置;若為ReactElement,則無任何作用)控制子組件的創建和刪除過程,這一點和 react-motion 類庫相仿。關於 react-motion,筆者將在後續文章中加以分析。
上述思路的簡單實現是通過容器組件向子組件傳遞 props.status,用於判斷動效是在執行中 transitionExcute,還是執行完成 transitionEnd。若執行完成,將觸發移除組件的操作。為此,react-transition-group 2 提供了 Transition 容器組件,props.status 狀態分別在子組件創建和移除階段添置了兩個值,合計四種狀態,即 ENTERING, ENTERED, EXITING, EXITED 。ENTERING, ENTERED 狀態針對新創建的子組件,EXITING, EXITED 狀態針對待移除的子組件。Transition 組件內部實現的主要業務邏輯就是協調 ENTERING 到 ENTERED,EXITING 到 EXITED 狀態的自動變更。對於不需要動效的場景,狀態將直接置為 ENTERED, EXITED;在 Transition 組件中,作為動效開關的是 props.appear, props.enter, props.exit 屬性。
除此之外,有必要區分組件在創建過程 onEnter 還是在移除過程 onExit,以便於控制組件的掛載狀態。然而 onEnter, onExit 兩個狀態值不適用於動效結束時仍保留組件的場景。為此,Transition 組件使用 http://props.in 屬性表示組件的顯示狀態,結合 props.unmountOnExit 屬性即表示在動效執行完成後,不必移除組件。子組件不能通過獲得的 status 值控制自身的掛載狀態,Transition 容器通過將 status 置為 UNMOUNTED,並在其 render 方法中輸出 null,以控制子組件的顯隱。這樣的設計也滿足了對子組件掛載時機的微調。像 1 版本中那樣,通常情況下,子組件往往而在。不同的是,在組件初始化時通過將 props.mountOnEnter 屬性置為真值,且 http://props.in 為否值,子組件將不在視圖顯示;當更新組件時,http://props.in 屬性首次切換為真值時,才將 status 置為 EXITED,以便在實際視圖中創建子組件,從而啟動動效。
constructor(props, context) { super(props, context); let parentGroup = context.transitionGroup; // In the context of a TransitionGroup all enters are really appears let appear = parentGroup && !parentGroup.isMounting ? props.enter : props.appear; let initialStatus; this.nextStatus = null; if (props.in) {// in 為真值,創建並顯示組件 if (appear) {// appear 為真值,執行動效 initialStatus = EXITED;// updateStatus 方法中滿足 initialStatus 非 null 校驗,以執行 performEnter 方法 this.nextStatus = ENTERING; } else {// appear 為否值,不執行動效 initialStatus = ENTERED; } } else { // mountOnEnter 為真值,意味著延遲掛載;unmountOnExit 為否值的情形,主要為著避過 updateStatus 中對 { state: EXITED } 的處理邏輯,即動效執行結束後移除組件 if (props.unmountOnExit || props.mountOnEnter) { initialStatus = UNMOUNTED; } else { // 懸空 initialStatus = EXITED; } } this.state = { status: initialStatus }; this.nextCallback = null;}componentDidMount() { this.updateStatus(true);}componentWillReceiveProps(nextProps) { const { status } = this.pendingState || this.state; if (nextProps.in) { // 子組件已被移除或初始化未被掛載,status 置為 EXITED,便於添加子組件 if (status === UNMOUNTED) { this.setState({ status: EXITED }); } // 子組件尚未在視圖中,執行顯示動效 if (status !== ENTERING && status !== ENTERED) { this.nextStatus = ENTERING; } } else { // 子組件已在視圖中,執行移除動效 if (status === ENTERING || status === ENTERED) { this.nextStatus = EXITING; } }}componentDidUpdate() { this.updateStatus();}updateStatus(mounting = false) { let nextStatus = this.nextStatus;// 只有兩種可能,ENTERING 或 EXITING if (nextStatus !== null) { this.nextStatus = null; this.cancelNextCallback(); const node = ReactDOM.findDOMNode(this); if (nextStatus === ENTERING) { this.performEnter(node, mounting); } else { this.performExit(node); } } else if (// 動效執行完成後 state 置為 EXITED 時,且 unmountOnExit 為真,移除子組件 this.props.unmountOnExit && this.state.status === EXITED ) { this.setState({ status: UNMOUNTED }); }}
(當初始化時,http://props.in 置為否值,子組件將不予渲染或渲染後無動效;當更新時置為否值,子組件已在視圖中,執行 performExit 方法,好啟動 exit 動效。)
上述代碼中,performEnter, performExit 方法用於操控容器組件在 props.timeout 時間後自動從 ENTERING 切換到 ENTERED 狀態,或者從 EXITING 切換到 EXITED 狀態。這兩個方法均調用了 safeSetState 方法,該方法的意義是設置 pendingState 屬性,以避免動效的多次執行;以及通過調用 onTransitionEnd 方法執行變更 status 的邏輯。onTransitionEnd 方法的延遲機制通過監聽 transitionEnd 事件(藉助於 props.addEventLinstener 配置)或者使用 setTimeout 執行回調實現,變更 status 狀態的回調通過 setNextCallback 方法免於多次執行。
Transition 容器在根據 props.in, props.appear, props.enter, props.exit, props.mountOnEnter, props.unmountOnExit 屬性自動處理 status 狀態的同時,還設置了多個在適當時機執行的鉤子函數,包含 props.onEnter, props.onEntering, props.onEntered, props.onExit, props.onExiting, props.onExited 配置項。
2.2 CSSTransition
在 react-transition-group 2 版本內部,props.onEnter 等鉤子在實現 CSSTransition 組件時極有意義。因為 Transition 容器傳給 props.children 函數的 status 只包含 enter, exit 相關狀態,沒有 appear 狀態。而 onEnter, onEntering, onEntered 鉤子的次參即為是否在 appear 狀態中(通過 context.transitionGroup.isMounting 或 mounting 獲知),CSSTransition 組件即在此基礎上實現,通過鉤子更新 props.children 對應節點的 class(依次由 *-appear, *-appear-active, *-enter, *-enter-active, *-enter-done, *-exit, *-exit-active, *-exit-done 變更,* 通過 props.classNames 設置,該屬性也設置為對象)。與 1 版本相同,2 版本在類名變更的同時,訪問了 node.scrollTop 促使瀏覽器重繪;也可以在 props.children 中執行 onEnter 等方法阻止 js 動效或懶載入等邏輯。CSSTransition 組件同樣向上拋出 props.onEnter, props.onEntering, props.onEntered, props.onExit, props.onExiting, props.onExited 鉤子。
2.2 TransitionGroup
TransitionGroup 的處理邏輯和 1 版本相同,即通過 state.children 控制待渲染的元素,以實現 exit 動效執行時仍駐留子組件的場景。不同的是,1 版本會用 CSSTransitionGroupChild 包裝子組件,2 版本需要用戶傳入 Transition 組件作為 TransitionGroup 的子組件。當然,因為 2 版本中,Transition 組件動效執行時機和組件渲染狀態的弱關聯,促使 TransitionGroup 容器的 componentWillReceiveProps 方法中更新 state.children 的邏輯也不一樣。
TransitionGroup 的一般適用場景為當子組件添加時,執行 enter 動效;當子組件移除時,執行 exit 動效。在這樣的場景中,子組件的渲染狀態不受 TransitionGroup 控制,也即 TransitionGroup 容器對子組件的 http://props.in 屬性的調控必須與子組件的渲染狀態向匹配。在 TransitionGroup 容器初始化階段,傳入子組件的 http://props.in 屬性必然為真值,因為在這階段,子組件都會得到渲染。在更新階段,當子組件移除時,通過設置 http://props.in 為否值,觸發 exit 動效;當子組件 exit 動效執行階段,子組件又被添加,通過設置 http://props.in 為真值,使 enter, exit 動效得以執行;當子組件 exit 動效執行完成、且組件已移除後,處理邏輯同 exit 動效執行階段;當子組件維持原樣,傳入子組件的 http://props.in 屬性維持原值,props.oEnter, props.onExit 屬性則同步為子組件最新的 props 值。
constructor(props, context) { super(props, context); // Initial children should all be entering, dependent on appear this.state = { children: getChildMapping(props.children, child => { return cloneElement(child, { onExited: this.handleExited.bind(this, child), in: true, appear: this.getProp(child, appear), enter: this.getProp(child, enter), exit: this.getProp(child, exit), }) }), };}componentWillReceiveProps(nextProps) { let prevChildMapping = this.state.children; let nextChildMapping = getChildMapping(nextProps.children); let children = mergeChildMappings(prevChildMapping, nextChildMapping); Object.keys(children).forEach((key) => { let child = children[key] if (!isValidElement(child)) return; const hasPrev = key in prevChildMapping; const hasNext = key in nextChildMapping; const prevChild = prevChildMapping[key]; // child 在移除過程中 const isLeaving = isValidElement(prevChild) && !prevChild.props.in; // child 新添加或者移除動效尚未執行完成,執行 enter 動效 if (hasNext && (!hasPrev || isLeaving)) { // console.log(entering, key) children[key] = cloneElement(child, { onExited: this.handleExited.bind(this, child), in: true, exit: this.getProp(child, exit, nextProps), enter: this.getProp(child, enter, nextProps), }); } // 組件已移除,執行 exit 動效 else if (!hasNext && hasPrev && !isLeaving) { // console.log(leaving, key) children[key] = cloneElement(child, { in: false }); } // 組件動效特性未作改變,保留原值 else if (hasNext && hasPrev && isValidElement(prevChild)) { // console.log(unchanged, key) children[key] = cloneElement(child, { onExited: this.handleExited.bind(this, child), in: prevChild.props.in, exit: this.getProp(child, exit, nextProps), enter: this.getProp(child, enter, nextProps), }); } }) this.setState({ children });}
handleExited 方法用於當子組件 exit 動效執行完成且移除後,更新 TransitionGroup 容器的 state.children 屬性,附帶調用原始傳入子組件的 props.onExited 方法(實際傳入子組件的 props.onExited 方法被改寫為 handleExited 方法)。
在變更 state.children 的處理邏輯之外,TransitionGroup 向外提供 props.appear, props.enter, props.exit, props.component, props.childFactory 配置項。props.appear, props.enter, props.exit 為當子組件元素沒有同名 props 屬性時,使用 TransitionGroup 容器的 props 屬性;props.component 為外層包裹元素(適配 react 中 props.children 不能置為數組的特性,react 16 可能不需要);props.childFactory 用於裝飾子元素。
2 版本沒有提供 CSSTransitionGroup 組件,因為在 TransitionGroup 組件下掛載 CSSTransition 組件即能實現與 1 版本中 CSSTransitionGroup 相同的效果,通過調整節點的 class 實現動效。在這裡,CSSTransition 組件作為特殊的 Transition 組件。除此而外,Transition, CSSTransition 組件都向外暴露 props.onEnter, props.onEntering, props.onEntered, props.onExit, props.onExiting, props.onExited 配置鉤子,因此相較於 1 版本中通過 CSSTransitionGroup 自應用的 CSSTransitionGroupChild 組件,2 版本能對 css 動效實現更細微的控制。
此外,TransitionGroup 組件會通過 context 屬性向 Transition 子組件傳遞 transitionGroup 屬性,以使 Transition 子組件能夠感知動效處於 appear 階段還是 enter 階段。Transition 組件又通過 getChildContext 方法,將其子孫組件的 transitionGroup 屬性置為 null,其意義時,當父 Transition 嵌套子 Transition 時,子 Transition 不至於嘗試通過 context.transitionGroup 屬性判斷動效在 appear 階段還是 enter 階段。
2.3 ReplaceTransition
2 版本中,ReplaceTransition 用於通過控制 http://props.in 切換顯示兩個子組件中的一個。因此實際使用過程中,需要有容器包裹,通過容器更新傳入 ReplaceTransition 組件中的http://props.in;循環切換需要在容器中設置定時器(循環切換過程中,不支持其中任意一個子組件 exit 動效,適用場景相對較小)。ReplaceTransition 組價向外提供 props.onEnter, props.onEntering, props.onEntered, props.onExit, props.onExiting, props.onExited 等配置屬性,前三個為第一個子組件 enter 動效相關鉤子,後三個為第二個子組件 enter 動效相關鉤子。其 ReplaceTransition 組價支持在 enter 發生過程中調用子組件的 props.onEnter, props.onEntering, props.onEntered, props.onExit, props.onExiting, props.onExited,前三個鉤子為第一個子組件獨有,後三個為第二個子組件獨有。源碼不作贅述。
3 總結
無論 1 版本的核心模塊 TransitionGroup,還是 2 版本的核心模塊 Transition,都通過容器管理子組件的動效執行過程。1 版本切入的視角在於通過判斷子組件從列表中創建、移除的過程,觸發調用子組件的 componentWillAppear 等方法,以管理動效。對於 css 動效,1 版本又通過包含特定 componentWillAppear 等實例方法的 CSSTransitionGroupChild 包裝用戶實際開發的子組件,從而對接上 TransitionGroup 容器,使子組件中的動效得到管理。2 版本在處理上更為細緻,獨立對待用戶開發的動效組件,而不是視為列表形式。2 版本通過 Transition 容器將子組件可能包含的 enter, exit 動效抽象為 state.status,並傳入子組件,由子組件自身通過 status 啟用特定的動效;並且同樣提供了以鉤子方式(props.onEnter 等配置方法)管理動效的實現,2 版本自身的 CSSTransition 組件即通過鉤子調節添加到節點上的 class。若說 1 版本還滯留於子組件從列表中移入移出的視點,2 版本的觀察角度則從列表中脫離,實打實地介入了子組件動效執行周期的管理,will - excute - did,並向下注入執行狀態。
推薦閱讀:
※如何看待 React is a tax Facebook levies on startup 的說法?
※【譯】React如何抓取數據
※如何評價數據流管理架構 Redux?
※[譯]處理非同步利器 -- Redux-saga
※JS/React 開發者的 Atom 終極配置