Element中select自定义过滤函数

引言

Element中的el-select组件使用非常方便。

<el-select
    filterable
    v-model="value"
    placeholder="请选择">
    <el-option
        v-for="item in options"
        :key="item.value"
        :label="item.label"
        :value="item.value">
    </el-option>
</el-select>
1
2
3
4
5
6
7
8
9
10
11

像这样,就可以实现一个简单的选择器,并且带有输入框可以 根据用户输入过滤选项。

export default {
    data() {
      return {
        value: '',
        options:[
          {label:'苹果',key:0},
          {label:'梨子',key:1},
          {label:'菠萝',key:2},
          {label:'香蕉',key:3},
        ]
      };
    }
  };
1
2
3
4
5
6
7
8
9
10
11
12
13

但是有一个问题,默认的选择器只会根据label过滤内容, 如果我希望根据options中其他属性过滤应该怎么办呢?

变通方案

网上许多博客给出了一种变通的方案:绑定另一个数组。
代码如下:

<el-select
    filterable
    :filter-method="filterMethod"
    v-model="value"
    placeholder="请选择">
    <el-option
        v-for="item in otherOptions"
        :key="item.value"
        :label="item.label"
        :value="item.value">
    </el-option>
</el-select>
1
2
3
4
5
6
7
8
9
10
11
12

仔细看会发现,这时候v-for中遍历的不是原来的option了, 接着看js代码

export default {
  data() {
    return {
      value: '',
      options:[
        {label:'苹果',eng:'pingguo'},
        {label:'梨子',eng:'lizi'},
        {label:'菠萝',eng:'boluo'},
        {label:'香蕉',eng:'xiangjiao'},
      ]
    };
  },
  methods:{
    filterMethod(query){
      if (query) {
        this.otherOptions = this.options.filter(item => {
          return (
            (item.label && item.label.indexOf(query) > -1) ||
            (item.eng && item.eng.indexOf(query) > -1)
          )
        })
      } else {
        this.otherOptions = this.options
      }
    }
  }
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27

上述代码中,query是输入框中内容。
这个逻辑也很简单,当输入内容的时候判断是否有过滤条件query, 如果没有那么otherOptions就是所有的options, 如果有,那么对item中label和eng属性做判断,如果符合条件, 则将这个item加入otherOptions。
GIF

思考

本来这样就可以正常使用了,但是如果数组比较大, 那么每次输入,都要遍历一遍整个数组吗?
Element本身就可以对options进行筛选,他是怎么实现的呢?
根据element官方的指南, 首先clone下来,所有组件都在package目录下,通过修改examples/play/index.vue文件可以快速看到结果。
修改后可以通过npm run dev:play并访问http:localhost:8085查看修改结果。

源码

既然我们要研究的是select模块,那我们打开package/select.vue,一共八百多行代码,这次我们不需要全部阅读。
在上面模板中,看到其中包含了许多内容,而我关心的只是官方是如何操作“每次输入触发过滤器进行过滤”的。
所以我在打开http:localhost:8085,通过开发者工具找到了相关的HTML代码。如下:

可以看到,这里有一个<input>,去select.vue中查找,找到如下定义:

<input
    type="text"
    class="el-select__input"
    :class="[selectSize ? `is-${ selectSize }` : '']"
    :disabled="selectDisabled"
    :autocomplete="autoComplete || autocomplete"
    @focus="handleFocus"
    @blur="softFocus = false"
    @click.stop
    @keyup="managePlaceholder"
    @keydown="resetInputState"
    @keydown.down.prevent="navigateOptions('next')"
    @keydown.up.prevent="navigateOptions('prev')"
    @keydown.enter.prevent="selectOption"
    @keydown.esc.stop.prevent="visible = false"
    @keydown.delete="deletePrevTag"
    @compositionstart="handleComposition"
    @compositionupdate="handleComposition"
    @compositionend="handleComposition"
    v-model="query"
    @input="debouncedQueryChange"
    v-if="filterable"
    :style="{ 'flex-grow': '1', width: inputLength / (inputWidth - 32) 
    + '%', 'max-width': inputWidth - 42 + 'px' }"
    ref="input">
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25

于是我们发现:

  1. 需要filterable存在才会渲染这个input
  2. input绑定了query
  3. input事件会触发debouncedQueryChange函数,看名字,作用 大概是防抖触发QueryChange
    那么我们自然要去看一下debouncedQueryChange具体是什么样了。

跳转到函数定义处,查看代码。

this.debouncedQueryChange = debounce(this.debounce, (e) => {
  this.handleQueryChange(e.target.value);
});
1
2
3

继续查看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;
  if (this.multiple && this.filterable) {
    const length = this.$refs.input.value.length * 15 + 20;
    this.inputLength = this.collapseTags ? Math.min(50, length) : length;
    this.managePlaceholder();
    this.resetInputHeight();
  }
  if (this.remote && typeof this.remoteMethod === 'function') {
    this.hoverIndex = -1;
    this.remoteMethod(val);
  } 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();
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37

代码比较长,慢慢来看。
前半部分是一些判断,用途应该是减少执行次数、提高性能。
第24行开始才是本文的重点:24行判断了filterMethod是否为function, 如果我们传入自定义的函数,就会在25行执行。
27行进入else,也就是如果没有传入自定义函数,进入28-30行, 29、30行执行了broadcast函数,我们继续跟踪,去看下这个函数有什么用。 发现这个函数定义在src/mixins/emitter.js中,应该是个公用函数。

function broadcast(componentName, eventName, params) {
  this.$children.forEach(child => {
    var name = child.$options.componentName;

    if (name === componentName) {
      child.$emit.apply(child, [eventName].concat(params));
    } else {
      broadcast.apply(child, [componentName, eventName].concat([params]));
    }
  });
}
1
2
3
4
5
6
7
8
9
10
11

大概作用是向指定的子组件遍历“广播”。 29行指定了向ElOption广播queryChange,并且带着val参数。 那么我们自然要去ElOption中看一下具体发生了什么。
进入同级option.vue

<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>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

下面是JavaScript代码,已经删去部分无关代码。

<script type="text/babel">
  export default {
    props: {
      value: {
        required: true
      },
      disabled: {
        type: Boolean,
        default: false
      }
    },
    data() {
      return {
        index: -1,
        visible: true,
      };
    },
    methods: {
      queryChange(query) {
        this.visible = new RegExp(escapeRegexpString(query), 'i')
        .test(this.currentLabel) || this.created;
        if (!this.visible) {
          this.select.filteredOptionsCount--;
        }
      }
    },
    created() {
      this.$on('queryChange', this.queryChange);
    }
  };
</script>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31

代码比较简单。刚才广播了queryChange,那我们找一下。 在created()中发现,广播queryChange时会执行queryChange。 其中queryChange在19行,query是传入的查询条件。
代码中通过正则的方式,判断query是否包含在本组件的currentLabel中, 然后修改this.visible来控制显示或隐藏。
这也符合正常思路:用户进行筛选的时候,除非是远程动态获取options,否则 options本体应该不变,变化的只是结果。 像我们上面的变通方案, 在每一次输入时都有可能改变v-for所绑定的数据,然后触发reflow,实在是南辕北辙。

实现自己的函数

既然整个流程了解了,那么实现起来也很容易, 让select组件进行自己的广播,比如广播myQueryChangeoption组件听到广播后执行自己的函数myQueryChange()来动态修改option.visible。 于是我们回到index.vue,编写我们自己的filterMethod。
初步想法是传入val,然后根据val修改visible。
假设我们的数据结构是

data:[
    {label:'苹果',eng:'apple'},
    {label:'梨子',eng:'pear'},
    {label:'菠萝',eng:'pineapple'},
    {label:'香蕉',eng:'banana'},
    ...
]
1
2
3
4
5
6
7

我们想让用户无论输入中文还是英文都能返回相应的数据。函数如下:

filterMethod(val){
  return function(that){
    let fn=function(query,that){
      that.visible = that.myItem.eng.indexOf(query)>-1||
      that.myItem.label.indexOf(query)>-1
      if (!that.visible) {
        that.select.filteredOptionsCount--;
      }
    }
    that.broadcast('ElOption', 'myQueryChange', [val,fn]);
  }
}
1
2
3
4
5
6
7
8
9
10
11
12

返回一个匿名函数,函数传入that,在这里充当原函数的this,将我们自己的filterMethod 绑定:filter-method="filterMethod"
进入select.vue,继续修改。

else if (typeof this.filterMethod === 'function') {
    let method = this.filterMethod(val);
    method(this);
    this.broadcast('ElOptionGroup', 'queryChange');
}
1
2
3
4
5

修改之前代码25行的位置,如果传入了filterMethod,我们将此时的this传给他。 这里将会执行

that.broadcast('ElOption', 'myQueryChange', [val,fn]);
1

并且将val和fn通过广播传播出去,其中fn()就是我们自定义的业务代码。 到了option.vue中,监听广播myQueryChange,然后触发自己的函数

myQueryChange(query, fn) {
  fn(query, this);
}
1
2
3

这里我们简单的执行fn()即可。记得传入query和that哦。

结果

然后尝试一下?


实现了我们需要的功能。

性能

有个有趣的问题:如果这个option非常大怎么办呢?
那我们试一下呗!
我们先写一个函数用于生成options

function optionsGenerator(num) {
    let ops = [];
    for (let i = 0; i < num; i++) {
        let oddEven = i % 2 === 0 ? 'even' : 'odd';
        let op = {label: '用户' + i, eng: 'user-' + oddEven};
        ops.push(op);
    }
    return ops;
}
let ops = optionsGenerator(10000);

export default { 
  data() {
    return {
        value: '',
        options: '',
        optionFilter:''
    };
  },
  methods: {
      oldFilterMethod(query) {
         if (query) {
           this.optionFilter = this.options.filter(item => {
             return (
               (item.label && item.label.indexOf(query) > -1) ||
               (item.eng && item.eng.indexOf(query) > -1)
             )
           })
           } else {
             this.optionFilter = this.options
           }
      }
  },
  mounted() {
    this.options = ops
    this.optionFilter = ops
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38

mounted()中将生成的数据赋给options和optionsFilter, 通过改变optionsFilter来改变下拉框的数据。
在数据量不大的时候,速度都很快,那么如果数据有一万条呢?


可以看到,每次输入、删除之后,都会有一段时间的卡顿, 那么如果使用我们上面的方法呢?
结果如下:

原因分析

因为都是需要监听用户输入,在这一步都可以做防抖节流等操作, 真正的区别在于如何执行filterMethod方法。

  • 变通方案中,绑定了一个新的数组,在一次执行过程中, 完整遍历了一次数组,然后VUE需要根据遍历结果再完整渲染一次列表, 时间复杂度为O(n);
  • 而新方法中,虽然每次执行也是要完整遍历一次数组,时间复杂度也是O(n), 但是不需要渲染原有的列表,只需要执行option.visible=false, 模板中v-show="visible",也就是说不需要的属性被隐藏了, reflow的开销较小。

总结

本次性能优化效果很明显,主要的性能瓶颈不在于JavaScript的执行, 而在浏览器的渲染上,可以看出,如何减少回流和重绘, 是前端性能优化的一个重要方向。