react-transition-group原始碼淺析(一):Transition.md

hy醬發表於2019-02-17

react-transition-group原始碼淺析(一):Transition.md

閱讀本文你會獲得:

  • 一個相應的使用案例請看專案react-music-lhy,文件在blog中基於react-transition-group的react過渡動畫找到:元件掛載與解除安裝動畫的可以藉助appear以及onExit回撥函式實現。案例中onExit回撥函式主要用於通過路由跳轉解除安裝元件。

  • 一個比較有用的技巧:本文中工具函式一節的safeSetState函式;以及TransitionGroup種dom-helpers工具庫的使用以及封裝。

react-transition-group官方指南,結合react-router的專案使用案例請參照此文件

全文中提到的第一次掛載與掛載的概念是指:Transition單獨使用的時候,不區分第一掛載與其他掛載,只有在父元件是TransitionGroup的時候才區分。這可以從constructor中如下程式碼看出來:

    //  初始化appear:
    //  當單獨使用Transition沒有被TransitionGroup包裹時,appear = props.appear
    //  當被TransitionGroup包裹的時候,TransitionGroup處於正在掛載階段,子元件Transition是第一次掛載,因此appear = props.appear
    //  當被TransitionGroup包裹的時候,TransitionGroup已經掛載完成,說明子元件Transition之前掛載並解除安裝過,因此appear = props.enter
    let parentGroup = context.transitionGroup
    let appear =
      parentGroup && !parentGroup.isMounting ? props.enter : props.appear
複製程式碼

appear主要用於設定:this.appearStatus = ENTERING,詳細分析可以參考後續對constructor的分析。

Props介紹

children

type: Function | element
required
複製程式碼

某個狀態下需要過渡效果的目標元件,可以是函式

<Transition timeout={150}>
  {(status) => (
    <MyComponent className={`fade fade-${status}`} />
  )}
</Transition>
複製程式碼

每個狀態'entering', 'entered', 'exiting', 'exited', 'unmounted'的時候執行的回撥函式,上面程式碼實現的是,每一個狀態就給某個子元件增加一個過渡樣式,可以非常靈活的給任意元件增加樣式,實現過渡效果。

in

type: boolean
default: false
複製程式碼

用於在enter與exit狀態之間翻轉,預設為false,表示不掛載元件或者處於exit狀態。

mountOnEnter

type: boolean
default: false
複製程式碼

在第一次in={true}即掛載的時候,設定mountOnEnter={true}表示延遲掛載,懶載入元件。

unmountOnExit

type: boolean
default: false
複製程式碼

如果為true,在元件處於exited狀態的時候,解除安裝元件。

appear

type: boolean
default: false
複製程式碼

如果為true,在元件掛載的時候,展示過渡動畫。預設為false,第一次掛載過渡動畫不生效。

enter

type: boolean
default: true
複製程式碼

如果為true,表示允許enter狀態的過渡動畫生效,預設為true

exit

type: boolean
default: true
複製程式碼

如果為true,表示允許exit狀態的過渡動畫生效,預設為true

addEndListener

type: Function
複製程式碼

過渡動畫結束時執行的毀掉函式

timeout

type: number | { enter?: number, exit?: number }
複製程式碼

addEndListener存在的時候,需要設定timeout,表示過渡動畫時間

timeout={{
 enter: 300, //enter狀態動畫時間
 exit: 500,  //exit狀態動畫時間
}}
複製程式碼

onEnter,onEntering,onEntered

type: Function(node: HtmlElement, isAppearing: bool)
default: function noop() {}
複製程式碼

原始碼內部,status分別為entering前後,entered之後執行的回撥函式,CSSTransition元件即是利用這三個回撥函式給元件增加不同的樣式,利用CSS動畫實現過渡效果。

onExit,onExiting,onExited

type: Function(node: HtmlElement) -> void
default: function noop() {}
複製程式碼

原始碼內部,status分別為exiting前後,exited之後執行的回撥函式,CSSTransition元件即是利用這三個回撥函式給元件增加不同的樣式,利用CSS動畫實現過渡效果。

原始碼工具函式

getTimeouts函式

// 通過設定props.timeout,獲取各個元件不同狀態下的timeout
  getTimeouts() {
    const { timeout } = this.props
    let exit, enter, appear

    exit = enter = appear = timeout

    if (timeout != null && typeof timeout !== 'number') {
      exit = timeout.exit
      enter = timeout.enter
      appear = timeout.appear
    }
    return { exit, enter, appear }
  }
複製程式碼

setNextCallback函式:將函式封裝為只可執行一次的自毀回撥函式

//setNextCallback為一個閉包
    // 傳入一個回撥函式,返回一個只能執行一次回撥函式的函式,可以手動取消回撥函式的執行
	//執行一次之後自毀
  setNextCallback(callback) {
    //標誌位active用於保證只執行一次callback
    let active = true

    this.nextCallback = event => {
      if (active) {
        active = false
        //  垃圾回收
        this.nextCallback = null

        callback(event)
      }
    }

    //用於手動取消回撥函式的執行
    this.nextCallback.cancel = () => {
      active = false
    }

    return this.nextCallback
  }
複製程式碼

safeSetState函式:確保setState回撥函式只執行一次

  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)
    //  callback執行一次之後不再允許執行
    this.setState(nextState, callback)
  }
複製程式碼

onTransitionEnd函式

入場或者退場過渡動畫結束之後,根據addEndListener以及timeout執行自毀回撥函式handler

 // handler為入場或者退場過渡動畫結束之後的處理函式
  onTransitionEnd(node, timeout, handler) {
    //給this.nextCallback重新設定回撥函式
    this.setNextCallback(handler)

    //  無論是否設定了addEndListener還是timeout,this.nextCallback都只執行一次
    //  執行時機並不確定,這裡經常會存在一些與預期不符的現象
    if (node) {
      //如果設定了addEndListener,並且監聽了事件,則事件觸發變執行this.nextCallback
      if (this.props.addEndListener) {
        // 執行自定義的過渡動畫結束後的回撥函式
        this.props.addEndListener(node, this.nextCallback)
      }
      //如果設定了timeout,則timeout之後執行this.nextCallback
      if (timeout != null) {
        setTimeout(this.nextCallback, timeout)
      }
    } else {
      setTimeout(this.nextCallback, 0)
    }
  }
複製程式碼

updateStatus

 //在掛載階段與更新階段根據nextStatus的狀態執行入場或者退場動畫
  updateStatus(mounting = false, nextStatus){...}
複製程式碼

原始碼分析

掛載階段

constructor

根據是否是第一次掛載,是否被TransitionGroup包裹,來設定元件的初始state。涉及到的props有: enter,appear,in

	// 元件Transition掛載階段
  constructor(props, context) {
    super(props, context)

    //  初始化appear:
    //  當單獨使用Transition沒有被TransitionGroup包裹時,appear = props.appear
    //  當被TransitionGroup包裹的時候,TransitionGroup處於正在掛載階段,子元件Transition是第一次掛載,因此appear = props.appear
    //  當被TransitionGroup包裹的時候,TransitionGroup已經掛載完成,說明子元件Transition之前掛載並解除安裝過,因此appear = props.enter
    let parentGroup = context.transitionGroup
    let appear =
      parentGroup && !parentGroup.isMounting ? props.enter : props.appear

    let initialStatus

    this.appearStatus = null
      
    //  初始化this.appearStatus以及this.state.status
    //  掛載的時候:
    //  in = true && appear = true : this.state.status = EXITED , this.appearStatus = ENTERING
    //  in = true && appear = false : this.state.status = ENTERED
    //  in = false && ( unmountOnExit = true || mountOnEnter = true ) : this.state.status = UNMOUNTED
    //  in = false && unmountOnExit = false && mountOnEnter = fasle : this.state.status = EXITED
    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
  }
複製程式碼

getDerivedStateFromProps

掛載階段該函式返回null,不需要對state修改

static getDerivedStateFromProps({ in: nextIn }, prevState) {
    // 掛載階段if條件為false,返回null,不需要對state修改
    // 更新階段,在執行退場動畫的時候,可能會返回{ status: EXITED }
    if (nextIn && prevState.status === UNMOUNTED) {
      return { status: EXITED }
    }
    return null
  }
複製程式碼

render

render() {
    const status = this.state.status

    //掛載階段:
      // in = false && ( unmountOnExit = true || mountOnEnter = true ),Transition不會渲染任何元件
    if (status === UNMOUNTED) {
      return null
    }

    //掛載階段:
      //  in = true && appear = true : this.state.status = EXITED , this.appearStatus = ENTERING
      //  in = true && appear = false : this.state.status = ENTERED
      //  in = false && unmountOnExit = false && mountOnEnter = fasle : this.state.status = EXITED
    const { children, ...childProps } = this.props
    // filter props for Transtition
    //  濾除與Transtition元件功能相關的props,其他的props依舊可以正常傳入需要過渡效果的業務元件
    delete childProps.in
    delete childProps.mountOnEnter
    delete childProps.unmountOnExit
    delete childProps.appear
    delete childProps.enter
    delete childProps.exit
    delete childProps.timeout
    delete childProps.addEndListener
    delete childProps.onEnter
    delete childProps.onEntering
    delete childProps.onEntered
    delete childProps.onExit
    delete childProps.onExiting
    delete childProps.onExited

    //  當children === 'function',children函式可以根據元件狀態執行相應邏輯
    // (status) => (
    //     <MyComponent className={`fade fade-${status}`} />
    //   )
    if (typeof children === 'function') {
      return children(status, childProps)
    }

    //React.Children.only判斷是否只有一個子元件,如果是則返回這個子元件,如果不是則丟擲一個錯誤
    const child = React.Children.only(children)
    return React.cloneElement(child, childProps)
  }
複製程式碼

componentDidMount

開始執行

  componentDidMount() {
    // 第一次掛載的時候,如果in = true && appear = true,則appearStatus=ENTERING,否則為null。
    this.updateStatus(true, this.appearStatus)
  }
複製程式碼

其中updateStatus函式為:appearStatus = ENTERING的時候執行performEnter

updateStatus(mounting = false, nextStatus) {
    if (nextStatus !== null) {
      // 掛載階段:如果nextStatus !== null,則只會出現 nextStatus = ENTERING
        // in = true && appear = true:nextStatus = ENTERING
        
      // nextStatus will always be ENTERING or EXITING.
      this.cancelNextCallback()  // 掛載階段無操作
      const node = ReactDOM.findDOMNode(this) // 掛載階段找到真實DOM

      //  掛載階段:如果in = true && appear = true,則執行performEnter
      if (nextStatus === ENTERING) {
        this.performEnter(node, mounting)
      } else {
        this.performExit(node)
      }
    } else if (this.props.unmountOnExit && this.state.status === EXITED) {
      this.setState({ status: UNMOUNTED })
    }
  }
複製程式碼

其中performEnter函式為:執行onEnter回撥函式 --> 設定{ status: ENTERING } --> 執行onEntering回撥函式 --> 監聽onTransitionEnd過渡動畫是否完成 --> 設定{ status: ENTERED } --> 執行onEntered回撥函式

performEnter(node, mounting) {
    const { enter } = this.props
    //  掛載階段:如果in = true && appear = true,則appearing = true
    const appearing = this.context.transitionGroup
      ? this.context.transitionGroup.isMounting
      : mounting

    //  獲取timeouts
    const timeouts = this.getTimeouts()

    //  掛載階段以下if程式碼不執行
    // no enter animation skip right to ENTERED
    // if we are mounting and running this it means appear _must_ be set
    if (!mounting && !enter) {
      this.safeSetState({ status: ENTERED }, () => {
        this.props.onEntered(node)
      })
      return
    }

    //執行props.onEnter函式
    //掛載階段,如果in = true && appear = true,則appearing始終為true
    // 如果在Transition元件上設定onEnter函式,可以通過獲取該函式第二引數來獲取第一次掛載的時候是否是enter
    this.props.onEnter(node, appearing)

    //  改變{ status: ENTERING },改變之後執行一次回撥函式
    this.safeSetState({ status: ENTERING }, () => {
      // 將狀態設定為ENTERING之後,開始執行過渡動畫
      this.props.onEntering(node, appearing)

      // FIXME: appear timeout?
      //  timeouts.enter為入場enter的持續時間
      // 過渡動畫結束,設定{ status: ENTERED },執行onEntered回撥函式
      this.onTransitionEnd(node, timeouts.enter, () => {
        //將狀態設定為ENTERED,然後再執行onEntered回撥函式
        this.safeSetState({ status: ENTERED }, () => {
          this.props.onEntered(node, appearing)
        })
      })
    })
複製程式碼

}

更新階段

getDerivedStateFromProps

 static getDerivedStateFromProps({ in: nextIn }, prevState) {
    // 更新階段:
      // 如果掛載階段in=true,那麼第一次更新if條件中prevState.status!== UNMOUNTED
      // 如果掛載階段in=false,並且(props.mountOnEnter=true||props.mountOnEnter=true)
      // 那麼第一次更新if條件中prevState.status === UNMOUNTED,可以通過in的翻轉改變
      // 如果(props.mountOnEnter=true||props.mountOnEnter=true)的時候,設定狀態status的狀態為EXITED
    if (nextIn && prevState.status === UNMOUNTED) {
      return { status: EXITED }
    }
    return null
  }
複製程式碼

render

與掛載階段分析類似,元件保持原來狀態。

componentDidUpdate

componentDidUpdate(prevProps) {
    let nextStatus = null
    if (prevProps !== this.props) {
      const { status } = this.state

      if (this.props.in) {
        //根據in=true判斷此時需要進行入場動畫
        if (status !== ENTERING && status !== ENTERED) {
          //如果當前狀態既不是正在入場也不是已經入場,則將下一個狀態置為正在入場
          nextStatus = ENTERING
        }
      } else {
          //根據in=false判斷此時需要進行退場動畫
        if (status === ENTERING || status === ENTERED) {
            //如果當前狀態是正在入場或者已經入場,則將下一個狀態置為退場
          nextStatus = EXITING
        }
      }
    }
    //更新狀態,執行過渡動畫,第一參數列示是否正在掛載
	//如果Transition元件更新但是prevProps沒有變化,有可能是多餘的重新。因此將nextStatus為null
    this.updateStatus(false, nextStatus)
  }
複製程式碼

其中updateStatus函式為:

	updateStatus(mounting = false, nextStatus) {
	    if (nextStatus !== null) {
	      // nextStatus will always be ENTERING or EXITING.
	      this.cancelNextCallback()  // 掛載階段無操作
	      const node = ReactDOM.findDOMNode(this) // 掛載階段找到真實DOM
	
	      	//  更新階段nextStatus只有兩種狀態ENTERING與EXITING:
    		// 如果為ENTERING執行入場,EXITING執行退場
	      if (nextStatus === ENTERING) {
	        this.performEnter(node, mounting)
	      } else {
	        this.performExit(node)
	      }
	    } else if (this.props.unmountOnExit && this.state.status === EXITED) {
	      this.setState({ status: UNMOUNTED })
	    }
  }
複製程式碼

其中退場動畫performExit函式為

//與performEnter邏輯相似
  performExit(node) {
    const { exit } = this.props
    const timeouts = this.getTimeouts()

    // no exit animation skip right to EXITED
    if (!exit) {
      this.safeSetState({ status: EXITED }, () => {
        this.props.onExited(node)
      })
      return
    }
    this.props.onExit(node)

    this.safeSetState({ status: EXITING }, () => {
      this.props.onExiting(node)

      this.onTransitionEnd(node, timeouts.exit, () => {
        this.safeSetState({ status: EXITED }, () => {
          this.props.onExited(node)
        })
      })
    })
  }
複製程式碼

總結

本文根據元件生命週期詳細的分析了react-transition-group中關鍵元件Transition的原始碼,工作流程。CSSTransition元件就是對Transition元件的封裝,在其props.onEnter等等元件上新增對應的class實現css的動畫。該元件庫還有一個比較重要的地方就是TransitionGroup元件如何管理子元件動畫,弄清這個是實現複雜動畫邏輯的關鍵。

相關文章