目录
  • 1、问题起源
  • 2、问题分析
  • 3、设计
    • 3.1 事件处理
    • 3.2 属性处理
    • 3.3 slots 的处理
  • 4.应用
    • 4.1组件调用
    • 4.2 使用 Composition API
  • 总结

    1、问题起源

    由于 Vue 基于组件化的设计,得益于这个思想,我们在 Vue 的项目中可以通过封装组件提高代码的复用性。根据我目前的使用心得,知道 Vue 拆分组件至少有两个优点:

    1、代码复用。

    2、代码拆分

    在基于 element-ui 开发的项目中,可能我们要写出一个类似的调度弹窗功能,很容易编写出以下代码:

    <template>
      <div>
        <el-dialog :visible.sync="cnMapVisible">我是中国地图的弹窗</el-dialog>
        <el-dialog :visible.sync="usaMapVisible">我是美国地图的弹窗</el-dialog>
        <el-dialog :visible.sync="ukMapVisible">我是英国地图的弹窗</el-dialog>
        <el-button @click="openChina">打开中国地图</el-button>
        <el-button @click="openUSA">打开美国地图</el-button>
        <el-button @click="openUK">打开英国地图</el-button>
      </div>
    </template>
    <script>
    export default {
      name: "View",
      data() {
        return {
          // 对百度地图和谷歌地图的一些业务处理代码 省略
          cnMapVisible: false,
          usaMapVisible: false,
          ukMapVisible: false,
        };
      },
      methods: {
        // 对百度地图和谷歌地图的一些业务处理代码 省略
        openChina() {},
        openUSA() {},
        openUK() {},
      },
    };
    </script>
    

    上述代码存在的问题非常多,首先当我们的弹窗越来越多的时候,我们会发现此时需要定义越来越多的变量去控制这个弹窗的显示或者隐藏。

    由于当我们的弹窗的内部还有业务逻辑需要处理,那么此时会有相当多的业务处理代码夹杂在一起(比如我调用中国地图我需要用高德地图或者百度地图,而调用美国、英国地图我只能用谷歌地图,这会使得两套业务逻辑分别位于一个文件,严重加大了业务的耦合度)

    我们按照分离业务,降低耦合度的原则,将代码按以下思路进行拆分:

    1、View.vue

    <template>
      <div>
        <china-map-dialog ref="china"></china-map-dialog>
        <usa-map-dialog ref="usa"></usa-map-dialog>
        <uk-map-dialog ref="uk"></uk-map-dialog>
        <el-button @click="openChina">打开中国地图</el-button>
        <el-button @click="openUSA">打开美国地图</el-button>
        <el-button @click="openUK">打开英国地图</el-button>
      </div>
    </template>
    <script>
    export default {
      name: "View",
      data() {
        return {
          /**
           将地图的业务全部抽离到对应的dialog里面去,View只存放调度业务代码
          */
        };
      },
      methods: {
        openChina() {
          this.$refs.china && this.$refs.china.openDialog();
        },
        openUSA() {
          this.$refs.usa && this.$refs.usa.openDialog();
        },
        openUK() {
          this.$refs.uk && this.$refs.uk.openDialog();
        },
      },
    };
    </script>
    

    2、ChinaMapDialog.vue

    <template>
      <div>
        <el-dialog :visible.sync="baiduMapVisible">我是中国地图的弹窗</el-dialog>
      </div>
    </template>
    <script>
    export default {
      name: "ChinaMapDialog",
      data() {
        return {
          // 对中国地图业务逻辑的封装处理 省略
          baiduMapVisible: false,
        };
      },
      methods: {
        // 对百度地图和谷歌地图的一些业务处理代码 省略
        openDialog() {
          this.baiduMapVisible = true;
        },
        closeDialog() {
          this.baiduMapVisible = false;
        },
      },
    };
    </script>
    

    3、由于此处仅仅展示伪代码,且和 ChinaMapDialog.vue 表达的含义一致, 为避免篇幅过长 USAMapDialog.vue 和 UKMapDialog.vue 已省略

    2、问题分析

    我们通过对这几个弹窗的分析,对刚才的设计进行抽象发现,这里面都有一个共同的部分,那就是我们对 dialog 的操作代码都是可以重用的代码,如果我们能够编写出一个抽象的弹窗,
    然后在恰当的时候将其和业务代码进行组合,就可以实现 1+1=2 的效果。

    3、设计

    由于 Vue 在不改变默认的 mixin 原则(默认也最好不要改变,可能会给后来的维护人员带来困惑)的情况下,如果在混入过程中发生了命名冲突,默认会将方法合并(数据对象在内部会进行递归合并,并在发生冲突时以组件数据优先),因此,mixin 无法改写本来的实现,而我们期望的是,父类提供一个比较抽象的实现,子类继承父类,若子类需要改表这个行为,子类可以重写父类的方法(多态的一种实现)。

    因此我们决定使用 vue-class-component 这个库,以类的形式来编写这个抽象弹窗。

    import Vue from "vue";
    import Component from "vue-class-component";
    @Component({
      name: "AbstractDialog",
    })
    export default class AbstractDialog extends Vue {}
    

    3.1 事件处理

    查看 Element-UI 的官方网站,我们发现 ElDialog 对外抛出 4 个事件,因此,我们需要预先接管这 4 个事件。
    因此需要在我们的抽象弹窗里预设这个 4 个事件的 handler(因为对于组件的行为的划分,而对于弹窗的处理本来就应该从属于弹窗本身,因此我并没有通过$listeners 去穿透外部调用时的监听方法)

    import Vue from "vue";
    import Component from "vue-class-component";
    @Component({
      name: "AbstractDialog",
    })
    export default class AbstractDialog extends Vue {
      open() {
        console.log("弹窗打开,我啥也不做");
      }
    
      close() {
        console.log("弹窗关闭,我啥也不做");
      }
    
      opened() {
        console.log("弹窗打开,我啥也不做");
      }
    
      closed() {
        console.log("弹窗关闭,我啥也不做");
      }
    }
    
    

    3.2 属性处理

    dialog 有很多属性,默认我们只需要关注的是 before-close 和 title 两者,因为这两个属性从职责上划分是从属于弹窗本身的行为,所以我们会在抽象弹窗里面处理开关和 title 的任务

    import Vue from "vue";
    import Component from "vue-class-component";
    @Component({
      name: "AbstractDialog",
    })
    export default class AbstractDialog extends Vue {
      visible = false;
    
      t = "";
    
      loading = false;
    
      //定义这个属性的目的是为了实现既可以外界通过传入属性改变dialog的属性,也支持组件内部预设dialog的属性
      attrs = {};
    
      get title() {
        return this.t;
      }
    
      setTitle(title) {
        this.t = title;
      }
    }
    
    

    3.3 slots 的处理

    查看 Element-UI 的官方网站,我们发现,ElDialog 有三个插槽,因此,我们需要接管这三个插槽

    1、对 header 的处理

    import Vue from "vue";
    import Component from "vue-class-component";
    @Component({
      name: "AbstractDialog",
    })
    class AbstractDialog extends Vue {
      /*
       构建弹窗的Header
       */
      _createHeader(h) {
        // 判断在调用的时候,外界是否传入header的插槽,若有的话,则以外界传入的插槽为准
        var slotHeader = this.$scopedSlots["header"] || this.$slots["header"];
        if (typeof slotHeader === "function") {
          return slotHeader();
        }
        //若用户没有传入插槽,则判断用户是否想改写Header
        var renderHeader = this.renderHeader;
        if (typeof renderHeader === "function") {
          return <div slot="header">{renderHeader(h)}</div>;
        }
        //如果都没有的话, 返回undefined,则dialog会使用我们预设好的title
      }
    }
    

    2、对 body 的处理

    import Vue from "vue";
    import Component from "vue-class-component";
    @Component({
      name: "AbstractDialog",
    })
    class AbstractDialog extends Vue {
      /**
       * 构建弹窗的Body部分
       */
      _createBody(h) {
        // 判断在调用的时候,外界是否传入default的插槽,若有的话,则以外界传入的插槽为准
        var slotBody = this.$scopedSlots["default"] || this.$slots["default"];
        if (typeof slotBody === "function") {
          return slotBody();
        }
        //若用户没有传入插槽,则判断用户想插入到body部分的内容
        var renderBody = this.renderBody;
        if (typeof renderBody === "function") {
          return renderBody(h);
        }
      }
    }
    

    3、对 footer 的处理

    由于 dialog 的 footer 经常都有一些相似的业务,因此,我们需要把这些重复率高的代码封装在此,若在某种时候,用户需要改写 footer 的时候,再重写,否则使用默认行为

    import Vue from "vue";
    import Component from "vue-class-component";
    @Component({
      name: "BaseDialog",
    })
    export default class BaseDialog extends Vue {
      showLoading() {
        this.loading = true;
      }
    
      closeLoading() {
        this.loading = false;
      }
    
      onSubmit() {
        this.closeDialog();
      }
    
      onClose() {
        this.closeDialog();
      }
    
      /**
       * 构建弹窗的Footer
       */
      _createFooter(h) {
        var footer = this.$scopedSlots.footer || this.$slots.footer;
        if (typeof footer == "function") {
          return footer();
        }
        var renderFooter = this.renderFooter;
        if (typeof renderFooter === "function") {
          return <div slot="footer">{renderFooter(h)}</div>;
        }
    
        return this.defaultFooter(h);
      }
    
      defaultFooter(h) {
        return (
          <div slot="footer">
            <el-button
              type="primary"
              loading={this.loading}
              on-click={() => {
                this.onSubmit();
              }}
            >
              保存
            </el-button>
            <el-button
              on-click={() => {
                this.onClose();
              }}
            >
              取消
            </el-button>
          </div>
        );
      }
    }

    最后,我们再通过 JSX 将我们编写的这些代码组织起来,就得到了我们最终想要的抽象弹窗
    代码如下:

    import Vue from "vue";
    import Component from "vue-class-component";
    @Component({
      name: "AbstractDialog",
    })
    export default class AbstractDialog extends Vue {
      visible = false;
    
      t = "";
    
      loading = false;
    
      attrs = {};
    
      get title() {
        return this.t;
      }
    
      setTitle(title) {
        this.t = title;
      }
    
      open() {
        console.log("弹窗打开,我啥也不做");
      }
    
      close() {
        console.log("弹窗关闭,我啥也不做");
      }
    
      opened() {
        console.log("弹窗打开,我啥也不做");
      }
    
      closed() {
        console.log("弹窗关闭,我啥也不做");
      }
    
      showLoading() {
        this.loading = true;
      }
    
      closeLoading() {
        this.loading = false;
      }
    
      openDialog() {
        this.visible = true;
      }
    
      closeDialog() {
        if (this.loading) {
          this.$message.warning("请等待操作完成!");
          return;
        }
        this.visible = false;
      }
    
      onSubmit() {
        this.closeDialog();
      }
    
      onClose() {
        this.closeDialog();
      }
    
      /*
       构建弹窗的Header
       */
      _createHeader(h) {
        var slotHeader = this.$scopedSlots["header"] || this.$slots["header"];
        if (typeof slotHeader === "function") {
          return slotHeader();
        }
        var renderHeader = this.renderHeader;
        if (typeof renderHeader === "function") {
          return <div slot="header">{renderHeader(h)}</div>;
        }
      }
    
      /**
       * 构建弹窗的Body部分
       */
      _createBody(h) {
        var slotBody = this.$scopedSlots["default"] || this.$slots["default"];
        if (typeof slotBody === "function") {
          return slotBody();
        }
        var renderBody = this.renderBody;
        if (typeof renderBody === "function") {
          return renderBody(h);
        }
      }
    
      /**
       * 构建弹窗的Footer
       */
      _createFooter(h) {
        var footer = this.$scopedSlots.footer || this.$slots.footer;
        if (typeof footer == "function") {
          return footer();
        }
        var renderFooter = this.renderFooter;
        if (typeof renderFooter === "function") {
          return <div slot="footer">{renderFooter(h)}</div>;
        }
    
        return this.defaultFooter(h);
      }
    
      defaultFooter(h) {
        return (
          <div slot="footer">
            <el-button
              type="primary"
              loading={this.loading}
              on-click={() => {
                this.onSubmit();
              }}
            >
              保存
            </el-button>
            <el-button
              on-click={() => {
                this.onClose();
              }}
            >
              取消
            </el-button>
          </div>
        );
      }
    
      createContainer(h) {
        //防止外界误传参数影响弹窗本来的设计,因此,需要将某些参数过滤开来,有title beforeClose, visible
        var { title, beforeClose, visible, ...rest } = Object.assign({}, this.$attrs, this.attrs);
        return (
          <el-dialog
            {...{
              props: {
                ...rest,
                visible: this.visible,
                title: this.title || title || "弹窗",
                beforeClose: this.closeDialog,
              },
              on: {
                close: this.close,
                closed: this.closed,
                opened: this.opened,
                open: this.open,
              },
            }}
          >
            {/* 根据JSX的渲染规则 null、 undefined、 false、 '' 等内容将不会在页面显示,若createHeader返回undefined,将会使用默认的title */}
            {this._createHeader(h)}
            {this._createBody(h)}
            {this._createFooter(h)}
          </el-dialog>
        );
      }
    
      render(h) {
        return this.createContainer(h);
      }
    }
    
    

    4.应用

    4.1组件调用

    我们就以编写 ChinaMapDialog.vue 为例,将其进行改写

    <script>
    import Vue from "vue";
    import AbstractDialog from "@/components/AbstractDialog.vue";
    import Component from "vue-class-component";
    @Component({
      name: "ChinaMapDialog",
    })
    class ChinaMapDialog extends AbstractDialog {
      get title() {
        return "这是中国地图";
      }
      
      attrs = {
       width: "600px",
      }
    
      //编写一些中国地图的处理业务逻辑代码
    
      //编写弹窗的内容部分
      renderBody(h) {
        return <div>我是中国地图,我讲为你呈现华夏最壮丽的美</div>;
      }
    }
    </script>
    
    

    4.2 使用 Composition API

    由于我们是通过组件的实例调用组件的方法,因此我们每次都需要获取当前组件的 refs 上面的属性,这样会使得我们的调用特别长,写起来也特别麻烦。
    我们可以通过使用 Composition API 来简化这个写法

    <template>
      <div>
        <china-map-dialog ref="china"></china-map-dialog>
        <usa-map-dialog ref="usa"></usa-map-dialog>
        <uk-map-dialog ref="uk"></uk-map-dialog>
        <el-button @click="openChina">打开中国地图</el-button>
        <el-button @click="openUSA">打开美国地图</el-button>
        <el-button @click="openUK">打开英国地图</el-button>
      </div>
    </template>
    <script>
    import { ref } from "@vue/composition-api";
    export default {
      name: "View",
      setup() {
        const china = ref(null);
        const usa = ref(null);
        const uk = ref(null);
        return {
          china,
          usa,
          uk,
        };
      },
      data() {
        return {
          /**
           将地图的业务全部抽离到对应的dialog里面去,View只存放调度业务代码
          */
        };
      },
      methods: {
        // 对百度地图和谷歌地图的一些业务处理代码 省略
        openChina() {
          this.china && this.china.openDialog();
        },
        openUSA() {
          this.usa && this.usa.openDialog();
        },
        openUK() {
          this.uk && this.uk.openDialog();
        },
      },
    };
    </script>
    

    总结

    开发这个弹窗所用到的知识点:
    1、面向对象设计在前端开发中的应用;
    2、如何编写基于类风格的组件(vue-class-component 或 vue-property-decorator);
    3、JSX 在 vue 中的应用;
    4、$attrs和$listeners 在开发高阶组件(个人叫法)中的应用;
    5、slots 插槽,以及插槽在 JSX 中的用法;
    6、在 Vue2.x 中使用 Composition API;