在某个月黑风高的晚上...没剧刷的我无意想起以前处理的一些弹窗的坑。
然后又无意间刷到“Portal
”,才知道Modal
的实现还有如此妙的方式,顺而想着干脆把UI
组件库的实现原理看完。
本文将讲述Modal
弹窗类组件的实现原理:
Modal
弹窗的基本原理我给弹窗类的定义是脱离固定的层级关系,不再受制于层叠上下文的组件。
常见的Modal
模态框、Dialog
对话框、Notification
通知框等都是最最常用的交互方式。
UI
组件过于麻烦。
这时切图仔级别的会想:简单啊,创建一个<div/>
给绝对定位不就得了。
倘若只是当前路由页用,也还凑合。可一旦涉及到了组件复用以及抽象为声明式,就会有很大的隐患:
<div/>
,易造成样式污染。Jquery
时代的弹窗实现初初入行时,去各种资源站,找Jquery
的UI
组件,想必三四年经验的前端们都曾乐此不疲。
React
/Vue
根节点的概念,普遍都是:
document.body.appendChild
。overflow: hidden
或display:none
(或调整z-index
)来隐藏。这种操作真实dom
的代价,在大型项目中不停触发重绘/回流,是很糟糕的,且内部数据/样式不易更改。像以下这种情况就容易出现:
React / Vue
先进库的发展,也陆续有了多种方案选择。。。React / Vue
早期实现。其实React / Vue
早期的实现和Jquery
时代的并无二异:依赖于父节点数据,在当前组件内挂载弹窗。
Vue
的情况稍好,有自定义指令这条路走。
以下引自:《Vue中的Portal技术》
以vue-dom-portal
为例,代码非常简单无非就是将当前的 dom
移动到指定地方:
function (node = document.body) { if (node === true) return document.body; return node instanceof window.Node ? node : document.querySelector(node); } const homes = new Map(); const directive = { inserted(el, { value }, vnode) { const { parentNode } = el; const home = document.createComment(""); let hasMovedOut = false; if (value !== false) { parentNode.replaceChild(home, el); // moving out, el is no longer in the document getTarget(value).appendChild(el); // moving into new place hasMovedOut = true; } if (!homes.has(el)) homes.set(el, { parentNode, home, hasMovedOut }); // remember where home is or should be }, componentUpdated(el, { value }) { // 对比子组件更新 const { parentNode, home, hasMovedOut } = homes.get(el); // recall where home is if (!hasMovedOut && value) { parentNode.replaceChild(home, el); getTarget(value).appendChild(el); homes.set(el, Object.assign({}, homes.get(el), { hasMovedOut: true })); } else if (hasMovedOut && value === false) { parentNode.replaceChild(el, home); homes.set(el, Object.assign({}, homes.get(el), { hasMovedOut: false })); } else if (value) { getTarget(value).appendChild(el); } }, unbind(el, binding) { homes.delete(el); } }; function plugin(Vue, { name = "dom-portal" } = {}) { Vue.directive(name, directive); } plugin.version = "0.1.6"; export default plugin; if (typeof window !== "undefined" && window.Vue) { window.Vue.use(plugin); } 复制代码
可以看到在 inserted
的时候就拿到实例的 el(真实 dom),然后进行替换操作,在 componentUpdated
的时候再次根据指令的值去操作 dom。
为了能够在不同声明周期函数中使用缓存的一些数据,这里在 inserted
的时候就把当前节点的父节点和替换成的 dom
节点(一个注释节点),以及节点是否移出去的状态都记录在外部的一个 map
中,这样可以在其他的声明周期函数中使用,可以避免重复计算。
但是React / Vue
的实现都有类似的通病:
redux
或props
管理数据,可这对于一个UI
组件来说过于臃肿了。React
官方也意识到构建脱离于父组件的组件挺麻烦的,于是在v16
版本推了一个叫“Portal
”的功能。而Vue3
也是借鉴并吸纳了优秀插件,将Portal
作为内置组件了。
Portal
方案React / Vue
的第二套方案都是基于操作虚拟dom
:
定义一套组件,将组件内的 vnode/ReactDOM
转移到另外一个组件中去,然后各自渲染。
React
的Portal
React Portal
之所以叫Portal
,因为做的就是和“传送门”一样的事情:render
到一个组件里面去,实际改变的是网页上另一处的DOM
结构。
ReactDOM.createPortal(child, container) 复制代码
child
)是任何可渲染的 React
子元素,例如一个元素,字符串或碎片。container
)则是一个 DOM
元素。在v16
中,使用Portal
创建Dialog
组件简单多了,不需要牵扯到componentDidMount
、componentDidUpdate
,也不用调用API
清理Portal
,关键代码在render中,像下面这样就行:
import React from 'react'; import {createPortal} from 'react-dom'; class Dialog extends React.Component { constructor() { super(...arguments); const doc = window.document; this.node = doc.createElement('div'); doc.body.appendChild(this.node); } render() { return createPortal( <div class="dialog"> {this.props.children} </div>, //塞进传送门的JSX this.node //传送门的另一端DOM node ); } componentWillUnmount() { window.document.body.removeChild(this.node); } 复制代码
当然,我们作为一个React Hooks
选手,不骚一下咋行。
Ant Design
中的实现原本是想从Ant Design
库中一窥究竟,却发现事情并不简单。。
前后寻址了三个库/地方,才发现实现的关键:
import Dialog from 'rc-dialog';
import Portal from 'rc-util/lib/PortalWrapper';
import Portal from './Portal';
具体实现也算如我所料:
import React from 'react'; import ReactDOM from 'react-dom'; import PropTypes from 'prop-types'; export default class Portal extends React.Component { static propTypes = { getContainer: PropTypes.func.isRequired, children: PropTypes.node.isRequired, didUpdate: PropTypes.func, } componentDidMount() { this.createContainer(); } componentDidUpdate(prevProps) { const { didUpdate } = this.props; if (didUpdate) { didUpdate(prevProps); } } componentWillUnmount() { this.removeContainer(); } createContainer() { this._container = this.props.getContainer(); this.forceUpdate(); } removeContainer() { if (this._container) { this._container.parentNode.removeChild(this._container); } } render() { if (this._container) { return ReactDOM.createPortal(this.props.children, this._container); } return null; } } 复制代码
render
里用了ReactDOM.createPortal
**这也是为什么多数Modal
组件不会提供篡改整体样式的API
,只能通过全局重置样式。`
React Hooks
版弹窗:useModal
Modal
组件import React from 'react' import ReactDOM from 'react-dom' type Props = { children: React.ReactChild closeModal: () => void } const Modal = React.memo(({ children, closeModal }: Props) => { const domEl = document.getElementById('modal-root') if (!domEl) return null return ReactDOM.createPortal( <div> <button onClick={closeModal}>Close</button> {children} </div>, domEl ) }) export default Modal 复制代码
useModal
import React, { useState } from 'react' import Modal from './Modal' // Modal组件最基础的两个事件,show/hide export const useModal = () => { const [isVisible, setIsVisible] = useState(false) const show = () => setIsVisible(true) const hide = () => setIsVisible(false) const RenderModal = ({ children }: { children: React.ReactChild }) => ( <React.Fragment> {isVisible && <Modal closeModal={hide}>{children}</Modal>} </React.Fragment> ) return { show, hide, RenderModal, } } 复制代码
很好理解,不懂的建议转行写Vue
。
import React from 'react' import { useModal } from './useModal' const App = React.memo(() => { const { show, hide, RenderModal } = useModal() return ( <div> <div> <p>some content...</p> <button onClick={show}>打开</button> <button onClick={hide}>关闭</button> <RenderModal> <p>这里面的内容将会被渲染到'modal-root'容器里.</p> </RenderModal> </div> <div id='modal-root' /> </div> ) }) export default App 复制代码
Vue 3
的Portal
Vue
虽说是借鉴,但使用方式可容易多了。
<OtherComponent> <Portal target="#popup-target"> <Modal /> </Portal> </OtherComponent> .... <div id="popup-target"></div> 复制代码
在上面的示例中,该<Modal />
组件将在id=portal-target
的容器中渲染,即使它位于OtherComponent
组件内。
这,这...这也太香了吧。 进一步的用法如下:
<!-- UserCard.vue --> <template> <div class="user-card"> <b> {{ user.name }} </b> <button @click="isPopUpOpen = true">Remove user</button> <Portal target="#popup-target"> <div v-show="isPopUpOpen"> <p>Are you sure?</p> <button @click="removeUser">Yes</button> <button @click="isPopUpOpen = false">No</button> </div> </Portal> </div> </template> 复制代码
然后我再去找了下Vue 3
的源码实现:
在packages/runtime-core/src/components/Portal.ts
目录中:
import { ComponentInternalInstance } from '../component' import { SuspenseBoundary } from './Suspense' import { RendererInternals, MoveType } from '../renderer' import { VNode, VNodeArrayChildren, VNodeProps } from '../vnode' import { isString, ShapeFlags, PatchFlags } from '@vue/shared' import { warn } from '../warning' export const isPortal = (type: any): boolean => type.__isPortal export interface PortalProps { target: string | object } export const PortalImpl = { __isPortal: true, process( n1: VNode | null, n2: VNode, container: object, anchor: object | null, parentComponent: ComponentInternalInstance | null, parentSuspense: SuspenseBoundary | null, isSVG: boolean, optimized: boolean, { mc: mountChildren, pc: patchChildren, pbc: patchBlockChildren, m: move, o: { insert, querySelector, setElementText, createComment } }: RendererInternals ) { const targetSelector = n2.props && n2.props.target const { patchFlag, shapeFlag, children } = n2 if (n1 == null) { // insert an empty node as the placeholder for the portal insert((n2.el = createComment(`portal`)), container, anchor) if (__DEV__ && isString(targetSelector) && !querySelector) { warn( `Current renderer does not support string target for Portals. ` + `(missing querySelector renderer option)` ) } } else { //....中间忽略了,大致的意思就是对比两个VNode,以及在不同生命周期的边界处理 // 核心就是通过createComment,创建注释节点,将其插入不同节点中。 // 最后setElementText,重置插入节点的内容。 } } } // Force-casted public typing for h and TSX props inference export const Portal = (PortalImpl as any) as { __isPortal: true new (): { $props: VNodeProps & PortalProps } } 复制代码
重要的解释,都在上述注释中了,临时看的,说得不对的谢谢指正。
其中:createComment
是Vue
对DOM.createComment
的进一步封装。
这篇算是自己半夜无聊折腾出来的,原定计划是一篇写三种组件,但弹窗类的实现比较有意思。
这个系列我会边找工作边写,不出意外下一篇就是讲Steps
步骤条和Transfer
穿梭框的实现(当然,太难了就忽悠一下,嘿嘿。)
对了,有适合的内推记得发我。
参考文章:
如果你觉得这篇内容对你挺有启发,我想邀请你帮我三个小忙:
劝退师个人微信:huab119
也可以来我的GitHub
博客里拿所有文章的源文件:
前端劝退指南:github.com/roger-hiro/… 一起玩耍呀。~