tldr: 如果你很熟悉 react context & react portals,这篇文章对于你的价值不大

📽 Background

历史原因

  • 过时的 react 版本以及过时的 antd 版本

  • antd@v2.x 没有 <Drawer /> 组件

需求

  • 行为与 antd@latest 中的 <Drawer /> 保持一致

    • 支持抽屉 push & pull 的 动态效果

    • 抽屉 pull out 时,背景附加 暗化效果

    • 支持 多层抽屉

    • 子抽屉拉出的同时,父抽屉也需要 发生位移

示例

如何优雅地写一个 <Drawer /> - 图1

🗿 Lame Idea

Step.01 组件布局 & 组件接口

按照 自顶向下 的设计思维,抽屉组件由三部分组成:

  • container: 用于承载整个抽屉组件

  • mask: 遮罩层,用与暗化抽屉外的背景区域,点击遮罩层可以关闭抽屉

  • content: 抽屉内部,又包括:

    • header

    • body

    • footer

content 部分平淡无奇,简单的 flex 布局即可搞定。重点放在 container 和 mask 的部分

有了布局之后,即可定义组件接口。这里给出接口的最小集:

  • visible: boolean,控制抽屉的显示和隐藏

  • onClose: function,外部容器用于控制抽屉关闭的函数

  • title: string or obeject,抽屉标题栏的显示内容,可以是简单字符串或是复杂对象(下拉菜单等)

  • footer: object,抽屉底部区域,一般来说由若干个 <Button /> 组成

完成上述设计之后,写出以下代码:

如何优雅地写一个 <Drawer /> - 图2

几点说明:

  • line 30: 在多层抽屉嵌套的情况下,push 用于判断 父抽屉 是否需要因为 子抽屉 而发生位移

  • line 37: 抽屉打开时,mask 遮罩层需要从 opacity: 0 过渡到 opacity: 0.3,并添加一些别的 css 属性,后面会解释

  • line 38: 抽屉打开时,用于给 content 部分添加特殊的 css 属性,后面会解释

  • line 45: 抽屉关闭时,整个抽屉的 content 部分是隐藏在浏览器窗口的最右侧的。而 containermask 部分是以 100% 的 width 和 height 直接覆盖在整个窗口之上的,并通过 z-indexpointer-events 这两个 css 属性来让用户感知不到他们的存在,后面会解释

Step.02 Css 细节处理

先长话短说,z-index 要很大,确保抽屉能在所有页面内容的最上层,且 containermask 部分在抽屉隐藏的状态,不能影响页面其他内容的操作。

下面是具体做法:

  • z-index: 在没有很极限的特殊情况下,设置为 1000 即可

  • pointer-events

定义:指定在什么情况下 (如果有) 某个特定的图形元素可以成为鼠标事件的 target

除了指示该元素不是鼠标事件的目标之外,值 none 表示鼠标事件 “穿透” 该元素并且指定该元素“下面”的任何东西。因此,给 container 部分添加 pointer-events: none; 之后,即使它拥有一个很大的 z-index,抽屉外部的下层内容还是可以响应到用户的鼠标事件

回到 line: 37/38/45,由于 mask 部分需要响应 onClose 事件,content 部分需要响应抽屉内部的所有鼠标事件,因此,在抽屉打开的状态下,需要给 maskcontent 部分添加 pinter-events: auto;

下面给出样式代码,content 的样式名为 .drawer, 其内部的部分省略

如何优雅地写一个 <Drawer /> - 图3

完成以上两步之后,一个基本的 单层 抽屉就做好了,很 simple 也很 basic

下面进行嵌套设计

Step.03 多层抽屉嵌套设计

多层嵌套的核心在于:子抽屉拉出时,父抽屉需要向左位移一定的距离。针对这一点,有以下几个问题

  • 如何在 子抽屉 中控制 父抽屉 的行为?如果嵌套的层次很多,采用一堆的 callback 和 props 来控制显然是不合适的。实际上在 三层抽屉 的情况下,代码就已经很丑了

  • 位移的是整个 container 部分还是只是 content 部分?虽然乍一看,这两种做法在视觉效果上类似,但仔细一想,根据需求,mask 部分是一个 fade in & fade out 的效果,所以在一开始的组件布局设计中, containermask 部分是一开始就覆盖在页面之上的,抽屉打开时,仅仅只是把 content 部分从右侧隐藏区域拉出来。因此,在子抽屉打开时,如果父抽屉位移的是 content 部分,那在最后父抽屉关闭时,还需要把先前这段位移的距离再补回来,就不是简单的 tranlateX(100%) 了。所以位移整个 container 应该才是比较方便的做法

  • 多层抽屉的情况下,<Drawer /> 组件渲染到 dom 中应该是怎样的层级关系?举例来说,如果层级关系如下所示:

  1. <div id='root'>
  2. // ...
  3. // 页面 A 中包含了一个多层嵌套的抽屉
  4. <div>
  5. // ...
  6. // 第一层抽屉
  7. <Drawer>
  8. // 第二层抽屉
  9. <Drawer>
  10. </Drawer>
  11. </Drawer>
  12. </div>
  13. </div>

根据第二个问题中,子抽屉打开时,父抽屉发生位移的是整个 container 部分,那么按照上面的层级关系,父抽屉的 container 实际上是包裹着子抽屉的,所以其实此时子抽屉也发生了不该发生的位移。因此,这样的设计显然是不合理的,替代为:

  1. <div id='root'>
  2. // ...
  3. // 页面 A 中包含了一个多层嵌套的抽屉
  4. <div>
  5. // ...
  6. </div>
  7. </div>
  8. // 第一层抽屉
  9. <Drawer>
  10. </Drawer>
  11. // 第二层抽屉
  12. <Drawer>
  13. </Drawer>

如上面的代码所示,最终渲染到 dom 中之后,抽屉应当于 root 并列,但在书写代码的层面,<Drawer /> 还是采用正常的嵌套逻辑写在 A 页面中,即在 virtual dom 中,<Drawer /> 是嵌套的,在 真实 dom 中,<Drawer /> 是独立且并列的

下面来解决这几个问题

💎 Smart Idea

Technically this part can be regard as an analysis of source code of antd-drawer and rc-drawer

React Context

回顾一下 Redux 的做法,Redux 通过 Context 机制实现了管理全局 state 的功能,使得任意位置的组件都能够很方便地获取到任何它想要的 props,这里不赘述

回到上面的第一个问题:如何在 子抽屉 中控制 父抽屉 的行为? 如果能在多层嵌套的抽屉中建立一个上下文,子抽屉 能够控制离它最近的 父抽屉 的行为,那么这个问题就解决了

Context 的定义:

Context provides a way to pass data through the component tree without having to pass props down manually at every level.

Context is primarily used when some data needs to be accessible by many components at different nesting levels.

具体的 API 和使用方法这里不展开了。这里得出的结论是:通过 ProviderConsumer 的形式,父抽屉 可以把自己本身(或者自己内部的某些属性和方法)传递给 子抽屉,这样 子抽屉 就可以去控制 父抽屉 的位移行为了。后面会给出具体代码

React Portals

Portals 的定义:

Portals provide a first-class way to render children into a DOM node that exists outside the DOM hierarchy of the parent component.

A typical use case for portals is when a parent component has an overflow: hidden or z-index style, but you need the child to visually “break out” of its container. For example, dialogs, hovercards, and tooltips.

回到上面的第三个问题,实际上已经可以很明显地从 Portals 的定义中找到答案。同样给出结论:使用 ReactDOM.createPortal() 这个方法,把 <Drawer /> 组件直接渲染到 document.body 中去

下面给出完整的 <Drawer /> 代码

如何优雅地写一个 <Drawer /> - 图4

几点说明:

  • line 9: DrawerContext 定义了嵌套抽屉的上下文,方法中传入 null 表示最顶层的抽屉没有父抽屉

  • line 34: parentDrawer 的值是通过 line 104 的 DrawerContext.Provider 传递过来的

  • line 37: push 定义为 number 类型,可以理解为”被子抽屉 push 了几次“,并根据 push 的值来渲染父抽屉的位移距离

  • line 38, 41 ~ 45: 在 createPortal 的过程中,不能直接把 <Drawer /> 扔到 document.body 中,要在外面包裹一层 <div> ,然后再扔到body 中。因为 <Drawer /> 最外层的 contaner 部分有 z-index 属性,如果直接扔到 body 中,在多层嵌套的情况下会有问题(暂时没找到原因)

  • line 47 ~ 62: 子抽屉打开时,控制父抽屉的位移行为

最终示例:

如何优雅地写一个 <Drawer /> - 图5

💡 Shallow Compnent, Deep Code

至此,一个扩展性还不错的多层嵌套抽屉就写好了。当然还可以做很多优化,比如添加更多可配置属性,控制抽屉的 destroy 行为等

想说的是,在刚开始写这个组件时,觉得是个很简单的普通组件,无非是控制一些动画效果而已。当然,通过一些简单粗暴的代码,也确实能够实现一开始提到的那些需求

但那样就没意思了