今天上午组内小朋友们谈到 React 实践,提到 React 模态框(弹窗)的使用。我发现很多一些 React 开发者对于 React 模态框的具体设计思路和实现存在一些疑惑。因而特写此文,分享我对模态框这个“重要且典型”的前端交互,在 React 框架里实现的一些想法。准备时间短促且匆忙,难免有遗漏之处,希望大神给予斧正。
这篇文章“进阶式”渐进地,由浅入深分析三种实现。从最初的简单粗暴到接近 react-modal 库设计思想,一步步打磨分析,适合初学者阅读思考。
原始级实现 —— 暴力美学
世界上大部分网站都离不开模态框交互。事实上,模态框就是我们俗称的“弹窗”,只不过这个弹窗相比简单的:
alert('我是一个简单、原生的 alert~');
多了更多的信息承载和交互行为。同时为了更佳美化和吸引眼球,模态框往往伴随着深色透明的遮罩。比如下图:
想想常见的用户登录框、错误信息提示等等,都是非常典型的模态框实现。
在传统的 jQuery 操作 DOM 类库的技术栈下,我们可以“肆无忌惮”地选择 DOM 节点,完成 append, remove 等操作,实现模态框并不复杂。可是在 React 和 Redux 世界里,我们该如何实现?
我们先来看一下场景和初版设计思路:
如图,箭头标记的组件需要触发模态框的出现。
图中组件树对应基本页面代码如下:
export default class App extends Component {
render() {
<div className="app">
<div className="left">
<h1>Hello left</h1>
// ...
</div>
<div className="right">
<h1>Hello right</h1>
// ...
<div>
<BadModal>
// 模态框内容
<h1> Modal title </h1>
<p> Modal content</p>
</BadModal>
</div>
</div>
</div>
}
}
细心的读者会发现,作为 amazing 的程序员,尽管这是最初版本的实现,但是还是思考一些最基本的“复用”问题。
我们设计完成的模态框组件 <BadModal>,因为每个模态框里内容和交互不尽相同,所以在 <BadModal> 组件内,我们渲染 child component,这个 child component 即业务对应的模态框内容,它将会由业务逻辑开发完成,实现模态框内容、交互的复用。如下代码:
class BadModal extends Comment {
render() {
return (
<div className="modal">
{ this.props.children }
</div>
)
}
}
至此,我们已经实现了最基本的模态框。可是为什么说这是最原始、简陋的方法呢?细想一下,似乎不完美的地方还很多。翻开我们的样式表:
body .modal {
position: fixed;
// ...
}
.left {
z-index: 3
}
.right {
z-index: 1
}
你会发现恼人的 z-index 问题,我们模态框是 .right 节点的子孙节点,而 .right 的 z-index 小于 .left 的 z-index,这样造成的直接问题就是模态框最终不能脱离页面整体而“突出显示”!
细想一下,这个问题的根本就出现在我们的组件设计图中。
仔细观察上图,因为很深层次的子孙组件触发模态框,而使得该组件内的模态框组件层级较深。如果你对 z-index 比较规则有所了解的话,这样的情况很难完成模态框凌驾于页面整体而出现的,遮罩也无法覆盖整个页面。
想想我们平时使用的 jQuery 是怎么做的吧:
$('body').append('<div class="overlay"></div>');
一般情况,模态框和遮罩总是作为在 body 下的第一层子节点出现。由此,引出了我们的第二种进阶思路。请读者继续阅读。
实现方案二 —— 乾坤大挪移
解决方法很简单,我们可以很自然地想到:只需要对 <Modal> 组件出现的位置进行移动。可是这就需要 <Modal> 组件和触发模态框出现的深层次组件进行某种意义上的通信。
传统的 React 组件间通信无外乎 props 和基于 props 的回调实现(不考虑 context 的黑魔法)。可是这样的做法太过复杂,也难以实现复用,更不利于维护。
至于我这里采用的做法,还要从调整后的页面组件树设计出发:
如图,我们在 document.body 下加入了 <Modal> 组件,并列于 Root Component。同时,至关重要的一步设计是,我们在触发模态框的组件下,加入了一个 Fake Modal 组件。
这个神秘的 Fake Modal 组件做了什么呢?
事实上,他并不渲染任何结果,而是借助其生命周期函数,完成在 document.body 下新建并插入 <Modal> 组件的使命。
借助代码进行理解:
class Modal extends Comment {
componentDidMount() {
this.modalTarget = document.createElement('div');
this.modalTarget.className = 'modal';
document.body.appendChild(this.modalTarget);
this.renderModal();
}
componentWillUpdate() {
this.renderModal();
}
componentWillUnmount() {
ReactDom.unmountComponentAtNode(this.modalTarget);
document.body.removeChild(this.modalTarget)l
}
renderModal() {
ReactDom.render(
<div>{ this.props.children }</div>,
this.modalTarget
);
}
render() {
return <noscript />
}
}
具体进行分析在真正的 render 方法中,我们不渲染任何实质的内容,而是:
return <noscript />;
同时,借助生命周期函数 componentDidMount,我们使用原生 JavaScript 实现在 body 下的模态框创建:
this.modalTarget = document.createElement('div');
this.modalTarget.className = 'modal';
document.body.appendChild(this.modalTarget);
this.renderModal();
并最终调用 renderModal 方法完成插入:
ReactDom.render(
<div>{ this.props.children }</div>,
this.modalTarget
);
实现方案三 —— 搭配 Redux
相信很多 React 开发者都会使用 Redux 来做数据管理。仔细看上图的结构中,我们难以实现对 Redux 的友好兼容。
图片
比如说,如果在 <Modal> 组件的子组件 child component 中,需要使用 Redux store 里的数据,那么因为 <Provider> 实质上是一个“高阶组件”且不在 <Modal> 组件的组件链中,因为 child component 无法感知 Redux store 的存在。
为了解决这个问题,我们继续改进组件树结构为:
图片
为此,我们引入应用的 store,以及 react-redux 包提供的 <Provider> 组件:
import { store } from '../index';
import { Provider } from 'react-redux';
同时改动先前的 renderModal 方法,加入对 <Provider> 的支持:
renderModal() {
ReactDom.render(
<Provider store={ store }>
<div>{ this.props.children }</div>
<Provider>,
this.modalTarget
);
}
著名的 react-modal 探秘和 React16 版本惊喜
在 React 开发中,我想很多工程师对 react-modal 非常熟悉。我们往往依赖它,完成模态框的使用。
这个库设计良好,请封装完善。如果你好奇它是如何实现的,源码又是如何组织?那么我可以告诉你,你已经了解了他的设计哲学。事实上,文章介绍的思路就是它的奥秘。
了解了这些,你也可以动手实现“一个轮子”,或者扩充本文源码,实现更多的功能。比如样式的自定义、弹出前后的回调等等。相信一定会有很多收获。
同时,React 最新版本 0.16 已经横空出世,它带来的很多新特性之一就与本文密切相关。那就是 —— Portal,Portal 我们把它翻译为“传送门、任意门”。Portals 允许将组件渲染到父节点之外的 DOM 节点中。它的基本使用如下代码示例:
render() {
return ReactDOM.createPortal(
this.props.children,
anyDomNode,
);
}
这里 React 并不会在当前结构中渲染组件,而是向 anyDomNode 中渲染 this.props.children,这里的 anyDomNode 是任何有效的DOM节点,无论它处于哪个层级位置。
了解了这些,我们当然能够使用此特性,简化上文逻辑。翻开 react-modal 最新提交的源码,便能够发现对这一新特性的支持,react-modal/src/components/Modal.js 文件中:
const isReact16 = ReactDOM.createPortal !== undefined;
const createPortal = isReact16
? ReactDOM.createPortal
: ReactDOM.unstable_renderSubtreeIntoContainer;
这里对 React 版本进行判断,并设置 isReact16 标识位表示是否支持 createPortal 的方法(个人认为这个标识位的命名非常不合适...)
最终在 render 方法内:
render() {
if (!canUseDOM || !isReact16) {
return null;
}
if (!this.node && isReact16) {
this.node = document.createElement("div");
}
return createPortal(
<ModalPortal
ref={this.portalRef}
defaultStyles={Modal.defaultStyles}
{...this.props}
/>,
this.node
);
}
非常明显地看到,对于不支持 createPortal 的情况采用与我们类似的 return null; 否则愉快地使用 createPortal 方法。
总结
本文介绍内容虽然基础,但是很好地贯穿了 React 思想以及实现一个“模态框轮子”的演进思路。同时介绍了 React 新版本的一项特性。
我的其他几篇关于React技术栈的文章:
React Redux 中间件思想遇见 Web Worker 的灵感(附demo)
了解 Twitter 前端架构 学习复杂场景数据设计
React 探秘 - React Component 和 Element(文末附彩蛋demo和源码)
从setState promise化的探讨 体会React团队设计思想
通过实例,学习编写 React 组件的“最佳实践”
React 组件设计和分解思考
从 React 绑定 this,看 JS 语言发展和框架设计
React 服务端渲染如此轻松 从零开始构建前后端应用
做出Uber移动网页版还不够 极致性能打造才见真章**
React+Redux打造“NEWS EARLY”单页应用 一个项目理解最前沿技术栈真谛**
Happy Coding!
PS: 作者 Github仓库** 和 知乎问答链接 欢迎各种形式交流。