级联选择器,以下图,也是一种经常使用的组件,这个组件会比较复杂一点html
main.vue
和
menu.vue
2个文件,前者表明输入框部分,后者表明下方的级联选择部分,以及附加的js文件popper.js以及vue.popper.js,用来处理弹出框逻辑,前面文章介绍过,这4个文件总代码量2000行左右,首先要明确,Element中把弹出框的逻辑分离出去了,放在专门的popper.js中,由于许多组件都要用到该弹出框。该组件官网代码
点此
先来看main.vue
中的html结构,main.vue
表明输入框部分,简化后的html结构以下vue
<span class="el-cascader">
<el-input>
<template slot="suffix">
<i v-if></i>
<i v-else></i>
</template>
</el-input>
<span class="el-cascader__label">
...
</span>
</span>
复制代码
结构很简单,最外层一个span包裹全部元素,该span相对定位且inline-block,里面是一个<el-input>
输入框组件,该输入框用来搜索目标内容(内容就是级联选择器的data),而后<el-input>
里面一个template做为插槽存放了2个i标签,注意这里的slot="suffix"
这是将这2个i标签做为具名插槽的内容插入<el-input>
中的对应位置,带表了下箭头和清空输入框的按钮。而后是一个span,这个span就是下图中输入框内的文字react
是否是没有发现下拉菜单的html结构?由于下拉菜单是挂载在document.body上的,经过popper.js来控制,因此结构被分离出去了git
先来看最外层span的代码github
<span
class="el-cascader"
:class="[ { 'is-opened': menuVisible, 'is-disabled': cascaderDisabled }, cascaderSize ? 'el-cascader--' + cascaderSize : '' ]"
@click="handleClick"
@mouseenter="inputHover = true"
@focus="inputHover = true"
@mouseleave="inputHover = false"
@blur="inputHover = false"
ref="reference"
v-clickoutside="handleClickoutside"
@keydown="handleKeydown"
>
复制代码
做为组件最外层的span,其功能主要就是点击以后会弹出/隐藏下拉框,前面class部分就是控制该输入框是否禁用的样式,is-opened
这个类很奇怪,源码里没有,并且审查元素也发现该类是空,menuVisible
是组件内data中的变量,控制是否显示下拉菜单,天然能够想到,下面的@click="handleClick"
中有控制该变量的代码,该方法以下ajax
handleClick() {
if (this.cascaderDisabled) return;
this.$refs.input.focus();
if (this.filterable) {
this.menuVisible = true;
return;
}
this.menuVisible = !this.menuVisible;
},
复制代码
首先判断组件是否禁用,若是禁用则直接返回,第二句this.$refs.input.focus()
是获取到该组件内的<el-input>
并让其得到焦点,focus是原生用法,注意这里默认状态下组件内的输入框是readonly只读的,只有在开启了搜索状态下才能得到焦点,而开启搜索由filterable
这个prop控制,用户传入,<el-input>
上:readonly="readonly"
这句话就是控制只读的,readonly是个计算属性,以下正则表达式
readonly() {
const isIE = !this.$isServer && !isNaN(Number(document.documentMode));
return !this.filterable || (!isIE && !this.menuVisible);
}
复制代码
这里首先判断是否是ie浏览器,首先判断是否是服务端渲染,若是是则直接返回false,而后这句话!isNaN(Number(document.documentMode)
就能够很轻松的判断是不是ie,以前我记得通常是用navigator.userAgent.indexOf("MSIE")>0
来判断的,documentMode是一个ie特有属性 chrome
document.documentMode!==undefined
来判断呢这里不明白,难道是怕undefined不是真正的undefined?由于undefined能够被修改。继续看return逻辑,若是是开启搜索状态(filterable为true,那么通常状况下输入框readonly应该为false,表示能够写入),注意这里还要继续判断
(!isIE && !this.menuVisible)
,若是浏览器是ie,那么输入框可写,问题来了,为啥要判断ie呢?这里有点迷糊,我试了下ie和chrome,没看出啥问题来
继续回到handleClick中, if (this.filterable)
这句话说明若是开启了搜索状态,则点击输入框后直接返回,不切换下拉菜单状态,这是合理的,由于搜索状态下须要让下拉菜单一直显示方便你查看,最后一句this.menuVisible = !this.menuVisible
才是真正切换的语句npm
接着看span上的这4句api
@mouseenter="inputHover = true"
@focus="inputHover = true"
@mouseleave="inputHover = false"
@blur="inputHover = false"
复制代码
这是控制是否显示输入框的叉按钮,用于清空输入框,以下图
@keydown="handleKeydown"
这一句,经过打印发现当组件内的input得到焦点时,这个span上的keydown会被触发。
@keydown="handleKeydown"
最后一句这里也很奇怪,给span绑定了一个keydown方法,只有在span得到焦点时按键才触发该方法,仔细观察后发现原来是span里面的input得到焦点触发focus方法, 而后冒泡到父span上触发父span的focus,这时候按键就可以触发父span的keydown
再来看<el-input>
的代码
<el-input
ref="input"
:readonly="readonly"
:placeholder="currentLabels.length ? undefined : placeholder"
v-model="inputValue"
@input="debouncedInputChange"
@focus="handleFocus"
@blur="handleBlur"
@compositionstart.native="handleComposition"
@compositionend.native="handleComposition"
:validate-event="false"
:size="size"
:disabled="cascaderDisabled"
:class="{ 'is-focus': menuVisible }"
>
复制代码
首先要明确这个输入框起到的做用仅仅承载是搜索功能时用户输入的文字,v-model="inputValue"
这句话指定了输入框绑定的值,当用户键入字符时,该值被更新,inputValue是组件内的data中的属性,@input="debouncedInputChange"
这句话声明了input事件绑定的函数,从名字看来这里用到了防抖,简而言之,这里的防抖就是用户输入文字时停顿了多久才触发debouncedInputChange
,由于搜索功能会调用ajax,所以是异步的,须要控制向服务器的请求频率,若是不设置,则输入一个字符触发一次,明显过高频,来看一下debouncedInputChange
this.debouncedInputChange = debounce(this.debounce, value => {
const before = this.beforeFilter(value);
if (before && before.then) {
this.menu.options = [{
__IS__FLAT__OPTIONS: true,
label: this.t('el.cascader.loading'),
value: '',
disabled: true
}];
before
.then(() => {
this.$nextTick(() => {
this.handleInputChange(value);
});
});
} else if (before !== false) {
this.$nextTick(() => {
this.handleInputChange(value);
});
}
});
},
复制代码
这里的debounce是一个高阶函数,一个完整的防抖函数实现,具体可参考npm,第一个参数是防抖时间,第二个参数就是指定的回调函数,返回一个新的函数做为input事件绑定的函数。这个回调函数的参数是value,就是输入框新输入的值,该函数内第一句const before = this.beforeFilter(value)
的beforeFilter是一个函数
beforeFilter: {
type: Function,
default: () => (() => {})
},
复制代码
这个函数是一个函数,before是其返回值,该函数是由用户自定义传入的,目的是做为搜索功能筛选以前的钩子,参数为输入的值,若返回 false 或者返回 Promise 且被 reject,则中止筛选。
接着if (before && before.then)
若是该函数的返回值为true且拥有then方法,说明是个promise,首先修改menu.options为加载状态, 而后在then里面执行this.handleInputChange(value)
进行真正的操做 ,else if那一段说明不是promise且返回值为true,则直接执行handleInputChange方法,这里为啥要用nextTick,暂时还不明白
<el-input>
后面的@compositionstart.native="handleComposition"
监听了一个原生的事件,注意这是在<el-input>
组件上给根元素监听的原生事件而不是给原生html元素监听事件,那么必须用native修饰符
而后注意到mounted方法里有这么一句话
mounted() {
this.flatOptions = this.flattenOptions(this.options);
}
复制代码
这就是在进行经典的数组展平操做,this.options是用户传入的数据数组,用来渲染下拉菜单,而数组的每一个值都是一个对象,有value,label,children,而children就是嵌套的子数组,至关于二级菜单以及多级菜单,那么为啥要展平呢?缘由是搜索功能须要遍历全部数据项,所以展平的数组更容易遍历,下面是代码
flattenOptions(options, ancestor = []) {
let flatOptions = [];
options.forEach((option) => {
const optionsStack = ancestor.concat(option);
if (!option[this.childrenKey]) {
flatOptions.push(optionsStack);
} else {
if (this.changeOnSelect) {
flatOptions.push(optionsStack);
}
flatOptions = flatOptions.concat(this.flattenOptions(option[this.childrenKey], optionsStack));
}
});
return flatOptions;
},
复制代码
原理就是递归操做,判断有没有children项存在,若是有,则递归调用本身,并concat到flatOptions 并返回,不然直接push,这里该方法的第二个参数是用来保存多级菜单的,而后到搜索的代码里看下,核心搜索逻辑以下
let filteredFlatOptions = flatOptions.filter(optionsStack => {
return optionsStack.some(option => new RegExp(escapeRegexpString(value), 'i')
.test(option[this.labelKey]));
});
复制代码
这就是对展开的数组进行filter操做,用正则表达式进行匹配,value就是用户输入的要查询的值,这里optionStack是数组,若是里面任何一项知足,都返回true表示找到,经过some高阶函数最终得到filteredFlatOptions
搜索的结果
经过查看main.vue
的的代码发现html部分并无下拉菜单这个结构,其实下拉菜单是挂载在body上的,那天然会问,输入框部分和下拉菜单部分是如何联系在一块儿的?查看源码发现一个initMenu的方法,该方法在第一次showMenu时会被调用,代码以下
initMenu() {
this.menu = new Vue(ElCascaderMenu).$mount();
this.menu.options = this.options;
this.menu.props = this.props;
this.menu.expandTrigger = this.expandTrigger;
this.menu.changeOnSelect = this.changeOnSelect;
this.menu.popperClass = this.popperClass;
this.menu.hoverThreshold = this.hoverThreshold;
this.popperElm = this.menu.$el;
this.menu.$refs.menus[0].setAttribute('id', `cascader-menu-${this.id}`);
this.menu.$on('pick', this.handlePick);
this.menu.$on('activeItemChange', this.handleActiveItemChange);
this.menu.$on('menuLeave', this.doDestroy);
this.menu.$on('closeInside', this.handleClickoutside);
},
复制代码
注意第一句话this.menu = new Vue(ElCascaderMenu).$mount()
这代表把ElCascaderMenu做为选项对象,而后new了一个Vue的实例出来,这个实例就是下拉菜单实例,ElCascaderMenu就是菜单组件,而$mount()
没有传递参数,表示在文档以外渲染,可是没有挂载到dom,具体的挂载操做在vue-popper.js中进行,这里用this.menu保存了下拉菜单的实例,所以对于用户操做下拉菜单,都能经过this.menu进行事件的处理,所以联系在一块儿了,再看this.popperElm = this.menu.$el
一句话,这一句也很重要,它将下拉菜单的根dom元素赋值给了popperElm,popperElm又是哪里来的呢?是这样来的
const popperMixin = {
props: {
placement: {
type: String,
default: 'bottom-start'
},
appendToBody: Popper.props.appendToBody,
arrowOffset: Popper.props.arrowOffset,
offset: Popper.props.offset,
boundariesPadding: Popper.props.boundariesPadding,
popperOptions: Popper.props.popperOptions
},
methods: Popper.methods,
data: Popper.data,
beforeDestroy: Popper.beforeDestroy
};
复制代码
经过popperMixin将vue-popper.js里面的方法,data等混入输入框这个部分,这样作的目的是可以在这个组件里操做popper组件的相关内容。initMenu中最后几句就是在监听下拉菜单用$emit触发的各类事件
到如今为止仍是没有看到下拉菜单是如何挂载到body上的,initMenu里没有,咱们继续看,当点击输入框时弹出下拉菜单,触发showMenu,进入showMenu
showMenu() {
if (!this.menu) {
this.initMenu();
}
...
this.$nextTick(_ => {
this.updatePopper();
this.menu.inputWidth = this.$refs.input.$el.offsetWidth - 2;
});
},
复制代码
能够看到里面的this.updatePopper
就是进行更新下拉菜单操做,注意这里必定要有nextTick,由于initMenu里修改了data,此时要获取更新后的dom,updatePopper是经过popperMixin混入到输入框部分的,它位于vue-popper.js中
updatePopper() {
const popperJS = this.popperJS;
if (popperJS) {
popperJS.update();
if (popperJS._popper) {
popperJS._popper.style.zIndex = PopupManager.nextZIndex();
}
} else {
this.createPopper();
}
},
复制代码
这里的popperJS是个成熟的popper插件,代码2000多行,有兴趣的能够去了解,这里首先判断popperJS是否存在,第一次操做时确定不存在,进入this.createPopper()
进行初始化操做,继续看this.createPopper()
createPopper() {
...
const popper = this.popperElm = this.popperElm || this.popper || this.$refs.popper;
...
if (this.appendToBody) document.body.appendChild(this.popperElm);
}
复制代码
这里先经过this.popperElm获取到下拉菜单的根dom元素,就是从以前分析的那里获得,而后判断是否要挂载到body上,若是是旧直接appendCHild,所以这里就完成了下拉菜单的挂载,具体的位置更新操做也在这个popperJS里,比较麻烦。
下面来看下拉菜单的html结构
return (
<transition name="el-zoom-in-top" on-before-enter={this.handleMenuEnter} on-after-leave={this.handleMenuLeave}>
<div
v-show={visible}
class={[
'el-cascader-menus el-popper',
popperClass
]}
ref="wrapper"
>
<div x-arrow class="popper__arrow"></div>
{menus}
</div>
</transition>
);
复制代码
这个return表示下拉菜单是经过render渲染函数生成的,相似于react的jsx形式,最外层一个transition声明了组件的动画效果,这个动画效果就是从transform: scaleY(1)到transform: scaleY(0)以及反过来的缩放过程,而后看2个钩子函数,this.handleMenuEnter
在下拉菜单插入dom时触发,那这里面作了什么呢?
handleMenuEnter() {
this.$nextTick(() => this.$refs.menus.forEach(menu => this.scrollMenu(menu)));
}
复制代码
这里包了一层nextTick,由于要保证dom插入完毕才能调用,不然可能会报错。而后看scrollMenu
scrollMenu(menu) {
scrollIntoView(menu, menu.getElementsByClassName('is-active')[0]);
},
复制代码
顾名思义,里面所作的就是将第二个参数的dom元素移入到第一个参数所在的dom的可见范围内,什么意思呢,见下图
export default function scrollIntoView(container, selected) {
if (!selected) {
container.scrollTop = 0;
return;
}
const offsetParents = [];
let pointer = selected.offsetParent;
while (pointer && container !== pointer && container.contains(pointer)) {
offsetParents.push(pointer);
pointer = pointer.offsetParent;
}
const top = selected.offsetTop + offsetParents.reduce((prev, curr) => (prev + curr.offsetTop), 0);
const bottom = top + selected.offsetHeight;
const viewRectTop = container.scrollTop;
const viewRectBottom = viewRectTop + container.clientHeight;
if (top < viewRectTop) {
container.scrollTop = top;
} else if (bottom > viewRectBottom) {
container.scrollTop = bottom - container.clientHeight;
}
}
复制代码
上述代码的核心思想就是不断累加selected元素的offsetTop值,while循环里面就是经过pointer.offsetParent来获取到本身的偏移父级元素,offsetParent就是离本身最近的一个position不为static的祖先元素,而后将其保存为数组,再经过 offsetParents.reduce一句依次累加offsetTop值,最终获得selected元素底部距离container元素顶部的距离。最后再更新container的scrollTop来移动滚动条让元素恰好进入视野,scrollIntoView实际上是h5的新特性,一个新的api,让元素可以移入页面视野范围内,可是不适用于容器内的元素滚动,并且兼容性不是很好。
而后继续看html结构部分v-show={visible}
经过visible控制下拉菜单的显示隐藏,visible是在main.vue中更新的,也就是在用户点击输入框时的showMenu里更新,而后是class部分'el-cascader-menus'
这个类里面声明了一些基本样式,而后el-popper
类让这个下拉菜单距离输入框有个margin。<div x-arrow class="popper__arrow"></div>
则表明下拉菜单的三角形小箭头,这个写法就是经典的3个border透明,一个border有颜色从而造成三角形。
而后div内只有一个{menu},这才是下拉菜单内的ul列表们,这个ul列表是经过下面的方法生成的
const menus = this._l(activeOptions, (menu, menuIndex) => {
...
const items = this._l(menu, item => {
...
return (
<li>...</li>
)
}
return (<ul>{items}</ul)
}
复制代码
这个方法里面很是长,上面是简化后的逻辑,可见就是先生成每一个ul里面的li列表,再生成ul列表,那么问题来了this._l
方法究竟是啥?在本文件和相关文件内是搜不到的,最后发现竟然是Vue源码里面的东西。在Vue源码里搜索到该方法是renderList的别名,renderList以下
export function renderList (
val: any,
render: (
val: any,
keyOrIndex: string | number,
index?: number
) => VNode
): ?Array<VNode> {
let ret: ?Array<VNode>, i, l, keys, key
if (Array.isArray(val) || typeof val === 'string')
ret = new Array(val.length)
for (i = 0, l = val.length; i < l; i++) {
ret[i] = render(val[i], i)
}
...
return ret
}
复制代码
这儿是flow格式的代码,用于类型控制,renderList是个高阶函数,第二个参数要传入一个rander方法,而后里面if的逻辑是若是参数val是数组,就ret[i] = render(val[i], i)
依次执行数组的每一项并将返回结果保存在ret数组中在,最后返回。这个函数就是对传入的val参数的每一项进行处理,而后返回处理后的新数组。因此上面的this._l(activeOptions, (menu, menuIndex)
处理后就返回了一个由<li>
组成的数组,而后插入到html中进行渲染
const menus = this._l(activeOptions, (menu, menuIndex) => {}
这个函数的第二个参数里超级复杂,里面处理了li的各类鼠标事件和键盘事件,具体逻辑就不写了,根本写不完。 最后说一下点击下拉菜单的某项时的click事件函数 首先回顾一下下图
activeItem(item, menuIndex) {
const len = this.activeOptions.length;
this.activeValue.splice(menuIndex, len, item.value);
this.activeOptions.splice(menuIndex + 1, len, item.children);
if (this.changeOnSelect) {
this.$emit('pick', this.activeValue.slice(), false);
} else {
this.$emit('activeItemChange', this.activeValue);
}
},
复制代码
第一个参数是li本身,第二个参数是menu的index,这个值就是前面const menus = this._l(activeOptions, (menu, menuIndex) => {}
里面传过来的值,表明了第几级菜单。而后先获取到activeOptions的长度,activeOptions是啥呢,它就是当前激活的选项列表,好比以下图的状态
this.activeValue.splice(menuIndex, len, item.value)
这句话,activeValue就是咱们所选择的激活项构成的数组,上图的activeValue就是['指南','设计原则','一致'],splice是用来从数组中添加删除项目
则上面的splice从menuIndex处开始删除,删除了len个元素,再把item.value新选择的值加入到数组中从而更新了所选的项目,注意下一句this.activeOptions.splice(menuIndex + 1, len, item.children)
这里第一个参数是menuIndex+1,是由于要删除本身的子菜单而不是本身,因此是下一个位置,而后将新的子菜单加入数组
这个组件的代码有点复杂,还有部分代码看不懂,反正慢慢看,第一次看确定不少地方不明白