在平常的開發工做中,封裝本身的公共組件是很常見的需求,但想要組件更加優雅和通用也是一門學問。剛好前段時間用過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.use
和i18n
是element爲了實現國際化(多語言)進行的操做,感興趣的朋友能夠看element文檔和vue-i18n(驚瞭如今vue-i18n文檔有中文了T T)
執行完批量註冊組件後,全局註冊了一個自定義指令Vue.use(Loading.directive)
,之因此進行這個操做是由於loading組件的使用方法是以指令的形式呈現(若是是ssr的話也支持方法調用的形式,以後會提到),舉個例子:git
<div v-loading="loading" class="test-element"></div>
複製代碼
size
和zIndex
是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
過來的。須要注意的一點事,這個組件中使用了inject
bash
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
中初始化onClose
爲null
,當咱們須要這個回調時,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中展現出來;