亲宝软件园·资讯

展开

VUE3 TS递归TreeList

老骥farmer 人气:7

前言

乘着活动,水一篇

虽然是标题党,但是不代表咱们的内容不真诚,如果对您各位有用,请不要吝啬您的小手,赞一赞!

今天和大家探讨的问题是,怎样设计一个类似vscode目录系统,也就是个treeList

不着急,您且听我慢慢道来

功能分析

我们这个目录系统的设计,由于我司乃vue为主栈,我们就使用vue3为例开发 ,在此感谢祖师爷尤大,让我等小民有口饭吃

功能如下:

数据结构

一个目录结构,在数据结构上的表示为一个树,表示如下

export const list = [
    {
        id: 1,
        isFolder: true,
        title: 'src',
        pid: null,
        fileNameArr: ['src', 'dist', 'package.json', 'README.md'],
        children: [
            {
                id: 7,
                pid: 1,
                isFolder: false,
                fileNameArr: ['index.js', 'index.vue'],
                title: 'index.js'
            },
            {
                id: 8,
                pid: 1,
                isFolder: false,
                fileNameArr: ['index.js', 'index.vue'],
                title: 'index.vue'
            }
        ]
    },
    {
        id: 2,
        isFolder: true,
        title: 'dist',
        pid: null,
        fileNameArr: ['src', 'dist', 'package.json', 'README.md'],
        children: [
            {
                id: 5,
                pid: 2,
                isFolder: false,
                fileNameArr: ['index.html', 'index.js'],
                title: 'index.html'
            },
            {
                id: 6,
                pid: 2,
                isFolder: false,
                fileNameArr: ['index.html', 'index.js'],
                title: 'index.js'
            },
        ]
    },
    {
        id: 3,
        pid: null,
        title: 'package.json',
        fileNameArr: ['src', 'dist', 'package.json', 'README.md'],
        isFolder: false
    }, {
        id: 4,
        pid: null,
        title: 'README.md',
        fileNameArr: ['src', 'dist', 'package.json', 'README.md'],
        isFolder: false
    }
]

此处我们需要注意几个问题, 为了方便后期操作, 我们需要确保几个字段 isFolder 是否是文件目录

fileNameArr 同层级目录名(为了防止新增名字重复)pid 建立父子关系的pid

在一般情况下后端存储的数据可能是一个数组,

const list = [
    {
        id: 1,
        isFolder: true,
        title: 'src',
        pid: null,
        fileNameArr: ['src', 'dist', 'package.json', 'README.md'],
    },
    {
        id: 7,
        pid: 1,
        isFolder: false,
        fileNameArr: ['index.js', 'index.vue'],
        title: 'index.js'
    },
    {
        id: 8,
        pid: 1,
        isFolder: false,
        fileNameArr: ['index.js', 'index.vue'],
        title: 'index.vue'
    },
    {
        id: 2,
        isFolder: true,
        title: 'dist',
        pid: null,
        fileNameArr: ['src', 'dist', 'package.json', 'README.md'],
    },
    {
        id: 5,
        pid: 2,
        isFolder: false,
        fileNameArr: ['index.html', 'index.js'],
        title: 'index.html'
    },
    {
        id: 6,
        pid: 2,
        isFolder: false,
        fileNameArr: ['index.html', 'index.js'],
        title: 'index.js'
    },
    {
        id: 3,
        pid: null,
        title: 'package.json',
        fileNameArr: ['src', 'dist', 'package.json', 'README.md'],
        isFolder: false
    },
    {
        id: 4,
        pid: null,
        title: 'README.md',
        fileNameArr: ['src', 'dist', 'package.json', 'README.md'],
        isFolder: false
    }
]

我们需要将数组装成tree,此时祭出经典算法

function list2tree(list) {
    list.forEach(child => {
        const pid = child.pid
        if (pid) {
            list.forEach(parent => {
                if (parent.id === pid) {
                    parent.children = parent.children || []
                    parent.children.push(child)
                }
            })
        }
    })
    return list.filter(n => !n.pid)
}

实现方式

本质上来说,他是一个逐级递归的分层的数据,并且每一层的数据和格式都大致相当,只是细节的不同,我们就可以使用vue的递归组件,来解决问题

这就符合关注度分离的原则 我们只关心当前这一层的内容,剩下的层级通过递归来实现 代码如下:

<template>
    <div class="vtl-node" :id="model.id" :class="{ 'vtl-leaf-node': !isFolder, 'vtl-tree-node': isFolder }">
        <div :class="treeNodeClass" >
            <div class="vtl-border-text">
                <span class="vtl-node-content ellipsis" v-if="!editable && !model.isAdd">
                    {{ model.title }}
                </span>
            </div>
        </div>
    </div>
    <div class="vtl-tree-margin" v-show="expanded" v-if="isFolder">
        <!-- 递归treeList -->
        <treeList  v-for="newmodel in model.children"
            :selected="selected" :model="newmodel" :key="newmodel.id">
        </treeList>
    </div>
</template>
<script setup lang="ts">
import { computed, ref, watchEffect } from 'vue'
interface IFileSystem {
    id: string;
    title: string;
    pid: string;
    isFolder: boolean;
    isAdd: boolean;
    children?: IFileSystem[];
}
// 吐出去的事件
const emit = defineEmits(['onClick', 'changeName', 'deleteNode', 'addNode', 'addFolder', 'onDrop', 'setDragEnterNode', 'setDragFile', 'setDragFolder', 'dragStart'])
// 拿到传入的值
const props = withDefaults(defineProps<{
    model: IFileSystem,
    draggable?: boolean,
    selected?: IFileSystem
}>(), {
    draggable: true,
})
// 修改目录名字
const editable = ref(false)
// 拖拽移入
const isDragEnterNode = ref(false)
// 是否拖拽文件
const isDragFile = ref(false)
// 是否展开
const expanded = ref(true)
// inputRef
const nodeInput = ref(null)
// 是否是文件夹
const isFolder = computed(() => {
    return props.model.isFolder
})
const isSelected = computed(() => props.selected.id === props.model.id)
// 拖拽样式
const treeNodeClass = computed(() => {
    return {
        'vtl-node-main': true,
        'vtl-active': isDragEnterNode.value,
        'vtl-active-file': isDragFile.value,
        'selected': isSelected.value
    }
})
// 最后一个移入的内容保存为了防止重复移入
let lastenter = null;
// 删除目录
</script>
<style lang="scss">
.vtl-node {
    .vtl-node-main {
        display: flex;
        align-items: center;
        padding: 2px 0 2px 1rem;
        cursor: pointer;
        &:hover {
            .vtl-border-text {
                width: 80%;
            }
        }
        .vtl-border-text {
            flex: 1;
            width: 100%;
            .iconfont {
                width: 16px;
                height: 16px;
                vertical-align: text-bottom;
            }
        }
        &.selected {
            background-color: rgb(36, 36, 36);
        }
        .vtl-input {
            border: none;
            max-width: 150px;
            padding: 5px 0;
            padding-left: 5px;
            margin-left: 5px;
            &:focus {
                outline: none;
            }
        }
        .vtl-node-content {
            color: rgb(153, 153, 153);
            padding-left: 5px;
            font-size: 14px;
            width: 80%;
            display: inline-block;
            vertical-align: bottom;
        }
        &:hover {
            .vtl-node-content {
                color: #fff;
                overflow: hidden;
            }
        }
        &.vtl-active {
            * {
                pointer-events: none;
            }
        }
        &.vtl-active-file {
            outline: 2px dashed #353f51;
        }
        .vtl-operation {
            padding-right: 10px;
        }
    }
}
.vtl-tree-margin {
    padding-left: 1em;
}
</style>

到这里,骨架算是搭建好了,效果如下:

接下来,就可以畅通无阻的实现功能了

插件式开发

先说最重要的一点,如果在面试环境中 也是你需要表达的最多的一点,你说的越花哨,你就越能唬住面试官

所谓插件式开发,就是提供数据,插件提供功能

其中有几个关键的点,务必需要表达清楚,(忽悠的越多,您啊可能就工资越高)

我们一个个来解析

插件如何注册

对于vue 来说,插件套路都一样,支持全局注册,和局部注册

// index.ts
import fileSystem from './fileSystem.vue';
// 在install中注册组件
function install(app) {
    app.component('fileSystem', fileSystem)
}
export { fileSystem }
export default {
    install
}
// 在使用的时候
import fileSystem from './components/index'
// 利用use方法俩完成全局组件注册,这也是现在的插件通用套路
createApp(App).use(fileSystem).mount('#app')

插件需要设计那些事件

按照理论来说,你的每一步操作,其实都需要有一个事件抛出,就那我们当前来说

点击事件、拖拽事件、添加文件事件、拖拽事件、删除文件事件、修改文件事件

 <fileSystem :selected="selected" :list="listArr" @add-node="onAddNode" :draggable="true" @delete-node="onDeltet"
      @on-click="onClick" @on-drop="drop" @change-name="onChangeName">
 </fileSystem>
<script setup lang="ts">
//点击目录
const onClick = (node) => {
  selected.value = node
}
// 拖拽结束
const drop = (node) => {
  console.log(node)
}
//  修改名字
const onChangeName = (node) => {
  console.log(node)
}
// 删除
const onDeltet = (node) => {
  console.log(node)
}
// 添加目录
const onAddNode = (node) => {
  console.log(node)
}
</script>

插件需要传入那些值

从目前的需求来看, 我们只需要传入四个参数

插槽内容

之所以需要插槽内容,是由于我们的图标不是固定,为了保证当前的目录的通用性

所以图标必须要放在插槽中,让用户自己定制

 <fileSystem :selected="selected" :list="listArr" @add-node="onAddNode" :draggable="true" @delete-node="onDeltet"
      @on-click="onClick" @on-drop="drop" @change-name="onChangeName">
      <template #icon="{ item }">
        <template v-if="item.isFolder">
          <icon v-if="item.expanded" class="iconfont" iconName="icon-24gf-folderOpen"></icon>
          <icon v-else class="iconfont" iconName="icon-bg-folder"></icon>
        </template>
        <treeIcon class="iconfont" :title="item.title" v-else></treeIcon>
      </template>
      <template #operation="{ type }">
        <i class="iconfont icon-add_file" v-if="type == 'addFolder'"></i>
        <i class="iconfont icon-xinzeng" v-if="type == 'addDocument'"></i>
        <i class="iconfont icon-bianji" v-if="type == 'Editable'"></i>
        <i class="iconfont icon-guanbi" v-if="type == 'deleteNode'"></i>
      </template>
    </fileSystem>

于是我们制定了两个具名插槽,来分别承载,操作按钮,和图标,他就变成这样了

需要注意的是,我们的插槽需要做透传,因为既然是递归组件,那么就需要他的插槽内容发散到子组件的方方面面

我们需要这样

 <treeList v-for="model in list" v-bind="$attrs" :model="model" :key="model.id" @delete-node="onDeltet"
   @add-node="onAddNode" @on-drop="drop" @add-folder="onAddFolder" @dragStart="dragStart">
   <template #icon="slotProps">
     <slot name="icon" v-bind="slotProps"></slot>
   </template>
   <template #operation="slotProps">
     <slot name="operation" v-bind="slotProps"></slot>
   </template>
 </treeList>

支持拖拽功能

效果如下:

在实现拖拽之前,我们需要了解一些基础问题

draggable

draggable 属性规定元素是否可拖动。

<div draggable="true"></div>

拖拽相关事件

开启draggable 之后大家伙可以测试一下,他只有个型,也就是有个样子,但是其实他还应该有个功能,也就是我需要使用,一些操作之后的回调, 来控制内容, 从而实现我们的功能,这个时候这些个拖动事件,必不可少

本次用到的事件如下

利用以下事件的组合来使用,就能达成拖拽的目的

我们来说一下,实现思路

首先,由于是递归组件,我们需要在每一个组件的根div 上绑定事件

  <div  :draggable="draggable" @dragover="dragOver" @drop="drop" @dragstart="dragStart"
             @dragenter="dragEnter" @dragleave="dragLeave" 
           >
            <div class="vtl-border-text">
                <span class="vtl-node-content ellipsis">
                    {{ model.title }}
                </span>    
          </div>
   </div>

接下来一个个来分析这些位事件

dragStart

dragStart 表示拖拽开始触发,这个时候我们需要保存当前组件的数据,但是我们不能保存在当前组件,于是需要向上找,找到最外层,来保存内容

// 拖拽开始
const dragStart = () => {
    console.log(0)
    emit('dragStart', {
        ...props.model
    })
}
//最外层
// 拖拽开始选中node
const dragStart = (node) => {
  compInOperation.value = node
}

dragOver

dragOver 当元素或者选择的文本被拖拽到一个有效的放置目标上时触发

这个事件就有意思了,其实他本来没啥用,但是不用他还不行,因为他会使得drop事件不生效

const dragOver = (e) => {
    // 需要组织默认行为
    e.preventDefault()
    return true
}

dragEnter和dragLeave

dragEnter 当拖动的元素或被选择的文本进入有效的放置目标时触发 dragleave当一个被拖动的元素或者被选择的文本离开一个有效的拖放目标时触发

这俩是一对 ,一个移入一个移出,值得注意的是dragEnter 发生在 dragLeave 之前 并且如果 移动到子元素,这两个事件会再次执行,于是我们需要做特殊处理

//  保存最新的进入节点, 为了解决移动到子元素,这两个事件会再次执问题
let lastenter = null
const dragEnter = (e) => {
    lastenter = e.target;
    console.log('进入', props.model.id)
    // 由于 dragEnter 发生在 dragLeave 之前,导致必须要使用定时器做一个延时
    setTimeout(() => {
        if (isFolder.value) {
            expanded.value = true
            isDragFile.value = true
        } else {
            emit('setDragFile', true)
        }
        isDragEnterNode.value = true
        emit('setDragEnterNode', true)
    });
}
const dragLeave = (e) => {
    // 为了防止多次选中问题
    if (lastenter == e.target) {
        console.log('离开', props.model.id)
        if (isFolder.value) {
            isDragFile.value = false
        } else {
            emit('setDragFile', false)
        }
        emit('setDragEnterNode', false)
        isDragEnterNode.value = false
    }
}

drop

drop 当一个元素或是选中的文字被拖拽释放到一个有效的释放目标位置时触发

这个就比较重要了,他承载着拖拽结束之后,向外抛出事件, 直到跑到最外层

const drop = (e) => {
    isDragFile.value = false
    isDragEnterNode.value = false
    emit('setDragEnterNode', false)
    emit('setDragFile', false)
    // 为了获取路径需要判断是不是文件夹,如果不是文件夹向上找
    if (isFolder.value) {
        emit('onDrop', props.model
        )
    } else {
        if (props.model.pid) {
            emit('setDragFolder')
        } else {
            emit('onDrop', props.model)
        }
    }
}

在当前需求中,由于我们相当于是拖拽到文件夹中, 在拖拽中做响应的判断,为了拿到正确的组件数据

举个例子,我移动到一个文件中,那么我就需要向上寻找,找到上级文件夹,再去抛出事件

所以我们有了emit('setDragFolder') 来找到上级文件夹,抛出事件

// 找到文件夹
const setDragFolder = () => {
    emit('onDrop', props.model)
}

这里需要注意的是,由于是个递归组件,我们需要将事件层层抛出,于是就有了透传事件

   <treeList @on-click="(depth) => $emit('onClick', depth)" @change-name="(depth) => $emit('changeName', depth)"
            @delete-node="(depth) => $emit('deleteNode', depth)" @add-node="(depth) => $emit('addNode', depth)"
            @on-drop="(depth) => $emit('onDrop', depth)" @add-folder="(depth) => $emit('addFolder', depth)"
            @dragStart="(depth) => $emit('dragStart', depth)" @setDragEnterNode="setDragEnterNode"
            @setDragFile="setDragFile" @setDragFolder="setDragFolder" v-for="newmodel in model.children"
            :selected="selected" :model="newmodel" :key="newmodel.id">
        </treeList>

那有人问了,为了不用v-bind='$attrs'来做透传啊,这个招我也试过,但是不灵啊,官方还未解决issues

支持展开收起

支持展开收起,就比较简单了 只需要根据之前isFolder 判断是否是文件夹

// 是否展开
const expanded = ref(true)
// 是否是文件夹
const isFolder = computed(() => {
    return props.model.isFolder
})
// 展开收起
const toggle = () => {
    if (isFolder.value) {
        expanded.value = !expanded.value
    } else {
        emit('onClick', {
            ...props.model
        })
    }
}

支持目录名修改

这个就很简单了通过v-if控制 input 是否显示

  <span class="vtl-node-content ellipsis" v-if="!editable && !model.isAdd">
                    {{ model.title }}
                </span>
 <input v-else class="vtl-input" type="text" ref="nodeInput" v-model="model.title"
@blur="setUnEditable" />
// 修改目录名字
const setUnEditable = (e) => {
    editable.value = false
    props.model.title = e.target.value
    emit('changeName', {
        id: props.model.id,
        pid: props.model.pid,
        isAdd: props.model.isAdd,
        newName: e.target.value,
        eventType: 'blur',
        isFolder: isFolder.value
    })
}

目录支持增删改查

支持增删改查,本质上就是四个方法来来对元数据做修改,并且抛出事件

 <div class="vtl-operation" v-show="isHover && !editable && !model.isAdd">
                <span @click.stop.prevent="addChildFolder" v-if="isFolder">
                    <slot name="operation" type="addFolder"></slot>
                </span>
                <span @click.stop.prevent="addChildDocument" v-if="isFolder">
                    <slot name="operation" type="addDocument"></slot>
                </span>
                <span @click.stop.prevent="setEditable">
                    <slot name="operation" type="Editable"></slot>
                </span>
                <span @click.stop.prevent="delNode">
                    <slot name="operation" type="deleteNode"></slot>
                </span>
            </div>
// 删除目录
const delNode = () => {
    emit('deleteNode', {
        ...props.model,
        eventType: 'delete',
    })
}
// 编辑目录名字
const setEditable = () => {
    editable.value = true
}
// 修改目录名字
const setUnEditable = (e) => {
    editable.value = false
    props.model.title = e.target.value
    emit('changeName', {
        id: props.model.id,
        pid: props.model.pid,
        isAdd: props.model.isAdd,
        newName: e.target.value,
        eventType: 'blur',
        isFolder: isFolder.value
    })
}
// 添加目录
const addChildFolder = () => {
    emit('addFolder', {
        id: props.model.id,
        isFolder: true
    })
}
// 添加文件
const addChildDocument = (node) => {
    emit('addNode', {
        id: props.model.id,
        isFolder: false
    })
}

支持名字重复验证

支持验证重复,其实也很简单,就是根据 fileNameArr 字段来判断

 fileNameArr: ['src', 'dist', 'package.json', 'README.md'],
//判断是否重复
   if (props.model.fileNameArr.includes(e.target.value)) {
        ElMessage({
            message: isFolder.value ? `目录名重复` : '文件名重复',
            type: 'warning',
        })
    }

ok,设计一个插件的方方面面 以及实现思路都给您说到了

您如果有面试,只需要拿着我这个话术,包您过关

总体就是思路就是按照当前这个需求指定几个实现方式,并且列出其中的难点,设计好传入值以及事件,就完事!

源码

treelist

加载全部内容

相关教程
猜你喜欢
用户评论