从Element ui看开发公共组件的三种方式

在日常的开发工作中,封装自己的公共组件是很常见的需求,但想要组件更加优雅和通用也是一门学问。恰好前段时间用过Element ui,于是想学习这种库是如何封装插件的,这篇文章就是我的一点理解。

从入口文件看实现方式

以下内容全部基于element 2.7.2版本
element的入口文件是src目录下的index.js,而我们平时使用的各个组件都放在了packages目录下;我们在index.js中可以看到,先将所有组件全部引入到了入口文件中:

import Pagination from '../packages/pagination/index.js';
import Dialog from '../packages/dialog/index.js';
import Autocomplete from '../packages/autocomplete/index.js';
...
...

之后将这些组件放在components数组中,用来批量注册组件。

const install = function(Vue, opts = {}) {
  locale.use(opts.locale);
  locale.i18n(opts.i18n);
 
  // 批量注册组件
  components.forEach(component => {
    Vue.component(component.name, component);
  });

  Vue.use(Loading.directive);

  // 全局配置
  Vue.prototype.$ELEMENT = {
    size: opts.size || '',
    zIndex: opts.zIndex || 2000
  };

  Vue.prototype.$loading = Loading.service;
  Vue.prototype.$msgbox = MessageBox;
  Vue.prototype.$alert = MessageBox.alert;
  Vue.prototype.$confirm = MessageBox.confirm;
  Vue.prototype.$prompt = MessageBox.prompt;
  Vue.prototype.$notify = Notification;
  Vue.prototype.$message = Message;
  ...
};

install方法是用来配合Vue.use()使用的,相信大家也都清楚就不细说了,后面的locale.usei18n是element为了实现国际化(多语言)进行的操作,感兴趣的朋友可以看element文档vue-i18n(惊了现在vue-i18n文档有中文了T T)

执行完批量注册组件后,全局注册了一个自定义指令Vue.use(Loading.directive),之所以进行这个操作是因为loading组件的使用方法是以指令的形式呈现(如果是ssr的话也支持方法调用的形式,之后会提到),举个例子:

<div v-loading="loading" class="test-element"></div>

sizezIndex是element暴露出来的两个全局配置项,size用来改变组件的默认尺寸,zIndex设置弹框的初始z-index;

接下来我们看到在Vue的原型上注册了一系列的方法,这也是element组件的另一种用法,我们以message组件为例:

<el-button :plain="true" @click="open">打开消息提示</el-button>
...
...
methods: {
  open() {
    this.$message('这是一条消息提示');
  }
}

至此,我们可以看到element使用了三种不同的组件实现方式,第一种是最普通,像我们平时开发组件一下,第二种是用过自定义指令的方式,最后一种是挂载一个全局方法,通过传入配置项的方式。接下来我将具体分析这三种是如何实现的。

最后每个组件都被export了是因为element支持按需引入,支持import引入某个组件

普通组件实现方式

我们就以button组件为例,组件的入口文件是packages/button/index.js,它引入了src/button.vue

<template>
  <button
    class="el-button"
    @click="handleClick"
    :disabled="buttonDisabled || loading"
    :autofocus="autofocus"
    :type="nativeType"
    :class="[
      type ? 'el-button--' + type : '',
      buttonSize ? 'el-button--' + buttonSize : '',
      {
        'is-disabled': buttonDisabled,
        'is-loading': loading,
        'is-plain': plain,
        'is-round': round,
        'is-circle': circle
      }
    ]"
  >
    <i class="el-icon-loading" v-if="loading"></i>
    <i :class="icon" v-if="icon && !loading"></i>
    <span v-if="$slots.default"><slot></slot></span>
  </button>
</template>
<script>
  export default {
    name: 'ElButton',

    inject: {
      elForm: {
        default: ''
      },
      elFormItem: {
        default: ''
      }
    },

    props: {
      type: {
        type: String,
        default: 'default'
      },
      size: String,
      icon: {
        type: String,
        default: ''
      },
      nativeType: {
        type: String,
        default: 'button'
      },
      loading: Boolean,
      disabled: Boolean,
      plain: Boolean,
      autofocus: Boolean,
      round: Boolean,
      circle: Boolean
    },

    computed: {
      _elFormItemSize() {
        return (this.elFormItem || {}).elFormItemSize;
      },
      buttonSize() {
        return this.size || this._elFormItemSize || (this.$ELEMENT || {}).size;
      },
      buttonDisabled() {
        return this.disabled || (this.elForm || {}).disabled;
      }
    },

    methods: {
      handleClick(evt) {
        this.$emit('click', evt);
      }
    }
  };
</script>

其实这个组件没什么可说的,结合着element的button组件的文档,这段代码中的功能基本上都可以看懂,type,size,icon,nativeType,loading,disabled,plain,autofocus,round,circle这些配置都是props过来的。需要注意的一点事,这个组件中使用了inject

inject: {
  elForm: {
    default: ''
  },
  elFormItem: {
    default: ''
  }
},

结合着computed来看:

computed: {
 _elFormItemSize() {
  return (this.elFormItem || {}).elFormItemSize;
  },
  buttonSize() {
    return this.size || this._elFormItemSize || (this.$ELEMENT || {}).size;
  },
  buttonDisabled() {
    return this.disabled || (this.elForm || {}).disabled;
  }
}

我们可以在代码中通过provide对按钮是否禁用和尺寸进行修改

<template>
  <div id="app">
    <el-button  type="primary">主要按钮</el-button>
  </div>
</template>
<script>
export default {
  provide () {
    return {
      elFormItem: {
        elFormItemSize: 'medium '
      },
      elForm: {
        disabled: true
      }
    }
  }
}
</script>

这就是第一种公共组件的实现方式,也是最常用的方式;

在原型上挂载方法

这里我以message组件为例,入口文件是packages/message/index.js,它引入里src/main.js,而src/main.vue才是message组件的本身,当我们调用this.$message("测试")时,组件就会弹出:

<template>
  <transition name="el-message-fade" @after-leave="handleAfterLeave">
    <div
      :class="[
        'el-message',
        type && !iconClass ? `el-message--${ type }` : '',
        center ? 'is-center' : '',
        showClose ? 'is-closable' : '',
        customClass
      ]"
      v-show="visible"
      @mouseenter="clearTimer"
      @mouseleave="startTimer"
      role="alert">
      <i :class="iconClass" v-if="iconClass"></i>
      <i :class="typeClass" v-else></i>
      <slot>
        <p v-if="!dangerouslyUseHTMLString" class="el-message__content">{{ message }}</p>
        <p v-else v-html="message" class="el-message__content"></p>
      </slot>
      <i v-if="showClose" class="el-message__closeBtn el-icon-close" @click="close"></i>
    </div>
  </transition>
</template>

<script type="text/babel">
  const typeMap = {
    success: 'success',
    info: 'info',
    warning: 'warning',
    error: 'error'
  };

  export default {
    data() {
      return {
        visible: false,
        message: '',
        duration: 3000,
        type: 'info',
        iconClass: '',
        customClass: '',
        onClose: null,
        showClose: false,
        closed: false,
        timer: null,
        dangerouslyUseHTMLString: false,
        center: false
      };
    },

    computed: {
      typeClass() {
        return this.type && !this.iconClass
          ? `el-message__icon el-icon-${ typeMap[this.type] }`
          : '';
      }
    },

    watch: {
      closed(newVal) {
        if (newVal) {
          this.visible = false;
        }
      }
    },

    methods: {
      handleAfterLeave() {
        this.$destroy(true);
        this.$el.parentNode.removeChild(this.$el);
      },

      close() {
        this.closed = true;
        if (typeof this.onClose === 'function') {
          this.onClose(this);
        }
      },

      clearTimer() {
        clearTimeout(this.timer);
      },

      startTimer() {
        if (this.duration > 0) {
          this.timer = setTimeout(() => {
            if (!this.closed) {
              this.close();
            }
          }, this.duration);
        }
      },
      keydown(e) {
        if (e.keyCode === 27) { // esc关闭消息
          if (!this.closed) {
            this.close();
          }
        }
      }
    },
    mounted() {
      this.startTimer();
      document.addEventListener('keydown', this.keydown);
    },
    beforeDestroy() {
      document.removeEventListener('keydown', this.keydown);
    }
  };
</script>

这段代码也不难理解,不过与button组件不同的是,需要传入组件内的配置项并不是通过props传入的,这些配置都写在data中了,那怎么实现将配置项传入到组件中呢?这就需要看main.js的了,不过在此之前,有一个技巧需要分享一下:我们看message的配置项中有一个onClose参数,它的作用是关闭弹窗时的回调,那么在组件中是如何实现的呢?

data () {
  onClose: null,
	closed: false
},
methods: {
  this.closed = true;
	if (typeof this.onClose === 'function') {
    this.onClose(this);
  }
}

data中初始化onClosenull,当我们需要这个回调时,onClose就为函数了,此时在关闭的时候调用this.onClose(this),同时,我们将message实例传入到函数中,方便使用者进行更多自定义的操作。

ok我们接着看main.js,考虑到篇幅我就挑重点的讲了:

import Vue from 'vue';
import Main from './main.vue';
import { PopupManager } from 'element-ui/src/utils/popup';
import { isVNode } from 'element-ui/src/utils/vdom';
let MessageConstructor = Vue.extend(Main);

let instance;
let instances = [];
let seed = 1;

const Message = function(options) {
  if (Vue.prototype.$isServer) return;
  options = options || {};
  if (typeof options === 'string') {
    options = {
      message: options
    };
  }
  let userOnClose = options.onClose;
  let id = 'message_' + seed++;

  options.onClose = function() {
    Message.close(id, userOnClose);
  };
  instance = new MessageConstructor({
    data: options
  });
  instance.id = id;
  if (isVNode(instance.message)) {
    instance.$slots.default = [instance.message];
    instance.message = null;
  }
  instance.vm = instance.$mount();
  document.body.appendChild(instance.vm.$el);
  instance.vm.visible = true;
  instance.dom = instance.vm.$el;
  instance.dom.style.zIndex = PopupManager.nextZIndex();
  instances.push(instance);
  return instance.vm;
};

['success', 'warning', 'info', 'error'].forEach(type => {
  Message[type] = options => {
    if (typeof options === 'string') {
      options = {
        message: options
      };
    }
    options.type = type;
    return Message(options);
  };
});

Message.close = function(id, userOnClose) {
  for (let i = 0, len = instances.length; i < len; i++) {
    if (id === instances[i].id) {
      if (typeof userOnClose === 'function') {
        userOnClose(instances[i]);
      }
      instances.splice(i, 1);
      break;
    }
  }
};

Message.closeAll = function() {
  for (let i = instances.length - 1; i >= 0; i--) {
    instances[i].close();
  }
};

export default Message;

这段代码引入之前的组件文件,通过Vue.extend(Main)生成一个组件构建器,同时声明一个Message方法,挂载到Vue.prototype.$message上;Message内部首先对传入的配置做了兼容,如果传入的是字符串this.$message("测试"),就转变成这种形式:

options = {
  message: '传入的字符串'
};

如果传入的配置是对象的话,就依据上面的组件构建器创建一个新的实例,并将用户自定义的配置传入到实例的参数中:

instance = new MessageConstructor({
  data: options
});

之后,将这个实例挂载,虽然没有挂载到dom上,但可以通过$el来获取组件的dom,通过dom操作插入到指定dom中:

instance.vm = instance.$mount();
document.body.appendChild(instance.vm.$el);
// visible用来控制组件的隐藏和显示
instance.vm.visible = true;
instance.dom = instance.vm.$el;

再提个细节,由于可以多次触发弹窗,因此组件内部维护了一个数组instances,将每个Message组件实例push到数组中,触发组件关闭的时候,会对指定弹窗进行关闭;

总结一下这种用法,最重要的一点是通过组件构造器的方式Vue.extend()注册组件而不是Vue.component()extend的优势在于可以深度自定义,比如插入到具体哪个dom中;接着,用一个函数包裹着这个组件实例,暴露给Vue的原型方法上;

关于Vue.extend()Vue.component()的区别,推荐看这篇文章构建个人组件库——vue.extend和vue.component

通过自定义指令的方式

element中只有loading组件是这种方式,入口是文件packages/index.js,可以看到loading组件其实也实现了原型方法的挂载Vue.prototype.$loading = service,不过只有在服务端渲染的情况下才这样使用,上面也介绍过这种方法了,不细说。src/loading.vue是组件代码,很短也很简单:

<template>
  <transition name="el-loading-fade" @after-leave="handleAfterLeave">
    <div
      v-show="visible"
      class="el-loading-mask"
      :style="{ backgroundColor: background || '' }"
      :class="[customClass, { 'is-fullscreen': fullscreen }]">
      <div class="el-loading-spinner">
        <svg v-if="!spinner" class="circular" viewBox="25 25 50 50">
          <circle class="path" cx="50" cy="50" r="20" fill="none"/>
        </svg>
        <i v-else :class="spinner"></i>
        <p v-if="text" class="el-loading-text">{{ text }}</p>
      </div>
    </div>
  </transition>
</template>

<script>
  export default {
    data() {
      return {
        text: null,
        spinner: null,
        background: null,
        fullscreen: true,
        visible: false,
        customClass: ''
      };
    },

    methods: {
      handleAfterLeave() {
        this.$emit('after-leave');
      },
      setText(text) {
        this.text = text;
      }
    }
  };
</script>

src/directive.js是自定义指令的注册,其实代码也非常简单:

import Vue from 'vue';
import Loading from './loading.vue';
import { addClass, removeClass, getStyle } from 'element-ui/src/utils/dom';
import { PopupManager } from 'element-ui/src/utils/popup';
import afterLeave from 'element-ui/src/utils/after-leave';
const Mask = Vue.extend(Loading);

const loadingDirective = {};
loadingDirective.install = Vue => {
  if (Vue.prototype.$isServer) return;
  const toggleLoading = (el, binding) => {
    if (binding.value) {
      Vue.nextTick(() => {
        if (binding.modifiers.fullscreen) {
          el.originalPosition = getStyle(document.body, 'position');
          el.originalOverflow = getStyle(document.body, 'overflow');
          el.maskStyle.zIndex = PopupManager.nextZIndex();

          addClass(el.mask, 'is-fullscreen');
          insertDom(document.body, el, binding);
        } else {
          removeClass(el.mask, 'is-fullscreen');

          if (binding.modifiers.body) {
            el.originalPosition = getStyle(document.body, 'position');

            ['top', 'left'].forEach(property => {
              const scroll = property === 'top' ? 'scrollTop' : 'scrollLeft';
              el.maskStyle[property] = el.getBoundingClientRect()[property] +
                document.body[scroll] +
                document.documentElement[scroll] -
                parseInt(getStyle(document.body, `margin-${ property }`), 10) +
                'px';
            });
            ['height', 'width'].forEach(property => {
              el.maskStyle[property] = el.getBoundingClientRect()[property] + 'px';
            });

            insertDom(document.body, el, binding);
          } else {
            el.originalPosition = getStyle(el, 'position');
            insertDom(el, el, binding);
          }
        }
      });
    } else {
      afterLeave(el.instance, _ => {
        el.domVisible = false;
        const target = binding.modifiers.fullscreen || binding.modifiers.body
          ? document.body
          : el;
        removeClass(target, 'el-loading-parent--relative');
        removeClass(target, 'el-loading-parent--hidden');
        el.instance.hiding = false;
      }, 300, true);
      el.instance.visible = false;
      el.instance.hiding = true;
    }
  };
  const insertDom = (parent, el, binding) => {
    if (!el.domVisible && getStyle(el, 'display') !== 'none' && getStyle(el, 'visibility') !== 'hidden') {
      Object.keys(el.maskStyle).forEach(property => {
        el.mask.style[property] = el.maskStyle[property];
      });

      if (el.originalPosition !== 'absolute' && el.originalPosition !== 'fixed') {
        addClass(parent, 'el-loading-parent--relative');
      }
      if (binding.modifiers.fullscreen && binding.modifiers.lock) {
        addClass(parent, 'el-loading-parent--hidden');
      }
      el.domVisible = true;

      parent.appendChild(el.mask);
      Vue.nextTick(() => {
        if (el.instance.hiding) {
          el.instance.$emit('after-leave');
        } else {
          el.instance.visible = true;
        }
      });
      el.domInserted = true;
    }
  };

  Vue.directive('loading', {
    bind: function(el, binding, vnode) {
      const textExr = el.getAttribute('element-loading-text');
      const spinnerExr = el.getAttribute('element-loading-spinner');
      const backgroundExr = el.getAttribute('element-loading-background');
      const customClassExr = el.getAttribute('element-loading-custom-class');
      const vm = vnode.context;
      const mask = new Mask({
        el: document.createElement('div'),
        data: {
          text: vm && vm[textExr] || textExr,
          spinner: vm && vm[spinnerExr] || spinnerExr,
          background: vm && vm[backgroundExr] || backgroundExr,
          customClass: vm && vm[customClassExr] || customClassExr,
          fullscreen: !!binding.modifiers.fullscreen
        }
      });
      el.instance = mask;
      el.mask = mask.$el;
      el.maskStyle = {};

      binding.value && toggleLoading(el, binding);
    },

    update: function(el, binding) {
      el.instance.setText(el.getAttribute('element-loading-text'));
      if (binding.oldValue !== binding.value) {
        toggleLoading(el, binding);
      }
    },

    unbind: function(el, binding) {
      if (el.domInserted) {
        el.mask &&
        el.mask.parentNode &&
        el.mask.parentNode.removeChild(el.mask);
        toggleLoading(el, { value: false, modifiers: binding.modifiers });
      }
      el.instance && el.instance.$destroy();
    }
  });
};

export default loadingDirective;

由于loading组件需要获取dom的相关信息,为了保证dom渲染成功后正常获取信息,展示和关闭loading的操作——toggleLoading函数内部处理都放在了Vue.nextTick中了;关于toggleLoading函数简单介绍一下,当自定义指令的值发生变化的时候,即绑定的value值不相等binding.oldValue !== binding.value,就会调用toggleLoading函数,如果自定义指令没有值,就会销毁这个组件,如果有值就会根据是否全屏展示loading进行进一步判断,后续还会判断是否会插入到body这个dom中,最终才会插入到dom中展示出来;