React 模态框秘密和“轮子”渐进设计

今天上午组内小朋友们谈到 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 的友好兼容。

image.png

图片

比如说,如果在 <Modal> 组件的子组件 child component 中,需要使用 Redux store 里的数据,那么因为 <Provider> 实质上是一个“高阶组件”且不在 <Modal> 组件的组件链中,因为 child component 无法感知 Redux store 的存在。

为了解决这个问题,我们继续改进组件树结构为:

image.png

图片

为此,我们引入应用的 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仓库**知乎问答链接 欢迎各种形式交流。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,332评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,508评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,812评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,607评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,728评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,919评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,071评论 3 410
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,802评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,256评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,576评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,712评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,389评论 4 332
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,032评论 3 316
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,798评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,026评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,473评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,606评论 2 350

推荐阅读更多精彩内容

  • Android 自定义View的各种姿势1 Activity的显示之ViewRootImpl详解 Activity...
    passiontim阅读 171,832评论 25 707
  • React的学习资源 这个文章好久没有更新了,资源算比较老旧的了,毕竟前端更新还是非常快的。 半年不学习,都不知道...
    izhongxia阅读 23,186评论 11 629
  • 谢阿呸姑娘邀请,今儿北京刮起了大风,又想起了与你在维多利亚湾吹着海风聊占中、聊文学的那个晚上,靠近海的城市真好。 ...
    罗罗磊磊阅读 929评论 3 11
  • 今天下午的课,比较遗憾,没有上完。 回想经过:7、8班的电脑都不能用,原定计划不能进行,临时不用课件。于是让学...
    sunflower80阅读 236评论 0 0
  • 2017年9月17日,周天。参加了一次本人人生当中最虐,风景最美的越野跑比赛,身体与心灵都经历双重的虐待,有史以来...
    强O阅读 568评论 0 0