从0到1,手把手带你用 vue 实现最简单的树状控件

547 阅读4分钟

从0到1,手把手带你用 vue 实现最简单的树状控件

很多初学者可能觉得实现一个树结构的控件很难, 觉得树状结构很复杂,不好处理

实际上树状控件只需要你对递归数组有基本的掌握,就可以很轻松的实现出来了

1. 数据结构

对于数据结构,其实就是简单的树状结构,每一个节点如果有子节点,那么就放入到该节点的 children 列表中


const list = reactive([
    { 
        id: 1, 
        name: 1,
        // 子节点
        children: [
            { 
                id: 2, 
                name: 2, 
                // 子节点
                children: [
                    { 
                        id: 3, 
                        name: 3 
                    }
                ] 
           },
            {
                id: 4,
                name: 4
            }
        ] 
    },
    {
        id: 5,
        name: 5
    }
]);

需要注意的是,后端有可能只会返回给我们平铺的数据结构,类似于

    [
        {id: 1, name: 1}, 
        {id: 2, name: 2, parentId: 1},
        {id: 4, name: 4, parentId: 1},
        {id: 3, name: 3, parentId: 2},
        {id: 5, name: 5}
    ]

这种就需要我们对数据做处理,将数据转换为树状的结构,方便我们后续渲染


function listToTree (list) {
    let result = []
    for (let item of list) {
        // 如果父节点为空,则直接放入数组中
        if (item.parentId == null) {
            result.push(item)
        } else {
            // 否则找到父节点,放到父节点的 children 列表中
            let index = list.findIndex(child => child.id == item.parentId)
            if (list[index].children == null){
                list[index].children = []
            }
            list[index].children.push(item)
            
        }
        
    }
    return result
}

2. 组件编写

数据结构准备好之后,我们直接就可以开始组件编写了

首先我们需要准备一个父容器 TreeBox,用来处理整体树的逻辑、以及渲染整个第一层级的结构


    <script setup>
    
        
        const props = defineProps({
          data: {
            type: Array,
            default: () => []
          }
        });
    
    
    </script>

    <template>
      <div>
        <TreeItem :key="item.id" v-for="item in data" :value="item"></TreeItem>
      </div>
    </template>


这里 props 传入的 data 就是我们上面准备好的数据结构

模板中通过 for 循环来展示我们树结构中所有顶层的节点

然后我们就可以开始定义 TreeItem 子节点了



    <script setup>
    
        
      const props = defineProps({
          value: {
            type: Object,
            default: () => {}
          }
        });
    
    
    </script>

<template>
  <div>

      
  <div class="list-box">
    <div class="name">
      <div >{{ value.name }}</div>
    </div>
    // 渲染子节点
    <div class="child-box" v-if="value.children">
      <tree-item v-for="item in value.children" :key="item.id" :value="item"></tree-item>
    </div>
  </div>

  </div>
</template>

子节点也是非常简单

value 就是我们刚刚传入的顶层节点本身,然后我们在 child-box 判断节点是否有子元素列表,如果有就循环子元素列表,递归我们的 tree-item 节点

这样就可以一层一层递归渲染我们所有的子节点了

image.png

www.processon.com/view/link/6…

其实最主要是就 递归 , 也就是子节点自己来判断自己是否还需要渲染子节点

子子节点中也有判断自己是否还需要渲染子节点

这样依次判断之后,所有节点就都渲染出来了

如果我们不使用 递归子节点 而是使用 单个节点 循环来做的话,当然也可以,但是会非常的麻烦。

递归子节点 大部分情况是最省事的做法

3. 实现展开和收起

对于展开和收起我们可以用一个数组来进行标记

我们首先来看 treeBox 组件的变化


// 展开的ID数组
const expandedKeys = ref([]);

// 展开,将元素的ID放入
function expand(item) {
  expandedKeys.value = [...new Set([...expandedKeys.value, item.id])];
}

// 收起,移出该元素ID
function unExpand(item) {
  expandedKeys.value = expandedKeys.value.filter((child) => child != item.id);
}


// 展开所有
function expandAll() {
  let list = [];
  for (let item of flatTree) {
    list.push(item.id);
  }
  expandedKeys.value = list;
}

// 收起所有
function unExpandAll() {
  expandedKeys.value = [];
}

需要注意的是,上面我们使用到了 flatTree 这个是树展开后的结构。

这样可以方便的在展开所有时,插入所有需要展开的ID


const flatTree = [];

// 展开树节点到数组中
function doFlattenChildren() {
  flatTree.length = 0;
  if (props.data == null || props.data.length === 0) {
    return;
  }
  function flattenChildren(node, parent) {
    if (parent) {
      node.parent = parent;
    }
    // 将节点放到 flatTree中
    flatTree.push(node);
    // 如果有子节点则循环放入
    if (node.children) {
      for (let child of node.children) {
        flattenChildren(child, node);
      }
    }
  }

  for (let item of props.data) {
    flattenChildren(item);
  }
}


// 初始化和树结构变化时调用
doFlattenChildren();

watch(
  () => props.data,
  () => {
    doFlattenChildren();
  },
  {
    deep: true
  }
);

最后我们需要使用到 vueprovidetreeItem 需要使用到的,展开、不展开,以及展开列表提供给子元素

provide('treeParent', {
  expand,
  unExpand,
  expandedKeys
});


然后我们在 treeItem 中就可以很方便的实现展开收起了


const treeParent = inject('treeParent');


// 判断元素是否展开
const isExpand = computed(() => {
  return treeParent.expandedKeys.value.includes(props.value.id);
});

// 展开 收起 元素
function expand(item) {
  if (isExpand.value) {
    treeParent.unExpand(item);
  } else {
    treeParent.expand(item);
  }
}

模板部分


<div
        :class="isExpand ? 'rotate-90' : ''"
        v-if="value.children && value.children.length > 0"
        style="rotate: -90deg;"
        @click="expand(value)"
      >▼</div>
      <div>{{ value.name }}</div>
    </div>
    // 如果展开才展示
    <div class="child-box" v-if="isExpand && value.children">
      <tree-item v-for="item in value.children" :key="item.id" :value="item"></tree-item>
    </div>

这样我们就轻松实现了元素的展开、收起功能

4. 节点选中

节点选中就更加简单了,我们可以定义一个 selectId 变量,然后通过改变这个变量就实现了选中功能

先看看 treeBox 组件的改变


// 新增 selectId props 和  update:selectId emit 做双向数据绑定
const props = defineProps({
  data: {
    type: Array,
    default: () => []
  },
  
  selectId: {
    type: [String, Number],
    default: null
  }
});


const emit = defineEmits(['update:selectId']);

// 新增 selectIdInner 变量,作为 props.selectId 的缓存

const selectIdInner = ref(props.selectId);


// 选中方法,同时更新内部和外部变量
function selectItem(item) {
  selectIdInner.value = item.id;
  emit('update:selectId', item.id);
}

// 如果传入的 props.selectId 变化 更新 内部缓存
watch(
  () => props.selectId,
  () => {
    selectIdInner.value = props.selectId;
  }
);

// 将 selectIdInner 缓存和 selectItem方法 传递给子组件
provide('treeParent', {
  expand,
  unExpand,
  expandedKeys,
  selectId: selectIdInner,
  selectItem
});


最后子组件只需要判断是否选中就大功告成了


// 调用 treeBox 的 selectItem
function selectItem() {
  treeParent.selectItem(props.value);
}

// 再 div 上 加上对应的方法,和 class 就完成了单选选中了
<div @click="selectItem" :class="{ 'active': treeParent.selectId.value == value.id }">{{ value.name }}</div>

源码可见 vuejs play

后记

这里抛砖引玉,只是实现了一个最简单的树组件

没有样式,没有选择框,只是站在一个初学者可能不太了解如何快速实现树组件的角度,做一定的分享

希望对大家有所帮助