基于Element UI实现全局Dialog

1,290 阅读3分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 10 月更文挑战」的第1天,点击查看活动详情

前言

在使用 element-ui dialog 组件的时候,需要通过一个布尔值去控制 dialog 的显示与隐藏,那么这里就会有一些问题存在,例如弹框关闭后,需要清空数据,否则下次显示弹框时,会保留上次的数据;在例如如果页面中拥有多个弹框,使用布尔值控制就显得繁琐。为了解决这个问题,我们就需要设计一个更加便捷的弹框使用方式。

分析

想要一个更加的便捷的使用方式,我们想到了 message, confirm 等组件的使用方式,以 message 为例,其使用方式时这样的

this.$message('内容', '提示', options)

这种调用方式就十分简单,那么我们就可以把 dialog 改成这种命令式的方式去调用,从而提升开发体验。

根据这个核心的需要我们可以很快得出我们需要做的事情:

graph TD
命令式Dialog调用 --> 需要提供全局调用API
命令式Dialog调用 --> 需要提供dialog模板内容的渲染
命令式Dialog调用 --> 需要提供dialog参数的传递
需要提供全局调用API --> 显示dialog
需要提供全局调用API --> 关闭dialog
显示dialog --> this.$dialog
关闭dialog --> this.$closeDialog
需要提供dialog模板内容的渲染 --> 传递一个组件
需要提供dialog参数的传递 --> 传递一个对象
传递一个组件 --> 根元素是el-dialog
传递一个组件 --> 根元素是其它任何元素

Vue.extend()

要使用全局组件,就需要使用Vue.extendVue.extend 属于 Vue2 的全局 API,是vue的一个构造器,继承自vue,他创建一个“子类”,在实际业务开发中我们很少使用,因为相比常用的 Vue.component 写法使用 extend 步骤要更加繁琐一些。但是在一些独立组件开发场景中,Vue.extend + $mount 这对组合是我们需要去关注的。

全局组件

// main.vue
<template>
  <div>
    <p>{{firstName}} {{lastName}} == {{alias}}</p>
  </div>
</template>

<script>
export default {
  data() {
     return {
       firstName: 'Jone',
       lastName: 'Brus',
       alias: 'JB'
     }
  },
};
</script>
import main from './main.js'
var Profile = Vue.extend(main)
// 创建 Profile 实例,并挂载到一个元素上。
new Profile().$mount('#container')

全局数据仓库

在一些特殊场景使用 Vue.extend,可以当作一个全局数据仓库,例如 element-uitable组件就用到了这个。

image.png

那么使用这个好处是什么呢?

例如有一个页面,这个页面有很多个组件,如果进行采用传统方式,多个父子组件的传参,使用 propsemit,就会显得很麻烦,那么就可以使用 Vue.extend 来简化操作,减少代码量,把Vue.extend当作一个全局数据仓库来使用,你可以配合watch,computed,method,mixins等方法

🙋🌰

mounted 中模拟请求,给 a,b,c,d 4个属性赋值,a 组件需要 a,b,c 3个属性,b 组件需要 a,b,c,d 4个属性,a 组件有 changeAchangeB 事件分别用来改变 ab 的值,b 组件有 changeBchangeC 事件分别用来改变 cd 的值,但是需要分别加上 ab的值。

未使用 Vue.extend (93 line)

<template>
  <div>
    <a-component :a="a" :b="b" :c="c" @changeA="changeA" @changeB="changeB" />
    <b-component :a="a" :b="b" :c="c" :d="d" @changeC="changeC" @changeD="changeD" />
  </div>
</template>
<script>
export default {
  data() {
    return {
      a: 0,
      b: 0,
      c: 0,
      d: 0,
    };
  },
  mounted() {
    // 模拟请求
    setTimeout(() => {
      this.a = 10;
      this.b = 20;
      this.c = 30;
      this.d = 40;
    }, 100);
  },
  methods: {
    changeA(a) {
      this.a = a;
    },
    changeB(b) {
      this.b = b;
    },
    changeC(c) {
      this.c = c;
    },
    changeD(d) {
      this.d = d;
    },
  },
};
</script>

a-component

<template>
  <div>
    <p @click="changeA">a: {{ a }}</p>
    <p @click="changeB">b: {{ b }}</p>
    <p>c: {{ c }}</p>
  </div>
</template>
<script>
export default {
  props: {
    a: Number,
    b: Number,
    c: Number,
  },
  data() {},
  methods: {
    changeA() {
      this.$emit('changeA', 100);
    },
    changeB() {
      this.$emit('changeB', 200);
    },
  },
};
</script>

b-component

<template>
  <div>
    <p>a: {{ a }}</p>
    <p>b: {{ b }}</p>
    <p @click="changeC">c: {{ c }}</p>
    <p @click="changeD">d: {{ d }}</p>
  </div>
</template>
<script>
export default {
  props: {
    a: Number,
    b: Number,
    c: Number,
    d: Number,
  },
  data() {},
  methods: {
    changeC() {
      this.$emit('changeC', 300 + this.a);
    },
    changeD() {
      this.$emit('changeD', 400 + this.b);
    },
  },
};
</script>

使用 Vue.extend (87 line)

// watcher.js
import Vue from 'vue';
export default Vue.extend({
  data() {
    return {
      a: 0,
      b: 0,
      c: 0,
      d: 0,
    };
  },
  computed: {},
  watch: {},
  methods: {
    changeC() {
      this.c = 300 + this.a;
    },
    changeD() {
      this.d = 400 + this.b;
    },
  },
});

// store.js
import Store from './watcher';

let store = {};

if (!store.demo) {
  store.demo = new Store();
}

export default store;
<template>
  <div>
    <a-component />
    <b-component />
  </div>
</template>
<script>
import store from './store'
export default {
  data() {
    return {};
  },
  mounted() {
    // 模拟请求
    setTimeout(() => {
      store.demo.a = 10;
      store.demo.b = 20;
      store.demo.c = 30;
      store.demo.d = 40;
    }, 100);
  },
  methods: {},
};
</script>

a-component

<template>
  <div>
    <p @click="changeA">a: {{ demo.a }}</p>
    <p @click="changeB">b: {{ demo.b }}</p>
    <p>c: {{ demo.c }}</p>
  </div>
</template>
<script>
import store from './store'
export default {
  computed: {
    demo() {
      return store.demo
    }
  },
  methods: {
    changeA() {
      store.demo.a = 100;
      // this.demo.a = 100;
    },
    changeB() {
      store.demo.b = 200;
    },
  },
};
</script>

b-component

<template>
  <div>
    <p>a: {{ demo.a }}</p>
    <p>b: {{ demo.b }}</p>
    <p @click="demo.changeC">c: {{ demo.c }}</p>
    <p @click="demo.changeD">d: {{ demo.d }}</p>
  </div>
</template>
<script>
import store from './store'
export default {
  computed: {
    demo() {
      return store.demo
    }
  },
  methods: {},
};
</script>

显示dialog

在上面的分析中我们可以把调用dialog显示的API的用法先设计好,例如这样

this.$dialog(component, isAnyDialog=false)(props)
    .then(data => {})
    .catch(data => {})
参数类型说明
componentVueComponent弹框内容组件
isAnyDialogbooleanfalse: component的根组件是 el-dialog ; true: 让所有非 element-dialog 组件都可以变成弹框
props对象传递给弹框内容组件的参数,当 isAnyDialog 为 true 时,可以传递 el-dialog 组件的默认属性

关闭dialog

关闭弹框时调用全局关闭API

this.$closeDialog(state)
参数类型说明
stateboolean状态,true: this.$dialog执行then,否则执行catch

详细代码

我们首先创建一个 store,用来存放弹框组件和传递给弹框组件的 props;接着创建一个容器用来挂载这个弹框;如果不是任意弹窗则直接使用传过来的 component,如果是任意弹窗则 component 就变成我们封装的弹框组件 main.vue,并且把传入的属性和传入组件存储下来;然后在 main.vue 中使用 component 标签动态加载组件。

设计好 API 就可以实现具体的代码了

// watcher.js
import Vue from 'vue';
export default Vue.extend({
  data() {
    return {
      propsData: {},
      component: null,
    };
  },
  computed: {},
  watch: {},
  methods: {},
});

// store.js
import Store from './watcher';

let store = {};

if (!store.dialog) {
  store.dialog = new Store();
}

export default store;


// dialog.js
import Main from './main.vue';
import store from './store';
const openDialog = (Vue, component, isAnyDialog = false) => {
  let componentTmp = component;

  // 创建容器
  const div = document.createElement('div');
  // 承载弹框挂在的位置
  const el = document.createElement('div');
  div.appendChild(el);
  document.body.appendChild(div);

  if (!component) {
    throw new Error('component 组件不能为空');
  }
  // 如果是 true,则使用 Main 组件
  if (isAnyDialog) {
    component = Main;
  }
  // 创建组件类
  const ComponentConstructor = Vue.extend(component);

  return (propsData = {}, parent = undefined) => {
    if (propsData.component) {
      throw new Error('this.$dialog 的属性 component 属于内置属性,不能传递 component 属性,入需要传递组件,请使用其它参数名');
    }
    if (isAnyDialog) {
      // 用 dialog store 存储一下
      store.dialog.propsData = propsData;
      store.dialog.component = componentTmp;
    }
    let instance = new ComponentConstructor({
      // 使用 propsData 对象传递参数,子组件在 props 中可以接收到
      propsData,
      parent,
    }).$mount(el);

    // 销毁
    const destroyDialog = () => {
      if (instance && div.parentNode) {
        instance.$destroy();
        instance = null;
        div.parentNode && div.parentNode.removeChild(div);
      }
    };

    // visible控制
    if (instance['visible'] !== undefined) {
      // 监听 visible 属性的变化,如果 false 就销毁这个弹框组件,并且从 dom 中移除
      instance.$watch('visible', val => {
        !val && destroyDialog();
      });
      // 当组件触发式,显示弹框
      Vue.nextTick(() => (instance['visible'] = true));
    }

    return new Promise((resolve, reject) => {
      instance.$once('done', data => {
        destroyDialog();
        resolve(data);
      });
      instance.$once('cancel', data => {
        destroyDialog();
        reject(data);
      });
    });
  };
};

// index.js 代码
import openDialog from './dialog';

function install(Vue) {
  if (install.installed) {
    return;
  }
  install.installed = true;
  // 挂载
  Vue.prototype.$dialog = (comp, isAnyDialog) => openDialog(Vue, comp, isAnyDialog);

  // 添加 $closeDialog API
  Vue.mixin({
    methods: {
      $closeDialog(isDone = false, ...args) {
        this.$emit(isDone ? 'done' : 'cancel', ...args);
      },
    },
  });
}

// auto plugin install
let GlobalVue = null;
if (typeof window !== 'undefined') {
  GlobalVue = window.Vue;
} else if (typeof global !== 'undefined') {
  GlobalVue = global.vue;
}
if (GlobalVue) {
  GlobalVue.use({
    install,
  });
}

// export default
export default {
  install,
};

main.vue

<template>
  <el-dialog :visible.sync="visible" :before-close="handleClose" v-bind="store.dialog.propsData" :width="width">
    <component v-if="store.dialog.component" ref="component" v-bind:is="store.dialog.component" v-bind="store.dialog.propsData" title=""> </component>
  </el-dialog>
</template>

el-dialog 上就绑定了所有的 propsData,这样只要 propsData 里面有 对应的 el-dialog 属性就会自动绑定上去,component 也是如此。

结语

vue.extend 的使用非常强大,上文中只是说明了其中一部分作用,除此之外,还可以对某个组件继承,从而实现一些的新的功能,例如 el-table 使用配合 cell-dblclick 就可以在不使用 v-if 的情况下实现单元格的输入框和文本切换,又或者把一个组件变成一个高阶组件,有兴趣的小伙伴可以自行研究。