VUE3+TS遞歸組件實現(xiàn)TreeList設計實例詳解
前言
乘著活動,水一篇
雖然是標題黨,但是不代表咱們的內容不真誠,如果對您各位有用,請不要吝嗇您的小手,贊一贊!
今天和大家探討的問題是,怎樣設計一個類似vscode目錄系統(tǒng),也就是個treeList
不著急,您且聽我慢慢道來
功能分析
我們這個目錄系統(tǒng)的設計,由于我司乃vue為主棧,我們就使用vue3為例開發(fā) ,在此感謝祖師爺尤大,讓我等小民有口飯吃
功能如下:
- 1、插件式開發(fā)
- 2、支持拖拽功能
- 3、支持展開收起
- 4、支持目錄名修改
- 5、目錄支持增刪改查
- 6、使用vue3開發(fā)
- 7、支持名字重復驗證
- 8、支持完整事件
數(shù)據(jù)結構
一個目錄結構,在數(shù)據(jù)結構上的表示為一個樹,表示如下
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
在一般情況下后端存儲的數(shù)據(jù)可能是一個數(shù)組,
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 } ]
我們需要將數(shù)組裝成tree,此時祭出經(jīng)典算法
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) }
實現(xiàn)方式
本質上來說,他是一個逐級遞歸的分層的數(shù)據(jù),并且每一層的數(shù)據(jù)和格式都大致相當,只是細節(jié)的不同,我們就可以使用vue的遞歸組件,來解決問題
這就符合關注度分離的原則 我們只關心當前這一層的內容,剩下的層級通過遞歸來實現(xiàn) 代碼如下:
<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>
到這里,骨架算是搭建好了,效果如下:
接下來,就可以暢通無阻的實現(xiàn)功能了
插件式開發(fā)
先說最重要的一點,如果在面試環(huán)境中 也是你需要表達的最多的一點,你說的越花哨,你就越能唬住面試官
所謂插件式開發(fā),就是提供數(shù)據(jù),插件提供功能
其中有幾個關鍵的點,務必需要表達清楚,(忽悠的越多,您啊可能就工資越高)
- 1、插件如何注冊
- 2、插件需要設計那些事件
- 3、插件需要傳入那些值,從而實現(xiàn)更大的靈活性
- 4、插件能包攬那些功能
我們一個個來解析
插件如何注冊
對于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方法倆完成全局組件注冊,這也是現(xiàn)在的插件通用套路 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>
插件需要傳入那些值
從目前的需求來看, 我們只需要傳入四個參數(shù)
- 1、 treelist數(shù)據(jù)字段,這個是必須的list
- 2、 是否支持拖拽 draggable
- 3、是否支持修改isEdit
- 4、選中內容 selected
- 5、插槽內容
插槽內容
之所以需要插槽內容,是由于我們的圖標不是固定,為了保證當前的目錄的通用性
所以圖標必須要放在插槽中,讓用戶自己定制
<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>
于是我們制定了兩個具名插槽,來分別承載,操作按鈕,和圖標,他就變成這樣了
需要注意的是,我們的插槽需要做透傳,因為既然是遞歸組件,那么就需要他的插槽內容發(fā)散到子組件的方方面面
我們需要這樣
<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>
支持拖拽功能
效果如下:
在實現(xiàn)拖拽之前,我們需要了解一些基礎問題
draggable
draggable 屬性規(guī)定元素是否可拖動。
<div draggable="true"></div>
拖拽相關事件
開啟draggable 之后大家伙可以測試一下,他只有個型,也就是有個樣子,但是其實他還應該有個功能,也就是我需要使用,一些操作之后的回調, 來控制內容, 從而實現(xiàn)我們的功能,這個時候這些個拖動事件,必不可少
本次用到的事件如下
- 1、dragstart 當用戶開始拖動一個元素或者一個選擇文本觸發(fā)
- 2、dragenter 當拖動的元素或被選擇的文本進入有效的放置目標時觸發(fā)
- 3、dragover 當元素或者選擇的文本被拖拽到一個有效的放置目標上時觸發(fā)
- 4、dragleave當一個被拖動的元素或者被選擇的文本離開一個有效的拖放目標時觸發(fā)
- 5、drop 當一個元素或是選中的文字被拖拽釋放到一個有效的釋放目標位置時觸發(fā)
利用以下事件的組合來使用,就能達成拖拽的目的
我們來說一下,實現(xiàn)思路
首先,由于是遞歸組件,我們需要在每一個組件的根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 表示拖拽開始觸發(fā),這個時候我們需要保存當前組件的數(shù)據(jù),但是我們不能保存在當前組件,于是需要向上找,找到最外層,來保存內容
// 拖拽開始 const dragStart = () => { console.log(0) emit('dragStart', { ...props.model }) } //最外層 // 拖拽開始選中node const dragStart = (node) => { compInOperation.value = node }
dragOver
dragOver 當元素或者選擇的文本被拖拽到一個有效的放置目標上時觸發(fā)
這個事件就有意思了,其實他本來沒啥用,但是不用他還不行,因為他會使得drop事件不生效
const dragOver = (e) => { // 需要組織默認行為 e.preventDefault() return true }
dragEnter和dragLeave
dragEnter 當拖動的元素或被選擇的文本進入有效的放置目標時觸發(fā) dragleave當一個被拖動的元素或者被選擇的文本離開一個有效的拖放目標時觸發(fā)
這倆是一對 ,一個移入一個移出,值得注意的是dragEnter 發(fā)生在 dragLeave 之前 并且如果 移動到子元素,這兩個事件會再次執(zhí)行,于是我們需要做特殊處理
// 保存最新的進入節(jié)點, 為了解決移動到子元素,這兩個事件會再次執(zhí)問題 let lastenter = null const dragEnter = (e) => { lastenter = e.target; console.log('進入', props.model.id) // 由于 dragEnter 發(fā)生在 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 當一個元素或是選中的文字被拖拽釋放到一個有效的釋放目標位置時觸發(fā)
這個就比較重要了,他承載著拖拽結束之后,向外拋出事件, 直到跑到最外層
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) } } }
在當前需求中,由于我們相當于是拖拽到文件夾中, 在拖拽中做響應的判斷,為了拿到正確的組件數(shù)據(jù)
舉個例子,我移動到一個文件中,那么我就需要向上尋找,找到上級文件夾,再去拋出事件
所以我們有了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
支持展開收起
支持展開收起,就比較簡單了 只需要根據(jù)之前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 }) }
目錄支持增刪改查
支持增刪改查,本質上就是四個方法來來對元數(shù)據(jù)做修改,并且拋出事件
<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 }) }
支持名字重復驗證
支持驗證重復,其實也很簡單,就是根據(jù) fileNameArr 字段來判斷
fileNameArr: ['src', 'dist', 'package.json', 'README.md'], //判斷是否重復 if (props.model.fileNameArr.includes(e.target.value)) { ElMessage({ message: isFolder.value ? `目錄名重復` : '文件名重復', type: 'warning', }) }
ok,設計一個插件的方方面面 以及實現(xiàn)思路都給您說到了
您如果有面試,只需要拿著我這個話術,包您過關
總體就是思路就是按照當前這個需求指定幾個實現(xiàn)方式,并且列出其中的難點,設計好傳入值以及事件,就完事!
源碼
以上就是VUE3+TS遞歸組件實現(xiàn)TreeList設計實例詳解的詳細內容,更多關于VUE3 TS遞歸TreeList的資料請關注腳本之家其它相關文章!
相關文章
Vue實現(xiàn)動態(tài)圓環(huán)百分比進度條
這篇文章主要為大家詳細介紹了Vue實現(xiàn)動態(tài)圓環(huán)百分比進度條,文中示例代碼介紹的非常詳細,具有一定的參考價值,感興趣的小伙伴們可以參考一下2021-09-09vue+element+oss實現(xiàn)前端分片上傳和斷點續(xù)傳
這篇文章主要介紹了vue+element+oss實現(xiàn)前端分片上傳和斷點續(xù)傳,文中通過示例代碼介紹的非常詳細,對大家的學習或者工作具有一定的參考學習價值,需要的朋友們下面隨著小編來一起學習學習吧2021-03-03詳解vue beforeRouteEnter 異步獲取數(shù)據(jù)給實例問題
這篇文章主要介紹了vue beforeRouteEnter 異步獲取數(shù)據(jù)給實例問題,文中通過示例代碼介紹的非常詳細,對大家的學習或者工作具有一定的參考學習價值,需要的朋友們下面隨著小編來一起學習學習吧2019-08-08