Element源码分析系列7-Select(下拉选择框)

12,795 阅读10分钟

简介

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里面很多东西是值得学习的,官网代码点此

下拉选择器的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>这个滚动条组件内,这个组件的实现很考验基本功,略复杂,代码点此,因此所有的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目录下,代码点此 进入该方法的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就是用来更新弹出框的位置信息,里面是各种子方法进行对应的位置更新