javascript - react modal




如何在Redux中顯示執行異步操作的模式對話框? (4)

JS社區的知名專家就此主題提供了許多優秀的解決方案和有價值的評論。 它可能是一個指標,它可能看起來不是那麼微不足道的問題。 我認為這就是為什麼它可能成為問題的疑點和不確定性的原因。

這裡的基本問題是,在React中,您只能將組件安裝到其父組件,這並不總是所需的行為。 但是如何解決這個問題呢?

我提出了解決這個問題的解決方案。 更詳細的問題定義,src和示例可以在這裡找到: https://github.com/fckt/react-layer-stack#rationalehttps://github.com/fckt/react-layer-stack#rationale

合理

react / react-dom 有兩個基本的假設/想法:

  • 每個UI都是自然分層的。 這就是為什麼我們有相互包裝的 components 的想法
  • react-dom 默認情況下(物理上)將子組件安裝到其父DOM節點

問題是有時第二個屬性不是你想要的。 有時您希望將組件安裝到不同的物理DOM節點中,同時保持父節點和子節點之間的邏輯連接。

Canonical示例是類似Tooltip的組件:在開發過程的某個階段,您可能會發現需要為 UI element 添加一些描述:它將在固定層中呈現並且應該知道它的坐標(這是 UI element 坐標或鼠標) coords)同時它需要信息是否需要立即顯示,其內容和來自父組件的一些上下文。 此示例顯示有時邏輯層次結構與物理DOM層次結構不匹配。

請查看 https://github.com/fckt/react-layer-stack/blob/master/README.md#real-world-usage-example 以查看回答您問題的具體示例:

import { Layer, LayerContext } from 'react-layer-stack'
// ... for each `object` in array of `objects`
  const modalId = 'DeleteObjectConfirmation' + objects[rowIndex].id
  return (
    <Cell {...props}>
        // the layer definition. The content will show up in the LayerStackMountPoint when `show(modalId)` be fired in LayerContext
        <Layer use={[objects[rowIndex], rowIndex]} id={modalId}> {({
            hideMe, // alias for `hide(modalId)`
            index } // useful to know to set zIndex, for example
            , e) => // access to the arguments (click event data in this example)
          <Modal onClick={ hideMe } zIndex={(index + 1) * 1000}>
            <ConfirmationDialog
              title={ 'Delete' }
              message={ "You're about to delete to " + '"' + objects[rowIndex].name + '"' }
              confirmButton={ <Button type="primary">DELETE</Button> }
              onConfirm={ this.handleDeleteObject.bind(this, objects[rowIndex].name, hideMe) } // hide after confirmation
              close={ hideMe } />
          </Modal> }
        </Layer>

        // this is the toggle for Layer with `id === modalId` can be defined everywhere in the components tree
        <LayerContext id={ modalId }> {({showMe}) => // showMe is alias for `show(modalId)`
          <div style={styles.iconOverlay} onClick={ (e) => showMe(e) }> // additional arguments can be passed (like event)
            <Icon type="trash" />
          </div> }
        </LayerContext>
    </Cell>)
// ...

https://code.i-harness.com

我正在構建一個需要在某些情況下顯示確認對話框的應用程序。

假設我想刪除一些內容,然後我將調度一個類似 deleteSomething(id) 的動作,這樣一些reducer會捕獲該事件並填充對話框reducer以顯示它。

當這個對話提交時,我懷疑了。

  • 該組件如何根據調度的第一個操作調度正確的操作?
  • 動作創建者應該處理這個邏輯嗎?
  • 我們可以在減速機內添加動作嗎?

編輯:

使它更清楚:

deleteThingA(id) => show dialog with Questions => deleteThingARemotely(id)

createThingB(id) => Show dialog with Questions => createThingBRemotely(id)

所以我正在嘗試重用對話框組件。 顯示/隱藏對話框不是問題,因為這可以在reducer中輕鬆完成。 我想要指定的是如何根據左側開始流動的動作從右側調度動作。


在我看來,最低限度的實現有兩個要求。 跟踪模態是否打開的狀態,以及將模式呈現在標準反應樹之外的門戶。

下面的ModalContainer組件實現了這些要求以及模態和触發器的相應渲染函數,它們負責執行回調以打開模態。

import React from 'react';
import PropTypes from 'prop-types';
import Portal from 'react-portal';

class ModalContainer extends React.Component {
  state = {
    isOpen: false,
  };

  openModal = () => {
    this.setState(() => ({ isOpen: true }));
  }

  closeModal = () => {
    this.setState(() => ({ isOpen: false }));
  }

  renderModal() {
    return (
      this.props.renderModal({
        isOpen: this.state.isOpen,
        closeModal: this.closeModal,
      })
    );
  }

  renderTrigger() {
     return (
       this.props.renderTrigger({
         openModal: this.openModal
       })
     )
  }

  render() {
    return (
      <React.Fragment>
        <Portal>
          {this.renderModal()}
        </Portal>
        {this.renderTrigger()}
      </React.Fragment>
    );
  }
}

ModalContainer.propTypes = {
  renderModal: PropTypes.func.isRequired,
  renderTrigger: PropTypes.func.isRequired,
};

export default ModalContainer;

這是一個簡單的用例......

import React from 'react';
import Modal from 'react-modal';
import Fade from 'components/Animations/Fade';
import ModalContainer from 'components/ModalContainer';

const SimpleModal = ({ isOpen, closeModal }) => (
  <Fade visible={isOpen}> // example use case with animation components
    <Modal>
      <Button onClick={closeModal}>
        close modal
      </Button>
    </Modal>
  </Fade>
);

const SimpleModalButton = ({ openModal }) => (
  <button onClick={openModal}>
    open modal
  </button>
);

const SimpleButtonWithModal = () => (
   <ModalContainer
     renderModal={props => <SimpleModal {...props} />}
     renderTrigger={props => <SimpleModalButton {...props} />}
   />
);

export default SimpleButtonWithModal;

我使用渲染函數,因為我想將狀態管理和样板邏輯與渲染的模態和触發器組件的實現隔離開來。 這允許渲染的組件成為您想要的任何組件。 在您的情況下,我認為模態組件可以是一個連接組件,它接收一個調度異步操作的回調函數。

如果你需要從觸發器組件向模態組件發送動態道具,這有希望不經常發生,我建議用一個容器組件包裝ModalContainer,該組件管理自己狀態的動態道具並增強原始的渲染方法,如所以。

import React from 'react'
import partialRight from 'lodash/partialRight';
import ModalContainer from 'components/ModalContainer';

class ErrorModalContainer extends React.Component {
  state = { message: '' }

  onError = (message, callback) => {
    this.setState(
      () => ({ message }),
      () => callback && callback()
    );
  }

  renderModal = (props) => (
    this.props.renderModal({
       ...props,
       message: this.state.message,
    })
  )

  renderTrigger = (props) => (
    this.props.renderTrigger({
      openModal: partialRight(this.onError, props.openModal)
    })
  )

  render() {
    return (
      <ModalContainer
        renderModal={this.renderModal}
        renderTrigger={this.renderTrigger}
      />
    )
  }
}

ErrorModalContainer.propTypes = (
  ModalContainer.propTypes
);

export default ErrorModalContainer;

我建議的方法有點冗長但我發現它可以很好地擴展到復雜的應用程序。 當您想要顯示模態時,觸發描述您想要查看 哪個 模態的動作:

調度操作以顯示模態

this.props.dispatch({
  type: 'SHOW_MODAL',
  modalType: 'DELETE_POST',
  modalProps: {
    postId: 42
  }
})

(字符串當然可以是常量;為簡單起見,我使用內聯字符串。)

編寫Reducer來管理模態

然後確保你有一個只接受這些值的reducer:

const initialState = {
  modalType: null,
  modalProps: {}
}

function modal(state = initialState, action) {
  switch (action.type) {
    case 'SHOW_MODAL':
      return {
        modalType: action.modalType,
        modalProps: action.modalProps
      }
    case 'HIDE_MODAL':
      return initialState
    default:
      return state
  }
}

/* .... */

const rootReducer = combineReducers({
  modal,
  /* other reducers */
})

大! 現在,當您分派操作時, state.modal 將更新以包含有關當前可見模式窗口的信息。

編寫根模式組件

在組件層次結構的根目錄中,添加連接到Redux存儲的 <ModalRoot> 組件。 它將偵聽 state.modal 並顯示一個適當的模態組件,從 state.modal.modalProps 轉發props。

// These are regular React components we will write soon
import DeletePostModal from './DeletePostModal'
import ConfirmLogoutModal from './ConfirmLogoutModal'

const MODAL_COMPONENTS = {
  'DELETE_POST': DeletePostModal,
  'CONFIRM_LOGOUT': ConfirmLogoutModal,
  /* other modals */
}

const ModalRoot = ({ modalType, modalProps }) => {
  if (!modalType) {
    return <span /> // after React v15 you can return null here
  }

  const SpecificModal = MODAL_COMPONENTS[modalType]
  return <SpecificModal {...modalProps} />
}

export default connect(
  state => state.modal
)(ModalRoot)

我們在這做了什麼? ModalRootstate.modal 連接的 ModalRoot 讀取當前的 modalTypemodalProps ,並呈現相應的組件,如 DeletePostModalConfirmLogoutModal 。 每個模態都是一個組件!

編寫特定的模態組件

這裡沒有一般規則。 它們只是React組件,可以調度操作,從存儲狀態讀取內容, 恰好是模態

例如, DeletePostModal 可能如下所示:

import { deletePost, hideModal } from '../actions'

const DeletePostModal = ({ post, dispatch }) => (
  <div>
    <p>Delete post {post.name}?</p>
    <button onClick={() => {
      dispatch(deletePost(post.id)).then(() => {
        dispatch(hideModal())
      })
    }}>
      Yes
    </button>
    <button onClick={() => dispatch(hideModal())}>
      Nope
    </button>
  </div>
)

export default connect(
  (state, ownProps) => ({
    post: state.postsById[ownProps.postId]
  })
)(DeletePostModal)

DeletePostModal 連接到商店,因此它可以顯示帖子標題並像任何連接的組件一樣工作:它可以在需要隱藏自身時調度動作,包括 hideModal

提取演示組件

為每個“特定”模態復制粘貼相同的佈局邏輯會很尷尬。 但你有組件,對嗎? 因此,您可以提取一個 presentational <Modal> 組件,該組件不知道特定模態的作用,但會處理它們的外觀。

然後,特定模態(如 DeletePostModal 可以使用它進行渲染:

import { deletePost, hideModal } from '../actions'
import Modal from './Modal'

const DeletePostModal = ({ post, dispatch }) => (
  <Modal
    dangerText={`Delete post ${post.name}?`}
    onDangerClick={() =>
      dispatch(deletePost(post.id)).then(() => {
        dispatch(hideModal())
      })
    })
  />
)

export default connect(
  (state, ownProps) => ({
    post: state.postsById[ownProps.postId]
  })
)(DeletePostModal)

你可以在你的應用程序中提出一套 <Modal> 可以接受的道具,但我想你可能有幾種模態(例如信息模態,確認模態等),以及它們的幾種風格。

可訪問性和隱藏單擊外部或退出鍵

關於模態的最後一個重要部分是,當用戶點擊外部或按Escape時,我們通常希望隱藏它們。

我建議你不要自己實現它,而不是給你實現這個的建議。 考慮可訪問性很難做到正確。

相反,我建議你使用一個 可訪問 的現成模態組件,如 react-modal 。 它是完全可定制的,您可以在其中放置任何您想要的內容,但它正確處理可訪問性,以便盲人仍然可以使用您的模態。

您甚至可以在您自己的 <Modal> 中包裝 react-modal ,它接受特定於您的應用程序的道具並生成子按鈕或其他內容。 這一切都只是組件!

其他方法

有不止一種方法可以做到這一點。

有些人不喜歡這種方法的冗長,並且更喜歡使用 <Modal> 組件,他們可以使用稱為“門戶”的技術 在組件內部 進行渲染。 Portals允許您在您的內部渲染組件,而 實際上 它將在DOM中的預定位置渲染,這對於模態非常方便。

事實上,我之前鏈接的 react-modal 已經在內部進行,所以從技術上來說,你甚至不需要從頂部渲染它。 我仍然覺得將我要顯示的模態與顯示它的組件分離很好,但你也可以直接從組件中使用 react-modal ,並跳過我上面寫的大部分內容。

我鼓勵您考慮這兩種方法,嘗試使用它們,並選擇最適合您的應用和團隊的方法。


更新 :React 16.0通過 ReactDOM.createPortal link 引入了門戶

更新 :React的下一個版本(光纖:可能是16或17)將包含一個創建門戶的方法: ReactDOM.unstable_createPortal() link

使用門戶網站

Dan Abramov回答第一部分很好,但涉及很多樣板。 正如他所說,你也可以使用門戶網站。 我會對這個想法進行一些擴展。

門戶網站的優點是彈出窗口和按鈕保持非常靠近React樹,使用props進行非常簡單的父/子通信:您可以輕鬆處理與門戶網站的異步操作,或讓父級自定義門戶網站。

什麼是門戶網站?

門戶允許您直接在 document.body 呈現一個深層嵌套在React樹中的元素。

例如,您可以將以下React樹渲染到正文中:

<div className="layout">
  <div className="outside-portal">
    <Portal>
      <div className="inside-portal">
        PortalContent
      </div>
    </Portal>
  </div>
</div>

你得到輸出:

<body>
  <div class="layout">
    <div class="outside-portal">
    </div>
  </div>
  <div class="inside-portal">
    PortalContent
  </div>
</body>

inside-portal 節點已在 <body> 內翻譯,而不是其正常的深層嵌套位置。

何時使用門戶網站

門戶網站特別有助於顯示應該在現有React組件之上的元素:彈出窗口,下拉列表,建議,熱點

為什麼要使用門戶網站

沒有z-index問題 :門戶網站允許您渲染到 <body> 。 如果你想顯示一個彈出窗口或下拉列表,如果你不想打擊z-index問題,這是一個非常好的主意。 門戶元素以mount順序添加到 document.body 中,這意味著除非您使用 z-index ,否則默認行為是按安裝順序將門戶堆疊在彼此之上。 在實踐中,這意味著您可以安全地從另一個彈出窗口中打開彈出窗口,並確保第二個彈出窗口將顯示在第一個彈出窗口的頂部,而不必考慮 z-index

在實踐中

最簡單:使用本地React狀態: 如果您認為,對於簡單的刪除確認彈出窗口,不值得使用Redux樣板,那麼您可以使用門戶網站,它可以大大簡化您的代碼。 對於這樣一個用例,交互是非常本地化的,實際上是一個實現細節,你真的關心熱重載,時間旅行,動作日誌記錄以及Redux帶給你的所有好處嗎? 就個人而言,在這種情況下我不會使用當地的州。 代碼變得如此簡單:

class DeleteButton extends React.Component {
  static propTypes = {
    onDelete: PropTypes.func.isRequired,
  };

  state = { confirmationPopup: false };

  open = () => {
    this.setState({ confirmationPopup: true });
  };

  close = () => {
    this.setState({ confirmationPopup: false });
  };

  render() {
    return (
      <div className="delete-button">
        <div onClick={() => this.open()}>Delete</div>
        {this.state.confirmationPopup && (
          <Portal>
            <DeleteConfirmationPopup
              onCancel={() => this.close()}
              onConfirm={() => {
                this.close();
                this.props.onDelete();
              }}
            />
          </Portal>
        )}
      </div>
    );
  }
}

簡單:您仍然可以使用Redux狀態 :如果您真的想要,您仍然可以使用 connect 來選擇是否顯示 DeleteConfirmationPopup 。 由於門戶網站仍然深深嵌套在您的React樹中,因此您可以非常簡單地自定義此門戶網站的行為,因為您的父級可以將道具傳遞給門戶網站。 如果你不使用門戶網站,你通常必須在你的React樹頂部渲染彈出窗口以獲得 z-index 原因,並且通常必須考慮諸如“我如何根據用途自定義我構建的泛型DeleteConfirmationPopup”之類的內容案件”。 而且通常你會發現這個問題的解決方案相當苛刻,比如調度包含嵌套確認/取消操作的動作,翻譯包密鑰,甚至更糟糕的是渲染函數(或其他不可序列化的東西)。 您不必使用門戶網站,並且可以只傳遞常規道具,因為 DeleteConfirmationPopup 只是 DeleteButton 的子項

結論

門戶對於簡化代碼非常有用。 我不能沒有他們了。

請注意,門戶實現還可以幫助您使用其他有用的功能,例如:

  • 無障礙
  • 用於關閉門戶的Espace快捷方式
  • 處理外部點擊(關閉門戶網站與否)
  • 處理鏈接點擊(關閉門戶網站或不關閉)
  • React Context在門戶樹中可用

react-portal react-modal 適用於應該是全屏的彈出窗口,模態和疊加層,通常位於屏幕中間。

react-tether 對於大多數React開發人員來說都是未知的,但它是你可以找到的最有用的工具之一。 Tether 允許您創建門戶,但相對於給定目標,將自動定位門戶。 這非常適用於工具提示,下拉菜單,熱點,幫助框...如果您對位置 absolute / relativez-index 有任何問題,或者您的下拉菜單位於視口之外,Tether將為您解決所有這些問題。

例如,您可以輕鬆實現入職熱點,一旦點擊即可擴展為工具提示:

真實的生產代碼在這裡 不能更簡單:)

<MenuHotspots.contacts>
  <ContactButton/>
</MenuHotspots.contacts>

編輯 :剛剛發現 react-gateway ,允許將門戶網站渲染到您選擇的節點(不一定是正文)

編輯 :似乎 react-popper 可以成為 react-popper 一個不錯的選擇。 PopperJS 是一個只計算元素的適當​​位置的庫,不直接觸及DOM,讓用戶選擇他想要放置DOM節點的位置和時間,而Tether直接附加到正文。

編輯 :還有 react-slot-fill 這很有意思,它可以幫助解決類似的問題,允許將一個元素渲染到一個保留的元素槽,你可以在樹中放置你想要的任何地方





react-redux