简介
Element 的下拉选择器示意图如下
确实做的很漂亮, 交互体验非常好, html 有原生的选择器 < select>, 但是太丑了, 而且各浏览器样式不统一, 因此要做一个漂亮且实用的下拉选择器必须自己模拟全部方法和结构, Element 的下拉选择器代码量非常大, 仅 select.vue 一个文件就快 1000 行, 而且里面是由 Element 的其他组件组合而成, 算上其他组件的话, 又得加上 1000 行, 最后是这个选择器引用了非常多的 util 以及第三方 js, 再加上这些至少得再加 2000 行, 所以只能分析部分核心原理, 下面是下拉选择器的 import
- import Emitter from 'element-ui/src/mixins/emitter';
- import Focus from 'element-ui/src/mixins/focus';
- import Locale from 'element-ui/src/mixins/locale';
- import ElInput from 'element-ui/packages/input';
- import ElSelectMenu from './select-dropdown.vue';
- import ElOption from './option.vue';
- import ElTag from 'element-ui/packages/tag';
- import ElScrollbar from 'element-ui/packages/scrollbar';
- import debounce from 'throttle-debounce/debounce';
- import Clickoutside from 'element-ui/src/utils/clickoutside';
- import { addClass, removeClass, hasClass } from 'element-ui/src/utils/dom';
- import { addResizeListener, removeResizeListener } from 'element-ui/src/utils/resize-event';
- import { t } from 'element-ui/src/locale';
- import scrollIntoView from 'element-ui/src/utils/scroll-into-view';
- import { getValueByPath } from 'element-ui/src/utils/util';
- import { valueEquals } from 'element-ui/src/utils/util';
- import NavigationMixin from './navigation-mixin';
- import { isKorean } from 'element-ui/src/utils/shared';
复制代码
不过这些 import 里面很多东西是值得学习的, 官网代码点此 https://github.com/ElemeFE/element/blob/dev/packages/select/src/select.vue
下拉选择器的 html 结构
还是先来分析这个下拉选择器的 html 结构, 简化后的 html 代码如下
<template>
<div class="el-select">
<div class="el-select__tags"
</div>
<el-input></el-input>
<transition>
<el-select-menu>
<el-select-menu>
</transtion>
</div>
</template>
复制代码
最外层一个 div 包裹所有子元素(相对定位), 里面第一个 div 是展示下拉选择器的 tag 的包裹 div, 如下图, 这个 div 绝对定位, 然后通过
top:50%;transform:translateY(-50%)
垂直居中于最外层的 div 内
然后第二个 < el-input > 是 Element 封装的输入组件, 前面文章介绍过, 这个输入框宽度和最外层的 div 一样, 如下图, 右侧的箭头按钮是放在其 padding 位置上
然后最后的 < transtion > 不是组件, 是 Vue 的过渡动画的标志, 不会渲染出来, 里面包裹着 < el-select-menu > 这也是 Element 封装的组件, 表示弹出的下拉菜单, 也是绝对定位, 所以整个下拉组件只有中间的 input 是相对定位, 其他都是绝对定位, 而且要善于复用自己已有的组件, 而不是又重头写
部分功能源码分析
如果要写完所有功能, 那至少得一周以上, 所以只能写一部分
下拉框主体操作流程逻辑梳理
下面分析下下拉框主体操作流程以及其中的数据传递过程
首先看下下拉框的用法, 官网代码如下
<el-select v-model="value" placeholder="请选择">
<el-option
v-for="item in options"
:key="item.value"
:label="item.label"
:value="item.value">
</el-option>
</el-select>
复制代码
数据部分如下
<script>
export default {
data() {
return {
options: [{
value: '选项 1',
label: '黄金糕'
}, {
value: '选项 2',
label: '双皮奶'
}]
value: ''
}
}
}
</script>
复制代码
可见最外层的 < el-select > 有一个 v-model, 这个是组件的 v-model 用法, 具体参考官网, value 初始为空, 当选择了下拉菜单的某一项后, value 变成那一项的值.<el-select > 标签内是用 v-for 循环出所有的 options,<el-option > 也是 Element 封装的组件, 可以明确上面肯定绑定了 click 事件, options 由 label 和 value 组成, 分别代表该下拉项的显示文本和实际的值, 而 data 中的 options 也提供了对应的 key.
这里注意下 < el-option > 是作为 slot 插槽被插入到 < el-select > 中的, 因此在 < el-select > 需要有 < slot > 来承载内容, 如果组件没有包含一个 元素, 则任何传入它的内容都会被抛弃. 查看 html 代码, 发现 slot 的位置如下
<el-select-menu
<el-scrollbar>
<el-option>
</el-option>
- <slot></slot>
- </el-scrollbar>
- <p
- ...
- </p>
- </el-select-menu>
复制代码
slot 被包含在 < el-scrollbar > 这个滚动条组件内, 这个组件的实现很考验基本功, 略复杂, 代码点此 https://github.com/ElemeFE/element/blob/dev/packages/scrollbar/src/bar.js , 因此所有的 option 选项都会被放入滚动条组件内
当用户点击初始状态下的下拉框, 触发 toggleMenu 显示出下拉菜单, toggleMenu 如下
- toggleMenu() {
- if (!this.selectDisabled) {
- if (this.menuVisibleOnFocus) {
- this.menuVisibleOnFocus = false;
- } else {
- this.visible = !this.visible;
- }
- if (this.visible) {
- (this.$refs.input || this.$refs.reference).focus();
- }
- }
- },
复制代码
由代码可知首先判断是否禁用, 如果是在禁用状态下则不触发事件, 接着判断
this.menuVisibleOnFocus
, 这又是干嘛的呢, 仔细查看源码得知, 当时多选状态下时, 也就是下图中可以多个 tag 并排, 这时组件里面的另一个输入框 (下图光标处) 会渲染出来, 然后该输入框会聚焦, 此时下拉菜单不需要隐藏(方便你查看已有的条目), 所以这里进行了 if 判断.
this.visible = !this.visible
然后这句就是在切换下拉菜单的状态
下拉菜单显示出来后, 点击某个 option, 会关闭下拉菜单且将这个值传递给父组件, 先来看 option 组件的内容
<template>
<li
@mouseenter="hoverItem"
@click.stop="selectOptionClick"
class="el-select-dropdown__item"
v-show="visible"
:class="{
'selected': itemSelected,
'is-disabled': disabled || groupDisabled || limitReached,
'hover': hover
}">
<slot>
<span>{{ currentLabel }}</span>
</slot>
</li>
</template>
复制代码
很简单, 由 li 元素封装而成,
@mouseenter="hoverItem"
这句话说明了当你鼠标 hover 在某项上时触发 hoverItem 事件, 这里你可能会问, 为啥要在鼠标 hover 时做这件事? 其实这里有这个操作: 当你鼠标悬浮在某个 option 上时, 按下 enter 键也能达到选中项的目的, 当然单击也行, 所以在 mouseenter 时就要更新被 hover 的 option, 来看 hoverItem 的内容
- hoverItem() {
- if (!this.disabled && !this.groupDisabled) {
- this.select.hoverIndex = this.select.options.indexOf(this);
- }
- },
复制代码
??? 黑人问号! 这是在干嘛? 仅仅是一条赋值语句, 不慌, 先看 this.select 是啥, 搜索后发现 select 在如下位置
inject: ['select'],
复制代码
它既不是一个 prop 也不是 data, 是依赖注入, 依赖注入的核心思想是让后代组件能够访问到祖先组件的内容, 因为如果是父子组件则通过 $parent 就可以访问父组件, 但是爷爷组件呢? 所以有了依赖注入, 依赖注入的使用很简单, 在祖先组件内声明如下 provide 属性, value 是祖先组件的方法或者属性
- provide: function () {
- return {
- xxMethod: this.xxMethod
- }
- }
复制代码
然后在后代组件内声明如下
inject: ['xxMethod']
复制代码
则在后代组件中可以使用 xxMethod, 回过头来看 option 组件的依赖注入 select, 它的位置在祖先组件(不是父组件)<el-select > 中, 也就是在本文的下拉选择器组件中, 如下
- provide() {
- return {
- 'select': this
- };
- },
复制代码
它返回了 this,this 就是指这个下拉选择器组件的实例, 因此就能通过
this.select.hoverIndex
下拉选择器上的 hoverIndex 属性, 那么继续来分析
this.select.hoverIndex = this.select.options.indexOf(this)
, 这句话的意思是按下回车后, 将鼠标悬浮所在的 option 在 options 里的序号赋值给 hoverIndex, 意思就是找到被悬浮的那个 option 在数组中的序号, 然后其余的逻辑就在 < el-select > 里处理了. 前面说鼠标 hover 时按下 enter 也能够选中, 这是怎么实现的呢? 可以猜到肯定在 input 上绑定了 keydown.enter 事件, 源码里 input 上有这么一句
@keydown.native.enter.prevent="selectOption"
复制代码
这里这么多修饰符闹哪样? native 修饰符是必须的, 官网说在组件用 v-on 只能监听自定义事件, 要监听原生的事件必须用 native 修饰, prevent 是防止触发默认 enter 事件, 比如按下 enter 提交了表单之类的, 肯定不行. 然后看 selectOption 方法
- selectOption() {
- if (!this.visible) {
- this.toggleMenu();
- } else {
- if (this.options[this.hoverIndex]) {
- this.handleOptionSelect(this.options[this.hoverIndex]);
- }
- }
- },
复制代码
这里就用到了 hoverIndex 来更新选中的项, 接下来看 handleOptionSelect 是如何更新所选的项的, 这个方法传入了 option 实例
- handleOptionSelect(option, byClick) {
- if (this.multiple) {
- const value = this.value.slice();
- const optionIndex = this.getValueIndex(value, option.value);
- if (optionIndex> -1) {
- value.splice(optionIndex, 1);
- } else if (this.multipleLimit <= 0 || value.length <this.multipleLimit) {
- value.push(option.value);
- }
- this.$emit('input', value);
- this.emitChange(value);
- ...
- } else {
- this.$emit('input', option.value);
- this.emitChange(option.value);
- this.visible = false;
- }
- ...
- },
复制代码
这里只保留核心逻辑, 可以看出首先要判断是否是多选状态, 因为多选状态下
<el-select v-model="value">
v-model 的 value 是个数组, 单选状态下是一个单独的值, 如果是多选, 首先获得 value 的副本, 这里有必要搞清楚 value 是啥, 其实 value 就是这个组件的一个 prop, 就是 v-model 语法糖拆分开来的产物, 也就是上面的 v-model 中的 value, 也就是用户传入的 data 中的数据项, 所以这个 value 变化了就会导致用户的传入的 value 变化. 接着上面通过 indexOf 在 value 数组中查找是否存在 option 选项, 如果存在则 splice 去除掉, 不存在则 push 进来, 让后通过 emit 触发父组件的 input 事件改变 value, 同时触发父组件的 change 通知用户我的值改变啦! 如果是单选状态, 那就能简单了, 直接 emit 即可.
当直接鼠标点击某个 option 时, 触发
@click.stop="selectOptionClick"
中的 selectOptionClick
- selectOptionClick() {
- if (this.disabled !== true && this.groupDisabled !== true) {
- this.dispatch('ElSelect', 'handleOptionClick', [this, true]);
- }
- },
复制代码
这个方法里面用了通用的 dispatch 方法在 < el-select > 上触发 handleOptionClick 事件, 传入当前 option 实例, 这个 dispatch 其实就是完成了子组件向祖先组件传递事件的逻辑, 在 < el-select > 肯定有一个 on 方法接收该事件, 如下
this.$on('handleOptionClick', this.handleOptionSelect)
复制代码
可以看出这个 handleOptionSelect 和上面说的是一个方法, 因此点击某一个 option 和按 enter 最终都会触发这个方法从而更新 value
综上所述, 这就是一个完整的流程逻辑描述
点击 Select 框外收起下拉菜单
查看最外层的 div 代码
- <div
- class="el-select"
- :class="[selectSize ?'el-select--'+ selectSize :'']" @click.stop="toggleMenu" v-clickoutside="handleClose">
复制代码
这里 @click 绑定了点击事件来切换菜单的隐藏和显示, 下面的
v-clickoutside="handleClose"
是重点, 这是个 Vue 的指令, handleClose 里面的逻辑就是
this.visible = false
设置菜单的 visible 为 false 从而隐藏下拉菜单, 当鼠标点击范围在下拉组件外时, 触发这个 handleClose, 这是个很常见的需求, 不过这里的实现却不是很简单, 核心思想就是给 document 绑定 mouseup 事件, 然后在这个事件里判断点击的 target 是否包含在目标组件内. 这个指令对应的对象通过
import Clickoutside from 'element-ui/src/utils/clickoutside'
引入, 因为很多组件都要用这个方法, 所以给单独抽离出去放在 util 目录下, 代码点此 https://github.com/ElemeFE/element/blob/dev/src/utils/clickoutside.js 进入该方法的 bind 方法内看到如下 2 句
- !Vue.prototype.$isServer && on(document, 'mousedown', e => (startClick = e));
- !Vue.prototype.$isServer && on(document, 'mouseup', e => {
- nodeList.forEach(node => node[ctx].documentHandler(e, startClick));
- });
复制代码
这就给 document 绑定了鼠标按下抬起事件(服务端渲染无效), 按下时记录一个按下的 dom 元素, 抬起时遍历所有有该指令的 dom, 然后执行 documentHandler 进行判断, 该方法如下
- function createDocumentHandler(el, binding, vnode) {
- return function(mouseup = {}, mousedown = {}) {
- if (!vnode ||
- !vnode.context ||
- !mouseup.target ||
- !mousedown.target ||
- el.contains(mouseup.target) ||
- el.contains(mousedown.target) ||
- el === mouseup.target ||
- (vnode.context.popperElm &&
- (vnode.context.popperElm.contains(mouseup.target) ||
- vnode.context.popperElm.contains(mousedown.target)))) return;
- if (binding.expression &&
- el[ctx].methodName &&
- vnode.context[el[ctx].methodName]) {
- vnode.context[el[ctx].methodName]();
- } else {
- el[ctx].bindingFn && el[ctx].bindingFn();
- }
- };
- }
复制代码
注意这个是由 createDocumentHandler 生成一个 documentHandler, 里面的第一个 if 中的
el.contains(mouseup.target),el.contains(mousedown.target)
就通过原生的 contains 方法判断点击处是否被 el 这个 dom 元素包含, 如果是则 return, 如果不包含, 也就是点击在下拉菜单外, 则执行
vnode.context[el[ctx].methodName]()
调用
v-clickoutside="handleClose"
中的 handleClose 方法隐藏下拉菜单, el[ctx].methodName 是在指令的 bind 方法里初始化的, 如下
- bind(el, binding, vnode) {
- nodeList.push(el);
- const id = seed++;
- el[ctx] = {
- id,
- documentHandler: createDocumentHandler(el, binding, vnode),
- methodName: binding.expression,
- bindingFn: binding.value
- };
- },
复制代码
将 expression 赋值给 methodName,ctx 又是啥? ctx 在最上面
const ctx = '@@clickoutsideContext'
这句话我觉得是给 el 这个 dom 加了个属性, 这个属性名字 2 个 @开头, 表示很特殊, 不容易被覆盖, 然后这个属性的值是一个对象, 里面存储了很多信息, 这里的逻辑大体是, 在指令第一次被绑定到 dom 元素时, 给 dom 元素加上要执行的方法等属性, 然后给 document 绑定 mouseup 事件, 后来当用户点击时取出对应的元素的 dom 进行判断, 如果判断为 true 再取出该 dom 上之前绑定的方法进行执行
下拉菜单的定位
你可能觉得这个下拉菜单是绝对定位于输入框, 那就错了, 其实这个下拉框是添加在 document.body 上的
是不是很神奇, 当初始状态没有点击选择框时, 这个下拉菜单 display:none, 这时候是绝对定位且包含在 < el-select > 内, 见下图
然而当我们点击组件时, 这个下拉菜单就跑到 body 上了
为什么要这样做? 官网有说明下拉菜单默认是添加在 body 上的, 不过可以修改. 这是因为 element 用了一个第三方 js:popper.js, 这个是用来专门处理弹出框的 js,1000 多行, 然后 Element 又写了个 vue-popper.vue 来进一步控制, 这个文件里有如下代码
- createPopper() {
- ...
- if (!popper || !reference) return;
- if (this.visibleArrow) this.appendArrow(popper);
- if (this.appendToBody) document.body.appendChild(this.popperElm);
- if (this.popperJS && this.popperJS.destroy) {
- this.popperJS.destroy();
- }
- ...
- this.popperJS = new PopperJS(reference, popper, options);
- this.popperJS.onCreate(_ => {
- this.$emit('created', this);
- this.resetTransformOrigin();
- this.$nextTick(this.updatePopper);
- });
- },
复制代码
creatPopper 就是初始化时进行的逻辑, 里面
if (this.appendToBody) document.body.appendChild(this.popperElm)
这句话就是关键, 通过 appendChild 将弹出的下拉菜单移动到 body 上, 注意 appendChild 如果参数是已存在的元素则会移动它. 然后你会发现鼠标滚轮滚动时下拉菜单也会随着一起移动, 注意下拉菜单是在 body 上的, 那么这里的移动逻辑就是在 popperJS 里实现的, 有点复杂, 首先里面得有个 addEventListener 监听 scroll 事件, 一查果然有
- Popper.prototype._setupEventListeners = function() {
- // NOTE: 1 DOM access here
- this.state.updateBound = this.update.bind(this);
- root.addEventListener('resize', this.state.updateBound);
- // if the boundariesElement is window we don't need to listen for the scroll event
- if (this._options.boundariesElement !== 'window') {
- var target = getScrollParent(this._reference);
- // here it could be both `body` or `documentElement` thanks to Firefox, we then check both
- if (target === root.document.body || target === root.document.documentElement) {
- target = root;
- }
- target.addEventListener('scroll', this.state.updateBound);
- this.state.scrollTarget = target;
- }
- };
复制代码
上面的这句话
target.addEventListener('scroll', this.state.updateBound);
就是绑定了事件监听, 继续看 updateBound, 发现它是通过 update 方法绑定到 this,update 如下
- /**
- * Updates the position of the popper, computing the new offsets and applying the new style
- * @method
- * @memberof Popper
- */
- Popper.prototype.update = function() {
- var data = { instance: this, styles: {} };
- // store placement inside the data object, modifiers will be able to edit `placement` if needed
- // and refer to _originalPlacement to know the original value
- data.placement = this._options.placement;
- data._originalPlacement = this._options.placement;
- // compute the popper and reference offsets and put them inside data.offsets
- data.offsets = this._getOffsets(this._popper, this._reference, data.placement);
- // get boundaries
- data.boundaries = this._getBoundaries(data, this._options.boundariesPadding, this._options.boundariesElement);
- data = this.runModifiers(data, this._options.modifiers);
- if (typeof this.state.updateCallback === 'function') {
- this.state.updateCallback(data);
- }
- };
复制代码
顾名思义, update 就是用来更新弹出框的位置信息, 里面是各种子方法进行对应的位置更新
来源: https://juejin.im/post/5b7fb2da6fb9a019ef32ba9b