如何在 Redux 中显示执行异步操作的模式对话框?

IT技术 javascript modal-dialog redux react-redux
2021-01-29 06:46:01

我正在构建一个在某些情况下需要显示确认对话框的应用程序。

假设我想删除一些东西,然后我将发送一个动作,deleteSomething(id)这样一些减速器将捕获该事件并填充对话框减速器以显示它。

当这个对话框提交时,我的怀疑就来了。

  • 这个组件如何根据调度的第一个动作调度正确的动作?
  • 动作创建者应该处理这个逻辑吗?
  • 我们可以在减速器中添加动作吗?

编辑:

更清楚地说明:

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

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

所以我试图重用对话框组件。显示/隐藏对话框不是问题,因为这可以在减速器中轻松完成。我试图指定的是如何根据在左侧启动流程的操作从右侧分派操作。

5个回答

我建议的方法有点冗长,但我发现它可以很好地扩展到复杂的应用程序中。当你想显示一个模态时,触发一个描述你想看到哪个模态的动作

调度一个动作来显示模态

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

(字符串当然可以是常量;为了简单起见,我使用内联字符串。)

编写一个 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将更新以包含有关当前可见模态窗口的信息。

编写根模态组件

在组件层次结构的根部,添加一个<ModalRoot>连接到 Redux 存储组件。它将侦听state.modal并显示适当的模态组件,从state.modal.modalProps.

// 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)

我们在这里做了什么?ModalRoot读取它所连接的电流modalTypemodalProps来源state.modal,并呈现相应的组件,例如DeletePostModalConfirmLogoutModal每个模态都是一个组件!

编写特定的模态组件

这里没有一般规则。它们只是 React 组件,可以分派动作,从 store 状态中读取一些东西,并且恰好是 modals

例如,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在必要时可以隐藏自身。

提取展示组件

为每个“特定”模态复制粘贴相同的布局逻辑会很尴尬。但是你有组件,对吧?因此,您可以提取一个不知道特定模态做什么但处理它们的外观表示 <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>可以在您的应用程序中接受的props,但我想您可能有多种模态(例如信息模态、确认模态等),以及它们的多种样式。

可访问性和隐藏点击外部或退出键

关于模态的最后一个重要部分是,通常我们希望在用户单击外部或按下 Escape 时隐藏它们。

我建议您不要自己实施,而不是就实施此问题给您建议。考虑到可访问性,很难做到正确。

相反,我建议您使用可访问的现成模态组件,例如react-modal. 它是完全可定制的,你可以在里面放任何你想要的东西,但它正确地处理了可访问性,这样盲人仍然可以使用你的模式。

您甚至react-modal可以自己包装<Modal>接受特定于您的应用程序的props并生成子按钮或其他内容。一切都只是组件!

其他方法

有不止一种方法可以做到。

有些人不喜欢这种方法的冗长,而更喜欢有一个<Modal>组件,他们可以使用称为“门户”的技术直接在其组件内部呈现Portals 允许你在你的内部渲染一个组件,而实际上它会在 DOM 中的预定位置渲染,这对于模态来说非常方便。

事实上,react-modal我之前链接到的已经在内部这样做了,所以从技术上讲,你甚至不需要从顶部渲染它。我仍然觉得将我想显示的模态与显示它的组件分离是很好的,但是您也可以react-modal直接从组件中使用,并跳过我上面写的大部分内容。

我鼓励您考虑这两种方法,对它们进行试验,然后选择最适合您的应用程序和团队的方法。

我建议的一件事是让减速器维护一个可以推送和弹出的模态列表。尽管听起来很愚蠢,但我一直遇到设计师/产品类型希望我从模态打开模态的情况,并且允许用户“返回”很好。
2021-03-19 06:46:01
根据我的经验,我会说:如果模态与本地组件相关(就像删除确认模态与删除按钮相关),则使用门户更简单,否则使用 redux 操作。同意@Kyle 应该能够从模态打开模态。默认情况下,它也适用于门户,因为添加它们是为了记录正文,因此门户可以很好地堆叠在一起(直到您用 z-index 将所有内容都搞砸了:p)
2021-03-23 06:46:01
@DanAbramov,您的解决方案很棒,但我有一个小问题。不严重。我在项目中使用 Material-ui,关闭模态时它只是关闭它,而不是“播放”淡出动画。可能需要做一些延迟?或者将每个模态作为一个列表保存在 ModalRoot 中?建议?
2021-03-27 06:46:01
有时我想在模态关闭后调用某些函数(例如调用具有模态内输入字段值的函数)。我会将这些功能传递modalProps给操作。这违反了保持状态可序列化的规则。我怎样才能克服这个问题?
2021-03-27 06:46:01
是的,当然,这是 Redux 易于构建的那种东西,因为您可以将状态更改为数组。就我个人而言,我曾与设计师合作过,他们恰恰相反,希望模态是独占的,所以我写的方法解决了意外嵌套的问题。但是,是的,你可以同时拥有它。
2021-04-01 06:46:01

更新:React 16.0 通过链接引入了门户ReactDOM.createPortal

更新:React 的下一个版本(Fiber:可能是 16 或 17)将包括一种创建门户的方法:ReactDOM.unstable_createPortal() 链接


使用门户

Dan Abramov 回答第一部分很好,但涉及很多样板。正如他所说,您也可以使用门户网站。我将扩展一下这个想法。

门户的优点是弹出窗口和按钮与 React 树保持非常接近,使用 props 进行非常简单的父/子通信:您可以轻松处理门户的异步操作,或者让父级自定义门户。

什么是门户?

门户允许您直接在document.body深深嵌套在 React 树中的元素内进行渲染

这个想法是,例如,您将以下 React 树渲染到 body 中:

<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 问题作斗争,这是一个非常好的主意。门户元素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 树中,因此自定义此门户的行为非常简单,因为您的父级可以将 props 传递给门户。如果你不使用门户,你通常必须在你的 React 树的顶部渲染你的弹出窗口z-index原因,并且通常必须考虑诸如“如何自定义我根据用例构建的通用 DeleteConfirmationPopup”之类的事情。通常你会发现这个问题的解决方案非常笨拙,比如调度一个包含嵌套确认/取消操作的操作、一个翻译包键,或者更糟糕的是,一个渲染函数(或其他不可序列化的东西)。你不必用传送门做这件事,可以只传递常规props,因为DeleteConfirmationPopup它只是DeleteButton

结论

门户对于简化代码非常有用。我不能没有他们了。

请注意,门户实现还可以帮助您使用其他有用的功能,例如:

  • 无障碍
  • 关闭门户的 Espace 快捷方式
  • 处理外部点击(关闭门户与否)
  • 处理链接点击(是否关闭门户)
  • React Context 在门户树中可用

react-portalreact-modal非常适合应该全屏显示的弹出窗口、模式和覆盖,通常位于屏幕中间。

大多数 React 开发人员都不知道react-tether,但它是您可以在那里找到的最有用的工具之一。Tether允许您创建门户,但会相对于给定目标自动定位门户。这非常适用于工具提示、下拉菜单、热点、帮助框……如果您对位置absolute/relativez-index或下拉菜单有任何问题,Tether 将为您解决所有问题。

例如,您可以轻松实现入职热点,点击后会扩展为工具提示:

入职热点

真正的生产代码在这里。不能更简单:)

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

编辑:刚刚发现react网关,它允许将门户渲染到您选择的节点(不一定是主体)

编辑:似乎react-popper是 react-tether 的一个不错的替代品。PopperJS是一个库,它只为一个元素计算一个合适的位置,不直接接触 DOM,让用户选择他想要放置 DOM 节点的位置和时间,而 Tether 直接附加到主体。

编辑:还有react-slot-fill这很有趣,可以通过允许将元素渲染到保留的元素槽来帮助解决类似的问题,该槽可以放置在树中的任何位置

react-gateway 太棒了!它支持服务器端渲染:)
2021-03-22 06:46:01
在代码片段中包含您的门户导入会很有帮助。来自哪个库<Portal>我猜它是 react-portal,但很高兴知道肯定。
2021-03-23 06:46:01
@skypecakes 请将我的实现视为伪代码。我没有针对任何具体的库对其进行测试。我只是尝试在这里教这个概念,而不是具体的实现。我习惯于 react-portal,上面的代码应该可以很好地使用它,但它应该可以很好地与几乎任何类似的库一起使用。
2021-03-26 06:46:01
在您的示例代码段中,如果您确认操作(与单击取消时相反),则确认弹出窗口将不会关闭
2021-03-26 06:46:01
我是个初学者,所以很高兴能对这种方法进行一些解释。即使你真的在另一个地方渲染模态,在这种方法中,如果你应该渲染模态的特定实例,你将不得不检查每个删除按钮。在 redux 方法中,我只有一个显示或不显示的模式实例。这不是性能问题吗?
2021-04-07 06:46:01

在这里可以找到很多来自 JS 社区的知名专家关于该主题的很好的解决方案和有value的评论。这可能表明这不是看起来那么简单的问题。我认为这就是为什么它可能成为这个问题的怀疑和不确定性的来源。

这里的基本问题是,在 React 中,您只允许将组件挂载到其父级,这并不总是所需的行为。但是如何解决这个问题呢?

我提出了解决方案,旨在解决这个问题。更详细的问题定义、源代码和示例可以在这里找到:https : //github.com/fckt/react-layer-stack#rationale

基本原理

react/react-dom带有 2 个基本假设/想法:

  • 每个 UI 都是自然分层的。这就是为什么我们有components相互包裹的想法
  • react-dom 默认情况下(物理上)将子组件挂载到其父 DOM 节点

问题是有时第二个属性不是您想要的。有时你想将你的组件挂载到不同的物理 DOM 节点,同时保持父子节点之间的逻辑连接。

规范示例是类似工具提示的组件:在开发过程的某个时刻,您可能会发现您需要为您添加一些描述UI element:它将在固定层中呈现并且应该知道其坐标(即UI element坐标或鼠标坐标)和同时它需要信息是否需要立即显示,它的内容和来自父组件的一些上下文。此示例显示有时逻辑层次结构与物理 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>)
// ...

在我看来,最低限度的实现有两个要求。跟踪模态是否打开的状态,以及在标准react树之外渲染模态的门户。

下面的 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;

我使用渲染函数,因为我想将状态管理和样板逻辑与渲染的模态和触发器组件的实现隔离开来。这允许渲染的组件成为你想要的任何东西。在您的情况下,我认为模态组件可能是一个连接组件,它接收一个调度异步操作的回调函数。

如果您需要从触发器组件向模态组件发送动态props(希望不会经常发生),我建议将 ModalContainer 与一个容器组件包装起来,该容器组件在其自己的状态下管理动态props并增强原始渲染方法,例如所以。

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;

将模态包装到一个连接的容器中并在此处执行异步操作。通过这种方式,您可以同时访问调度以触发操作和 onClose props。为了达到dispatch从props,千万不能传递mapDispatchToProps功能connect

class ModalContainer extends React.Component {
  handleDelete = () => {
    const { dispatch, onClose } = this.props;
    dispatch({type: 'DELETE_POST'});

    someAsyncOperation().then(() => {
      dispatch({type: 'DELETE_POST_SUCCESS'});
      onClose();
    })
  }

  render() {
    const { onClose } = this.props;
    return <Modal onClose={onClose} onSubmit={this.handleDelete} />
  }
}

export default connect(/* no map dispatch to props here! */)(ModalContainer);

呈现模态并设置其可见性状态的应用程序:

class App extends React.Component {
  state = {
    isModalOpen: false
  }

  handleModalClose = () => this.setState({ isModalOpen: false });

  ...

  render(){
    return (
      ...
      <ModalContainer onClose={this.handleModalClose} />  
      ...
    )
  }

}