在日常的开发工作中,封装自己的公共组件是很常见的需求,但想要组件更加优雅和通用也是一门学问。恰好前段时间用过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.use
和i18n
是element为了实现国际化(多语言)进行的操作,感兴趣的朋友可以看element文档和vue-i18n(惊了现在vue-i18n文档有中文了T T)
执行完批量注册组件后,全局注册了一个自定义指令Vue.use(Loading.directive)
,之所以进行这个操作是因为loading组件的使用方法是以指令的形式呈现(如果是ssr的话也支持方法调用的形式,之后会提到),举个例子:
<div v-loading="loading" class="test-element"></div>
复制代码
size
和zIndex
是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
中初始化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中展示出来;