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

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

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

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

  • 这个组件如何根据第一个被分派的动作分派正确的动作?
  • 动作创建者应该处理这个逻辑吗?
  • 我们可以在减速机内部添加动作吗?

编辑:

更清楚地说:

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


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

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

142151 次浏览

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

调度一个动作来显示模态

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

编写根模态组件

在组件层次结构的根,添加一个连接到Redux存储的<ModalRoot>组件。它将监听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组件,可以分派动作,从存储状态而且刚好是情态动词中读取内容。

例如,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>可以接受在你的应用程序,但我可以想象,你可能有几种情态(例如信息情态,确认情态等),和几种风格为他们。

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

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

我不建议您如何实现它,而是建议您不要自己实现它。考虑到可访问性,很难做出正确的选择。

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

你甚至可以在你自己的<Modal>中包装react-modal,它接受特定于你的应用程序的道具,并生成子按钮或其他内容。这都只是组成部分!

其他方法

做这件事的方法不止一种。

有些人不喜欢这种方法的冗长,更喜欢有一个<Modal>组件,他们可以用一种叫做“门户”的技术来呈现就在它们的组件里。门户让你在你的内部渲染一个组件,而实际上将在DOM中预先确定的位置渲染,这对于模态非常方便。

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

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

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

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


使用门户网站

丹·阿布拉莫夫回答的第一部分很好,但涉及很多样板。正如他所说,您还可以使用传送门。我将在这个观点上展开一点。

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

什么是门户?

入口允许你在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指数问题了:门户允许你渲染到<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树中,因此自定义该门户的行为非常简单,因为您的父类可以将道具传递给门户。如果你不使用门户,你通常不得不在你的React树的顶部呈现你的弹出窗口,因为z-index的原因,并且通常不得不考虑像“我如何定制我根据用例构建的通用DeleteConfirmationPopup”这样的事情。通常你会发现这个问题的解决方案,比如调度一个包含嵌套确认/取消操作的操作,一个翻译包键,或者更糟糕的是,一个呈现函数(或其他不可序列化的东西)。对于传送门,你不必这样做,可以只传递常规的道具,因为DeleteConfirmationPopup只是DeleteButton的子元素

结论

门户对于简化代码非常有用。我再也离不开他们了。

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

  • 可访问性
  • Espace可以通过快捷方式关闭门户
  • 处理外部单击(是否关闭门户)
  • 处理链接单击(是否关闭门户)
  • React上下文在门户树中可用

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

react-tether对于大多数React开发人员来说是未知的,但它是你能找到的最有用的工具之一。系绳允许您创建门户,但将自动定位门户,相对于给定的目标。这是完美的工具提示,下拉菜单,热点,帮助框…如果你曾经有任何问题的位置absolute/relativez-index,或你的下拉出去你的视口,Tether将为你解决这一切。

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

Onboarding hotspot

这里是真正的生产代码。不能再简单了:)

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

编辑:刚刚发现了react-gateway,它允许将门户呈现到您选择的节点(不一定是body)

编辑:似乎react-popper可以作为react-tether的一个不错的替代品。PopperJS是一个库,它只计算元素的适当位置,而不直接触及DOM,让用户选择何时何地放置DOM节点,而Tether则直接追加到主体。

编辑:还有一个有趣的react-slot-fill,它可以帮助解决类似的问题,它允许将一个元素呈现到一个保留的元素槽中,你可以把它放在树中任何你想要的地方

在这里可以找到许多来自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>)
// ...

在我看来,最基本的实现有两个要求。跟踪模式是否打开的状态,以及在标准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;

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

如果你需要从触发器组件向模态组件发送动态道具(希望这种情况不会经常发生),我建议用容器组件包装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;

将模态包装到连接的容器中,并在这里执行异步操作。通过这种方式,您可以同时到达触发动作的分派和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} />
...
)
}


}