使用 React 製做一個模態框

使用 React 製做一個模態框

模態框是一個常見的組件,下面讓咱們使用 React 實現一個現代化的模態框吧。

組件設計

模態框想必你們都很熟悉,是工做中經常使用的組件,可讓咱們填寫或展現一些信息而沒必要打開一個新頁面。在開始編碼以前,咱們先來了解一個 React 模態框組件應該如何設計。
React 是一個狀態(數據)驅動的前端框架,一個模態框最重要的狀態就是打開和關閉,visible,當 visible 爲 true 時,模態框打開,反之亦然。css

因爲 React 所提倡的是一種聲明式,組件化的開發體驗,每一個組件都是 狀態 => 界面 的映射,因此,咱們把 visible 作爲模態框組件的一個 prop,經過傳入 prop 來控制
模態框的顯示和隱藏,同時該組件還接受一個 onClose 的 prop,用來關閉模態框。前端

<Modal visible={modalVisble} onClose={this.onModalClose} />

一個完整的模態框還須要標題和內容,所以,咱們還須要一個 header 的 prop 來傳遞模態框的 header,並把 Modal 組件的 children 做爲模態框的內容 content。最後,咱們的模態框 Modal 的調用方式是這樣的:node

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,請升級到最新版本的 react 來體驗。

實際上,一個完整的模態框組件還應該提供一些額外的配置來方便用戶使用,好比 header 和 content 的自定義樣式 headerClassName,contentClassName,定製操做按鈕的 footer,控制是否顯示關閉按鈕的 showClose 等等,
但這裏爲了保持教程的簡單,這些簡單的配置就不一一實現了,若是感興趣能夠自行練習。react

肯定了咱們的模態框的調用方式,如今咱們來總結一下完整的模態框應該具有那些特性:git

  1. 模態框組件應該掛載在 body 的第一層中,不要將模態框放置到父組件中,由於模態框放置到父組件中很容易受到其餘元素的干擾。
  2. 模態框顯示後,模態框背後的背景不能隨着鼠標滾輪而滾動。
  3. 點擊模態框的遮罩層後,應該關閉模態框。

基礎功能

上面分析玩模態框的功能後,讓咱們先開始實現一版最基礎的模態框。從 HTML 結構上來說,模態框組件分爲 overlay 遮罩層和 content 內容兩部分組成,其中 content 裏面還應該分爲 header, content, footer(這裏咱們沒有實現)三部分組成。
因此,模態框的最基本的結構以下github

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 元素是模態框組件的最外層的容器,因此咱們能夠經過控制 overlay 的顯示和隱藏(在上面的基礎結構中,經過 visible 屬性的值來給 overlay 添加或刪除類 'visible' 來控制 )實現模態框的打開關閉效果。在這裏咱們使用 display 實現控制 overlay 的顯示和隱藏(這樣在關閉時並無刪除該模態框,方便下次打開能夠保存內容),同時 overlay 仍是一個佔據整個窗口的半透明暗色背景,因此 overlay 的樣式應該爲chrome

.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 中元素的樣式,都很簡單,你們看一下就行了,能夠根據本身的組件規範修改這些樣式。瀏覽器

.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;
}

這樣,咱們最基礎的一版模態框就作好了,可是這個模態框是渲染在父組件中,那麼如何才能將這個模態框放到 body 下,做爲頂層元素呢?咱們可使用 Portal 這個 React 新提供的功能。前端框架

使用 portal 將模態框送到 body 中

Portal 是 React 16 中的新功能,就像它的名稱傳送門同樣,這個功能的做用就是將組件的 DOM 嗖的一下傳送到另一個地方,換句話說就是可讓你的組件渲染到其餘地方,而不只僅是在父組件中。從上面的描述中,咱們知道 Portal 是一個做用於 DOM 的功能,因此 Portal 就在 react-dom 這個包下,react-dom 提供了 createPortal 方法來建立 Portal,它的第一參數是 React 組件,第二個參數則是接收這個組件的 DOM 節點。app

回到咱們的模態框來,爲了方便的使用 Portal,咱們首先建立一個 ModalPortal 組件,該組件會首先使用 createElement 建立一個表示 overlay 的 div,並使用 appendChild 將此 div 插入到 body 的末尾中,而後在 render 中,使用 createPortal 將 ModalPortal 接受的全部子組件送入 overlay 這個 div 中。經過這種方式,咱們就把模態框組件變成 body 中的頂層元素了。

因爲 overlay 是手動建立的 DOM 元素,因此當 visible 發生變化時,咱們須要使用 DOM API 來控制 overlay 的顯示和隱藏,因此咱們在 ModalPortal 組件的 componetDidMount 和 componetDidUpdate 兩個生命週期中,根據 visible 的值來增刪 overlay 的 visible 類控制 overlay 的顯示/隱藏。

import React, { PureComponent } from 'react';
import { createPortal } from 'react-dom'
class ModalPortal extends PureComponent {
  constructor(props) {
    super(props);
    // createElement 是一個封裝後的函數,方便在建立元素時添加屬性
    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>
    )
  }
}

阻止背景滾動

當咱們完成上面的編碼以後,咱們的模態框就能夠實現顯示/隱藏,而且處於 body 的頂層,可是還有一個問題,那就是若是 body 內容太長出現滾動時,滾動鼠標就會發現,模態框後邊的背景也在滾動,這顯然不是咱們但願的結果。如何應對這種狀況呢?
解決辦法很巧妙,就是在模態框打開時,咱們給 body 添加一個 overflow: hidden 的樣式讓 body 不滾動,而後關閉模態框再去除這個屬性。經過這樣的方式,咱們就是實如今模態框打開時背景不滾動的功能了。
明白來原理以後就開始修改代碼了,咱們首先在 constructor 中使用一個變量 savedBodyOverflow 來保持 body 原始的 overflow 值,而後修改 checkIfVisble 使之能夠控制 overflow 類的增刪。

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;
    }
  }
}

點擊遮罩層關閉

點擊遮罩層關閉,這個應該很容易實現,給 overlay 添加一個點擊事件監聽就行了,可是要注意一點就是,當你點擊遮罩層中的 content 時,不該當關閉。咱們先回顧一下 DOM2 事件模型中的規定的事件流,事件從 window 開始,執行捕獲過程,而後到目標階段,接着執行冒泡過程,回到 window,這個流程就致使咱們若是點擊了 content,overlay 一樣也會觸發點擊事件(DOM 2 默認冒泡階段觸發事件)。針對這種狀況,咱們可使用事件中提供的 path 屬性,該屬性描述了事件冒泡過程當中從目標元素的 window 的一個路徑,因此經過 path 的第一個參數,咱們就能夠判斷這個 click 是哪一個元素觸發的了。

在咱們的 modal 中,若是要實現點擊遮罩層關閉,咱們能夠監聽 overlay 元素的點擊事件,而後經過 path 屬性判斷事件是不是 overlay 觸發的,是否應該關閉模態框。由於 overlay 的 div 使咱們本身生產的因此在 constructor 過程當中就能夠綁定事件了,注意在 componentWillUnMount 中要記得清除綁定,爲了關閉模態框,別忘記將 onClose 經過 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 關閉

上面咱們實現了點擊遮罩層關閉模態框,而後咱們應該實現按下 ESC 關閉這個功能。通點擊事件同樣,咱們只須要監聽 keydown 事件就能夠了,這一次不用考慮究竟是哪裏觸發的問題了,只要 overlay 監聽到 keydown 就關閉模態框。可是這裏也有一個小問題,就是 overlay 是 div,默認是監聽不到 keydown 事件的,對於這個問題,咱們能夠給 div 添加一個 tabIndex: 0 的屬性,經過指定 tabIndex,將 div 賦予 focusable 的能力,當模態框打開後,咱們手動調用 focus 將焦點放到 overlay 上,這樣就能監聽到鍵盤事件。

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();
    }
  };
  ...
}

消除滾動條致使的頁面抖動

在上面的防止遮罩層後面背景滾動是經過在 body 上設置 overflow: hidden 來防止滾動,可是若是 body 已經有了滾動條,那麼 overflow 屬性會形成滾動條消失。滾動條在 chrome 上爲 15px,打開和關閉模態框會使頁面不停地對這 15px 作處理,導則頁面抖動。爲了防止抖動,咱們能夠在滾動條消失後給 body 添加 15px 的右邊距,滾動條出現後在刪除右邊距,經過這樣的方法,頁面就不會發生抖動了。

由於各個瀏覽器的標準不一致,因此咱們應該想辦法計算出滾動條的寬度。爲了計算出滾動條的寬度,咱們可使用 innerWidth 和 offsetWidth 這兩個屬性。offsetWidth 是包含邊框的長度,理所固然的包含了滾動條的寬度,只須要使用 offsetWidth 減去 innerWidth,獲得的差值就是滾動條的寬度了。咱們能夠手動建立一個隱藏的有寬度的且有滾動條的元素,而後經過這個元素來獲取滾動條的寬度。

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';
  }
};

結語

咱們上面討論了作好一個模態框所須要考慮的技術,可是確定還有不完善和錯誤的地方,因此,若是錯誤的地方請給我提 issue 我會盡快修正。代碼

相關文章
相關標籤/搜索