詳解使用React制作一個(gè)模態(tài)框
模態(tài)框是一個(gè)常見(jiàn)的組件,下面讓我們使用 React 實(shí)現(xiàn)一個(gè)現(xiàn)代化的模態(tài)框吧。
組件設(shè)計(jì)
模態(tài)框想必大家都很熟悉,是工作中常用的組件,可以讓我們填寫(xiě)或展示一些信息而不必打開(kāi)一個(gè)新頁(yè)面。在開(kāi)始編碼之前,我們先來(lái)了解一個(gè) React 模態(tài)框組件應(yīng)該如何設(shè)計(jì)。
React 是一個(gè)狀態(tài)(數(shù)據(jù))驅(qū)動(dòng)的前端框架,一個(gè)模態(tài)框最重要的狀態(tài)就是打開(kāi)和關(guān)閉,visible,當(dāng) visible 為 true 時(shí),模態(tài)框打開(kāi),反之亦然。
由于 React 所提倡的是一種聲明式,組件化的開(kāi)發(fā)體驗(yàn),每個(gè)組件都是 狀態(tài) => 界面 的映射,所以,我們把 visible 做為模態(tài)框組件的一個(gè) prop,通過(guò)傳入 prop 來(lái)控制
模態(tài)框的顯示和隱藏,同時(shí)該組件還接受一個(gè) onClose 的 prop,用來(lái)關(guān)閉模態(tài)框。
<Modal visible={modalVisble} onClose={this.onModalClose} />
一個(gè)完整的模態(tài)框還需要標(biāo)題和內(nèi)容,因此,我們還需要一個(gè) header 的 prop 來(lái)傳遞模態(tài)框的 header,并把 Modal 組件的 children 作為模態(tài)框的內(nèi)容 content。最后,我們的模態(tài)框 Modal 的調(diào)用方式是這樣的:
import React, { useEffect, useState } from 'react';
import Modal from './components/modal';
function App() {
const [modalVisible, setModalVisible] = useState(true);
const openModal = function() { setModalVisible(true) };
const closeModal = function() { setModalVisible(false) };
return (
<>
<button onClick={openModal}>Click</div>
<Modal visible={modalVisible} onClose={closeModal} header="Create a modal">
<p>This is my content</p>
</Modal>
</>
);
}
export default App;
這里使用了 hooks,請(qǐng)升級(jí)到最新版本的 react 來(lái)體驗(yàn)。
實(shí)際上,一個(gè)完整的模態(tài)框組件還應(yīng)該提供一些額外的配置來(lái)方便用戶(hù)使用,比如 header 和 content 的自定義樣式 headerClassName,contentClassName,定制操作按鈕的 footer,控制是否顯示關(guān)閉按鈕的 showClose 等等,
但這里為了保持教程的簡(jiǎn)單,這些簡(jiǎn)單的配置就不一一實(shí)現(xiàn)了,如果感興趣可以自行練習(xí)。
確定了我們的模態(tài)框的調(diào)用方式,現(xiàn)在我們來(lái)總結(jié)一下完整的模態(tài)框應(yīng)該具備那些特性:
- 模態(tài)框組件應(yīng)該掛載在 body 的第一層中,不要將模態(tài)框放置到父組件中,因?yàn)槟B(tài)框放置到父組件中很容易受到其他元素的干擾。
- 模態(tài)框顯示后,模態(tài)框背后的背景不能隨著鼠標(biāo)滾輪而滾動(dòng)。
- 點(diǎn)擊模態(tài)框的遮罩層后,應(yīng)該關(guān)閉模態(tài)框。
基礎(chǔ)功能
上面分析玩模態(tài)框的功能后,讓我們先開(kāi)始實(shí)現(xiàn)一版最基礎(chǔ)的模態(tài)框。從 HTML 結(jié)構(gòu)上來(lái)講,模態(tài)框組件分為 overlay 遮罩層和 content 內(nèi)容兩部分組成,其中 content 里面還應(yīng)該分為 header, content, footer(這里我們沒(méi)有實(shí)現(xiàn))三部分組成。
所以,模態(tài)框的最基本的結(jié)構(gòu)如下
import React, { PureComponent } from 'react';
class Modal extends PureComponent {
render() {
const { visible, onClose, header, children } = this.props;
return (
<div className={`overlay ${visible ? 'visible' : ''}`}>
<div className="content">
<div className="header">
{header}
<button onClick={onClose}>Close</button>
</div>
<div className="content">{children}</div>
</div>
</div>
);
}
}
由于 overlay 元素是模態(tài)框組件的最外層的容器,所以我們可以通過(guò)控制 overlay 的顯示和隱藏(在上面的基礎(chǔ)結(jié)構(gòu)中,通過(guò) visible 屬性的值來(lái)給 overlay 添加或刪除類(lèi) 'visible' 來(lái)控制 )實(shí)現(xiàn)模態(tài)框的打開(kāi)關(guān)閉效果。在這里我們使用 display 實(shí)現(xiàn)控制 overlay 的顯示和隱藏(這樣在關(guān)閉時(shí)并沒(méi)有刪除該模態(tài)框,方便下次打開(kāi)可以保存內(nèi)容),同時(shí) overlay 還是一個(gè)占據(jù)整個(gè)窗口的半透明暗色背景,所以 overlay 的樣式應(yīng)該為
.overlay {
display: none;
position: fixed;
top: 0;
right: 0;
bottom: 0;
right: 0;
background: rgba(0, 0, 0, 0.3);
visibility: hidden;
}
.overlay.visible {
display: block;
visibility: visible;
}
然后就是 content 中元素的樣式,都很簡(jiǎn)單,大家看一下就好了,可以根據(jù)自己的組件規(guī)范修改這些樣式。
.container {
margin: 80px auto;
width: 80%;
min-height: 800px;
background: #fff;
border-radius: 4px;
}
.header {
display: flex;
justify-content: space-between;
padding: 16px;
font-size: 24px;
border-bottom: 1px solid #d3d3d3;
}
.body {
padding: 16px;
}
.closeBtn {
outline: none;
border: none;
appearance: none;
font-size: 18px;
color: #d5d5d5;
cursor: pointer;
}
這樣,我們最基礎(chǔ)的一版模態(tài)框就做好了,但是這個(gè)模態(tài)框是渲染在父組件中,那么如何才能將這個(gè)模態(tài)框放到 body 下,作為頂層元素呢?我們可以使用 Portal 這個(gè) React 新提供的功能。
使用 portal 將模態(tài)框送到 body 中
Portal 是 React 16 中的新功能,就像它的名稱(chēng)傳送門(mén)一樣,這個(gè)功能的作用就是將組件的 DOM 嗖的一下傳送到另外一個(gè)地方,換句話說(shuō)就是可以讓你的組件渲染到其他地方,而不僅僅是在父組件中。從上面的描述中,我們知道 Portal 是一個(gè)作用于 DOM 的功能,所以 Portal 就在 react-dom 這個(gè)包下,react-dom 提供了 createPortal 方法來(lái)創(chuàng)建 Portal,它的第一參數(shù)是 React 組件,第二個(gè)參數(shù)則是接收這個(gè)組件的 DOM 節(jié)點(diǎn)。
回到我們的模態(tài)框來(lái),為了方便的使用 Portal,我們首先創(chuàng)建一個(gè) ModalPortal 組件,該組件會(huì)首先使用 createElement 創(chuàng)建一個(gè)表示 overlay 的 div,并使用 appendChild 將此 div 插入到 body 的末尾中,然后在 render 中,使用 createPortal 將 ModalPortal 接受的所有子組件送入 overlay 這個(gè) div 中。通過(guò)這種方式,我們就把模態(tài)框組件變成 body 中的頂層元素了。
由于 overlay 是手動(dòng)創(chuàng)建的 DOM 元素,所以當(dāng) visible 發(fā)生變化時(shí),我們需要使用 DOM API 來(lái)控制 overlay 的顯示和隱藏,所以我們?cè)?ModalPortal 組件的 componetDidMount 和 componetDidUpdate 兩個(gè)生命周期中,根據(jù) visible 的值來(lái)增刪 overlay 的 visible 類(lèi)控制 overlay 的顯示/隱藏。
import React, { PureComponent } from 'react';
import { createPortal } from 'react-dom'
class ModalPortal extends PureComponent {
constructor(props) {
super(props);
// createElement 是一個(gè)封裝后的函數(shù),方便在創(chuàng)建元素時(shí)添加屬性
this.node = createElement('div', {
class: `modal-${random()} ${props.className}`,
});
document.body.appendChild(this.node);
}
componentDidMount() {
this.checkIfVisible();
}
componentDidUpdate(prevProps) {
if (prevProps.visible !== this.props.visible) {
this.checkIfVisible();
}
}
// 控制 overlay 的顯示隱藏
checkIfVisible = () => {
const { visible } = this.props;
if (visible) {
this.node.classList.add(styles.visible);
} else {
this.node.classList.remove(styles.visible);
}
};
render() {
const { children } = this.props;
return createPortal(children, this.node);
}
}
class Modal extends PureComponent {
...
render() {
return (
<ModalPortal className='overlay' overlay={overlay}>
...
</ModalPortal>
)
}
}
阻止背景滾動(dòng)
當(dāng)我們完成上面的編碼之后,我們的模態(tài)框就可以實(shí)現(xiàn)顯示/隱藏,并且處于 body 的頂層,但是還有一個(gè)問(wèn)題,那就是如果 body 內(nèi)容太長(zhǎng)出現(xiàn)滾動(dòng)時(shí),滾動(dòng)鼠標(biāo)就會(huì)發(fā)現(xiàn),模態(tài)框后邊的背景也在滾動(dòng),這顯然不是我們希望的結(jié)果。如何應(yīng)對(duì)這種情況呢?
解決辦法很巧妙,就是在模態(tài)框打開(kāi)時(shí),我們給 body 添加一個(gè) overflow: hidden 的樣式讓 body 不滾動(dòng),然后關(guān)閉模態(tài)框再去除這個(gè)屬性。通過(guò)這樣的方式,我們就是實(shí)現(xiàn)在模態(tài)框打開(kāi)時(shí)背景不滾動(dòng)的功能了。
明白來(lái)原理之后就開(kāi)始修改代碼了,我們首先在 constructor 中使用一個(gè)變量 savedBodyOverflow 來(lái)保持 body 原始的 overflow 值,然后修改 checkIfVisble 使之可以控制 overflow 類(lèi)的增刪。
class ModalPortal extends PureComponent {
constructor(props) {
...
this.savedBodyOverflow = document.body.style.overflow;
}
...
checkIfVisible = () => {
const { visible } = this.props;
if (visible) {
this.node.classList.add(styles.visible);
document.body.style.overflow = 'hidden';
} else {
this.node.classList.remove(styles.visible);
document.body.style.overflow = this.saveBodyOverflow;
}
}
}
點(diǎn)擊遮罩層關(guān)閉
點(diǎn)擊遮罩層關(guān)閉,這個(gè)應(yīng)該很容易實(shí)現(xiàn),給 overlay 添加一個(gè)點(diǎn)擊事件監(jiān)聽(tīng)就好了,但是要注意一點(diǎn)就是,當(dāng)你點(diǎn)擊遮罩層中的 content 時(shí),不應(yīng)當(dāng)關(guān)閉。我們先回顧一下 DOM2 事件模型中的規(guī)定的事件流,事件從 window 開(kāi)始,執(zhí)行捕獲過(guò)程,然后到目標(biāo)階段,接著執(zhí)行冒泡過(guò)程,回到 window,這個(gè)流程就導(dǎo)致我們?nèi)绻c(diǎn)擊了 content,overlay 同樣也會(huì)觸發(fā)點(diǎn)擊事件(DOM 2 默認(rèn)冒泡階段觸發(fā)事件)。針對(duì)這種情況,我們可以使用事件中提供的 path 屬性,該屬性描述了事件冒泡過(guò)程中從目標(biāo)元素的 window 的一個(gè)路徑,所以通過(guò) path 的第一個(gè)參數(shù),我們就可以判斷這個(gè) click 是哪個(gè)元素觸發(fā)的了。
在我們的 modal 中,如果要實(shí)現(xiàn)點(diǎn)擊遮罩層關(guān)閉,我們可以監(jiān)聽(tīng) overlay 元素的點(diǎn)擊事件,然后通過(guò) path 屬性判斷事件是否是 overlay 觸發(fā)的,是否應(yīng)該關(guān)閉模態(tài)框。因?yàn)?overlay 的 div 使我們自己生產(chǎn)的所以在 constructor 過(guò)程中就可以綁定事件了,注意在 componentWillUnMount 中要記得清除綁定,為了關(guān)閉模態(tài)框,別忘記將 onClose 通過(guò) props 傳遞給 ModalPortal 組件。
class ModalPortal extends PureComponent {
constructor(props) {
...
this.node.addEventListener('click', this.handleClick);
}
componentWillUnmount() {
this.node.removeEventListener('click', this.handleClick);
}
handleClick = e => {
const { closeModal } = this.props;
const target = e.path[0];
if (target === this.node) {
onClose();
}
};
...
}
按下 ESC 關(guān)閉
上面我們實(shí)現(xiàn)了點(diǎn)擊遮罩層關(guān)閉模態(tài)框,然后我們應(yīng)該實(shí)現(xiàn)按下 ESC 關(guān)閉這個(gè)功能。通點(diǎn)擊事件一樣,我們只需要監(jiān)聽(tīng) keydown 事件就可以了,這一次不用考慮到底是哪里觸發(fā)的問(wèn)題了,只要 overlay 監(jiān)聽(tīng)到 keydown 就關(guān)閉模態(tài)框。但是這里也有一個(gè)小問(wèn)題,就是 overlay 是 div,默認(rèn)是監(jiān)聽(tīng)不到 keydown 事件的,對(duì)于這個(gè)問(wèn)題,我們可以給 div 添加一個(gè) tabIndex: 0 的屬性,通過(guò)指定 tabIndex,將 div 賦予 focusable 的能力,當(dāng)模態(tài)框打開(kāi)后,我們手動(dòng)調(diào)用 focus 將焦點(diǎn)放到 overlay 上,這樣就能監(jiān)聽(tīng)到鍵盤(pán)事件。
const ESC_KEY = 27;
class ModalPortal extends PureComponent {
constructor(props) {
...
this.node = createElement('div', {
class: `modal-${random()} ${props.className}`,
tabIndex: 0,
});
this.node.addEventListener('keydown', this.handleKeyDown);
}
componentWillUnmount() {
...
this.node.removeEventListener('keydown', this.handleKeyDown);
}
checkIfVisible = () => {
const { visible } = this.props;
if (visible) {
...
this.node.focus();
} else {
...
}
};
handleKeyDown = e => {
const { closeModal } = this.props;
if (e.keyCode === ESC_KEY) {
closeModal();
}
};
...
}
消除滾動(dòng)條導(dǎo)致的頁(yè)面抖動(dòng)
在上面的防止遮罩層后面背景滾動(dòng)是通過(guò)在 body 上設(shè)置 overflow: hidden 來(lái)防止?jié)L動(dòng),但是如果 body 已經(jīng)有了滾動(dòng)條,那么 overflow 屬性會(huì)造成滾動(dòng)條消失。滾動(dòng)條在 chrome 上為 15px,打開(kāi)和關(guān)閉模態(tài)框會(huì)使頁(yè)面不停地對(duì)這 15px 做處理,導(dǎo)則頁(yè)面抖動(dòng)。為了防止抖動(dòng),我們可以在滾動(dòng)條消失后給 body 添加 15px 的右邊距,滾動(dòng)條出現(xiàn)后在刪除右邊距,通過(guò)這樣的方法,頁(yè)面就不會(huì)發(fā)生抖動(dòng)了。
因?yàn)楦鱾€(gè)瀏覽器的標(biāo)準(zhǔn)不一致,所以我們應(yīng)該想辦法計(jì)算出滾動(dòng)條的寬度。為了計(jì)算出滾動(dòng)條的寬度,我們可以使用 innerWidth 和 offsetWidth 這兩個(gè)屬性。offsetWidth 是包含邊框的長(zhǎng)度,理所當(dāng)然的包含了滾動(dòng)條的寬度,只需要使用 offsetWidth 減去 innerWidth,得到的差值就是滾動(dòng)條的寬度了。我們可以手動(dòng)創(chuàng)建一個(gè)隱藏的有寬度的且有滾動(dòng)條的元素,然后通過(guò)這個(gè)元素來(lái)獲取滾動(dòng)條的寬度。
const calcScrollBarWidth = function() {
const testNode = createElement('div', {
style: 'visibility: hidden; position: absolute; width: 100px; height: 100px; z-index: -999; overflow: scroll;'
});
document.body.appendChild(testNode);
const scrollBarWidth = testNode.offsetWidth - testNode.clientWidth;
document.body.removeChild(testNode);
return scrollBarWidth;
};
const preventJitter = function() {
const scrollBarWidth = calcScrollBarWidth();
if (parseInt(document.documentElement.style.marginRight) === scrollBarWidth) {
document.documentElement.style.marginRight = 0;
} else {
document.documentElement.style.marginRight = scrollBarWidth + 'px';
}
};
結(jié)語(yǔ)
我們上面討論了做好一個(gè)模態(tài)框所需要考慮的技術(shù),但是肯定還有不完善和錯(cuò)誤的地方,所以,如果錯(cuò)誤的地方請(qǐng)給我提 issue 我會(huì)盡快修正。代碼
以上就是本文的全部?jī)?nèi)容,希望對(duì)大家的學(xué)習(xí)有所幫助,也希望大家多多支持腳本之家。
相關(guān)文章
React中的useState如何改變值不重新渲染的問(wèn)題
這篇文章主要介紹了React中的useState如何改變值不重新渲染的問(wèn)題,具有很好的參考價(jià)值,希望對(duì)大家有所幫助,如有錯(cuò)誤或未考慮完全的地方,望不吝賜教2024-03-03
React Native提供自動(dòng)完成的下拉菜單的方法示例
這篇文章主要為大家介紹了React Native提供自動(dòng)完成的下拉菜單的方法示例詳解,有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進(jìn)步,早日升職加薪2022-10-10
react-native?父函數(shù)組件調(diào)用類(lèi)子組件的方法(實(shí)例詳解)
這篇文章主要介紹了react-native?父函數(shù)組件調(diào)用類(lèi)子組件的方法,通過(guò)詳細(xì)步驟介紹了React 函數(shù)式組件之父組件調(diào)用子組件的方法,代碼簡(jiǎn)單易懂,對(duì)大家的學(xué)習(xí)或工作具有一定的參考借鑒價(jià)值,需要的朋友可以參考下2022-09-09
詳解React-Native全球化多語(yǔ)言切換工具庫(kù)react-native-i18n
這篇文章主要介紹了詳解React-Native全球化語(yǔ)言切換工具庫(kù)react-native-i18n,小編覺(jué)得挺不錯(cuò)的,現(xiàn)在分享給大家,也給大家做個(gè)參考。一起跟隨小編過(guò)來(lái)看看吧2017-11-11
解決webpack -p壓縮打包react報(bào)語(yǔ)法錯(cuò)誤的方法
這篇文章主要給大家介紹了關(guān)于解決webpack -p壓縮打包react報(bào)語(yǔ)法錯(cuò)誤的方法,文中通過(guò)示例代碼介紹的非常詳細(xì),對(duì)大家具有一定的參考學(xué)習(xí)價(jià)值,需要的朋友們下面來(lái)一起看看吧。2017-07-07

