前言
继续更新react组件库源码,大家有没有想过,我们在用Modal或者Dialog组件时,这个Modal被渲染到哪了?答案是body的下面:如下图:
如图,主流的组件库都是把弹框渲染到body元素下面的。好了,我们接下来就写这个组件。
之前写的react组件如下:
-
Affix组件: react组件库源码+ 单测解析(Affix 固钉组件)
-
GridLayout组件:秒杀ant design布局组件
-
Button和ButtonGroup 按钮组件: react组件库源码+ 单测解析(Button和ButtonGroup 按钮组件)
-
日历组件: react 日历组件上
-
日组件拖拽逻辑部分: react 日历组件下
-
Modal组件:实现一个比ant功能更丰富的Modal组件
渲染API
import { createPortal } from 'react-dom';
在 CSS 中,可以通过position: absolute, fixed等等方式让元素脱离文档流。而在 React 中,createPortal 直接改变了组件的挂载方式,不再是挂载到上层父节点上,而是可以让用户指定一个挂载节点。
Portal 实战案例
正常来说,我们写一个Dialog组件,传入children,如下,那么这个Dialog肯定是在文档流里面的。
import { Component } from 'react'
import Dialog from './Dialog'
class App extends Component {
render() {
return (
<>
<p>我在 root 里面</p>
<Dialog>
<p>我不在 root 里面</p>
</Dialog>
</>
)
}
}
export default App
那么,我们有时候渲染弹框组件想让这个传入children渲染到外层,也就是ReactDOM.render(, document.getElementById('root')),render函数不包括的地方,该怎么办呢。
如下:
import { Component } from 'react'
import { createPortal } from 'react-dom'
class Dialog extends Component {
constructor(props) {
super(props)
this.dom = document.createElement('div')
this.dom.setAttribute('id', 'portal')
document.body.appendChild(this.dom)
}
render() {
return createPortal(<>{this.props.children}</>, this.dom)
}
}
export default Dialog
可以看下图,我们传入的children就渲染到了root外层
封装Portal组件
代码比较短,我就直接贴代码了,注释写在里面
import React, { forwardRef, useEffect, useMemo, useImperativeHandle } from 'react';
import { createPortal } from 'react-dom';
// 判断是否是浏览器环境
import { canUseDocument } from '../_util/dom';
export interface PortalProps {
/**
* 指定挂载的 HTML 节点, false 为挂载在 body
*/
attach?: React.ReactElement | AttachNode | boolean;
/**
* 触发元素
*/
triggerNode?: HTMLElement;
children: React.ReactNode;
}
// 获取要挂载的dom元素,可以简单理解为如果是字符串就用querySelector去查询此dom
// 如果是函数就调用,如果是HTMLement就返回该dom
// 默认返回 document.body
export function getAttach(attach: PortalProps['attach'], triggerNode?: HTMLElement): AttachNodeReturnValue {
if (!canUseDocument) return null;
let el: AttachNodeReturnValue;
if (typeof attach === 'string') {
el = document.querySelector(attach);
}
if (typeof attach === 'function') {
el = attach(triggerNode);
}
if (typeof attach === 'object' && attach instanceof window.HTMLElement) {
el = attach;
}
// fix el in iframe
if (el && el.nodeType === 1) return el;
return document.body;
}
const Portal = forwardRef((props: PortalProps, ref) => {
// attach 指定挂载的 HTML 节点, false 为挂载在 body
// triggerNode 触发元素
const { attach, children, triggerNode } = props;
// 创建 container
const container = useMemo(() => {
if (!canUseDocument) return null;
const el = document.createElement('div');
el.className = `${classPrefix}-portal-wrapper`;
return el;
}, [classPrefix]);
// 把元素挂载到parentElement上, 默认parentElement是document.body
useEffect(() => {
const parentElement = getAttach(attach, triggerNode);
parentElement?.appendChild?.(container);
return () => {
parentElement?.removeChild?.(container);
};
}, [container, attach, triggerNode]);
useImperativeHandle(ref, () => container);
// 因为container挂载到parentElement上,默认parentElement是document.body
// 然后children就挂载了外面(本应该所有元素都在root下,这个被挂载了跟body标签同级下)
return canUseDocument ? createPortal(children, container) : null;
});
Portal.displayName = 'Portal';
export default Portal;