幾個月前遇到了寫模態(tài)窗(modal)的需求,當初其實沒什么思路,不知道怎么用更React的方式實現(xiàn)模態(tài)窗,于是去學習了下ReactBootstrap的源代碼,發(fā)現(xiàn)了一個Portal組件,通過這個Portal的概念實現(xiàn)了React式的模態(tài)窗,諸如tooltip或者是notification等組件也是同樣的道理。最近在看React-conf的視頻時又聽到Ryan提到,最近重新回去看ReactBootstrap的源代碼,發(fā)現(xiàn)其實變化挺大的,原先Portal的部分已經(jīng)被抽象出了另一個庫react-overlays,于是準備總結(jié)下這個部分。
模態(tài)窗的實現(xiàn)思路
模態(tài)窗扮演這相當于桌面應用的MessageBox的角色,各個瀏覽器對這個部分的支持有些缺陷(這里指alert或confirm這些),比如每個瀏覽器實現(xiàn)效果有差異、用戶可以禁止其顯示,還有最重要的是沒有辦法靈活控制。
于是我們自己來實現(xiàn),要扮演一個MessageBox,那么我們希望一個modal應該是永遠被置于頂層的,而又由于Stacking Context的關(guān)系(在此不贅述),我們會將modal直接append在body元素下,設(shè)置一個屬于它的z-index區(qū)間什么的。
回到React,在React中要實現(xiàn)一個模態(tài)窗,可以是這樣的:
handleClick () {
$('.modal').modalShow() // 假設(shè)這是一個jquery的modal插件
}
我不知道是不是存在這樣一個jquery插件(應該是有的,不過我jquery用的不多),不過大家應該明白我的意思,利用React對其他庫的友好來曲線救國。
另外的一種方式是實現(xiàn)一個Modal類,通過Modal.show()這樣的方法調(diào)用,這個方法會負責將模態(tài)窗render在它應該出現(xiàn)的地方,這個思路我一開始也有想到,不過自己其實更傾向于嘗試聲明式的React組件實現(xiàn)。
那么實現(xiàn)React式的模態(tài)窗會遇到什么問題呢?比如有一個Container組件(承載頁面結(jié)構(gòu)和業(yè)務邏輯的組件),在頁面的邏輯中會有一個modal彈出來,那么我們希望聲明式的寫法是這樣的:
<div>
<button>Show</button>
{/* portals */}
<Modal isShowed={this.state.modalShowed}>
<p>Modal showed</p>
</Modal>
</div>
這里存在的問題就是Stacking Context,對于一個通用組件而言沒有辦法保證上下文的樣式,于是就要講講這個Portal組件。
什么是Portal組件
所以我們需要的一個通用組件,它做如下的事情:
- 可以聲明式的寫在一個組件中
- 并不真正render在被聲明的地方
- 支持過渡動畫
那么,像modal、tooltip、notification等組件都是可以基于這個組件的。我們叫這個組件為Portal。
Portal這個東西我不知道怎么給它一個合適的中文名,最初是在ReactBootstrap的項目里看到,之后React-conf又提到,那么相信應該是一個通用的概念了,由于這個組件并不真正render在它被聲明的地方,姑且就翻譯為『傳送門』吧......
實現(xiàn)一個Portal組件
首先,由于它并不真正render在被聲明的地方,那么:
render () {
return null
}
恩,是的,沒有辦法在render方法里做文章,直接讓它返回null即可,它會在被聲明處留下一個noscript標簽,無所謂了。
那么真正的render是在哪里進行的呢?我們先準備下_renderOverlay這個方法:
_renderOverlay() {
let overlay = !this.props.children ? null :
React.Children.only(this.props.children)
if (overlay !== null) {
this._mountOverlayTarget()
// Save reference for future access.
this._overlayInstance = React.render(overlay, this._overlayTarget)
} else {
// Unrender if the component is null for transitions to null
this._unrenderOverlay()
this._unmountOverlayTarget()
}
}
我們把Portal的唯一子組件作為是要一個遮罩物(overlay),要承載這個遮罩物,我們需要一個DOM容器,于是我們在_mountOverlayTarget方法里創(chuàng)建一個div,也就是this._overlayTarget,于是調(diào)用React.render方法將組件掛載到這個div節(jié)點上,并將保持對該實例的引用this._overlayInstance。
通常情況下,對于React組件來說,不直接操作DOM,而且React.render方法我們通常都是在入口點調(diào)用一次,其他時候基本不用,然而對于Portal組件來說,這兩點都是必要的。
相應的unrender的部分,比較簡單,分別釋放this._overlayTarget和this._overlayInstance:
_unmountOverlayTarget() {
if (this._overlayTarget) {
this.getContainerDOMNode().removeChild(this._overlayTarget)
this._overlayTarget = null
}
}
_unrenderOverlay() {
if (this._overlayTarget) {
React.unmountComponentAtNode(this._overlayTarget)
this._overlayInstance = null
}
}
好了,那么我們需要在何處調(diào)用_renderOverlay呢,很容易想到:
componentDidMount () {
this._renderOverlay()
}
componentDidUpdate () {
this._renderOverlay()
}
然后記得要擦屁股:
componentWillUnmount() {
this._unrenderOverlay();
this._unmountOverlayTarget();
}
為了增加Portal的靈活性,可以給它傳一個container屬性,用來指定『傳送門』的位置(默認為body元素)。
實現(xiàn)上其實基本上就是這樣了,這里要簡單提一下,之前就ReactBootstrap對Portal組件的實現(xiàn)而言,把isShowed的邏輯給加在Portal里,增加了一些實現(xiàn)的復雜度,這個項目好像重構(gòu)過一波,現(xiàn)在的實現(xiàn)中isShowed的邏輯被移出去了,Portal僅用于充當『傳送門』的角色,那么以Modal為例:
render () {
if (this.props.isShowed) {
return (
<Portal>
<div>
<div className='modal'>{this.props.children}</div>
<div className='backdrop'></div>
</div>
</Portal>
)
}
else return null
}
感覺這樣的設(shè)計確實比之前更科學,而這個部分也被單獨抽象到了react-overlays中。
過渡動畫
并不想在Portal組件里再額外加入動畫相關(guān)的邏輯了,于是準備再封裝一層,加上對過渡動畫的支持。
提供幾個思路,一個是通過操作classname,這里以模態(tài)窗為例,先上代碼:
componentWillReceiveProps (nextProps) {
const { show } = nextProps
if (!show && this.props.show && this.props.closeTimeout) { // ready to close
this.setState({ delaying: true, closing: true, opened: false })
setTimeout(() => {
this.setState({ delaying: false, closing: false })
}, this.props.closeTimeout)
}
}
componentDidUpdate (prevProps, prevState) {
const { show } = prevProps
if (!show && this.props.show) { // first show
setTimeout(() => { // need do it in next loop
this.setState({ opened: true })
})
}
}
分別在合適的時機加上相應的class即可,對于show這個動作來說沒什么問題,但對于close而言,顯然我們需要等到transition的過渡時間結(jié)束后才真正unrender我們的組件,于是我們給它一個可傳入的屬性叫closeTimeout,并在組件內(nèi)具有一個this.state.delaying這個狀態(tài),那么我們的render邏輯應該是這樣的:
if (this.props.show || this.state.delaying) {
return (
<Portal>
<div className={classnames([
'modal',
{ opened: this.state.opened,
closing: this.state.closing }
])}>
{this.props.children}
</div>
</Portal>
)
}
else {
return null
}
再靈活一點就是自定義opened和closing的classname了,這里不贅述。
這是一種方法,不過動畫的部分不怎么React式,是的,React動畫又是另一塊內(nèi)容了,這里不會詳述,因為似乎還不怎么成熟,不過還是給出一些可供參考的庫吧:
簡單地貼點代碼:
render () {
return (
<Portal>
<TimeoutTransitionGroup
enterTimeout={200}
leaveTimeout={250}
transitionName='modal-anim'>
{this.props.isShowed ?
(<div className='modal'>
{this.props.children}
</div>) : null}
</TimeoutTransitionGroup>
</Portal>
)
}
當然這里的是否需要transition、timeout以及transitionName都應該是可配置的,作為示例代碼就簡單點寫了。
最后
推薦大家看看react-overlays,可以直接使用里面的Portal實現(xiàn)還有一些其他有用的通用組件,文檔在這里?;蛘咂鋵嵱幸粋€單獨的react-modal的實現(xiàn)也可以直接用。
好了,結(jié)束了。