Vue2中 Modal弹出层的花式用法

3,614 阅读3分钟

日常项目开发中,经常需要封装弹出层组件,不同的人会有不同的封装方法,弹出层组件因为涉及到 v-model ,各种事件和显示隐藏的状态管理、数据的流动和父子组件通信,可以设计的很简单,也可以设计的很复杂,管理不善会给后期维护带来诸多麻烦,这里我总结了一些弹出层的封装方法,比较了优劣,方便以后在处理类似问题上可以很清晰的选择合适的方案。

问题

  • 页面很多弹窗,代码已经上千行,细节太多,维护时容易出错
  • 弹窗的状态和事件应该在哪一层管理?
  • 如何封装弹出层组件?

「导致单文件组件代码过长的几个原因」

  1. 循环列表: 循环项的结构复杂,嵌套多
  2. 没有分组件: 各个独立区域没有封装都在一个页面
  3. Form表单: 表单项多(rules配置项)
  4. Table列表: 列数多,列数要格式化(列配置项)
  5. 弹出层: 弹出层内容结构复杂(多个弹出层未封装)
  6. 业务逻辑没有抽象和分层
  7. 工具类函数和静态数据配置

「如何避免单文件组件的代码长度」

  • 单文件组件代码长度控制在200行左右
  • 弹出层、表单、独立区域、列表都封装成组件 ./components
  • 工具类、配置类单独抽离出来 ./utils ./config.js
  • 相似的代码进行重构
  • 减少嵌套层级,使用 async await
  • 合理的使用Mixin 抽象公共逻辑

弹出层组件的花式用法

状态由父组件管理,只封装Modal 内容

<template>
<a-button type="primary" @click="visible1=true">
  打开Modal1
</a-button>
<a-modal v-model="visible1" @ok="visible1=false">
  <ModalContent />
</a-modal>
</template>

<script>
export default {
  data() {
    return {
      visible1false
    };
  },
}
</script>

这种将modal的状态和事件放在了父组件这一层,内容数据处理逻辑放到了组件 ModalContent 里面

这种用法的好处是方便控制Modal的状态,但缺点是Modal不能复用,只能复用内容,父组件中需要管理

modal 的状态和事件。

封装整个Modal,状态由Modal内部管理,watch监听 显示隐藏

<template>
<div>
  <a-button type="primary" @click="visible2=true">
    打开Modal2
  </a-button>
  <TestModal2 :show="visible2" @ok="visible2=false"/>
</div>
</template>

<script>
export default {
  data() {
    return {
      visible2false
    };
  },
}
</script>
// TestModal2
<template>
  <a-modal v-model="visible" @click="$emit('ok')">
    <p>{{ form.name || ''}}</p>
    <p>Some contents...</p>
    <p>Some contents...</p>
    <p>Some contents...</p>
    <p>Some contents...</p>
  </a-modal>
</template>
<script>
export default {
  props: {
    showfalse,
  },
  data(){
    return {
      visiblefalse,
      form: {
        name'张三'
      }
    }
  },
  watch: {
    show: {
      immediatetrue,
      handler(v){
        this.visible = v;
      }
    }
  }
}
</script>

子组件需要定义props、需要watch 父组件传递进来的 show 去更新visible

这里不直接使用props 做为modal的v-modal的值,是因为这样会导致数据传递混乱,父子组件都在同时管理show状态,同时也会产生bug,modal关闭后,父组件无法再次打开modal

Modal状态透传

<template>
<div>
  <a-button type="primary" @click="visible3=true">
    打开Modal3
  </a-button>
  <TestModal3 :visible="visible3" @ok="visible3=false"/>
</div>
</template>

<script>
export default {
  data() {
    return {
      visible3false
    };
  },
}
</script>
// TestModal3
<template>
  <a-modal v-bind="$attrs" v-on="$listeners">
    <p>{{ form.name || ''}}</p>
    <p>Some contents...</p>
    <p>Some contents...</p>
    <p>Some contents...</p>
    <p>Some contents...</p>
  </a-modal>
</template>
<script>
export default {
  data(){
    return {
      form: {
        name'张三'
      }
    }
  },
}
</script>

利用子组件根节点绑定 和listeners 可以属性和事件通过父组件透传到子组件,在父组件使用封装的Modal组件时,相当于直接使用 Modal 组件

但缺点也很明显,所有的属性和事件都被父组件接收了,如果要使用modal 组件定义之外的属性和事件,需要从 和listeners 获取,取值比较隐蔽,同时父组件要管理 modal的状态和事件

【推荐用法】使用Ref调用

<template>
<div>
  <a-button type="primary" @click="showModal4">
    打开Modal4
  </a-button>
  <TestModal4 ref="testModal4" @ok="onOk4"/>
</div>
</template>

<script>
export default {
  methods: {
    showModal4() {
      this.$refs.testModal4.show({name'李四'});
    },
    onOk4(data){
      console.log(data);
      this.$refs.testModal4.close();
    }
  },
}
</script>
// TestModal4
<template>
  <a-modal v-model="visible">
    <p>{{ form.name || ''}}</p>
    <p>Some contents...</p>
    <p>Some contents...</p>
    <p>Some contents...</p>
    <p>Some contents...</p>
    <a-button type="primary" @click="onOk">确认</a-button>
  </a-modal>
</template>
<script>
export default {
  data(){
    return {
      visiblefalse,
      form: {
        name'张三'
      }
    }
  },
  methods: {
    show(initData){
      this.visible = true;
      if(initData) {
        this.form = {...initData }
      }else{
        this.form = { name'张三' }
      }
    },
    close(){
      this.visible = false;
    },
    onOk(){
      this.$emit('ok'this.form);
    }
  }
}
</script>

这里父组件利用refs 来调用子组件的方法,Modal 所有状态和逻辑都由Modal内部管理,父组件使用Modal组件时,只需要通过 refs 来调用即可,不需要通过prop 传递,使用更加灵活,通过 调用 refs.modal.show() 可以传递相关数据给子组件进行初始化,子组件复用性也比较好,可以在任意其它业务中复用。

使用JS函数式直接调用

在基于 refs 封装的基础之上,可再封装成函数式方法调用,不用再引用组件,直接使用函数调用,类型 组件库中常见的Message.show({}), this.$dialog.show() 直接显示一个弹出层组件

import Vue from 'vue';
import EditUserModal from './index.vue';

const createInstance = (options = {}) => {
  const div = document.createElement('div');
  const el = document.createElement('div');
  div.appendChild(el);
  document.body.appendChild(div);

  const modalProps = { props: {}, ref'editUserModal' };

  let instance = null;
  const refName = modalProps.ref;
  const { data = {}, isEdit = false, onOk, onCancel, onDelete, onAfterclose, ...otherProps } = options;

  const disdroy = () => {
    if (!instance) return;
    instance.$destroy();
    instance = null;
    div.parentNode.removeChild(div);
  };

  const render = (props) => {
    const handleAfterClose = () => {
      onAfterclose && onAfterclose();
      disdroy();
    };

    const modalProps = {
      props: otherProps,
      on: {
        cancel() => {
          onCancel && onCancel();
        },
        ok(data) => {
          onOk && onOk(data);
        },
        delete() => {
          onDelete && onDelete();
        },
      },
    };

    return new Vue({
      el,
      render() {
        return <EditUserModal {...modalPropsref={refName} onAfterclose={handleAfterClose} />;
      },
    });
  };

  instance = render(otherProps);
  instance.$refs[refName].show(data, isEdit);

  return instance;
};

/**
 * 函数式调用 编辑用户弹出层
 * @param {*} options.onCancel
 * @param {*} options.onOk
 * @param {*} options.onDelete
 * @param {*} options.onAfterclose
 * @param {*} options.data
 * @param {*} options.isEdit
 * @example editUserModal.show({ onOk: (data) => {}, title: ’编辑用户'})
 */
const show = (options) => createInstance(options);

export const editUserModal = { show };

这里的封装逻辑基本也是通用的,在组件库开发中是非常有用的,可以简单开发者的调用方式,在平常的业务开发中,封装这样的方式有一定的成本,如果是跨业务十分通用的弹出层组件可以这样封装。

这种封装要点是要处理好弹出层关闭后的逻辑,必须销毁掉实例,并从真实节点中移除,另外也是处理好show方法的传参问题,是否可以覆盖到组件的api。

弹出层通用逻辑封装为Mixin

利用Vue2 的 mixin 可以实现类似继承的特性,很方便复用一些逻辑

/**
 * modal.mixin.js
 * 通用弹出层逻辑封装
 * 用于编辑或新增弹出层,也可用于普通展示弹出层
 */
import { cloneDeep } from 'lodash-es';

export default {
  props: {
    width: {
      typeNumber,
      default520,
    },
    title: {
      typeString,
      default'编辑',
    },
  },
  data() {
    return {
      // 是否为编辑
      isEditfalse,
      // 隐藏/显示
      visiblefalse,
      // 用于重置表单
      defaultForm: {},
      // 用于存储表单数据,双向绑定
      form: {},
    };
  },
  methods: {
    // ------------------------- 对外接口(可覆盖,可通过ref调用)
    // 显示
    show(data, isEdit = false) {
      this.isEdit = isEdit;
      this.visible = true;
      this.form = this.formatEditData(cloneDeep({ ...this.defaultForm, ...data }));
    },
    // 关闭
    close() {
      this.visible = false;
    },
    // 处理提交时的数据 [外部重写]
    formatSubmitData(data) {
      return data;
    },
    // ------------------------- 私有方法
    // 关闭之后 重置表单
    _handleAfterClose() {
      this.$refs.modalForm && this.$refs.modalForm.resetFields();
      this.$emit('afterclose');
    },
    // 取消/关闭
    _handleCancel() {
      this.$emit('cancel');
      this.close();
    },
    _handleDelete() {
      this.$emit('delete');
      this.close();
    },
    // 确认
    _handleOk() {
      // 兼容非表单弹出层
      if (!this.$refs.modalForm) {
        this.$emit('ok');
        this.close();
        return;
      }
      // 表单不自动关闭,需要手动关闭
      this.$refs.modalForm.validate((v) => {
        if (!v) return;

        // 提交之前做些处理
        const formData = cloneDeep(this.form);
        const data = this.formatSubmitData(formData);
        this.$emit('ok', data);
      });
    },
  },
};

这个 mixin 可以用于普通的弹窗和表单弹窗,内置了显示隐藏的处理逻辑,在实现层只需要关心业务逻辑,也可以覆盖相关方法来扩展相关Modal的逻辑。

小结

弹出层组件的合理封装有利于代码的后期维护,也方便在业务中复用相关逻辑,不同的场景有不同的封装方式,这里我推荐在业务开发中尽量使用 refs 的封装方式,逻辑更聚焦,与父组件耦合度低,复用性强,也能进一步抽象出mixin,进一步复用,另外在组件库开发中,尽量提供函数式调用的方式,可以简化使用者的调用方式。

Demo源码 Github(vite+vue2.x)