# 三.数据输出(树)

前言 --> 树组件特点

  • 下拉菜单组件应该由两部分组成:
    • 选中项的文本
    • 待选菜单(默认隐藏)
  • 它的主要功能包括:
    • 节点可以无限延伸(递归)
    • 可以展开/收起子节点
    • 节点可以选中,选中父节点,它的所有子节点也全部被选中,同样,反选父节点,其所有子节点也取消选择
    • 同一级所有子节点选中时,它的父级也自动选中,一直递归判断到根节点

# 1.目录结构

├── tree
│   ├── tree-node-content.vue
│   ├── tree-node.vue
│   ├── tree.vue
│   └── index.js
1
2
3
4
5

Tree 是典型的数据驱动型组件,所以节点的配置就是一个 data,里面描述了所有节点的信息

data: [
  {
    title: "parent 1", // 节点的标题
    expand: true, // 是否展开子节点。开启后,其直属子节点将展开
    children: [
      // 子节点属性数组
      {
        title: "parent 1-1",
        expand: true,
        checked: true, //是否选中该节点。开启后,该节点的 Checkbox 将选中
        children: [
          {
            title: "leaf 1-1-1",
          },
          {
            title: "leaf 1-1-2",
          },
        ],
      },
      {
        title: "parent 1-2",
        children: [
          {
            title: "leaf 1-2-1",
          },
          {
            title: "leaf 1-2-1",
          },
        ],
      },
    ],
  },
]
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

如果一个节点的没有 children 字段,那它就是最后一个节点,这也是递归组件终结的判断依据

同时再提供一个是否显示多个选框的 props:showCheckbox,以及两个 events

  • on-toggle-expand:展开和收起子列表时触发
  • on-check-change:点击复选框时触发

# 2.组件封装

# 2.1 tree.vue

src/components中新建目录tree,并在 tree 下创建两个组件tree.vuenode.vue。tree.vue 是组件的入口,用于接收和处理数据,并将数据传递给 node.vue;node.vue 就是一个递归组件,它构成了每一个节点,即一个可展开/关闭的按钮(+或-)、一个多选框、节点标题以及递归的下一级节点。

tree.vue 主要负责两件事:

  • 1.定义了组件的入口,即组件的 API
  • 2.对接收的数据 props:data 初步处理,为了在 tree.vue 中不破坏使用者传递的源数据 data,所以克隆了一份数据

先来看 tree.vue 的代码

<template>
  <div v-if="cloneData.length">
    <tree-node
      v-for="(item, index) in cloneData"
      :key="index"
      :data="item"
      :show-checkbox="showCheckbox"
    />
  </div>
</template>
  <script>
import TreeNode from "./node.vue";
function typeOf(obj) {
  const toString = Object.prototype.toString;
  const map = {
    "[object Boolean]": "boolean",
    "[object Number]": "number",
    "[object String]": "string",
    "[object Function]": "function",
    "[object Array]": "array",
    "[object Date]": "date",
    "[object RegExp]": "regExp",
    "[object Undefined]": "undefined",
    "[object Null]": "null",
    "[object Object]": "object",
  };
  return map[toString.call(obj)];
}
function deepCopy(data) {
  const t = typeOf(data);
  let o;
  if (t === "array") {
    o = [];
  } else if (t === "object") {
    o = {};
  } else {
    return data;
  }
  if (t === "array") {
    for (let i = 0; i < data.length; i++) {
      o.push(deepCopy(data[i]));
    }
  } else if (t === "object") {
    for (let i in data) {
      o[i] = deepCopy(data[i]);
    }
  }
  return o;
}
export default {
  name: "VueTree",
  components: { TreeNode },
  props: {
    data: {
      type: Array,
      default() {
        return [];
      },
    },
    showCheckbox: {
      type: Boolean,
      default: false,
    },
  },
  data() {
    return {
      cloneData: [],
    };
  },
  created() {
    this.getData();
  },
  watch: {
    data() {
      this.getData();
    },
  },
  methods: {
    getData() {
      this.cloneData = deepCopy(this.data);
    },
    emitEvent(data) {
      this.$emit("change", data);
    },
  },
};
</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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86

在组件 created 时(以及 watch 监听 data 改变时,)调用了rebuildData方法克隆源数据,并赋值给了cloneData

在 template 中,先是渲染了一个 node.vue 组件(tree-node),这一级是 Tree 的根节点,因为 cloneData 是一个数组,所以这个根节点不一定只有一项,有肯能是并列的多项。不过这里使用的 node.vue 还没有用到 Vue.js 的递归调用,它只处理第一级根节点。

<tree-node>组件(node.vue)接受两个props:

  • 1.showCheckbox:与 tree.vue 的 showCheckbox 相同,只是进行传递;
  • 2.data:node.vue 接收的 data 是一个 Object 而非 Array,因为它只负责渲染当前的一个节点,并递归渲染下一个子节点(即 children),所以这里对 cloneData 进行循环,将每一项节点数据赋给了 tree-node。

# 2.2 node.vue

node.vue 是树组件 Tree 的核心,而一个 tree-node 节点包含 4 个部分:

  • 1.展开与关闭的按钮
  • 2.多选框
  • 3.节点标题
  • 4.递归子节点

先来看 node.vue 的基本结构

<template>
  <ul class="tree-ul">
    <li class="tree-li">
      <span class="tree-expand" @click="handleExpand">
        <span v-if="data.children && data.children.length && !data.expand">{{
          "+"
        }}</span>
        <span v-if="data.children && data.children.length && data.expand">{{
          "-"
        }}</span>
      </span>
      <el-checkbox
        v-if="showCheckbox"
        :value="data.checked"
        @input="handleCheck"
      />
      <span>{{ data.title }}</span>
      <template v-if="data.expand && data.children.length">
        <tree-node
          v-for="(item, index) in data.children"
          :key="index"
          :data="item"
          :show-checkbox="showCheckbox"
        />
      </template>
    </li>
  </ul>
</template>
<script>
function findComponentUpward(context, componentName) {
  let parent = context.$parent;
  let name = parent.$options.name;
  while (parent && (!name || [componentName].indexOf(name) < 0)) {
    parent = parent.$parent;
    if (parent) name = parent.$options.name;
  }
  return parent;
}
export default {
  name: "TreeNode",
  props: {
    data: {
      type: Object,
      default: () => ({}),
    },
    showCheckbox: {
      type: Boolean,
      default: false,
    },
  },
  data() {
    return {
        tree: findComponentUpward(this, "VueTree"),
    };
  },
  methods: {
    handleExpand() {
      this.$set(this.data, "expand", !this.data.expand);
      if (this.tree) {
        this.tree.emitEvent("on-toggle-expand", this.data);
      }
    },
  },
};
</script>
<style>
.tree-ul .tree-li {
  list-style: none;
  padding-left: 10px;
}
.tree-expand {
  cursor: pointer;
}
</style>
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
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73

props:data包含了当前节点的所有信息,比如是否展开子节点(expand),是否选中(checked),子节点数据(children)等。

第一部分 expand,如果当前节点不含有子节点,也就是没有 children 字段或 children 的长度为 0,那就说明当前节点已经是最后一级节点,所以不含有展开/收起的按钮。

上一节我们说到,一个 Vue.js 递归组件有两个必要条件:name 特性和终结条件。name 已经指定为 TreeNode,而这个终结递归的条件,就是 v-for="(item, index) in data.children",当 data.children 不存在或为空数组时,自然就不会继续渲染子节点,递归也就停止了。

注意,这里的 v-if="data.expand" 并不是递归组件的终结条件,虽然它看起来像是一个可以为 false 的判断语句,但它的用处是判断当前节点的子节点是否展开(渲染),如果当前节点不展开,那它所有的子节点也就不会展开(渲染)。

上面的代码保留了两个方法 handleExpandhandleCheck,先来看前者。

点击 + 号时,会展开直属子节点,点击 - 号关闭,这一步只需在 handleExpand 中修改 data 的 expand 数据即可,同时,我们通过 Tree 的根组件(tree.vue)触发一个自定义事件 @on-toggle-expand(上文已介绍):

<script>
// node.vue,部分代码省略
import { findComponentUpward } from "../../utils/assist.js"

export default {
  data() {
    return {
      tree: findComponentUpward(this, "Tree"),
    }
  },
  methods: {
    handleExpand() {
      this.$set(this.data, "expand", !this.data.expand)

      if (this.tree) {
        this.tree.emitEvent("on-toggle-expand", this.data)
      }
    },
  },
}
</script>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<script>
// tree.vue,部分代码省略
export default {
  methods: {
    emitEvent(eventName, data) {
      this.$emit(eventName, data, this.cloneData)
    },
  },
}
</script>
1
2
3
4
5
6
7
8
9
10

在 node.vue 中,通过findComponentUpward向上找到 Tree 的实例,并调用它的emitEvent方法来触发自定义事件@on-toggle-expand。之所以使用findComponentUpward寻找组件,而不是用$parent,是因为当前的 node.vue,它的父级不一定就是 tree.vue,因为它是递归组件,父级有可能还是自己。

这里有一点需要注意,修改 data.expand,我们是通过Vue$set方法来修改,并没有像下面这样修改:

this.data.expand = !this.data.expand
1

当选中(或取消选中)一个节点时:

  1. 它下面的所有子节点都会被选中;
  2. 如果同一级所有子节点选中时,它的父级也自动选中,一直递归判断到根节点。

第 1 个逻辑相对简单,当选中一个节点时,只要递归地遍历它下面所属的所有子节点数据,修改所有的 checked 字段即可:

// node.vue,部分代码省略
export default {
  methods: {
    handleCheck(checked) {
      this.updateTreeDown(this.data, checked)

      if (this.tree) {
        this.tree.emitEvent("on-check-change", this.data)
      }
    },
    updateTreeDown(data, checked) {
      this.$set(data, "checked", checked)

      if (data.children && data.children.length) {
        data.children.forEach((item) => {
          this.updateTreeDown(item, checked)
        })
      }
    },
  },
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

一个节点,除了手动选中(或反选),还有就是第 2 种逻辑的被动选中(或反选),也就是说,如果这个节点的所有直属子节点(就是它的第一级子节点)都选中(或反选)时,这个节点就自动被选中(或反选),递归地,可以一级一级响应上去。有了这个思路,我们就可以通过 watch 来监听当前节点的子节点是否都选中,进而修改当前的 checked 字段:

// node.vue,部分代码省略
export default {
  watch: {
    "data.children": {
      handler(data) {
        if (data) {
          const checkedAll = !data.some((item) => !item.checked)
          this.$set(this.data, "checked", checkedAll)
        }
      },
      deep: true,
    },
  },
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14

在 watch 中,监听了 data.children 的改变,并且是深度监听的。这段代码的意思是,当 data.children 中的数据的某个字段发生变化时(这里当然是指 checked 字段),也就是说它的某个子节点被选中(或反选)了,这时执行绑定的句柄 handler 中的逻辑。const checkedAll = !data.some(item => !item.checked); 也是一个巧妙的缩写,checkedAll 最终返回结果就是当前子节点是否都被选中了。

这里非常巧妙地利用了递归的特性,因为 node.vue 是一个递归组件,那每一个组件里都会有 watch 监听 data.children,要知道,当前的节点有两个”身份“,它既是下属节点的父节点,同时也是上级节点的子节点,它作为下属节点的父节点被修改的同时,也会触发上级节点中的 watch 监听函数。这就是递归

# 3.使用案例

刷新
全屏/自适应

总结

通过对前端组件的分析,需要重点关注组件中易变性对组件封装的影响,它会对组件的可复用性、可扩展性产生很大影响