從Element ui看開發公共組件的三種方式

在平常的開發工做中,封裝本身的公共組件是很常見的需求,但想要組件更加優雅和通用也是一門學問。剛好前段時間用過Element ui,因而想學習這種庫是如何封裝插件的,這篇文章就是個人一點理解。html

從入口文件看實現方式

如下內容所有基於element 2.7.2版本
element的入口文件是src目錄下的index.js,而咱們平時使用的各個組件都放在了packages目錄下;咱們在index.js中能夠看到,先將全部組件所有引入到了入口文件中:vue

import Pagination from '../packages/pagination/index.js';
import Dialog from '../packages/dialog/index.js';
import Autocomplete from '../packages/autocomplete/index.js';
...
...
複製代碼

以後將這些組件放在components數組中,用來批量註冊組件。node

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的話也支持方法調用的形式,以後會提到),舉個例子:git

<div v-loading="loading" class="test-element"></div>
複製代碼

sizezIndex是element暴露出來的兩個全局配置項,size用來改變組件的默認尺寸,zIndex設置彈框的初始z-index;

接下來咱們看到在Vue的原型上註冊了一系列的方法,這也是element組件的另外一種用法,咱們以message組件爲例:github

<el-button :plain="true" @click="open">打開消息提示</el-button>
...
...
methods: {
  open() {
    this.$message('這是一條消息提示');
  }
}
複製代碼

至此,咱們能夠看到element使用了三種不一樣的組件實現方式,第一種是最普通,像咱們平時開發組件一下,第二種是用過自定義指令的方式,最後一種是掛載一個全局方法,經過傳入配置項的方式。接下來我將具體分析這三種是如何實現的。

最後每一個組件都被export了是由於element支持按需引入,支持import引入某個組件element-ui

普通組件實現方式

咱們就以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過來的。須要注意的一點事,這個組件中使用了injectbash

inject: {
  elForm: {
    default: ''
  },
  elFormItem: {
    default: ''
  }
},
複製代碼

結合着computed來看:babel

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

咱們能夠在代碼中經過provide對按鈕是否禁用和尺寸進行修改app

<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中展現出來;

相關文章
相關標籤/搜索