React中的Portal组件
几个月前遇到了写模态窗(modal)的需求,当初其实没什么思路,不知道怎么用更React的方式实现模态窗,于是去学习了下ReactBootstrap的源代码,发现了一个Portal组件,通过这个Portal的概念实现了React式的模态窗,诸如tooltip或者是notification等组件也是同样的道理。最近在看React-conf的视频时又听到Ryan提到,最近重新回去看ReactBootstrap的源代码,发现其实变化挺大的,原先Portal的部分已经被抽象出了另一个库react-overlays,于是准备总结下这个部分。
模态窗的实现思路
模态窗扮演这相当于桌面应用的MessageBox的角色,各个浏览器对这个部分的支持有些缺陷(这里指alert或confirm这些),比如每个浏览器实现效果有差异、用户可以禁止其显示,还有最重要的是没有办法灵活控制。
于是我们自己来实现,要扮演一个MessageBox,那么我们希望一个modal应该是永远被置于顶层的,而又由于Stacking Context的关系(在此不赘述),我们会将modal直接append在body元素下,设置一个属于它的z-index区间什么的。
回到React,在React中要实现一个模态窗,可以是这样的:
handleClick () {
$('.modal').modalShow() // 假设这是一个jquery的modal插件
}
我不知道是不是存在这样一个jquery插件(应该是有的,不过我jquery用的不多),不过大家应该明白我的意思,利用React对其他库的友好来曲线救国。
另外的一种方式是实现一个Modal类,通过Modal.show()
这样的方法调用,这个方法会负责将模态窗render在它应该出现的地方,这个思路我一开始也有想到,不过自己其实更倾向于尝试声明式的React组件实现。
那么实现React式的模态窗会遇到什么问题呢?比如有一个Container组件(承载页面结构和业务逻辑的组件),在页面的逻辑中会有一个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在它被声明的地方,姑且就翻译为『传送门』吧......
实现一个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
方法里创建一个div
,也就是this._overlayTarget
,于是调用React.render
方法将组件挂载到这个div
节点上,并将保持对该实例的引用this._overlayInstance
。
通常情况下,对于React组件来说,不直接操作DOM,而且React.render
方法我们通常都是在入口点调用一次,其他时候基本不用,然而对于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
}
}
好了,那么我们需要在何处调用_renderOverlay
呢,很容易想到:
componentDidMount () {
this._renderOverlay()
}
componentDidUpdate () {
this._renderOverlay()
}
然后记得要擦屁股:
componentWillUnmount() {
this._unrenderOverlay();
this._unmountOverlayTarget();
}
为了增加Portal的灵活性,可以给它传一个container
属性,用来指定『传送门』的位置(默认为body元素)。
实现上其实基本上就是这样了,这里要简单提一下,之前就ReactBootstrap对Portal组件的实现而言,把isShowed
的逻辑给加在Portal里,增加了一些实现的复杂度,这个项目好像重构过一波,现在的实现中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
}
感觉这样的设计确实比之前更科学,而这个部分也被单独抽象到了react-overlays中。
过渡动画
并不想在Portal组件里再额外加入动画相关的逻辑了,于是准备再封装一层,加上对过渡动画的支持。
提供几个思路,一个是通过操作classname,这里以模态窗为例,先上代码:
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的过渡时间结束后才真正unrender我们的组件,于是我们给它一个可传入的属性叫closeTimeout
,并在组件内具有一个this.state.delaying
这个状态,那么我们的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动画又是另一块内容了,这里不会详述,因为似乎还不怎么成熟,不过还是给出一些可供参考的库吧:
简单地贴点代码:
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实现还有一些其他有用的通用组件,文档在这里。或者其实有一个单独的react-modal的实现也可以直接用。
好了,结束了。