作为一名前端开发者,开发中绝对离不开使用各种组件库,例如 Element-UI、Ant-design-vue 等,都是非常优秀的组件库,很多时候,我们只是会使用就行,不需要去考虑是如何实现的,一般来说都是基于这些组件进行二次封装。
但是,如果我们想要进一步提升自己的前端水平,我觉得很有必要去研究下组件库的实现细节,它里面用到的一些模式、技巧、代码处理等,都是值得我们去学习研究的。因此,我便开始了研究 Element-UI 源码之路,总结下其中的一些比较难处理的组件的实现思路, 那么这篇文章就从 Message 组件开始,后续也会不断更新,希望各位大佬多多指导!
一、使用示例
该组件的文档:element.eleme.cn/#/zh-CN/com…
先来看下在代码中是如何使用的:
this.$message.error('错了哦,这是一条错误消息');
this.$message({
message: '恭喜你,这是一条成功消息',
type: 'success'
});
this.$message({
message: h('p', null, [
h('span', null, '内容可以是 '),
h('i', { style: 'color: teal' }, 'VNode')
])
});
虽然有多种使用形式,但是本质都是函数调用,而且 message 方法是挂载在 Vue 实例中的,该方法接收一个配置项,根据传递的参数实现不同的效果,文档中给出了具体的参数值,其中 message 可以是字符串,也可以是虚拟结点。
二、梳理结构
在 github中将项目拷贝下来本地,观察项目的目录结构,packages 目录下是所有组件的源码,src 目录下是一些公用的方法,其中 index.js 文件中提供了 install 方法,用户注册组件以及挂载属性以及方法到 Vue 实例上。
接下来找到 message 组件所在的位置,main.vue 文件中主要是 HTML 结构代码,核心是 main.js 文件,里面向外暴露了 Message 方法:
三、源码剖析
main.vue
<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
]"
:style="positionStyle"
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>
主要的 HTML 代码在 main.vue
文件中,最外层使用了 Vue 的内置组件 transition,从而使得消息弹出和消失的时候有个过渡动画效果,HTML 代码结构比较简单,只需展示图标、消息文本、关闭按钮,其中消息文本可以是字符串,也可以是 HTML 片段:
但是官方文档上有提示:message
属性虽然支持传入 HTML 片段,但是在网站上动态渲染任意 HTML 是非常危险的,因为容易导致 XSS 攻击。
因此在 dangerouslyUseHTMLString 打开的情况下,请确保 message 的内容是可信的,永远不要将用户提交的内容赋值给 message 属性。
下面来看一下相关的逻辑代码:
<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,
verticalOffset: 20,
timer: null,
dangerouslyUseHTMLString: false,
center: false
};
},
computed: {
typeClass() {
return this.type && !this.iconClass
? `el-message__icon el-icon-${ typeMap[this.type] }`
: '';
},
positionStyle() {
return {
'top': `${ this.verticalOffset }px`
};
}
},
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) {
// 按下 ESC 键关闭消息
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>
下面我们来分析核心逻辑,主要是暴露 Message 方法,供外部调用唤起弹层:
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';
import { isObject } from 'element-ui/src/utils/types';
// 创建组件的构造函数
let MessageConstructor = Vue.extend(Main);
// 当前消息提示实例
let instance;
// 存储所有的消息提示实例
let instances = [];
// 用于生成唯一id
let seed = 1;
// 核心方法,以函数的形式唤起弹窗
const Message = function(options) {
// 配置项参数处理
options = options || {};
if (typeof options === 'string') {
options = {
message: options
};
}
// 获取配置里的关闭函数并保存
let userOnClose = options.onClose;
// 生成唯一id
let id = 'message_' + seed++;
// 重写 options 里的关闭方法
options.onClose = function() {
Message.close(id, userOnClose);
};
// 创建消息实例,将 options 传入
// instance 就相当于是 main.vue 组件实例
// 可以想象成 new class,能够使用里面的一些变量以及方法
instance = new MessageConstructor({
data: options
});
instance.id = id;
// 如果 message 是虚拟结点
if (isVNode(instance.message)) {
instance.$slots.default = [instance.message];
instance.message = null;
}
// 挂载元素 放到body下面
instance.$mount();
document.body.appendChild(instance.$el);
// 设置偏移量,当同时出现多个消息提示时,从上往下排列
let verticalOffset = options.offset || 20;
instances.forEach(item => {
verticalOffset += item.$el.offsetHeight + 16;
});
instance.verticalOffset = verticalOffset;
// 设置属性,并显示
instance.visible = true;
instance.$el.style.zIndex = PopupManager.nextZIndex();
instances.push(instance);
// 返回消息实例
return instance;
};
// 在 Message 原型上挂载四个方法,调用方式:this.$message.error('haha');
['success', 'warning', 'info', 'error'].forEach(type => {
Message[type] = (options) => {
if (isObject(options) && !isVNode(options)) {
return Message({
...options,
type
});
}
return Message({
type,
message: options
});
};
});
// 在 Message 上定义关闭方法
// 传入需要关闭项的id,以及关闭时的回调函数
Message.close = function(id, userOnClose) {
let len = instances.length;
let index = -1;
let removedHeight;
for (let i = 0; i < len; i++) {
if (id === instances[i].id) {
// 找到了
removedHeight = instances[i].$el.offsetHeight;
index = i;
if (typeof userOnClose === 'function') {
// 调用关闭时的回调函数, 参数为被关闭的 message 实例
userOnClose(instances[i]);
}
// 删掉并退出循环
instances.splice(i, 1);
break;
}
}
if (len <= 1 || index === -1 || index > instances.length - 1) return;
// 当同时显示多个消息提示的时候,删掉了其中一个,那么它下面的都需要往上移动一个偏移量
for (let i = index; i < len - 1 ; i++) {
let dom = instances[i].$el;
dom.style['top'] =
parseInt(dom.style['top'], 10) - removedHeight - 16 + 'px';
}
};
// 全部关闭的方法
Message.closeAll = function() {
for (let i = instances.length - 1; i >= 0; i--) {
instances[i].close();
}
};
export default Message;
这段代码里有两个方面值得我们去学习,下面我们分别来看下:
1. 使用PopupManager
顾名思义,弹窗管理者,我们知道有很多组件其实都是有弹出层的效果,比如 message、dialog、notifition、drawer 等,那么为了更好的管理这些弹出层组件的优先级,Element-UI 团队封装了 PopupManager 用于管理,关于 PopupManager 的内容有点难理解,后面再专门写一篇文章总结下。
instance.$el.style.zIndex = PopupManager.nextZIndex();
那么在 message 组件中,使用到了 PopupManager 中的一个方法 nextZIndex,用于获取下一个 z-index 的值,其内部就是将 z-index++,使得越后出现的组件显示在最上面。
2. Vue.extend()组件扩展
Vue.extend 可以创建一个组件的构造函数,这个构造函数可以继承父级组件的数据和方法,也可以添加自己的属性和方法,然后用这个构造函数去创建一个子组件。
这个 API 在实际业务开发中我们很少使用,因为相比常用的 Vue.component 写法使用 extend 步骤要更加繁琐一些,但是在一些独立组件开发场景中,Vue.extend + $mount 这对组合是我们需要去关注的。
Vue.extend() 的应用场景:在 Vue 项目中,初始化的根实例后,所有页面基本上都是通过 router 来管理,组件也是通过 import 来进行局部注册,所以组件的创建不需要去关注,相比 extend 要更省心一点点。
但是这样做会有几个缺点:
- 组件模板都是事先定义好的,如果我要从接口动态渲染组件怎么办?
- 所有内容都是在 #app 下渲染,注册组件都是在当前位置渲染。如果我要实现一个类似于 window.alert() 提示组件要求像调用 JS 函数一样调用它,该怎么办?
这时候,Vue.extend + vm.$mount 组合就派上用场了。
基本用法:
// 1. 定义一个vue模版
let tem ={
template:'{{firstName}} {{lastName}} aka {{alias}}',
data:function(){
return{
firstName:'Walter',
lastName:'White',
alias:'Heisenberg'
}
}
// 2. 调用
const TemConstructor = Vue.extend(tem)
// 生成一个实例,并且挂载在 #app 上
const intance = new TemConstructor({el:"#app"})
更多关于 Vue.extend 的用法请移步Vue官方文档