接下来是一段很痛苦的时间!!html
今天详细分析 Select 源码,在看到源码足足有九百行时,我整我的都是懵的,这是迄今为止读的最多的一篇源码,大概浏览了以后发现它里面有不少不少的知识点,光导入的模块就有 16 个,里面包含着各类组件、混入以及工具函数,因此鉴于本文篇幅有限,我打算分两部分来写,分别为:「模板篇」和「方法篇」,模板篇主要是分析 Select 模板以及一些比较简单的属性,方法篇着重分析 methods
里面的各类方法,如今就让咱们一块儿来看一下 Select 组件到底作了哪些事,建议先点赞/收藏再观看。前端
了解一个 UI 组件首先要从它的功能入手,只有了解了组件的具体功能以后才会知道为何要封装?怎样封装?在 ElementUI 官方文档上有详细使用功能及方法,不熟悉的同窗能够先研究一下。Select 主要有如下用法:vue
能够看到,一个 Select 的功能有这么多,基本上把咱们平常需求中须要使用到的功能所有考虑进来了,既然功能这么多,那么封装起来确定就特别麻烦了,毕竟人家九百行代码不是白写的!!node
理清了功能这对于咱们看源码是颇有帮助的,咱们能够根据功能来找对应的代码实现,接下来直接上源码:git
看结构最好不要在源码里直接看,这样很容易懵圈,首先咱们看一下最基础的用法渲染出来的 HTML 结构:github
首先最外层,是一个类名为 el-select
的 div
,若是声明了 size
,还会根据 size
添加 el-select-size
类,里面包裹着的是 el-select__tags
的 div
,咱们暂时先不看与 tags
相关的,先看一下 el-input
。web
这个 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
,这里简单介绍一下二者的区别:浏览器
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
,查看导入的模块可知使用的是 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
的分析了。
先来看一下入口文件 index.js
它导入的 ScrollBar
是src/main
,这才是 el-scrollbar
的组件的文件,官方说了这个文件整个思路是参考了 gemini-scrollbar,我去对比了一下,发现思路果真同样,连命名都是同样的,不过别人的作了兼容。
这里面导入的文件主要是:
utils/resize-event.js
:resize
事件的绑定与解除utils/scrollbar-width.js
:计算滚动条的宽度toObject
:将数组里面的全部对象合并到一个对象上去Bar
:自定义的滚动条组件对于每个文件的源码我都进行了分析,先看 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 解析。
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 move={this.moveX} size={this.sizeWidth}></Bar>,
<Bar vertical move={this.moveY} size={this.sizeHeight}></Bar>
复制代码
传递了两个或者三个参数:
move
:水平或者垂直移动的距离size
:滚动条的大小vertical
:是不是垂直滚动条,不是就为水平的滚动条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
组件,里面仍然是使用了 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
)传过来的。
接下来看它里面的几个方法,都是跟事件绑定有关的:
// 鼠标按钮在 滚动条上 被按下时的事件处理方法
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
这个文件很简单,就是为了计算出系统自带的滚动条的宽度,我看了一下,网上基本上都是这种方法。
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
系统上会怎么样。麻烦各位跑一遍这个方法,而后在评论区告诉我,谢谢。
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-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
组件未详细分析transition
组件未分析最后总结一下我看 select
组件的感觉吧,看源码加写这篇文章足足花了我一个周的时间,还只是看了一小部分,不得不说里面涉及到的知识点太多太多了,对于我这样一个前端小白来讲实在是难度太大了,这一个星期常常会有看不下去的时候,有一些知识点我历来就没有见过,经过不断地看文档,不断地查博客,渐渐地进入了一种享受的状态,你把部分代码拿到浏览器中跑一下,打个断点一会儿就能明白原理(我真的不懂如何把整个项目跑起来,我试了不少方法都没有成功)。这期间我也看了不少 Vue 的教程和 API,也在慢慢更新我对 Vue 的认知,我相信在之后开发中使用 Vue 必定会更加熟练,由于有了实际的项目去理解概念会变得很容易,慢慢地当你的积累足够时你就能造成一个完整的知识闭环。在看源码的时候「多去问几个为何」,真的很可以帮助你理解它。另一个就是组件的设计思想,它是一个很抽象的东西,光靠你看一两个组件是没有办法理解的,你须要大量的阅读组件源码,而且知道它解决了什么问题,有什么功能,为何要这样设计,当你看一个组件的时候能很快搞明白这三个问题那么组件的思想你也就具备了。这是必定要大量阅读和实践的状况下才能有的,光靠你看几篇博客,看两个组件是没有办法实现的。
好了,期待下一篇的文章吧,若是你喜欢这篇文章,不妨点个赞让更多人看见,若是文章中有分析的不对的地方,欢迎指出,也能够加个人微信【Liu472362746】一块儿讨论,同时详细代码我也会推送至 Github 仓库。
【2020.3.15】超详细 ElementUI 源码分析 —— Input
【2020.3.16】超详细 ElementUI 源码分析 —— Layout
【2020.3.18】超详细 ElementUI 源码分析 —— Radio