本篇文章記錄仿寫一個樹
tree
元件細節。原始碼在github
上,也有演示效果的網址,大家可以拉下來,npm start
執行跑起來,結合註釋有助於更好的理解。
github
倉庫地址:https://github.com/shuirongsh...網址演示效果地址:http://ashuai.work:8888/#/myTree
樹元件說明
關於樹結構形式的效果,在工作中很常見,如,選單樹、許可權樹、關係組織樹等一些展示層級關係的功能。本文使用元件遞迴的思想,實現一個樹元件,我們先看一下效果圖:
樹元件的功能
我們知道,樹元件三大核心功能:
- 基本功能
- 勾選相關功能
- 樹節點懶載入功能
考慮到篇幅和閱讀成本原因(作者懶),本文只講述基本的樹功能,後續樹的勾選和懶載入以後,不忙了,再去發文章。
樹的基本功能需要實現的效果
- 根據JSON樹結構的資料遞迴元件展示層級關係(樹節點要有縮排問題,樹層級越深,越往右靠)
- 點選樹的某個節點給樹節點加上一個聚焦狀態(點選加上背景色,點選別的地方背景色消失)
- 點選小圖示收起樹的子節點(展開與摺疊)
- 統一控制樹的展開或者收起(統一展開與統一折疊)
- 樹元件小圖示的更改(可以使用插槽,這裡筆者使用的類名變數更改的,思想是一樣的)
- 沒有資料時,樹元件做一個提示沒資料(加個判斷即可)
- 事件的傳遞(點選小圖示摺疊樹節點、點選樹節點item)
建議大家閱讀完此文章以後,可以做一個參考,然後自己去實現一個樹元件,實現樹元件的方式有很多種,有可能讀者的方式,更好呢,程式碼除了多看以外,還要多寫多總結
樹元件的實現
1. 根據JSON樹結構的資料遞迴元件展示層級關係
想要遞迴,首先要有相應的樹結構的資料,如下的資料:
let treeData = [
{
name: "中國",
eng: "China",
children: [
{
name: "北京",
eng: "Beijing",
},
{
name: "上海",
eng: "Shanghai",
children: [
{
name: "閔行區",
eng: "Minhang",
},
{
name: "靜安區",
eng: "Jingan",
},
],
},
],
},
{
name: "美國",
eng: "American",
children: [
{
name: "紐約",
eng: "NewYork",
children: [
{
name: "曼哈頓區",
eng: "ManHattan",
},
{
name: "皇后區",
eng: "Queen",
},
{
name: "布魯克林區",
eng: "Brooklyn",
},
],
},
{
name: "華盛頓",
eng: "Washington",
},
],
},
],
當然,實際上這裡的樹結構的資料可能會有很多層,這裡筆者定義的樹結構資料的規則是,有children
就說明有子節點,沒有children
欄位就說明沒有子節點。有的後端會定義無論是否有樹節點,都會返回children
欄位,只不過值(陣列)是否為長度為0
罷了,這個注意一下即可。
首先定義兩個元件
myTree
元件treeItem
元件
大家想要遞迴的話,可以考慮定義兩個元件,一個是主元件用於暴露給外部使用如(myTree
),另外一個是用來遞迴的元件(如treeItem
)
myTree
中引用treeItem
元件
用於遞迴的treeItem
元件一定要寫元件名name
哦,因為查詢的時候,就是去透過這個元件名查詢的
<my-tree :treeData="treeData"></my-tree>
元件要接收樹結構的資料,treeData
即上方的資料,工作中是後端返回的資料(有時可能需要做一些加工才能使用)
然後在my-tree
元件中接收treeData
資料用於初次迴圈,為什麼說是初次迴圈呢?我們先假設treeData
陣列中只有兩項,只有中國
和美國
且都沒有子節點,那麼直接一個迴圈就行了,如下:
<div
v-for="(item, index) in treeData"
:key="index"
>
{{item.name}}
</div>
name:"myTree",
props:{
treeData:Array
}
這樣的話,中國
和美國
就直接展示出來了,但是,因為其有子節點,我們可以考慮將迴圈的dom元素再做一個拆分,拆分成另一個用於遞迴迴圈的子元件treeItem
,最後的一些細節都寫在這個子元件中,如下:
<tree-item
v-for="(item, index) in treeData"
:key="index"
:item="item" // 因為子元件需要接收資料進行展示,所以我們可以將迴圈出來的每一項,依舊作為引數,繼續傳遞給每一個tree-item
>
</tree-item>
name:"myTree",
props:{
treeData:Array
}
所以,接下來,問題轉換成,如何寫tree-item
元件了。
在寫treeItem
元件之前,我們就要分清楚樹結構的dom
層級大概劃分,如下:
所以我們的dom
結構大致可以設計成如下
<!-- 每一個樹節點包含:圖示部分、名字部分 -->
<div class="treeNodeItem">
<div class="iconAndName" tabindex="-1" @click="clickTree">
<i class="el-icon-caret-right"></i>
<span>{{ item.name }}</span>
</div>
</div>
name:"treeItem" // 元件名一定要加哦!!!
props: {
// 每一個節點的資料,每一項
item: Object
},
這樣設計的dom
和上方是一樣的。但是這樣拆分依舊是無法渲染子節點項,所以這裡我們可以首先做一個判斷:
- 如果有子節點,有
children
欄位,那就再引入這個treeItem
元件。 - 注意:元件除了可以引入外部別的元件值之外,也可以引入自身元件
- 同函式遞迴一樣,需要加上停止條件,很顯然這裡的停止條件就是是否有有
children
欄位 - 是否有子節點,換句話說:
- 如果有
children
欄位,我才去引入這個treeItem
元件做渲染,否則利用v-if
去除掉。
經過這樣一波的分析,我們的程式碼,就變成了這個樣子:
<div class="treeNodeItem">
<div class="iconAndName" tabindex="-1" @click="clickTree">
<i class="el-icon-caret-right"></i>
<span>{{ item.name }}</span>
</div>
<div class="childrenTreeNode">
<!-- 存在子節點就遍歷並遞迴呼叫自身這個元件 -->
<template v-if="item.children">
<tree-item
v-for="(ite, ind) in item.children"
:key="ind"
:item="ite" // 注意,因為每一個都需要的props資料,所以這裡再次傳遞
></tree-item>
</template>
</div>
</div>
name:"treeItem" // 元件名一定要加哦!!!
props: {
// 每一個節點的資料,每一項
item: Object
},
至此,元件遞迴呼叫,就實現了,如果讀者們還有一些不太清楚,可以參考筆者之前的文章:
加上這兩篇文章,基本上就沒啥問題了
2.點選樹的某個節點給樹節點加上一個聚焦狀態
我們知道,說道聚焦,大家常常會想到input
輸入框之類的表單元素去聚焦,去:focus
之類的,實際上普通的dom元素也可以聚焦。
普通的dom元素想要聚焦,需要使用tabindex
屬性。
關於tabindex
屬性,官方文件有很多說明,大家只需要記住以下幾點常用的即可:
tabindex
值為整數,有正數、負數、0三種情況- 若是設定負值,一般設定負一,讓元素可以用聚焦(不能透過鍵盤導航來訪問到該元素),某些情況下,特別好用
tabindex
值設定正數,按下tab
鍵就可以訪問了
一般不去設定tabindex
,除非需要設定按下tab鍵盤切換的順序,設定為負值也只是為了讓這個dom
可以聚焦,可以:focus
。另附傳送門官方文件:https://developer.mozilla.org...
複習了這個知識點,我們就可以去做點選dom(聚焦)加上背景色,點選別的地方(失焦)恢復成原樣。程式碼如下:
<div class="tabC" tabindex="-1">tabindex="-1"可透過焦點訪問到</div>
.tabC {
width: 320px;
height: 120px;
line-height: 120px;
text-align: center;
border: 2px solid #333;
}
// 搭配tabindex就可以使用:focus選擇器了
.tabC:focus {
background-color: pink;
}
對應效果圖:
- 筆者的樹元件中實現,點選樹節點加上選中效果,就是透過這種方式,css方式去實現的。
- 實際上也可以透過
js
的方式去控制,思路:就是遞迴treeData
資料,給每一個節點都加上一個布林值,isFocus
變數,透過點選控制這個變數,從而控制點選選中的樣式效果(大家可以嘗試一下這種實現方式)
至此,點選樹的某個節點給樹節點加上一個聚焦狀態的功能就完成了
3.點選小圖示收起樹的子節點(展開與摺疊)
這個就簡單了,控制子節點的展開和摺疊,筆者使用:style的方式,控制display屬性為none還是block,如下程式碼:
<div
class="childrenTreeNode"
:style="{
display: isFold ? 'none' : 'block',
}"
>
<template v-if="item.children">
<tree-item
v-for="(ite, ind) in item.children"
:key="ind"
:item="ite"
></tree-item>
</template>
</div>
data() {
return {
isFold: true, // 預設有子節點都摺疊起來
};
},
當點選小圖示的時候,控制isFold
的值取反即可,就不停的顯示和隱藏了,即展開和摺疊了。
別忘了,給小圖示加上一個旋轉變換,這樣看著自然一些:transform: rotate(90deg); transition: all 0.3s;
(筆者是透過動態class
加的,最後方的完整程式碼中,可以看到)
4.統一控制樹的展開或者收起(統一展開與統一折疊)
既然是統一控制摺疊和收起,就是統一控制isFold
的屬性值。我們在props中定義一個變數:
proos:{
expandTree: Boolean, // 預設把樹摺疊起來
}
然後監聽這個變數的變化,控制fold
變數的值,就可以做到統一折疊和展開了。如下程式碼
watch: {
// 監聽布林值變數變化,統一折疊或者展開
expandTree(newVal) {
this.isFold = !newVal; // 是否要取反取決於大家定義的變數的布林值
},
},
注意,光這樣寫,還不夠,因為是遞迴元件,所以資料也要傳遞哦,無論是myTree
元件還是treeItem
元件中的tree-item
,都得傳遞,舉例如下:
<tree-item
v-for="(ite, ind) in item.children"
:key="ind"
:item="ite"
:expandTree="expandTree" // 傳遞...
></tree-item>
至此,統一控制樹的展開或者收起功能完成
5.樹元件小圖示的更改
這裡就和上方傳遞expandTree
欄位一樣的了,props
中定義接收,迴圈遞迴中寫,如下:
<tree-item
v-for="(ite, ind) in item.children"
:key="ind"
:item="ite"
:expandTree="expandTree"
:iconName="iconName" // 傳遞...
></tree-item>
props:{
iconName: String, // 樹元件的小圖示
}
6.沒有資料時,樹元件做一個提示沒資料(加個判斷即可)
這個簡單啦,判斷treeData
即可,搭配`v-if
和v-else。
筆者暫無資料中的emoji是從這個網站中的,推薦一下:https://getemoji.com/
? ? ? ? ? ? ? ?`
7.事件的傳遞(資料的傳遞)
這裡主要講的是$attr
和$listeners
的用法,這裡筆者就不贅述了,筆者之前寫過一篇文章,各位讀者可以瞅瞅:
https://segmentfault.com/a/11...
$attr
資料兜底、$listeners
事件橋樑...
8. 關於樹節點的縮排問題
筆者在這裡使用的是給每一個子節點都加上pandding-left
,這樣的話:
- 比如一級節點左邊距
12px
- 二級節點左邊距
24px
- 三級節點左邊距
36px
- ...
這樣的話,也能實現樹結構資料的右側縮排,如下圖:
再把第一級節點的左邊距清除掉,即可:
.my-tree-wrap > .treeNodeItem {
padding: 0; // 最佳化樹節點縮排效果,可註釋掉看效果
}
完整程式碼
單看文章,不太夠,程式碼貼出來也有註釋,方便讀者們除錯,完整程式碼在github上哦,如果對各位讀者有一點點幫助的話,可以給我們的github
倉庫一個star
哦?
使用樹元件的程式碼
<template>
<div>
<button @click="expandTree = !expandTree">統一展開摺疊</button>
<br />
<br />
<button @click="treeData = []">清空資料(重新整理恢復)</button>
<br />
<br />
<button @click="changeIcon">更改圖示</button>
<br />
<br />
<div class="treeBox">
<my-tree
:treeData="treeData"
:expandTree="expandTree"
@fold="fold"
@clickTree="clickTree"
clickNameClose
:iconName="iconName"
></my-tree>
</div>
</div>
</template>
<script>
export default {
/**
* 簡約樹元件需要實現的效果
* 1. 點選focus節點設定背景色,失去焦點背景色消失(tabindex設定)
* 2. 點選樹節點的小箭頭圖示旋轉效果,同時摺疊或者展開樹子節點
* (再新增一個變數,控制點選名字也可以達到同樣的效果)
* 3. 可透過傳參方式更改小圖示
* 4. 層級遞迴關係
* 5. 點選小圖示(摺疊樹)事件和點選樹節點事件
* */
data() {
return {
treeData: [
{
name: "中國",
eng: "China",
children: [
{
name: "北京",
eng: "Beijing",
},
{
name: "上海",
eng: "Shanghai",
children: [
{
name: "閔行區",
eng: "Minhang",
},
{
name: "靜安區",
eng: "Jingan",
},
],
},
],
},
{
name: "美國",
eng: "American",
children: [
{
name: "紐約",
eng: "NewYork",
children: [
{
name: "曼哈頓區",
eng: "ManHattan",
},
{
name: "皇后區",
eng: "Queen",
},
{
name: "布魯克林區",
eng: "Brooklyn",
},
],
},
{
name: "華盛頓",
eng: "Washington",
},
],
},
],
expandTree: true,
iconName: "el-icon-arrow-right",
};
},
methods: {
fold(params, key) {
console.log("fold", params, key);
},
clickTree(params) {
console.log("clickTree", params);
},
changeIcon() {
this.iconName =
this.iconName == "el-icon-caret-right"
? "el-icon-right"
: "el-icon-caret-right";
},
},
};
</script>
<style lang="less" scoped>
.treeBox {
width: 240px;
border: 1px solid pink;
box-sizing: border-box;
padding: 4px 0;
}
</style>
myTree元件程式碼
<template>
<div class="my-tree-wrap">
<!-- 有資料渲染沒資料提示暫無資料 -->
<div v-if="treeData.length > 0">
<!-- 初次迴圈,元件內再次遞迴迴圈,即可實現遞迴樹效果 -->
<tree-item
v-for="(item, index) in treeData"
:key="index"
:item="item"
:expandTree="expandTree"
:iconName="iconName"
:clickNameClose="clickNameClose"
v-on="$listeners"
v-bind="$attrs"
>
<!-- 關於$listeners和$attrs的詳細用法,可以看筆者的這篇文章:https://juejin.cn/post/6982727094937583647 -->
</tree-item>
</div>
<span class="noData" v-else>?暫無資料?</span>
</div>
</template>
<script>
import treeItem from "./treeItem.vue";
export default {
name: "myTree",
components: { treeItem },
props: {
// 樹元件需要的資料陣列
treeData: {
type: Array,
default: () => {
return [];
},
},
expandTree: Boolean, // 是否統一展開或關閉
iconName: String, // 樹元件的小圖示
clickNameClose: Boolean, // 點選樹節點名字也能關閉樹節點選單
},
};
</script>
<style lang="less" scoped>
.my-tree-wrap {
width: 100%;
.noData {
color: #666;
font-size: 14px;
}
}
// 透過css解決樹結構縮排問題,且要搭配.my-tree-wrap > .treeNodeItem { padding: 0; } (子元素選擇器,並非後代元素選擇器)
.my-tree-wrap > .treeNodeItem {
padding: 0; // 最佳化樹節點縮排效果,可註釋掉看效果
}
</style>
treeItem元件程式碼
<template>
<!-- 每一個樹節點包含:圖示部分、名字部分,因為考慮到遞迴,所以再拆分一個部分,即有樹子節點部分childrenTreeNode -->
<div class="treeNodeItem">
<!-- 圖示和名字部分,設定tabindex="-1"就可設定:focus的樣式了 -->
<div class="iconAndName" tabindex="-1" @click="clickTree">
<!-- 有樹子節點才去渲染圖示 -->
<i
v-if="item.children"
@click.stop="clickIconFold"
:class="[
'treeNodeItemIcon',
iconName ? iconName : 'el-icon-caret-right',
isFold ? 'iconLeft' : 'iconDown',
]"
></i>
<span
:class="['treeNodeItemName', item.children ? '' : 'noChildrenIcon']"
>{{ item.name }}</span
>
<!-- 注意上方几個動態樣式的使用,可以去掉看效果,更加直觀 -->
</div>
<!-- 展開摺疊透過display: none來控制,進一步延伸為透過變數isFold來控制 -->
<div
class="childrenTreeNode"
:style="{
display: isFold ? 'none' : 'block',
}"
>
<!-- 存在子節點就遍歷並遞迴呼叫自身這個元件 -->
<template v-if="item.children">
<tree-item
v-for="(ite, ind) in item.children"
:key="ind"
:item="ite"
:expandTree="expandTree"
:iconName="iconName"
:clickNameClose="clickNameClose"
v-on="$listeners"
v-bind="$attrs"
></tree-item>
</template>
</div>
</div>
</template>
<script>
export default {
name: "treeItem",
props: {
// 每一個節點的資料
item: {
type: Object,
default: () => {
return {};
},
},
expandTree: Boolean, // 預設把樹摺疊起來
iconName: String, // 自定義圖示名
clickNameClose: Boolean, // 點選樹節點名字,也可以摺疊展開選單(本來設定只能點選圖示)
},
watch: {
// 監聽布林值變數變化,統一折疊或者展開
expandTree(newVal) {
this.isFold = !newVal; // 是否要取反取決於大家定義的變數的布林值
},
},
data() {
return {
isFold: true, // 預設有子節點都摺疊起來
};
},
mounted() {
this.isFold = !this.expandTree; // 是否要取反取決於大家定義的變數的布林值
},
methods: {
clickIconFold() {
this.isFold = !this.isFold;
this.$emit("fold", this.item, this.isFold ? "摺疊咯" : "展開啦");
},
clickTree() {
// 預設是點選小圖示才能關閉,加上clickNameClose為true屬性,設定點選樹節點name也能關閉
if (this.clickNameClose) {
this.clickIconFold();
}
this.$emit("clickTree", this.item);
},
},
};
</script>
<style lang="less" scoped>
.treeNodeItem {
width: 100%;
height: auto;
// 透過css解決樹結構縮排問題,且要搭配.my-tree-wrap > .treeNodeItem { padding: 0; } (子元素選擇器,並非後代元素選擇器)
padding-left: 12px;
.iconAndName {
width: 100%;
height: 24px;
display: flex;
align-items: center;
cursor: pointer;
transition: all 0.3s;
box-sizing: border-box;
.treeNodeItemIcon {
margin-right: 4px;
}
// 點選圖示旋轉一下
.iconDown {
transform: rotate(90deg);
transition: all 0.3s;
}
.iconLeft {
transition: all 0.3s;
}
.treeNodeItemName {
color: #666;
word-break: keep-all; // 不換行
}
// 位置對齊一下
.noChildrenIcon {
margin-left: 20px;
}
}
.iconAndName:hover {
background-color: #f5f7fa;
}
// 搭配tabindex='-1'設定選中聚焦時的背景色
.iconAndName:focus {
background-color: #f5f7fa;
}
}
</style>
總結
A good memory is not as good as a bad pen, record it