本文是我学习 ElementUI 源码的第二篇文章,上一篇文章全面的学习了 ElementUI 的 CSS,这篇文章来学习一下 ElementUI 是如何实现 Collapse(折叠面板)组件的。

功能需求

在正式开始学习前,我们先思考一些问题,然后再带着问题去学习,效果会更好。

当我站在用户的角度来看 Collapse 组件时,我会希望它有哪些功能呢?我觉得:

  1. 内容一定是可以折叠的,这是最基本的需求。

  2. 当展开了好几个折叠面板后,再一个一个去关闭好像有点麻烦,可以展开一个面板的时候关闭其他面板吗?就是说一次只展开一个面板?

  3. 如果每个面板中的数据都是需要从远程获取的,那可不可以让没展开的面板就不加载数据呢?这样能让页面速度快一些,还能减小服务器压力。

  4. 头部内容可以自定义吗?使用的时候也许想加点图标啥的。

当我站在开发者的角度来看 Collapse 组件时,如何来实现这些用户需求呢?可能:

  1. 折叠通过 display 就可以实现,但这种闪现效果肯定不友好,还好 Vue 中的提供了 transition 可以制作过渡动画。

  2. 一次展开多个,那标识变量应该是一个数组,一次只展开一个,让这个数组始终是有一个元素就可以了。

  3. 需要在展开的面板发生变化的时候给用户一个通知。

  4. 通过 slot 可以做到。

接下来我们来看一看 ElementUI 的 Collapse 是如何实现的。

组件实现

ElementUI 的 Collapse 组件由两部分组成,collapse 是外层的包裹容器,collapse-item 才是真正可以折叠的组件。为什么会有一个外层包裹容器呢?稍后再做分析。

collapse

模版

collapse 组件的模版比较简单,就只有一个 div,内部的 slot 用于插入模版:

<div class="el-collapse" role="tablist" aria-multiselectable="true">
  <slot></slot>
</div>

collapse 内部可以写入任意内容,但任何事物在设计的时候都是建立在正确使用的基础上的,所以要想得到正确的效果就要按照正确的使用方式来,在使用的时候写入 collapse-tem

逻辑

collapse 的逻辑也比较简单,我们大致按照书写顺序从上往下看。

在导出的对象中,ElementUI 定义了一个自定义属性 componentName,其值为 ElCollapse,表示此组件的名称,和 name 的值是一样的:

export default {
  //...
  name: 'ElCollapse',
  componentName: 'ElCollapse',
  //...
};



 


这个 componentName 属性在 ElementUI 中的每个组件上都有,它是有特殊用途的,稍后再说。

props 中,有两个属性,一个是 accordion,用于设置是否使用的“手风琴”模式;另一个是 value,用于控制展开的折叠面板,其元素的值可以是数组、字符串或数字:

export default {
  //...
  props: {
    accordion: Boolean,
    value: {
      type: [Array, String, Number],
      default() {
        return [];
      }
    }
  },
  //...
};






 
 





data 中只定义了一个属性 activeNames,用于表示已激活的面板:

export default {
  //...
  data() {
    return {
      activeNames: [].concat(this.value)
    };
  },
  //...
};




 




provide 是重点知识,它通常和 inject 配合使用才有意义,首先来看一下 Vue 中官方文档中的释义:

这对选项需要一起使用,以允许一个祖先组件向其所有子孙后代注入一个依赖,不论组件层次有多深,并在起上下游关系成立的时间里始终生效。

简单来说就是让子孙组件能够访问到祖先组件中的一个“东西”,更多内容请查看官方文档open in new window

collapse 中的 provide 如下:

export default {
  //...
  provide() {
    return {
      collapse: this
    };
  },
 //...
};

在这里,ElementUI 直接将 collapse 组件本身作为依赖注入到子孙组件中,这样在 collapse-item 中就可以使用 inject 通过对应的属性名直接拿到 collapse

export default {
  //...
  inject: ['collapse'],
  //...
};

通过这种方式,子孙组件可以直接访问祖先组件中的属性及方法,单纯从 collapsecollapse-item 的关系来看,效果和在 collapse-item 中使用 this.$parent 一样,似乎意义并不大。

但是在有多层嵌套的时候,provide/inject 的优势就显现出来了,再者,可以看到 inject 的值类型为一个数组,是可以使用多个 provide 的,更加便捷。

注意

provide 和 inject 主要为高阶插件/组件库提供用例。并不推荐直接用于应用程序代码中。

这里的 watch 是优化代码,为的是在用户使用的过程中,当修改了传入的 value 值时,页面上能够立马呈现新的内容。

export default {
  //...
  watch: {
      value(value) {
        this.activeNames = [].concat(value);
      }
  },
  //...
};

methods 中定义了两个方法 setActiveNameshandleItemClick,用于实现面板的展开与折叠,具体实现方式我们放到后面再分析。

collapse 组件中的内容大致就是这样,接下来,我们来看看 collapse-item 的内容。

collapse-item

collapse-item 最外层的 div 主要作用是通过 CSS 控制是否启用,比较简单:

  <div class="el-collapse-item"
    :class="{'is-active': isActive, 'is-disabled': disabled }">
    ...
  </div>

collapse-item 内层由 header 和 content 两部分组成,header 用于控制面板的展开与折叠,content 的内容交由用户定义。

header 在外层包裹了一层 div,用于处理可访问性,这部分内容比较多,本文不会涉及,感兴趣的朋友可以查阅相关资料。

  <!--...-->
  <div
    role="tab"
    :aria-expanded="isActive"
    :aria-controls="`el-collapse-content-${id}`"
    :aria-describedby ="`el-collapse-content-${id}`"
  >
    <div
      class="el-collapse-item__header"
      @click="handleHeaderClick"
      role="button"
      :id="`el-collapse-head-${id}`"
      :tabindex="disabled ? undefined : 0"
      @keyup.space.enter.stop="handleEnterClick"
      :class="{
        'focusing': focusing,
        'is-active': isActive
      }"
      @focus="handleFocus"
      @blur="focusing = false"
    >
      <slot name="title">{{title}}</slot>
      <i
        class="el-collapse-item__arrow el-icon-arrow-right"
        :class="{'is-active': isActive}">
      </i>
    </div>
  </div>
  <!--...-->

::: info 思考 为什么可访问性的属性单独放到了外层 div 上呢? :::

内层的 div 是最重要的部分,我们一点一点来看。

ElementUI 提供了三种控制面板展开与折叠的方法,一是通过鼠标点击,二是通过按下空格键,三是通过按下回车键。是否可点击是由最外层的 div 控制的,而是否可以通过按键操作是由tabindex 控制的。

当设置某个面板为 disabled 后,起 tabindex 值为 undefined,这样就无法通过 Tab 键选中此面板的头部,也就无法进行展开与折叠操作;对于为被禁止的面板,其 tabindex 值全都为 0,这样能够保证切换顺序与代码书写顺序一致。

但在实际使用的时候会发现,空格键往往会造成页面向下滚动,体验并不是很好。

展开和折叠面板的方法 handleHeaderClick 代码如下:

export default {
  //...
  handleHeaderClick() {
    if (this.disabled) return;
    this.dispatch('ElCollapse', 'item-click', this);
    this.focusing = false;
    this.isClick = true;
  },
  //...
}




 





重点关注第 5 行,这里用到了一个方法 dispatch,这个方法是从外部引入的,专门用来**“传递”**事件:

import Emitter from 'element-ui/src/mixins/emitter';

我们来看一下 dispatch 方法:

export default {
  methods: {
    dispatch(componentName, eventName, params) {
      var parent = this.$parent || this.$root;
      var name = parent.$options.componentName;

      while (parent && (!name || name !== componentName)) {
        parent = parent.$parent;

        if (parent) {
          name = parent.$options.componentName;
        }
      }
      if (parent) {
        parent.$emit.apply(parent, [eventName].concat(params));
      }
    },
    //...
  }
};














 





这个方法的核心就是通过循环根据 componentName 逐级向上查找对应组件(前文所说的 componentName 的特殊用途就在这里),如果找到,则由 componentName 对应的组件触发 eventName 事件,最终的效果就是要监听事件的组件同时也是触发事件的组件

在触发事件的时候,ElementUI 使用了 apply 方法,这样不用担心参数个数问题,此处传入的 paramsthis

来看一下 collapse 组件中的事件监听:

export default {
  //...
  created() {
    this.$on('item-click', this.handleItemClick);
  }
  //...
};



 



思考

在做事件**“传递”**的时候为什么不直接使用 name,而是单独定义了一个 componentName 呢?

接下我们看看 ElementUI 是如何处理这个事件的:

export default {
  //...
  methods: {
    handleItemClick(item) {
      if (this.accordion) {
        this.setActiveNames(
          (this.activeNames[0] || this.activeNames[0] === 0) &&
          this.activeNames[0] === item.name
            ? '' : item.name
        );
      } else {
        let activeNames = this.activeNames.slice(0);
        let index = activeNames.indexOf(item.name);

        if (index > -1) {
          activeNames.splice(index, 1);
        } else {
          activeNames.push(item.name);
        }
        this.setActiveNames(activeNames);
      }
    }
  },
  //...
};





 
 
 
 
 









 





可以看到处理过程其实比较简单,简单来说就是,如果开启了“手风琴”模式,则直接传递新面板的 name,否则,更新 activeNames 的值;另外,name 值的类型是字符串或者数字,所以 ElementUI 对 0 做了判断,此处的重点在于参数 item 到底是什么。

前文中 collapse-item 调用 dispatch 的时候传入的最后一个参数是 this,也就是说这里的参数 item 实际上就是 collapse-item,所以这里才能使用 item.name 直接拿到其 props 中的 name 值:

export default {
    //...
    props: {
      title: String,
      name: {
        type: [String, Number],
        default() {
          return this._uid;
        }
      },
      disabled: Boolean
    },
    //...
};




 
 
 
 
 
 




在给 name 设置默认值时使用了 this._uid,这是 Vue 的一个属性,每个 Vue 的实例都有一个唯一的递增的 id,可以通过这个属性拿到。

handleItemClick 中调用的方法 setActiveNames 就是用于更新已展开的面板的 name,并触发一个 change 事件,让用户可以在展开的面板更新后处理业务:

export default {
  //...
  methods: {
     setActiveNames(activeNames) {
          activeNames = [].concat(activeNames);
          let value = this.accordion ? activeNames[0] : activeNames;
          this.activeNames = activeNames;
          this.$emit('input', value);
          this.$emit('change', value);
        },
  },
  //...
};







 
 




可以看到,setActiveNames 还触发了一个 input 事件,但在源码和文档中并没有看到有使用到这个方法。

另一个方法 handleEnterClick 中的代码和第 5 行是一样,就不再重复了。

ElementUI 对键盘操作做了友好提示,在 header 中,还绑定了两个方法 focusblur,用于高亮当前焦点(焦点伪类 :focus 不仅仅只能用于表单元素,详见 Collapse 组件的 CSS 样式)所在的面板头部,focus 的代码如下:

export default {
  //...
  methods: {
      handleFocus() {
        setTimeout(() => {
          if (!this.isClick) {
            this.focusing = true;
          } else {
            this.isClick = false;
          }
        }, 50);
      },
  },
  //...
};

之所以要在这里使用 setTimeout,是为了确保焦点所在面板在过渡动画结束前不发生改变,防止当用户操作过快时产生的误操作。

自定义头部内容是通过具名插槽 title 实现,这里有一个小技巧,那就是将绑定 title 的代码直接写到 slot 中,这样一来,如果 slot 有值,就会直接替换绑定代码,而不用写 if 判断。

content

content 的内容相对来说就简单多了,重点是处理好过渡问题:

<!--...-->
<el-collapse-transition>
  <div
    class="el-collapse-item__wrap"
    v-show="isActive"
    role="tabpanel"
    :aria-hidden="!isActive"
    :aria-labelledby="`el-collapse-head-${id}`"
    :id="`el-collapse-content-${id}`"
  >
    <div class="el-collapse-item__content">
      <slot></slot>
    </div>
  </div>
</el-collapse-transition>
<!--...-->

ElementUI 并没有简单的使用 CSS 实现过渡,而是专门写了一个 el-collapse-transition 过渡组件,因为 CSS 过渡不能很好的处理高度问题:

import { addClass, removeClass } from 'element-ui/src/utils/dom';

class Transition {
  beforeEnter(el) {
    addClass(el, 'collapse-transition');
    if (!el.dataset) el.dataset = {};

    el.dataset.oldPaddingTop = el.style.paddingTop;
    el.dataset.oldPaddingBottom = el.style.paddingBottom;

    el.style.height = '0';
    el.style.paddingTop = 0;
    el.style.paddingBottom = 0;
  }

  enter(el) {
    el.dataset.oldOverflow = el.style.overflow;
    if (el.scrollHeight !== 0) {
      el.style.height = el.scrollHeight + 'px';
      el.style.paddingTop = el.dataset.oldPaddingTop;
      el.style.paddingBottom = el.dataset.oldPaddingBottom;
    } else {
      el.style.height = '';
      el.style.paddingTop = el.dataset.oldPaddingTop;
      el.style.paddingBottom = el.dataset.oldPaddingBottom;
    }

    el.style.overflow = 'hidden';
  }

  afterEnter(el) {
    // for safari: remove class then reset height is necessary
    removeClass(el, 'collapse-transition');
    el.style.height = '';
    el.style.overflow = el.dataset.oldOverflow;
  }

  beforeLeave(el) {
    if (!el.dataset) el.dataset = {};
    el.dataset.oldPaddingTop = el.style.paddingTop;
    el.dataset.oldPaddingBottom = el.style.paddingBottom;
    el.dataset.oldOverflow = el.style.overflow;

    el.style.height = el.scrollHeight + 'px';
    el.style.overflow = 'hidden';
  }

  leave(el) {
    if (el.scrollHeight !== 0) {
      // for safari: add class after set height, or it will jump to zero height suddenly, weired
      addClass(el, 'collapse-transition');
      el.style.height = 0;
      el.style.paddingTop = 0;
      el.style.paddingBottom = 0;
    }
  }

  afterLeave(el) {
    removeClass(el, 'collapse-transition');
    el.style.height = '';
    el.style.overflow = el.dataset.oldOverflow;
    el.style.paddingTop = el.dataset.oldPaddingTop;
    el.style.paddingBottom = el.dataset.oldPaddingBottom;
  }
}

export default {
  name: 'ElCollapseTransition',
  functional: true,
  render(h, { children }) {
    const data = {
      on: new Transition()
    };

    return h('transition', data, children);
  }
};

第 1 行中引入了添加/移除 class 的方法,内容并不复杂,这里就不展开了,我们具体来看这个 transition 组件的实现。

从代码中可以看出,这个 transition 重点在于处理两个问题,一是何时添加/移除 collapse-transition 这个 class,二是对高度的处理。

collapse-transition 中的代码如下:

.collapse-transition {
  transition: 0.3s height ease-in-out, 0.3s padding-top ease-in-out, 0.3s padding-bottom ease-in-out;
}

在使用 transition 时,我通常都只定义一个过渡属性,或者直接用 all,看到这里才发现,原来还可以写多属性。

对于添加/移除 class,代码注释中已经解释了;而对于高度问题,ElementUI 使用了 dataset 来做缓存,存取都很方便。

enterbeforeLeave 钩子中,都将元素的 overflow 设为了 hidden,这是为了防止在过渡过程中因为内容过高而出现滚动条或内部元素有 margin 而导致展开高度不够的问题,影响美观。

同时,考虑到极少数情况下会有用对 .el-collapse-item__wrap 也设置 padding,所以 ElementUI 也做了处理。

之所以单独写这样一个过渡组件,想必也是开发人员经过一番尝试之后想到的最好的解决方案。

补充

文末,还有一个知识点必须提一下,就是 provide/inject 的使用,在 computed 中有这样一段代码:

export default {
    //...
    computed: {
      isActive() {
        return this.collapse.activeNames.indexOf(this.name) > -1;
      }
    },
    //...
};




 




这段代码的作用是当用户在代码中修改绑定值时实时展开对应面板,前文已经讲过 provide/inject 的引入,这段代码就是实际应用了,可以看到,可以直接通过 this 加上属性名进行访问,非常方便。

至此,Collapse 组件的学习就基本结束了,CSS 部分有兴趣的朋友可以自行查看。

结语

通过本文的分析,最大的收获就是学习到了 ElementUI 对于组件间数据交互的处理方法,如果能适当在项目开发中使用,会有很大帮助。

同时我们也发现,有时候一个看似简单的功能也许并不那么容易实现,必要时需要不断的尝试不同的方法,这也就需要我们对所使用的工具有比较深入的了解,才能更好的使用它。

本文分析基于 ElementUI 2.12.0 版本。

最近更新:
作者: MeFelixWang