Element源码分析系列8-Cascader(级联选择器)

8,608 阅读5分钟

简介

级联选择器,如下图,也是一种常用的组件,这个组件会比较复杂一点

Element中和该组件相关的文件有main.vuemenu.vue2个文件,前者代表输入框部分,后者代表下方的级联选择部分,以及附加的js文件popper.js以及vue.popper.js,用来处理弹出框逻辑,前面文章介绍过,这4个文件总代码量2000行左右,首先要明确,Element中把弹出框的逻辑分离出去了,放在专门的popper.js中,因为许多组件都要用到该弹出框。该组件官网代码点此

级联选择器输入框的html结构

先来看main.vue中的html结构,main.vue代表输入框部分,简化后的html结构如下

<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就是下图中输入框内的文字

注意这里输入框的文字不是直接作为value放在输入框内的,而是一个绝对定位的span放在输入框上,一般我们会直接把选中的文字作为输入框的value填充,但这里没有这么做,因为后面有个搜索功能,需要在输入框内输入文字

是不是没有发现下拉菜单的html结构?因为下拉菜单是挂载在document.body上的,通过popper.js来控制,所以结构被分离出去了

级联选择器输入框的代码分析

先来看最外层span的代码

<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"中有控制该变量的代码,该方法如下

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特有属性

ie返回一个数字,其他浏览器返回undefined,则Numer(undefined)就是NaN,那为啥不直接用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才是真正切换的语句

接着看span上的这4句

@mouseenter="inputHover = true"
@focus="inputHover = true"
@mouseleave="inputHover = false"
@blur="inputHover = false"

这是控制是否显示输入框的叉按钮,用于清空输入框,如下图

mouseenter和mouseleave表示鼠标移入移出span时切换inputHover,注意不是mouseover和mouseout,因为这2者会在子元素上触发。但是focus和blur就奇怪了,因为这是在普通html元素span上绑定的,一般来说只在input上做,span默认没有tabindex,因此按tab无法使得其获得焦点,除非加一个tabindex属性,但是官网又没有说明有这个属性,所以span到底是如何获得焦点的?仔细查看span元素的属性后,如下图

发现它有一个tabindex,但是为-1,-1的意思就是通过tab键无法访问到,这里我有2点不明白,一是这个tabindex属性是如何加上去的,二是span的@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的可见范围内,什么意思呢,见下图

当你选择了rate评分这个项时,再收起菜单,再点击输入框展开菜单,则rate评分项自动进入视野,这就是scrollIntoView的功能

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事件函数 首先回顾一下下图

当我们展开3级菜单时点击了rate评分,那么我们需要得到从一级菜单开始直到末级菜单的这么一个路径文本 组件/form/rate评分 这里菜单里的每一个li都绑定了点击事件,如下

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是啥呢,它就是当前激活的选项列表,比如如下图的状态

我们激活了3个子菜单,那么activeOptions就是下图的这么一个二维数组,存储了3个子菜单的数据

回到头来看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,是因为要删除自己的子菜单而不是自己,所以是下一个位置,然后将新的子菜单加入数组

总结

这个组件的代码有点复杂,还有部分代码看不懂,反正慢慢看,第一次看肯定很多地方不明白