前言
接下来是一段很痛苦的时间!!
今天详细分析 Select 源码,在看到源码足足有九百行时,我整个人都是懵的,这是迄今为止读的最多的一篇源码,大概浏览了之后发现它里面有很多很多的知识点,光导入的模块就有 16 个,里面包含着各种组件、混入以及工具函数,所以鉴于本文篇幅有限,我打算分两部分来写,分别为:「模板篇」和「方法篇」,模板篇主要是分析 Select 模板以及一些比较简单的属性,方法篇着重分析 methods
里面的各种方法,现在就让我们一起来看一下 Select 组件到底做了哪些事,建议先点赞/收藏再观看。
用法和功能
了解一个 UI 组件首先要从它的功能入手,只有了解了组件的具体功能之后才会知道为什么要封装?怎样封装?在 ElementUI 官方文档上有详细使用功能及方法,不熟悉的同学可以先研究一下。Select 主要有以下用法:
- 基础用法:就是一个基本的下拉框,下拉框的位置会根据页面上空间大小而选择性地出现在上方或下方
- 禁用选项:可以禁用某一个选项或者整个下拉框
- 清空按钮:这个不必多说,input 组件也有这个功能
- 多选:选择多个下拉选项,选中的选项可以以 tag 形式展示或者合并为一段
- 自定义下拉选项:下拉选项可以是自定义的样式
- 分组:可以对下拉选项按照某些类别分组
- 搜索:与服务器结合,进行搜索联想
- 创建条目:可以通过输入创建一个 tag 显示在输入框里
可以看到,一个 Select 的功能有这么多,基本上把我们日常需求中需要使用到的功能全部考虑进来了,既然功能这么多,那么封装起来肯定就特别麻烦了,毕竟人家九百行代码不是白写的!!
理清了功能这对于我们看源码是很有帮助的,我们可以根据功能来找对应的代码实现,接下来直接上源码:
基本结构
el-select
看结构最好不要在源码里直接看,这样很容易懵圈,首先我们看一下最基础的用法渲染出来的 HTML 结构:
从这个比较简单的结构里可以很容易的看出来主体是由 input 和 后缀元素组成,现在我们一点一点分析它的模板结构:首先最外层,是一个类名为 el-select
的 div
,如果声明了 size
,还会根据 size
添加 el-select-size
类,里面包裹着的是 el-select__tags
的 div
,我们暂时先不看与 tags
相关的,先看一下 el-input
。
这个 el-input
渲染出来就是 ElementUI 封装的 input 组件,如果你没有看过,可以先移步超详细 ElementUI 源码分析系列仔细阅读一下 input 源码。
<el-input>
<template slot="prefix" v-if="$slots.prefix">
<slot name="prefix"></slot>
</template>
<template slot="suffix">
<i v-show="!showClose" :class="['el-select__caret', 'el-input__icon', 'el-icon-' + iconClass]"></i>
<i v-if="showClose" class="el-select__caret el-input__icon el-icon-circle-close" @click="handleClearClick"></i>
</template>
</el-input>
input 组件里包含了两个插槽,前缀和后缀。prefix
插槽用于展示 Select 组件头部内容(如果有的话),而 suffix
是用来显示后面的清空按钮和小箭头的。可以看到这里有一个细节就是小箭头用了 v-show
而清空按钮用了 v-if
,这里简单介绍一下两者的区别:
- 两者都适用于切换 DOM 元素的显示和隐藏
v-show
操作的是 DOM 元素的display: none
属性,不会改变 DOM 树的结构v-if
操作的是 DOM 树,直接加入或者删除控制的 DOM 元素v-if
在「初始条件为假时不会渲染」,直到第一次为真时才开始渲染v-if
有更高的「切换开销」,v-show
有更高的「初始渲染开销」- 也就是说
v-show
适用于需要频繁操作 DOM,而v-if
则用于你不会去频繁操作它的 DOM 结构的时候
它所触发的事件也留到下一期再和大家分析,记得准时阅读。
由于后面的结构用到了很多以前没有看过的组件,所以接下来先对 Select 引用的组件进行分析。
el-select-menu
首先是 el-select-menu
,查看导入的模块可知使用的是 select-dropdown.vue
文件,进去瞅一眼。这个组件的结构很简单,只有一个 div
,里面包含一个插槽,这个 div
的 class
是 "el-select-dropdown el-popper"
,它渲染到页面上是下面这种结构:
可以看到这是一个下拉框的结构,它是被添加到 body 节点上了,通过 position
定位到了输入框的上方或者下方,并且可以根据输入框的位置进行调整。这个组件本身不是很复杂,但是它混入了 vue-popper
,而在 vue-popper
中又引入了 popper-manager
,同时 vue-popper
又引入了第三方的定位库 popper.js
,所以这里面的关系很复杂,看下面这张图:
先来说一下每一个模块的作用:
vue-popper
:用于管理组件的弹出框,什么时候创建、在哪个位置创建、什么时候又需要销毁以及怎么销毁popup
:主要是做弹出框的打开和关闭操作popup-manager
:用来管理页面中所有的 modal 层popper.js
:第三方库,主要是用来定位弹出框的
对于
popper.js
的分析参考了这篇 CSDN 博客
由于每个模块的内容都非常多,这里只挑和 Select 组件有关的分析一下,如果想看具体的,可以移步我的 github 上查看。
我们再回过头来看 Select 组件,el-select-menu
中包裹着 el-scrollbar
用于下拉框的内容滚动,那么接下来的内容就是 el-scrollbar
的分析了。
el-scrollbar
先来看一下入口文件 index.js
它导入的 ScrollBar
是src/main
,这才是 el-scrollbar
的组件的文件,官方说了这个文件整个思路是参考了 gemini-scrollbar,我去对比了一下,发现思路果然一样,连命名都是一样的,不过别人的做了兼容。
这里面导入的文件主要是:
utils/resize-event.js
:resize
事件的绑定与解除utils/scrollbar-width.js
:计算滚动条的宽度toObject
:将数组里面的所有对象合并到一个对象上去Bar
:自定义的滚动条组件
main.js/render
对于每一个文件的源码我都进行了分析,先看 main.js
里面的源码:
// main.js
render(h) {
// 获取系统自带的滚动条的宽度
// scrollbarWidth() 看后文
let gutter = scrollbarWidth();
let style = this.wrapStyle;
// 如果滚动条存在
if (gutter) {
// 我觉得这地方应该是 `gutterWidth` 不过不重要了
const gutterWith = `-${gutter}px`;
const gutterStyle = `margin-bottom: ${gutterWith}; margin-right: ${gutterWith};`;
if (Array.isArray(this.wrapStyle)) {
// toObject 看后文
style = toObject(this.wrapStyle);
style.marginRight = style.marginBottom = gutterWith;
} else if (typeof this.wrapStyle === 'string') {
style += gutterStyle;
} else {
style = gutterStyle;
}
}
// 这是最外层的 ul
const view = h(
this.tag,
{
class: ['el-scrollbar__view', this.viewClass],
style: this.viewStyle,
ref: 'resize'
},
// 子虚拟节点数组
this.$slots.default
);
// ul 外层包裹的 div
const wrap = (
<div
ref='wrap'
style={style}
onScroll={this.handleScroll}
class={[
this.wrapClass,
'el-scrollbar__wrap',
gutter ? '' : 'el-scrollbar__wrap--hidden-default'
]}
>
{[view]}
</div>
);
let nodes;
// 是否使用元素滚动条,默认是 false
// 使用自定义的 Bar 组件
if (!this.native) {
nodes = [
wrap,
<Bar move={this.moveX} size={this.sizeWidth}></Bar>,
<Bar vertical move={this.moveY} size={this.sizeHeight}></Bar>
];
} else {
nodes = [
<div
ref='wrap'
class={[this.wrapClass, 'el-scrollbar__wrap']}
style={style}
>
{[view]}
</div>
];
}
return h('div', { class: 'el-scrollbar' }, nodes);
},
可以看到,这个下拉框的滚动部分主要是使用 render
渲染函数来构建一个 DOM 结构的,整个渲染出来的结构如图所示:
关于 li
标签的渲染是由 el-option
完成的,稍后再分析。在渲染函数里给外层的 wrap
绑定了一个 onscroll
事件,监听方法在 methods
里面定义了:
// onscroll 事件处理函数
handleScroll() {
const wrap = this.wrap;
// 计算出滚动条需要滚动的距离(百分比)
this.moveY = (wrap.scrollTop * 100) / wrap.clientHeight;
this.moveX = (wrap.scrollLeft * 100) / wrap.clientWidth;
},
当内部的列表滚动时,计算出滚动条需要滚动的距离,这里是使用的百分比,然后在 Bar
组件里使用。这个 Bar
是官方自定义的一个滚动条组件,也放在下文分析。我们注意到组件接收了一个 native
属性,这个属性表示是否使用浏览器自带的滚动条,默认是 false
也就是不使用,而是去使用 Bar
组件,然后把整个结构放进 h
里交给 Vue 解析。
main.js/methods
methods
里定义了两个方法:
handleScroll
:onscroll
事件处理函数update
:当触发resize
事件时,改变滚动条的大小
update() {
// 宽高百分比
let heightPercentage, widthPercentage;
const wrap = this.wrap;
if (!wrap) return;
// 求出可视区域占内容总大小的百分比,这就是滚动条相对于内容的百分比
heightPercentage = (wrap.clientHeight * 100) / wrap.scrollHeight;
widthPercentage = (wrap.clientWidth * 100) / wrap.scrollWidth;
// 滚动条的大小
// 如果可视区域比内容总大小要小,证明需要滚动,把百分比赋值给 sizeXXX
// 如果不需要滚动 clientHeight = scrollHeight
this.sizeHeight = heightPercentage < 100 ? heightPercentage + '%' : '';
this.sizeWidth = widthPercentage < 100 ? widthPercentage + '%' : '';
}
当下拉框组件被挂载时,调用 update
方法,值得说明的是在组件的 prop
属性里有一个属性为 noresize
,这个属性是禁止框架调整大小的,官方给的注释是「如果 container
尺寸不会发生变化,最好设置它可以优化性能」,优化性能在后面可以看出来,组件挂载和被销毁前都调用了 update
方法,而频繁调用 update
会消耗一定的性能,所以我们不想要调整框架大小时,尽量声明 noresize
属性。
mounted() {
if (this.native) return;
// update 需要用到更新后的 DOM,所以放在 $nextTick 里
this.$nextTick(this.update);
// 如果可以调整框架的大小,就给元素添加一个 resize 监听事件
!this.noresize && addResizeListener(this.$refs.resize, this.update);
},
beforeDestroy() {
if (this.native) return;
// 移除元素的 resize 监听事件
!this.noresize && removeResizeListener(this.$refs.resize, this.update);
}
关于
addResizeListener
方法,官方是借用了第三方包 resize-observer-polyfill 来处理resize
事件的,ResizeObserver
是新出的 API,有非常好的性能,具体去 MDN 了解一下吧。
Bar
接下来我们看 Bar
组件的调用:
<Bar move={this.moveX} size={this.sizeWidth}></Bar>,
<Bar vertical move={this.moveY} size={this.sizeHeight}></Bar>
传递了两个或者三个参数:
move
:水平或者垂直移动的距离size
:滚动条的大小vertical
:是否是垂直滚动条,不是就为水平的滚动条
dom.js/on
bar.js
文件就是 Bar
组件,里面导入了两个工具类的对象,关于工具类的分析,我打算后期再专门写一个专栏,这里先简单的看一下相关的方法。
/**
* 封装 on 方法给指定元素绑定事件
* @param {HTMLElement} element 要绑定事件的元素
* @param {String} event 要绑定的事件
* @param {Function} handler 事件触发时执行的函数
*/
export const on = (function() {
if (!isServer && document.addEventListener) {
return function(element, event, handler) {
if (element && event && handler) {
// false 表示在冒泡阶段执行
// true 表示在捕获阶段执行
element.addEventListener(event, handler, false);
}
};
} else {
// IE 中使用 attachEvent 添加事件监听
return function(element, event, handler) {
if (element && event && handler) {
element.attachEvent('on' + event, handler);
}
};
}
})();
这个 on
方法是用于给指定元素绑定事件用的,仔细看源码发现,它使用的是一个立即执行函数(IIEF),然后将执行的结果导出,它执行的结果还是一个函数。这里有出现两个疑问:
- 为什么使用立即执行函数?
- 为什么返回一个函数呢?
先说第二个,返回一个函数明显是使用了「闭包」,闭包的好处是能够「访问到外层作用域」,比如说这里的 isServer
就是 dom.js
里面定义的变量。但是使用闭包会造成「内存泄漏」,如果不销毁的话,我们的内存将不堪重负,所以这里才会使用「立即执行函数」来消除闭包带来的副作用,
bar.js/render
再回过来看 Bar
组件,里面仍然是使用了 render
函数来渲染组件的:
render(h) {
const { size, move, bar } = this;
return (
<div
class={ ['el-scrollbar__bar', 'is-' + bar.key] }
onMousedown={ this.clickTrackHandler } >
<div
ref="thumb"
class="el-scrollbar__thumb"
onMousedown={ this.clickThumbHandler }
style={ renderThumbStyle({ size, move, bar }) }>
</div>
</div>
);
},
渲染出来的结构在上一张图中可以看出来,就是两个嵌套的 div
,这两个 div
上都绑定了 onmousedown
事件,用来处理鼠标按下的事件,在 style
中还有一个 renderThumbStyle
函数,我们先看一个这个函数的作用:
export function renderThumbStyle({ move, size, bar }) {
const style = {};
// 平移多少距离
const translate = `translate${bar.axis}(${ move }%)`;
// 设置滚动条的宽/高
style[bar.size] = size;
style.transform = translate;
style.msTransform = translate;
style.webkitTransform = translate;
return style;
};
每当滑动列表时,滚动条也会跟着变化,它的移动就是这个函数控制的,看一下滚动前后,它的 style
的变化:
translateY
发生了变化,也就是说它是靠平移来模拟滚动的,而具体的数值是有父组件(这里是el-scrollbar
)传过来的。
bar.js/methods
接下来看它里面的几个方法,都是跟事件绑定有关的:
// 鼠标按钮在 滚动条上 被按下时的事件处理方法
clickThumbHandler(e) {
// prevent click event of right button
// ctrlKey 事件属性可返回一个布尔值,指示当事件发生时,Ctrl 键是否被按下并保持住
// e.button = 2 表示鼠标右键
if (e.ctrlKey || e.button === 2) {
return;
}
this.startDrag(e);
this[this.bar.axis]
= (e.currentTarget[this.bar.offset]
- (e[this.bar.client]
- e.currentTarget.getBoundingClientRect()[this.bar.direction]));
},
如果点击的时候按下了 Ctrl
或者按下的是鼠标右键直接停止事件的执行,按下时执行拖动方法 startDrag
// 点击并拖拽滚动条
startDrag(e) {
// 拖动的时候当前元素剩下的监听函数将不会执行
e.stopImmediatePropagation();
this.cursorDown = true;
// 给 document 绑定鼠标移动事件 和 鼠标按钮抬起事件
on(document, 'mousemove', this.mouseMoveDocumentHandler);
on(document, 'mouseup', this.mouseUpDocumentHandler);
// 禁止文字被选中
// 参考 https://www.jianshu.com/p/701cc19d2c5a
document.onselectstart = () => false;
}
解释一下
e.stopImmediatePropagation()
方法,平时我们用到的比较少。当一个元素上绑定了很多同类型的事件时,它会按照绑定时的顺序依次执行回调函数,但是当我们在事件处理函数中声明了这个方法时,那么当前元素剩下的监听函数将不会执行。
// 鼠标按钮在 滚动条所在的区域 被按下时的事件处理方法
// 当鼠标点击滚动条 `上方空白处` 时,滚动条向上滚动
// 当鼠标点击滚动条 `下方空白处` 时,滚动条向下滚动
clickTrackHandler(e) {
// 获取点击的位置距离元素上边距的距离
// 即 IE 下的 offsetX/offsetY 属性
const offset
= Math.abs(e.target.getBoundingClientRect()[this.bar.direction]
- e[this.bar.client]);
// 滚动条宽/高的一半
const thumbHalf = (this.$refs.thumb[this.bar.offset] / 2);
const thumbPositionPercentage
= ((offset - thumbHalf) * 100 / this.$el[this.bar.offset]);
// 举个例子
// wrap.scrollTop = -10(假数据) * wrap.scrollHeight / 100
this.wrap[this.bar.scroll]
= (thumbPositionPercentage * this.wrap[this.bar.scrollSize] / 100);
}
这个方法用来处理滚动条外层被点击的事件,实现了点击空白处就能滚动的效果。 startDrag
里对于 mousemove
和 mouseup
事件的监听计算方法和上述类似,这里节省篇幅不再介绍了。
看了鼠标事件的绑定,我们要注意事件有绑定就一定要有取消监听,特别是鼠标移动时的事件,在源码里使用的是
off
方法,具体和on
类似。
继续来解决我们在 main.js/render
里面挖下的坑 scrollbarWidth
和 toObject
scrollbar-width.js
这个文件很简单,就是为了计算出系统自带的滚动条的宽度,我看了一下,网上基本上都是这种方法。
export default function() {
if (Vue.prototype.$isServer) return 0;
// 如果存在 scrollBarWidth 就直接返回
if (scrollBarWidth !== undefined) return scrollBarWidth;
const outer = document.createElement('div');
outer.className = 'el-scrollbar__wrap';
outer.style.visibility = 'hidden';
outer.style.width = '100px';
outer.style.position = 'absolute';
outer.style.top = '-9999px';
document.body.appendChild(outer);
// 没有滚动条时的宽度 = 元素的 offsetWidth
const widthNoScroll = outer.offsetWidth;
// 使外层可滚动并且出现滚动条
outer.style.overflow = 'scroll';
const inner = document.createElement('div');
// 设置 width 为 100% 时,强制子元素的内容宽度等于父元素内容宽度
// 当子元素内容宽度大于父元素的内容宽度时,就会出现滚动条
inner.style.width = '100%';
outer.appendChild(inner);
const widthWithScroll = inner.offsetWidth;
outer.parentNode.removeChild(outer);
scrollBarWidth = widthNoScroll - widthWithScroll;
return scrollBarWidth;
};
不过这里我觉得它计算的时候用错了属性,inner
不应该使用 offsetWidth
而应该使用 clientWidth
,因为 offsetWidth
是包含了滚动条在内的,这样根本计算不出来,不知道是他们写错了还是我理解错了,反正这个方法最后得到的结果都是 0,因为我在 Mac 的谷歌浏览器上跑了一遍,都不能拿到滚动条宽度,设置了 overflow
也没办法一直让滚动条存在,不知道在 Windows
系统上会怎么样。麻烦各位跑一遍这个方法,然后在评论区告诉我,谢谢。
util.js/toObject
function extend(to, _from) {
// _from 如果是基本数据类型就不会循环
for (let key in _from) {
to[key] = _from[key];
}
return to;
}
// 把数组里面的所有对象转成一个对象
export function toObject(arr) {
var res = {};
for (let i = 0; i < arr.length; i++) {
if (arr[i]) {
extend(res, arr[i]);
}
}
return res;
}
这里面的方法很简单,就是通过遍历将数组上的所有对象的属性都转到一个新对象上来,如果数组中有基本数据类型会直接跳过。
到此,我们的 scrollbar
已经全部分析完毕,但是还没到撒花完结的时候,接下来还有 el-option
组件。
el-option
el-option
部分包含组件本身和一个 el-option-group
组件,option
是真正渲染下拉框列表的组件,渲染到页面中就是 <li>
标签,这 option
的模板结构里有一个默认的插槽用于显示列表项的文本内容。由于 option
里面很多是和 select
组件的方法有关,所以我打算放在下一篇来分析,先看一些简单的:
// 判断两个参数是否相等
isEqual(a, b) {
if (!this.isObject) {
return a === b;
} else {
// 拿到 select 组件实例的 valueKey
// valueKey 是作为 value 唯一标识的键名,绑定值为对象类型时必填
const valueKey = this.select.valueKey;
return getValueByPath(a, valueKey) === getValueByPath(b, valueKey);
}
}
getValueByPath
是 util
里面导入的,这个方法主要是用来访问对象指定的属性的:
/**
* 深层次访问对象的属性
* @param {Object} object 目标对象
* @param {string} prop 属性名 xxx.xxx.xxx 形式
*/
export const getValueByPath = function(object, prop) {
prop = prop || '';
// paths => [xxx, xxx, xxx]
// object: {
// xxx: {
// xxx: {
// xxx: 'xxx'
// }
// }
// }
const paths = prop.split('.');
// 把对象保存起来,以免改变了原有对象
let current = object;
let result = null;
for (let i = 0, j = paths.length; i < j; i++) {
const path = paths[i];
if (!current) break;
// 当到达指定的属性名时,返回它的属性值
if (i === j - 1) {
result = current[path];
break;
}
// 否则继续往下遍历
current = current[path];
}
return result;
};
但是在我看来,官方的实现还可以简单一点,因为既然把属性名保存到了数组里,用数组的方法岂不是更好,然后再用一个 while
循环几行代码就能实现:
function getValByPath(obj, path) {
const paths = path.split('.')
let res = obj
let prop
while ((prop = paths.shift())) {
res = res[prop]
}
return res
}
再看一个方法:
// 鼠标移动时触发的事件监听方法
hoverItem() {
// 如果当前项没有被禁用,就设置 select 组件的 `hoverIndex`
// 它的值为当前列表项在 options 数组里的索引
if (!this.disabled && !this.groupDisabled) {
this.select.hoverIndex = this.select.options.indexOf(this);
}
}
主要是当鼠标移动到列表项上时显示出 hover
的状态,具体实现是放在了 select
里面。这里面其他的就需要后续再分析了,真的肝不动了...至于 option-group
里面的内容很简单,和 option
的相差不大,自己稍微扫一眼就行,这里就不写了。
总结与反思
至此,我们总算是把 select
组件的模板部分分析完了,请注意,这才是模板部分,真正的大头来没有来,在 select
里面方法占了大多数,大概 400 行的样子,其他的都是一些属性和生命周期钩子,关于方法的等我下一篇文章,先来总结一下 select
模板:
- 首先,整个
select
组件是由一个输入框和一个下拉框组成 - 输入框使用的是
el-input
组件,下拉框使用的是el-select-menu
组件 el-select-menu
是添加到 body 节点上的,通过v-show
切换显示与隐藏- 下拉框里面包含了一个内置的滚动组件
el-scrollbar
- 在滚动组件里使用了自定义的滚动条
Bar
组件 - 列表项是通过
el-option
和el-option-group
渲染出来的ul
标签和li
标签
针对本文还有一些未解决的问题:
el-tag
组件未详细分析- Vue 动画
transition
组件未分析
最后总结一下我看 select
组件的感受吧,看源码加写这篇文章足足花了我一个周的时间,还只是看了一小部分,不得不说里面涉及到的知识点太多太多了,对于我这样一个前端小白来说实在是难度太大了,这一个星期经常会有看不下去的时候,有一些知识点我从来就没有见过,通过不断地看文档,不断地查博客,渐渐地进入了一种享受的状态,你把部分代码拿到浏览器中跑一下,打个断点一下子就能明白原理(我真的不懂如何把整个项目跑起来,我试了很多方法都没有成功)。这期间我也看了很多 Vue 的教程和 API,也在慢慢更新我对 Vue 的认知,我相信在以后开发中使用 Vue 一定会更加熟练,因为有了实际的项目去理解概念会变得很容易,慢慢地当你的积累足够时你就能形成一个完整的知识闭环。在看源码的时候「多去问几个为什么」,真的很能够帮助你理解它。另外一个就是组件的设计思想,它是一个很抽象的东西,光靠你看一两个组件是没有办法理解的,你需要大量的阅读组件源码,并且知道它解决了什么问题,有什么功能,为什么要这样设计,当你看一个组件的时候能很快搞明白这三个问题那么组件的思想你也就具有了。这是一定要大量阅读和实践的情况下才能有的,光靠你看几篇博客,看两个组件是没有办法实现的。
好了,期待下一篇的文章吧,如果你喜欢这篇文章,不妨点个赞让更多人看见,如果文章中有分析的不对的地方,欢迎指出,也可以加我的微信【Liu472362746】一起讨论,同时详细代码我也会推送至 Github 仓库。
传送门
【2020.3.15】超详细 ElementUI 源码分析 —— Input
【2020.3.16】超详细 ElementUI 源码分析 —— Layout
【2020.3.18】超详细 ElementUI 源码分析 —— Radio