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

简介

Element的下拉选择器示意图以下html

确实作的很漂亮,交互体验很是好,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里面不少东西是值得学习的,官网代码点此vue

下拉选择器的html结构

仍是先来分析这个下拉选择器的html结构,简化后的html代码以下node

<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内git

而后第二个<el-input>是Element封装的输入组件,前面文章介绍过,这个输入框宽度和最外层的div同样,以下图,右侧的箭头按钮是放在其padding位置上github

而后最后的 <transtion>不是组件,是Vue的过渡动画的标志,不会渲染出来,里面包裹着 <el-select-menu>这也是Element封装的组件,表示弹出的下拉菜单,也是绝对定位, 因此整个下拉组件只有中间的input是相对定位,其余都是绝对定位,并且要善于复用本身已有的组件,而不是又重头写

部分功能源码分析

若是要写完全部功能,那至少得一周以上,因此只能写一部分express

下拉框主体操做流程逻辑梳理

下面分析下下拉框主体操做流程以及其中的数据传递过程
首先看下下拉框的用法,官网代码以下element-ui

<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选项都会被放入滚动条组件内
bash

当用户点击初始状态下的下拉框,触发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就是用来更新弹出框的位置信息,里面是各类子方法进行对应的位置更新

相关文章
相关标签/搜索