varHarrie/varharrie.github.io

从开发体验探讨React模态窗口实现

varHarrie opened this issue · 0 comments

业务场景

最近的项目出现大量模态窗口的应用场景,大多数业务流程也非常相似:

  • 用户点击某个按钮,弹出模态窗口,并传入一些数据
  • 这些数据可能直接用于窗口内部展示,也可能用于异步请求获取详情
  • 窗口内部有一些表单,详情的数据作为表单的初始值
  • 用户点击取消,直接关闭模态窗口
  • 用户点击确定,对表单数据进行校验或处理,发送请求保存数据,最后关闭模态窗口
  • 为了复用,这个窗口可以同时用于新增和编辑
  • 同一个页面类似功能的窗口可能同时存在多个

举个实际的例子,在一个用户列表的页面,用户列表上方有个新建用户按钮,用户列表的每一项后面都有个编辑用户按钮

  • 点击新建用户按钮,弹出用户模态窗口,里面表单初始值都是空的
  • 点击编辑用户按钮,弹出用户模态窗口,里面表单初始值从后端获取

实现和改进

按照以往做法,结合一些组件库,把每个业务场景的模态窗口封装成一个组件,例如用户窗口UserModal,然后在页面组件中引用和渲染,传入visibleonConfirm等等。

class UserListView extends React.Component {
  state = {
    modalVisible: boolean
    modalUserId: ''
  }

  // 打开新建窗口
  onCreateModalOpen = () => {
  	this.setState({modalVisible: true, modalUserId: ''})
  }
  
  // 打开编辑窗口
  onEditModal = (id) => {
    this.setState({modalVisible: true, modalUserId: id})
  }
  
  // 窗口确定
  onModalConfirm = (data) => {
    // 根据data有没有id,来判断调用新建还是保存的接口
    // ...
    // 根据接口返回信息,若成功,关闭窗口,若失败,弹出提示,保留窗口
    // ...
  }

  // 窗口取消
  onModalCancel = () => {
    this.setState({ modalVisible: false })
  }
  
  render () {
    const { modalVisible, modalUserId } = this.state
    const users = [/** 从后端获取 */]
    
		<div>
      <header>
        <button onClick={this.onCreateModalOpen}>新建用户</button>
      </header>
      <ul>
        {users.map((user) => (
          <li key={user.id}>
        		<span>{user.name}</span>
            <button onClick={() => this.onEditModal(user.id)}>编辑用户</button>
          </li>
        ))}
      </ul>
      <UserModal
        visible={modalVisible}
        id={modalUserId}
        onConfirm={this.onModalConfirm}
        onCancel={this.onModalCancel}
       />
    </div>
  }
}

页面组件UserListView需要维护模态窗口的显示状态visible,还要维护各种事件onConfirmonCancel。这还是仅仅只考虑模态窗口的逻辑的情况,而且该模态窗口依赖的modalUserId也放在的页面组件中,某种程度上说,这不是父组件需要的状态。

假如这时候需求变动原因,需要在编辑用户按钮后面再加一个编辑用户角色的按钮,点击之后弹出用户角色模态窗口,用于展示和勾选用户角色。这时,我们又要维护一份新的模态窗口状态、模态窗口事件,包括对应的命名也都要重新考虑。

经过一番思考,如果我们把模态窗口的状态、事件放进这个模块窗口组件中,会怎么样?

将窗口相关的事件、状态,都交给窗口组件自己去维护,只接受onConfirm事件,再暴露showhide之类的方法,供父组件调用。

class UserListView extends React.Component {
  refModal = React.createRef()

  // 打开新建或编辑窗口
  onModalOpen = (id) => {
  	this.refModal.current.show(id)
  }
  
  // 窗口确定
  onModalConfirm = (data) => {
    // 返回true,阻止窗口关闭
  }
  
  render () {
    const users = [/** 从后端获取 */]
    
		<div>
      <header>
        <button onClick={() => this.onModalOpen()}>新建用户</button>
      </header>
      <ul>
        {users.map((user) => (
          <li key={user.id}>
        		<span>{user.name}</span>
            <button onClick={() => this.onModalOpen(user.id)}>编辑用户</button>
          </li>
        ))}
      </ul>
      <UserModal
        ref={this.refModal}
        onConfirm={this.onModalConfirm}
       />
    </div>
  }
}

这个时候,页面组件UserListView对于UserModal就只需要关注打开窗口并传参(输入),窗口确定事件(输出)。

如果还有其他窗口,也只需要多维护一份refonModalOpenonModalConfirm

进一步改进

后来,在使用antd的modal函数调用时,联想到这个问题,发现后可以有进一步改进的实现方案。

从开发体验上讲,以上的实现方案,还有一些需要改进的地方:

  • 对于onModalOpenonModalConfirm的维护依然觉得繁琐。每个窗口都需要维护两个函数,从逻辑上讲,它们应该是“连续”的,先有onModalOpen触发窗口展示,用户操作完后触发onModalConfirm,获得反馈信息;但是代码上却分隔开来了,不同窗口间的事件还可以混合在一起。
  • ref创建之后,还需要通过在组件上传入ref={this.refModal},已将它们绑定在一起。在结合TypeScript使用时,this.refModal.current有可能是null的(在mounted之前使用会是null),需要在使用前加判断或者断言。

结合函数调用的方法,最终使用方法变成了:

class UserListView extends React.Component {
  modal = createModal(UserModal)

  // 打开新建或编辑窗口
  onModalOpen = (id) => {
    this.modal.show(id, (data) => {
      // 窗口确定回调
      // 返回true,阻止窗口关闭
    })
  }
  
  render () {
    const users = [/** 从后端获取 */]
    
		<div>
      <header>
        <button onClick={() => this.onModalOpen()}>新建用户</button>
      </header>
      <ul>
        {users.map((user) => (
          <li key={user.id}>
        		<span>{user.name}</span>
            <button onClick={() => this.onModalOpen(user.id)}>编辑用户</button>
          </li>
        ))}
      </ul>
      <this.modal.Component /> {/** 渲染窗口组件 */}
    </div>
  }
}

结语

React的魅力就在于它给予了足够广阔的实现空间,你总能发现更优的解决方案,甚至是颠覆性的。

以上功能createModal具体实现请查看gist,此外,通过结合React Hooks,还有一个新的方案@react-hero/modal,可以将<this.modal.Component />都省略了,具体实现就不展开了。