基于Vue2.0树形组件的实现

2,620 阅读3分钟

先简单列一下在实现树组件的过程中一些值得关注的节点。

~ 如何调用组件自身

由于树是一个递归的数据结构,必然需要对组件自身的递归调用。 我们只需给组件指定name属性,即可以在组件内部直接使用。此处需要注意的是每次调用都会生成一个独立的作用域。

<!-- html -->
<template>
  <div>
    ...
    <my-tree></my-tree>
  </div>
</template>
<!-- js -->
export default {
  name: 'myTree',
  ...
}
~ props及事件监听如何传递给子组件(以下属性需vue2.4.0+)

我们在使用组件的时候可能会指定一些属性以实现对组件的差异化定制,这就需要我们自己去实现对父组件的属性继承。

<my-tree v-bind="{...$props, ...$attrs}" v-on="$listeners"
  :data="item[props.children]"
  :child-node="true">
</my-tree>

$props表示用户调用时指定并且在组件props中有声明去接收的属性集合,没有声明接收的属性则会归类到$attrs中。但是此时我们肯定不希望继承父组件的数据,所以我们需要自己指定data去覆盖掉父组件中对应的属性。 此外,当我们想触发子组件的监听事件时,由于子组件的调用者是其父组件,然而我们想要通知的是外层树组件的调用者。此时我们就可以借用$listeners$listeners包含了父作用域中的 (不含 .native 修饰器的) v-on 事件监听器,这样不管子组件所处的层级,事件都会直接触发给外层树组件的调用者。 我们可以在子组件中指定一个child-node属性,通过props去接收它,这样就可以非常方便的区分是否是最顶层的作用域。

~ 组件slot

允许用户灵活自定义内容是必不可少的一环,我们不可能兼顾所有的使用的场景,所以slot的使用也是组件的一部分。 1、slot数据的传递 当我们自定义组件slot时,我们需要将当前组件的数据传出去以便用户展示数据的内容。 slot分具名slot和不具名slot(即默认的slot)

子组件 -- 不具名slot (以下两种写法是等价的)
<slot v-bind="{...item}">{{item.label}}</slot>
<slot v-bind:default="{...item}">{{item.label}}</slot>
子组件 -- 具名slot (指定了名字:emptyText)
<slot v-bind:emptyText="keywords">暂无数据</slot>

父组件自定义slot

<my-tree>
  <!-- scope[slotName]的值就是v-bind传递过来的数据 -->
  <template v-slot="scope">
    <div>{{scope.default.xxx}}</div>
  </template>
  <template v-slot:emptyText="scope">
    <span>{{scope.emptyText}}下无搜索结果</span>
  </template>
</my-tree>

2、slot递归 由于组件递归调用自身,所以组件内部需要将slot逐级传递给子组件。 以下以默认slot为例:

<my-tree v-bind="{...$props, ...$attrs}" v-on="$listeners"
  :data="item[props.children]"
  :child-node="true">
  <template v-slot="scope">
    <slot v-bind:default="scope.default"></slot>
  </template>
</my-tree>
~ 组件全局变量

前面提到组件的每次调用都会生成一个独立的作用域,但是很多情况下我们需要一个全局的变量去记录组件的状态,如:当前高亮选中的节点、已展开节点的keys等。 此时我们需求是不论在哪个组件作用域内,修改变量可以达到影响全局的效果,很容易就能想到它--Object对象。所以我们可以通过在顶层组件内定义一个对象,逐级传给所有的子组件,这样我们就能拥有一个全局的变量了。

<!-- 子组件 -->
<my-tree v-bind="{...$props, ...$attrs}" v-on="$listeners"
  :data="item[props.children]"
  :child-node="true"
  :treeGlobal="treeGlobal">
  ...
</my-tree>
<!-- js -->
export default {
  name: 'myTree',
  data() {
    let globalTemp = {
      currentKey: '', // 当前选中的key
      selectedItems: [], // 已选择的节点 (可选状态下)
      openedItems: [], // 已展开的节点
      ...
    }
    // treeGlobal 没有props 接收,在 $sttrsif(this.$attrs.treeGlobal) {
      globalTemp = this.$attrs.treeGlobal;
    }
    return {
      treeGlobal: globalTemp,
      treeData: [],
      ...
    }
  },
  ...
},

这样我们就拥有了一个全局变量treeGlobal,每个子组件对treeGlobal的修改都会在全局范围内生效。 需要注意的是,记录节点是否展开或者选中,最容易想到的方法就是给节点添加对应的属性,然后根据操作修改该属性的状态。然而这个属性是在申明了treeData后再在其子对象上追加上去的,vue并不会监听其值的变化,换句话说,它的变化并不会刷新视图(用 $set 也没用的)。当然可以通过$forceUpdate()去强制刷新视图,但官方并不推荐使用$forceUpdate(),更何况每个需要变化的组件自身都需要触发一下$forceUpdate()($forceUpdate()只会刷新当前组件的内容)。以上,我们可以在全局变量中申明一个对象去记录这些状态,监听的事就可以还给vue自身,我们只需关注数据的变化就好了。

~ 部分代码展示

代码太长就不贴了,贴一点主要的部分,辅助理解。

<!--
自定义props✔ 自定义缩进✔ 自定义行高✔ 内容slot✔ 默认展开(key)✔ 事件响应✔ 选中样式(默认选中及样式可配置)✔ 宽度问题✔ checkbox✔
-->
<template>
  <div :class="['my-tree-box', childNode ? '' : className]" ref="treeOwnSelf">
    <div v-for="item in treeData" :key="item[nodeKey]">
      <div :class="['list-cell-box', 'list-cell-leaf', {'current-cell-style': funCurrentItem(item)}]"
            v-if="item.isLeaf"
            @click="nodeClick(item, 1)"
            :style="{paddingLeft: `${indent * item.treeLevel + 10}px`, ...cellHeightStyle}">
        <div class="list-label-box">
          <div :class="['list-label', {'text-ellipsis': textEllipsis}, {'list-label-checkbox': showCheckbox}]">
            <slot v-bind:default="simplifyItem(item)">{{item[props.label]}}</slot>
          </div>
          <span v-if="showCheckbox"
                :class="['select-checkbox', {'active': nodeSelect[item[nodeKey]]}]"
                @click.stop="checkboxClick(item)"></span>
        </div>
      </div>
      <template v-if="!item.isLeaf">
        <div :class="['list-cell-box', {'current-cell-style': funCurrentItem(item)}]"
              @click="nodeClick(item, 1)"
              :style="{paddingLeft: `${indent * item.treeLevel + 10}px`, ...cellHeightStyle}">
          <div class="list-label-box">
            <span :class="['arrow-box', item.isExpand ? 'arrow-bottom' : 'arrow-top']"
                  @click.stop="nodeClick(item, 0)"></span>
            <div :class="['list-label', {'text-ellipsis': textEllipsis}, {'list-label-checkbox': showCheckbox}]">
              <slot v-bind:default="simplifyItem(item)">{{item[props.label]}}</slot>
            </div>
            <span v-if="showCheckbox"
                  :class="['select-checkbox', {'active': nodeSelect[item[nodeKey]] === 2}, {'half': nodeSelect[item[nodeKey]] === 1}]"
                  @click.stop="checkboxClick(item)"></span>
          </div>
        </div>
        <div class="list-childs-box" v-if="isExpand(item)">
          <my-tree v-bind="{...$props, ...$attrs}" v-on="$listeners"
                    :data="item[props.children]"
                    :child-node="true"
                    :treeGlobal="treeGlobal">
            <template v-slot="scope">
              <slot v-bind:default="scope.default"></slot>
            </template>
          </my-tree>
        </div>
      </template>
    </div>
  </div>
</template>

<!-- props 部分 -->
props: {
  data: {
    type: Array,
    default() {return []}
  },
  props: {
    type: Object,
    default() {
      return {
        label: 'label',
        children: 'children'
      }
    }
  },
  childNode: {
    type: Boolean, // 是否内部循环调用的子节点 若是则不重复调用格式化数据
    default: false
  },
  indent: {
    type: Number, // 缩进
    default: 20
  },
  cellHeight: {
    type: Number, // 单行高度
    default: 34
  },
  nodeKey: {
    type: String, // 必须 设置nodeKey
    default: ''
  },
  currentNodeKey: {
    type: [String, Number], // 当前选中的节点
    default: ''
  },
  highlightCurrent: {
    type: String, // 高亮展示
    default: 'leaf' // 'none': 都不高亮 leaf: 仅叶子节点(默认) all: 所有节点
  },
  className: {
    type: String, // 最顶层样式
    default: ''
  },
  showCheckbox: {
    type: Boolean, // 显示复选框
    default: false
  },
},

附一张效果图

以上,tks~