Dialog组件封装
# 0.前言
本篇博客将从零开始封装一个 Dialog
组件,其中涉及实现细节较多。主要包含以下内容:
- 待完成后总结
- 待完成后总结
组件演示地址:https://wangjs-jacky.github.io/jacky-workspace-html/ (opens new window)
项目仓库地址:https://github.com/wangjs-jacky/jacky-workspace (opens new window)
# 1. 实现方案
# 1.1 确定大致的 api
首先,我们大致确定下 Dialog
组件的基本使用方式:
const Demo: React.FC = () => {
const [isShow, { toggle, setFalse }] = useBoolean(false);
return (
<div style={{ zIndex: 9 }}>
<button onClick={() => toggle()}>切换</button>
<Dialog
visible={isShow}
buttons={[
<button onClick={setFalse}>确定</button>,
<button onClick={setFalse}>取消</button>,
]}
onClose={setFalse}
>
<strong>hi</strong>
</Dialog>
</div>
);
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
Dialog
对话框【基础版】实现参数
visible
用于控制组件是否显示buttons
用于底部footer
栏的显示与隐藏。onClose
为关闭弹窗的回调。
# 2.如何设计 Dialog
的 HTML
部分?
重新回顾下 Dialog
需求:
通过
visible
完成对JSX Element
的更新,即大致结构如下:return visible ? JSXElEment : null
1说明:当用户点击关闭按钮时,可能存在两种使用场景。一、将组件直接销毁。二、是将组件隐藏。这里我们只考虑第一种,第二种方案,后期会再开一篇博客从零搭建
KeepAlive
组件。通过
buttons
传入React.ReactNode[]
结构。
在 React
中的 HTML
设计如下:
const Dialog: React.FC<PropsType> = ({ visible, buttons, children, onClose }) => {
const result = visible ? (
<>
<div className="sui-dialog-mask" onClick={onClickMask}></div>
<div className="sui-dialog">
<div className="sui-dialog-close" onClick={onClickClose}>
<Icon name="close" />
</div>
<div className="sui-dialog-header"> 提示 </div>
<div className="sui-dialog-main">{children}</div>
<div className="sui-dialog-footer">
{buttons &&
buttons.map((button, index) => {
/* 在 Element 中绑定 key */
return React.cloneElement(button, { key: index });
})}
</div>
</div>
</>
) : null;
return ReactDOM.createPortal(result, document.body);
};
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# 细节1:遮罩层与组件的 HTML
的层级关系
上述 HTML
整体结构分为两层:遮罩层(sui-dialog-mask
) 及 组件自身(sui-dialog
)。
此处 HTML
两者的关系并非采用常见的嵌套结构,而是并排结构,如下:
嵌套结构:
<div className="sui-dialog-mask"> <div className="sui-dialog"></div> </div>
1
2
3并排结构(推荐)
<div className="sui-dialog-mask"> </div> <div className="sui-dialog"> </div>
1
2
3
4选择此方案的目的是为了实现:点击
Dialog
的遮罩层关闭弹窗功能。若为嵌套关系的话,需要考虑事件的冒泡。
# 细节2:使用穿梭门Portal
解决 zIndex
的问题?
在组件设计时,需要考虑到用户对组件 zIndex
的设定。在 HTML
的图层显示中存在一个特性,即一个组件的图层 level
永远取决于父组件的 level
,见下:
<div style={{zIndex:10}}></div>
<div style={{zIndex:5}}>
<div style={{zIndex:9999}}"></div>
</div>
2
3
4
在上述案例中,第二个div
的 children
的图层永远受限于其父组件的图层。因为我们无法限定用户使用组件的方式,故采取以下两个约定:
其一、在提供组件时,应在手册中明确组件库各组件的
zIndex
层级范围,如:Component zIndex Cascader 80 DatePicker 80 Menu 80 Popover 90 Affix 100 LightUp 100 Message 100 Modal 100 将
Dialog
组件通过Portal
穿梭到根节点。const result:JSX.ELEMent = xxxx; ReactDOM.createPortal(result, document.body);
1
2特别注意:
Portal
只会影响HTML DOM
结构,而不会影响React
组件树。
# 细节3:传入buttons
时无需显式绑定key
Dialog
组件支持接受 buttons
数组,实现对底部按钮的自定义渲染。
对于自定义组件的渲染实现思路有二:
定义
obj
对象,将obj
转化为ReactElement
// 用户编写 let obj = [ {key:"1",content:"按钮1",onClick:()=>{}}, {key:"2",content:"按钮2",onClick:()=>{}} ] // Dialog 组件内部处理 const obj2Ele = (obj)=>{ return obj.map(i => <button key={i.key} onClick={i.onClick}>i.content</button>) }
1
2
3
4
5
6
7
8
9
10(本案例使用)
// 用户编写 let buttons = [ <button onClick={()=>{}}>按钮1</button>, <button onClick={()=>{}}>按钮2</button> ] // Dialog 组件内部处理 // 如果需要添加额外的属性,如 key, 可通过 React.cloneElement 实现。 buttons.map((ele,index) => React.cloneElement(ele,{key:index}))
1
2
3
4
5
6
7
8
9
两个方案皆可,取决于用户使用时的自由裁量度,第一种方案只能用于生成 button
按钮,而第二种用户可自定义组件。
# 3. 如何编写Dialog
的 CSS
样式?
这里直接给出本案例的 CSS
实践方式
# 1. 使用 scss 作为预处理器
定义全局变量
_variable.scss
注:这里有下划线代表私有文件。
$main-color: #1890ff;
1通过
@import
的方式导入@import "../variable" .sui-dailog{ &-mask{ /* &-mask 即对 .sui-dialog-mask 的类名设定*/ } }
1
2
3
4
5
6
7
# 2. 处理同类元素
当存在多个同类元素时,需要单独处理第一个元素以及最后一个元素,如下:
button {
/* 处理第一个元素 */
&:first-child{
margin-left: 0px;
}
/* 处理最后一个元素 */
&:last-child{
margin-right: 0px;
}
}
2
3
4
5
6
7
8
9
10
# 3. 如何让 svg
着色?
对于 svg
图标,可以设置 fill
这个属性,如 icon
作为 svg
图标
.sheng-icon{
fill: currentColor;
}
2
3
使用时,直接设置 color 即可:
&-close{
color: white;
}
2
3
# 4. 扩展组件【操控 JSX.ELement
】
在前节中已将 Dialog
的基础功能完成,本节基于此组件进一步封装出两个实用 api
:
alert
用于取代
windows
的alert
函数。confirm
在
alert
的基础上扩展出confirm
弹窗。
预期效果:函数触发后,将 Dialog
渲染在页面上。
设计思路:
目前 Dialog
组件已提供基础的 HTML
布局和 CSS
样式,也即 React.ReactElement
或 JSX.Element
元素可以获取。如果能控制 Element
元素的渲染和卸载即可完成此功能的实现。
JSX.Element
及React.ReactElement
结构如下:
/*JSX.Element 或 React.ReactElement*/
const component = (
<Dialog
visible={true}
/* 注:可在此处插入 生命周期 (before 或者 after)*/
onClose={() => {
close();
afterClose && afterClose;
}}
buttons={bottons}
>
{content}
</Dialog>
);
2
3
4
5
6
7
8
9
10
11
12
13
14
# 1. 如何实现 React.Element
元素的渲染?
在 React
中 Element
元素渲染方案很多,并且 Class Component
还是 Function Component
获取 Element
元素的方式还是不一样的,打算新开一片博客梳理此部分内容。
在本案例中,由于触发的为 Dialog
组件,且不被嵌套在Fiber tree
结构中,因此使用 React.render()
方案渲染最为合适。
React.render
需要接受两个参数,一、是虚拟 JSX.Element
。二、是真实 DOM
节点。
/* 1. 创建 div 元素 */
const divEle = document.createElement('div');
/* 2. 挂载到真实的 body 下面 */
document.body.append(divEle);
/* 3. 通过 render 函数将虚拟DOM(component) 渲染到真实的 DOM(divELe) 上 */
ReactDOM.render(component, divEle);/*JSX.Element 或 React.ReactElement*/
2
3
4
5
6
# 2. 如何实现 React.Element
元素的卸载?
如果是常规情况的组件卸载,写法是非常简单的,如下:
const App = ( ) => {
const [visible,] = useState(false)
const Component/*Class Component*/;
return visible && <Component />
}
2
3
4
5
而通过之前的分析可知,由 alert
或者 confirm
触发的弹窗组件,最终是渲染在 body
节点下的 div
标签下,而非在原有的 Fiber
树结构下。
因此,采用 unmountComponentAtNode
函数对组件进行卸载:
注:这个
api
不是很常用,因为一般情况下,不会遇到脱离传统DOM
渲染结构的情况,只需通过在父节点中设置一个变量的方式即可控制子组件的挂载与卸载。
unmountComponentAtNode
可以对从DOM
中移除已经挂载的React
组件,清除相应的事件处理器和state
,在之后打算实现的useDataModal
这个自定义Hooks
可能还会用到这个api
。
const close = () => {
ReactDOM.render(React.cloneElement(component, { visible: false }), divEle);
ReactDOM.unmountComponentAtNode(divEle);
divEle.remove();
};
2
3
4
5
卸载分为三步:
第一步:给
Dialog
组件自身传递visible
为false
。说明:此处需要了解
React
组件渲染逻辑,就是将component
组件再次渲染一遍,并通过React.cloneElement
修改component
的props
。第二步:
ReactDOM.unmountComponentAtNode(divEle)
官方专用于卸载组件,主要是将多余的div
给删除掉。第三步:
.remove
是用于卸载组件。也是用于
div
元素的卸载,但是无法卸载事件处理函数,还是推荐使用unmountCompoentAtNode
函数,这里只是为了起一个双保险的策略。
# 3. 完整 API
总览
封装抽象函数
const modal = (content: ReactNode, bottons?: React.ReactElement[], afterClose?: () => void) => { const close = () => { ReactDOM.render(React.cloneElement(component, { visible: false }), divEle); ReactDOM.unmountComponentAtNode(divEle); divEle.remove(); }; const component = ( <Dialog visible={true} /* 注:可在此处插入 生命周期 (before 或者 after)*/ onClose={() => { close(); afterClose && afterClose; }} buttons={bottons} > {content} </Dialog> ); /* 1. 创建 div 元素 */ const divEle = document.createElement('div'); /* 2. 挂载到真实的 body 下面 */ document.body.append(divEle); /* 3. 通过 render 函数将虚拟DOM(component) 渲染到真实的 DOM(divELe) 上 */ ReactDOM.render(component, divEle); return { close }; }; /* alert 需要补充定义关闭 */ const alert = (content: React.ReactNode) => { const { close } = modal(content, [<button onClick={() => close()}> OK</button>]); };
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35modal
函数是alert
函数和confirm
函数封装结束后,发现存在大量重复代码,将通用部分抽取为抽象函数,在此函数的基础上,confirm
及alert
的实现就非常简单了。封装
alert
函数const alert = (content: React.ReactNode) => { const { close } = modal(content, [<button onClick={() => close()}> OK</button>]); };
1
2
3封装
confirm
函数const confirm = (content: string, yes?: () => void, no?: () => void) => { const onYes = () => { /* todo */ close(); yes && yes(); }; const onNo = () => { close(); no && no(); }; const buttons = [<button onClick={onYes}>yes</button>, <button onClick={onNo}>no</button>]; const { close } = modal(content, buttons, no); };
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15导出部分
/* 扩展函数:alert,confirm 函数*/ export { alert, confirm, modal }; /* 核心组件*/ export default Dialog;
1
2
3
4