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

Posted

技术标签:

【中文标题】如何在 Redux 中显示一个执行异步操作的模态对话框?【英文标题】:How can I display a modal dialog in Redux that performs asynchronous actions? 【发布时间】:2016-06-08 00:17:33 【问题描述】:

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

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

当这个对话框提交时,我产生了疑问。

此组件如何根据分派的第一个动作分派正确的动作? 动作创建者应该处理这个逻辑吗? 我们可以在 reducer 中添加操作吗?

编辑:

为了更清楚:

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

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

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

【问题讨论】:

我认为在您的情况下,对话状态(隐藏/显示)是本地的。我会选择使用反应状态来管理显示/隐藏对话框。这样,“按第一个动作适当动作”的问题就没有了。 【参考方案1】:

将模式包装到一个连接的容器中,并在此处执行异步操作。通过这种方式,您可以同时访问 dispatch 以触发操作和 onClose 道具。要从 props 访问 dispatch,请不要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);

呈现模态并设置其可见性状态的App:

class App extends React.Component 
  state = 
    isModalOpen: false
  

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

  ...

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


【讨论】:

【参考方案2】:

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

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

【讨论】:

【参考方案3】:

更新:React 16.0 通过ReactDOM.createPortallink引入门户

更新:React 的下一个版本(Fiber:可能是 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 节点已在 &lt;body&gt; 内部翻译,而不是其正常的、深度嵌套的位置。

何时使用门户

门户对于显示应该放在现有 React 组件之上的元素特别有用:弹出窗口、下拉菜单、建议、热点

为什么要使用门户

不再有 z-index 问题:门户允许您渲染到 &lt;body&gt;。如果您想显示弹出窗口或下拉菜单,如果您不想与 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 树中,因此自定义此门户的行为非常简单,因为您的父级可以将道具传递给门户。如果您不使用门户,您通常必须出于z-index 的原因在 React 树的顶部呈现弹出窗口,并且通常必须考虑诸如“如何自定义我根据使用构建的通用 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-tether。 PopperJS 是一个库,它只计算一个元素的适当位置,而不直接接触 DOM,让用户选择他想要放置 DOM 节点的位置和时间,而 Tether 直接附加到正文。

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

【讨论】:

在您的示例中,如果您确认操作,则确认弹出窗口不会关闭(与单击“取消”相反) 将门户导入包含在代码 sn-p 中会很有帮助。 &lt;Portal&gt; 来自哪个库?我猜它是 react-portal,但很高兴知道。 @skypecakes 请将我的实现视为伪代码。我没有针对任何具体的库对其进行测试。我只是尝试在这里教授这个概念,而不是具体的实现。我习惯于 react-portal,上面的代码应该可以正常使用它,但它应该可以正常使用几乎任何类似的库。 react-gateway 太棒了!它支持服务器端渲染:) 我是初学者,因此很高兴能对这种方法进行一些解释。即使您真的在另一个地方渲染模态,在这种方法中,如果您应该渲染模态的特定实例,您也必须检查每个删除按钮。在 redux 方法中,我只有一个显示或未显示的模态实例。这不是性能问题吗?【参考方案4】:

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

调度一个动作来显示模态

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 存储的 &lt;ModalRoot&gt; 组件。它将监听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 从所连接的state.modal 中读取当前的modalTypemodalProps,并渲染对应的组件如DeletePostModalConfirmLogoutModal。每个模态都是一个组件!

编写特定的模态组件

这里没有一般规则。它们只是 React 组件,可以调度操作、从 store 状态读取一些内容,恰好是模态

例如,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 &lt;Modal&gt; 组件,该组件不知道特定模式的作用,但会处理它们的外观。

然后,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)

由您来决定&lt;Modal&gt; 可以在您的应用程序中接受的一组道具,但我想您可能有几种模式(例如信息模式、确认模式等),以及几种他们的风格。

点击外部或退出键时的辅助功能和隐藏

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

我建议您不要自己实现它,而不是给您建议来实现它。考虑到可访问性,很难做到正确。

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

您甚至可以将react-modal 包装在您自己的&lt;Modal&gt; 中,该&lt;Modal&gt; 接受特定于您的应用程序的道具并生成子按钮或其他内容。这一切都只是组件!

其他方法

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

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

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

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

【讨论】:

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

在这里可以找到来自 JS 社区的知名专家关于该主题的许多好的解决方案和有价值的评论。这可能表明问题并不像看起来那么微不足道。我认为这就是为什么它可能成为对该问题的怀疑和不确定性的根源。

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

我提出解决方案,旨在解决此问题。更详细的问题定义、src和示例可以在这里找到: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>)
// ...

【讨论】:

以上是关于如何在 Redux 中显示一个执行异步操作的模态对话框?的主要内容,如果未能解决你的问题,请参考以下文章

无法在 redux 操作中执行异步操作

如何编写单元测试以检查单击某个链接后是否显示模态

如何将 thunk 或回调函数传递给 redux 操作。在 redux store 中为模态和 toast 确认通知序列化函数

Flutter Redux 小吃店

Redux工具包 - Redux Toolkit的异步操作(发送网络请求)

将异步操作分派到 redux-saga 后如何捕获 redux 存储中的更改