超详细 ElementUI 源码分析 —— Select(方法篇)

7,189 阅读17分钟

前言

千呼万唤始出来,今天终于是把 Select 组件完完全全搞清楚了,一个小小的下拉框竟然会有这么复杂,确实是看的头皮发麻,其中有很多地方特别绕,也感叹 ele 团队在写的时候思维这么清晰。好了进入正题吧,继上一篇模板的分析之后,很多人看了都觉得很有用,所以我今后的文章会更加用心写,希望能够解答你们心中的疑惑,有相关的知识点分析的不对的欢迎评论或者 issue

本文继续带你看 Select 源码的 methods,在正式开始之前先解决上一篇的遗留问题:

  • el-tag 组件源码
  • transition 动画

Tag 组件

tag 组件是用来「标记」和「选择」的,它有以下功能:

  • 展示标签
  • 可移除
  • 可以动态编辑
  • 提供不同尺寸
  • 切换不同的主题和颜色

看一下它接受的属性:

通过查看源码可以看出 tag 是使用 render 函数渲染出来的,它的结构很简单,外层 span 嵌套了一个图标元素 <i>(如果传入 closable 的话),里面是一个默认的插槽,用来显示标签内容,具体的这里就不展开了,因为它也是一个和 button 一样简单的组件,如果你连 Select 组件都会了还担心这个?

transition 动画组件

transition 组件是 Vue 提供的动画组件,详细介绍的话请看官网,这里就用简短的话语让你能够快速熟悉它。

当插入或删除包含在 transition 组件中的元素时,将会做以下处理:

  • 如果目标元素使用了 CSS 过渡或动画,就会在适当的时机添加/删除 CSS 类名
  • 如果过渡组件提供了 JavaScript 钩子函数,这些函数将会在适当的时候被调用
  • 如果都没有,DOM 操作(插入/删除)将在下一帧中立即执行(浏览器逐帧动画机制)

Vue 提供了 6 个过渡类名:

  • v-enter 定义进入过渡的开始状态
  • v-enter-active 定义进入过渡生效时的状态
  • v-enter-to 定义进入过渡的结束状态
  • v-leave 定义离开过渡的开始状态
  • v-leave-active 定义离开过渡生效时的状态
  • v-leave-to 定义离开过渡的结束状态

借用官网的一张图就能很好的理解这 6 个类名在哪个时间段作用的

同时 Vue 提供可以在属性中声明钩子函数的特性:

<transition
  v-on:before-enter="beforeEnter"
  v-on:enter="enter"
  v-on:after-enter="afterEnter"
  v-on:enter-cancelled="enterCancelled"

  v-on:before-leave="beforeLeave"
  v-on:leave="leave"
  v-on:after-leave="afterLeave"
  v-on:leave-cancelled="leaveCancelled"
>
  <!-- ... -->
</transition>
methods: {
  // --------
  // 进入中
  // --------

  beforeEnter: function (el) {
    // ...
  },
  // 当与 CSS 结合使用时
  // 回调函数 done 是可选的
  enter: function (el, done) {
    // ...
    done()
  },
  afterEnter: function (el) {
    // ...
  },
  enterCancelled: function (el) {
    // ...
  },

  // --------
  // 离开时
  // --------

  beforeLeave: function (el) {
    // ...
  },
  // 当与 CSS 结合使用时
  // 回调函数 done 是可选的
  leave: function (el, done) {
    // ...
    done()
  },
  afterLeave: function (el) {
    // ...
  },
  // leaveCancelled 只用于 v-show 中
  leaveCancelled: function (el) {
    // ...
  }
}

这些代码都是从 Vue 官网可以直接看到的,还有很多关于动画的知识这里不再展开了,先有个了解,看源码的时候会有帮助,毕竟现在我们还不是研究样式的时候。

从功能开始入手

由于这个组件非常的复杂,我尝试了从一些方法入手的时候,会变得非常绕,很多东西都是相互嵌套相互耦合的,而且各个方法之间初看的时候理不清各种联系,所以我还是决定从组件本身的功能入手,这样分析起来会比较简单一点。

既然是从功能入手,那还是先回顾一下上一篇所写的功能吧:

  • 基础用法:就是一个基本的下拉框,下拉框的位置会根据页面上空间大小而选择性地出现在上方或下方
  • 禁用选项:可以禁用某一个选项或者整个下拉框
  • 清空按钮:这个不必多说,input 组件也有这个功能
  • 多选:选择多个下拉选项,选中的选项可以以 tag 形式展示或者合并为一段
  • 自定义下拉选项:下拉选项可以是自定义的样式
  • 分组:可以对下拉选项按照某些类别分组
  • 搜索:与服务器结合,进行搜索联想
  • 创建条目:可以通过输入创建一个 tag 显示在输入框里

为了使大家看的方便,也能够快速理解各种方法的作用,我这里将所有的 methods 里面的函数全部做了分类,暂且就先参照着我这个分类来吧,如果你觉得不合理,你也可以按照你自己的想法分类,主要是为了方便查看。

以上分类仅作为接下来分析的一个参考,接下来就让我们从基础功能入手,逐渐去分解这个复杂的组件。

基础用法

我们在使用下拉框的时候,最基础的用法一般分为 3 步:

  1. 点击输入框,弹出下拉列表
  2. 点击某一项
  3. 下拉列表收起,输入框中展示所选的那一项

从这三步里面我们去寻找对应的方法,看一下它们是如何实现的。首先点击输入框的时候,会弹出下拉列表,这个我们可以在组件的根元素上找到 @click.stop="toggleMenu",这是给整个 Select 组件绑定的 click 事件,它会触发 toggleMenu 方法,这个方法是当你点击输入框时它会弹出下拉框,再一次点击的时候收起来,这样我们的第一步就完成了。

为了节省篇幅,对于一些简单函数的源码我这里就先不粘出来了,大家如果想看源码的逐行分析可以去 github 仓库看一下。这里我只会把整个组件实现的功能的流程讲出来,并且每个函数的具体作用我也会说明

当你点击某一项的时候,就会触发那一项的点击事件,我们在 option.vue 文件里可以找到 @click.stop="selectOptionClick",这个就是处理每一项的点击事件的,在 selectOptionClick 方法里:

// 处理列表项的点击事件
selectOptionClick() {
  if (this.disabled !== true && this.groupDisabled !== true) {
    this.dispatch("ElSelect", "handleOptionClick", [this, true]);
  }
}

可以看出来点击某一项的时候是触发了 Select 组件的 handleOptionClick 方法,在这个方法里的处理就有点麻烦了,因为要考虑到单选和多选的问题,看一下源码吧:

// 点击列表项的时候触发的事件处理函数
handleOptionSelect(option, byClick) {
  ...

  // 不是多选,点击了一个列表项就要隐藏下拉框
  else {
    this.$emit("input", option.value);
    this.emitChange(option.value);
    this.visible = false;
  }

  this.isSilentBlur = byClick;
  this.setSoftFocus();
  if (this.visible) return;
  // 在点击完之后将选中的那一项滚动到下拉框可视区域的最后一个
  this.$nextTick(() => {
    this.scrollToOption(option);
  });
}

这里面又调用了另外一个方法叫 scrollToOption,这个方法的作用是「使选中项能够出现在下拉框的可视区域」,通常出现在最后一个

scrollToOption(option) {
  // 获取 option 的根元素 <li> 标签
  const target =
        Array.isArray(option) && option[0] ? option[0].$el : option.$el;
  if (this.$refs.popper && target) {
    // menu 就是包裹着滚动元素的 div
    const menu = this.$refs.popper.$el.querySelector(
      ".el-select-dropdown__wrap"
    );
    // 滚动到可视区域
    scrollIntoView(menu, target);
  }
  // 执行滚动方法
  this.$refs.scrollbar && this.$refs.scrollbar.handleScroll();
}

scrollIntoView 是一个工具方法,具体作用是让容器中的元素滚动到指定的位置,是通过计算出点击项的 offsetTop+clientHeight 再减去容器的 clientHeight,最终得到的就是容器的 scrollTop

我这里只是在没有分组的情况下简单的分析了一下,具体还要考虑的东西很多,因为源码也比较长,就不贴了,自行去 github 查看

所以经过上面几个方法的调用,我们可以总结出:

  • 点击某一项的时候如果是单选,就直接隐藏下拉框
  • 然后重新聚焦
  • 将所选项滚动到可视区域

这样我们的第二步也算完成了,下拉框隐藏起来之后就该要显示选中的文字了,一般我们都会直接在点击的方法里赋值,使得点击完成就能显示出那一项的文字,但是 ele 官方是通过 visible 的变化来显示的,如果隐藏就需要根据点击的选项来显示文字,我们看一下具体的实现:

// 监听 visible 的变化,用来显示下拉框选择的选项
visible(val) {
  // visible 为 false 时,下拉框不可见
  if (!val) {
    // 通知父组件销毁下拉框
    this.broadcast("ElSelectDropdown", "destroyPopper");
    // 使可输入的输入框失去焦点
    if (this.$refs.input) {
      this.$refs.input.blur();
    }
    // 将这些变量都变成初始化时候的值
    this.query = "";
    ...

    // 更新 placeholder
    this.$nextTick(() => {
      if (
        this.$refs.input &&
        this.$refs.input.value === "" &&
        this.selected.length === 0
      ) {
        this.currentPlaceholder = this.cachedPlaceHolder;
      }
    });
    if (!this.multiple) {
      // 如果有选中的选项
      if (this.selected) {
        if (
          this.filterable &&
          this.allowCreate &&
          this.createdSelected &&
          this.createdLabel
        ) {
          this.selectedLabel = this.createdLabel;
        } else {
          // 将输入框里显示的文字变成选择的那一项
          this.selectedLabel = this.selected.currentLabel;
        }
        if (this.filterable) this.query = this.selectedLabel;
      }

      // 如果开启了搜索但是没有选中的,复原 placeholder
      if (this.filterable) {
        this.currentPlaceholder = this.cachedPlaceHolder;
      }
    }
  }

  // visible 为 true 时
  else {
    this.broadcast("ElSelectDropdown", "updatePopper");
    if (this.filterable) {
      this.query = this.remote ? "" : this.selectedLabel;
      this.handleQueryChange(this.query);
      // 如果是多选就要聚焦 `可以输入的输入框`
      if (this.multiple) {
        this.$refs.input.focus();
      }
      // 不是多选
      else {
        if (!this.remote) {
          this.broadcast("ElOption", "queryChange", "");
          this.broadcast("ElOptionGroup", "queryChange");
        }

        // 将已经选择的文字变成 placeholder,然后清空输入框
        if (this.selectedLabel) {
          this.currentPlaceholder = this.selectedLabel;
          this.selectedLabel = "";
        }
      }
    }
  }

  // 下拉框出现/隐藏时触发 visible-change 事件
  this.$emit("visible-change", val);
}

有点长,因为它有两种情况,需要根据 visible 的真假作出不同的操作,当我们点击完某一个列表项的时候,下拉框隐藏,就会执行 visible = false 的代码,将所选的 option 的值显示到输入框里,这样我们的基础功能就实现了,点击 => 选择 => 显示 就这么完成了,是不是瞬间觉得明朗了许多,我们继续往下看。

禁用

禁用就比较简单了呀,整个组件的禁用是通过 selectDisabled 这个计算属性来完成的,单个选项的禁用则是通过每一个 optiondisabled 属性完成的,就不多说了。

分组

不多说,直接通过 option-group 实现,使用起来和基础功能差不多。

清空

清空是当你传入了 clearable 属性时启用的,首先是清空按钮的展示,有一个计算属性 showClose 专门判断的。清空按钮的展示需要几个条件:

  1. 需要开启可清空功能
  2. 不能被禁用
  3. 鼠标必须放在输入框上
  4. 里面必须有值

然后在模板里面通过 v-if 来展示,通过 handleClearClick 方法来监听点击事件,这个方法里面又调用了 deleteSelected 方法,它是用于删除已经选中的列表项的,具体实现是通过清空 value 值,然后触发 input 事件,将最新的值放进去。

多选

多选功能就与 tag 有关了,在模板里有一个专门的 div 来显示 tags。多选里面又分两种情况:

  • 将所选的选项全部以 tag 的形式展示在输入框里
  • 将第一个展示出来,后面的都以一个数字来展示

所以再让我们回过头看模板里面的东西,第二个 div 就是显示 tags 的。第一种情况 tag 被包在了 transition-group 里,目的是为了在 tag 显示完的时候调整 input 高度,每一个 tag 都是从 selected 里面遍历出来的,点击关闭按钮时会触发 option 本身的 close 事件,然后在 Select 里面进行事件处理,这里使用的是 deleteTag 方法删除 tag 标签,同样该方法里面也是进行了判断,只要没有禁用,就可以触发 tag 的 remove-tag 事件删除标签。

第二种情况下会只显示 selected 数组里面第一个元素,剩下的以数字展示。关于多选点击的逻辑放在了 handleOptionSelect 函数里了,在分析基础用法的时候用到了,但是我把里面多选的逻辑删了,现在重新来看一下:

handleOptionSelect(option, byClick) {
  if (this.multiple) {
    const value = (this.value || []).slice();
    const optionIndex = this.getValueIndex(value, option.value);
    // 如果已经存在于 value 里面,那么点击的时候就移除它
    if (optionIndex > -1) {
      value.splice(optionIndex, 1);
    }
    // 没找到
    // 如果不限制用户选中的数量
    // 就往 value 里添加当前点击的列表项的值
    else if (
      this.multipleLimit <= 0 ||
      value.length < this.multipleLimit
    ) {
      value.push(option.value);
    }
    this.$emit("input", value);
    this.emitChange(value);

    // 如果当前项是创建出来的
    // 那么点击完之后将一些属性重置
    if (option.created) {
      this.query = "";
      this.handleQueryChange("");
      this.inputLength = 20;
    }
    if (this.filterable) this.$refs.input.focus();
  } 
  ...
  this.isSilentBlur = byClick;
  this.setSoftFocus();
  if (this.visible) return;
  // 在点击完之后将选中的那一项滚动到下拉框可视区域的最后一个
  this.$nextTick(() => {
    this.scrollToOption(option);
  });
},

多选的时候会将选中的存放到 value 数组里面,再触发 inputchange 事件将最新的值渲染到输入框,当值变化的时候,会触发 watch 监听 value 的回调:

value(val, oldVal) {
  // 多选
  if (this.multiple) {
    // 需要动态调整输入框高度
    this.resetInputHeight();
    if (
      (val && val.length > 0) ||
      (this.$refs.input && this.query !== "")
    ) {
      this.currentPlaceholder = "";
    }
    // 没有值的时候,placeholder 需要还原
    else {
      this.currentPlaceholder = this.cachedPlaceHolder;
    }
    // 如果不保存当前的搜索关键词就清空输入框里输过的文字
    if (this.filterable && !this.reserveKeyword) {
      this.query = "";
      this.handleQueryChange(this.query);
    }
  }
  
  this.setSelected();
  if (this.filterable && !this.multiple) {
    this.inputLength = 20;
  }
  // 触发表单的 change 事件
  if (!valueEquals(val, oldVal)) {
    this.dispatch("ElFormItem", "el.form.change", val);
  }
}

所以只要 value 有变化,就去调用 setSelected 方法设置被选中的选项:

// 设置被选中的选项
setSelected() {
  // 如果是单选
  // 将选中的那一项的文字设置为输入框里显示的文字
  if (!this.multiple) {
    let option = this.getOption(this.value);
    if (option.created) {
      this.createdLabel = option.currentLabel;
      this.createdSelected = true;
    } else {
      this.createdSelected = false;
    }
    this.selectedLabel = option.currentLabel;
    this.selected = option;
    if (this.filterable) this.query = this.selectedLabel;
    return;
  }
  // 多选的话就需要将所有选择的放进 selected 数组里
  let result = [];
  if (Array.isArray(this.value)) {
    this.value.forEach(value => {
      result.push(this.getOption(value));
    });
  }
  this.selected = result;
  this.$nextTick(() => {
    this.resetInputHeight();
  });
}

通过把 option 存放到数组里,最终在模板上通过 v-for 遍历 selected 就能够展现出所有的 tag,大家可以通过下面这张图理解上面的代码做了什么。

从上面的图闪烁的(就当是闪烁的吧)光标来看,如果同时启用了多选了搜索功能,就会出现一个可编辑的输入框,也就是原生的 input 元素,具体的内容看下面的搜索功能分析。

搜索 & 远程

将这两个功能放一起是因为它们的实现逻辑很类似,只不过远程搜索是需要从服务器返回数据。先从普通的搜索开始,只要使用的时候传入了 filterable 即可启用该功能,普通的搜索使用的是 el-input 渲染出来的输入框。当检测到键盘有按下时,执行 debouncedOnInputChange 方法,这是 ele 对输入框做的一个防抖优化,使用的是第三方 throttle-debounce 库,那个库的源码很简单,简化之后不到 30 行代码,有兴趣可以看一下。

对输入事件做了防抖之后,就需要有函数来处理输入事件了,onInputChange 就来了,它在内部调用了 handleQueryChange 方法,用来处理输入文字发生改变时的事件,看一下它的源码:

// 处理 `输入的查询内容发生变化` 的事件
handleQueryChange(val) {
  // 如果查询出来的或者正在输入就不往下执行
  if (this.previousQuery === val || this.isOnComposition) return;
  if (
    this.previousQuery === null &&
    (typeof this.filterMethod === "function" ||
     typeof this.remoteMethod === "function")
  ) {
    // 将查询的内容先存储起来
    this.previousQuery = val;
    return;
  }
  this.previousQuery = val;
  // 通知下拉框组件更新
  this.$nextTick(() => {
    if (this.visible) this.broadcast("ElSelectDropdown", "updatePopper");
  });
  this.hoverIndex = -1;
  ...
  // 如果使用了 自定义的搜索逻辑
  else if (typeof this.filterMethod === "function") {
    // 执行搜索逻辑
    this.filterMethod(val);
    this.broadcast("ElOptionGroup", "queryChange");
  } else {
    this.filteredOptionsCount = this.optionsCount;
    this.broadcast("ElOption", "queryChange", val);
    this.broadcast("ElOptionGroup", "queryChange");
  }
  // 开启 在输入框按下回车,选择第一个匹配项 的功能
  // 并且当前列表项中有匹配的项时
  if (
    this.defaultFirstOption &&
    (this.filterable || this.remote) &&
    this.filteredOptionsCount
  ) {
    this.checkDefaultFirstOption();
  }
}

这里省略了多选和远程的情况,等会我们再看。省略号前面的是对输入的文字进行检查并存起来,后面的就是真正的搜索,首先判断有没有传入自定义的搜索逻辑,有就先执行自己的逻辑。触发 option 的 queryChange 事件:

// option.vue
// 启用搜索功能输入框的文字发生改变时执行
queryChange(query) {
  // RegExp 的第二个参数为 i 表示区分大小写
  // test() 函数测试一个字符串是否包含某个模式
  // escapeRegexpString 转义正则表达式中特殊的字符串
  // 匹配当前列表项中是否存在输入的字符或者开启了可创建列表项功能
  this.visible =
    new RegExp(escapeRegexpString(query), "i").test(this.currentLabel) ||
    this.created;
  if (!this.visible) {
    // 如果当前这一个列表项不显示,就将 筛选出来的列表项 -1
    this.select.filteredOptionsCount--;
  }
}

这个方法非常巧妙的使用了正则表达式使不匹配的 option 隐藏了起来,从渲染出来的 HTML 结构里更容易看出来

只要某一项的 visiblefalse,就添加 display: none 将它隐藏起来。

这里面还有一个用户友好的功能是按下回车键选中当前 hover 状态下的那一项(当然普通单选也有),这是在 el-input 组件上加了一个 enter 的事件绑定(等会说),而在搜索的时候如果传入了 default-first-option 就能按下回车选中第一个匹配到的,这是通过 checkDefaultFirstOption 实现的,通过这个方法将匹配到的第一个选项高亮,然后按下回车就能直接选中,是不是很 nice。这里面的实现就不细说了,主要是一些逻辑的判断,没有什么难点。

基础搜索解决了,现在就是多选和搜索同时启用了,看 handleQueryChange 省略的部分:

handleQueryChange(val) {
  ...
  // 多选和可搜索同时启用时
  if (this.multiple && this.filterable) {
    this.$nextTick(() => {
      // this.$refs.input 是可以输入的输入框
      // 多选标记的数字长度是 35
      const length = this.$refs.input.value.length * 15 + 20;
      // 如果输入的超过两个字符,那么就固定长度为 50
      this.inputLength = this.collapseTags ? Math.min(50, length) : length;
      this.managePlaceholder();
      this.resetInputHeight();
    });
  }
  ...
}

当它们同时启用时,就需要计算一下输入框的宽度了,只要超过两个字,就固定 50,这个宽度最终会以百分比的形式加到原生的 input 上,这个元素的 input 双向绑定了 query 也就是查询的时候输入的文字,它上面也绑定了很多键盘相关的事件,主要都是为了增加用户体验的。就拿上下按钮来说,可以通过上下按钮来操纵选项的选中状态,然后一按回车就能直接选上了,手都不用碰鼠标了(滑稽)。具体是通过混入一些方法和属性来实现的,在 navigation-mixin.js 文件里有一个 navigateOptions 方法,通过传入的参数来决定选项的 hover 状态。

这里面的实现有一个比较值得学习的地方就是非常巧妙的使用了「递归」来跳过禁用的那一项,直接跳到下一个没有被禁用的选项上。

OK,现在搜索里就只剩下远程了,当我们使用远程搜索的时候有一个细节就是它没有箭头(通过 iconClass 计算属性来实现的)!!这里的远程搜索逻辑就需要我们自己传进入了,官网有例子自行查看,远程搜索就没了...没了...了......

关于搜索还有一些细节就是在没有匹配到时要怎么办,这时候要通过一个计算属性 emptyText 来解决,这里面也是一些逻辑上的判断,什么时候该显示什么提示,当然我们也可以自己传。

创建一个新条目

在模板里有这样一个结构:

<!-- 如果是启用了创建新条目功能,就将输入的文字作为列表项的内容 -->
<el-option :value="query" created v-if="showNewOption"></el-option>

如果是创建的,就在列表项的第一个显示,而显示与否则是由 showNewOption 属性决定的。在输入框里输入要创建的条目,就会自动添加一个新的 tag,所以也很简单呀。

至此,我们的 Select 组件差不多就分析完毕了,本文只是带你从组件功能这个角度入手,一步步去探索组件的功能是怎么实现的,毕竟我们日常开发都是先有需求再编码的,只要把实现功能的思路理清楚了,代码简直 so easy 呀。从整篇文章来看,只要基础功能实现了,其他的都很容易,基本上都是在基础功能上添加一点逻辑判断。所以我们在实现一个组件的时候首先要想清楚它的核心功能是什么,只要这样才不会乱。

还有一个内容是自定义指令 v-clickoutside 问题,由于网上有很多详细的分析,我这里就不写了,具体参考知乎的这篇文章

总结

好了,关于 Select 组件分析已经全部结束了,结合上一篇文章,就能够把整个组件理解的很透彻了,我这里只是系统的梳理了一下,还有很多值得学习的细节没有写出来,主要还是希望能通过这两篇文章对组件的封装有一个更加熟悉的认知,如果你看完之后能简单的封装一个 Select 组件了,那么就证明你已经对组件有了更深的理解。

在总结 Select 组件之前,先说一下我看源码的一点收获吧,由于我之前也很少去看源码,所以没有掌握一些好的技巧来帮助我更快地阅读,走了很多弯路,这些东西只有当你真正看完了才会恍然大悟,从功能入手也是我后来才想出来的,一开始我也是这里看一点,那里看一点,看多了就懵了。所以看源码的时候脑子一定要清醒,理清逻辑,知道下一步要干嘛,虽然有的函数看的过程中不明白,但是你知道了整体的思路就不会打断你继续往下看。

分享一下我看源码的模式,我觉得这种真的很能提高效率:

最后再梳理一下 Select 组件吧:

  • 由输入框 el-input 加上一个下拉菜单 el-select-menu 组成
  • 下拉菜单是添加在 body 节点上的,通过定位使它能够显示在输入框的下方
  • 每一个选项都是一个 option 组件
  • 点击输入框 => 出现下拉框 => 点击 option => 处理逻辑 => 收起下拉框 => 展示

以上就是 Select 组件的全部内容,最后再说一下,不要觉得它很长你就放弃了,学会一次看一小部分,一次实现一个小模块,所有复杂的都是简单模块拼凑的。另外告诉你们一个小技巧,点赞 + 关注比收藏更容易找到。88 ❤️

传送门

【2020.3.15】超详细 ElementUI 源码分析 —— Input

【2020.3.16】超详细 ElementUI 源码分析 —— Layout

【2020.3.18】超详细 ElementUI 源码分析 —— Radio

【2020.4.02】超详细 ElementUI 源码分析 —— Select(模板篇)