vue通俗易懂封裝一個樹元件?(複製貼上就是自己的了?)

水冗水孚發表於2022-12-17

本篇文章記錄仿寫一個樹tree元件細節。原始碼在github上,也有演示效果的網址,大家可以拉下來,npm start執行跑起來,結合註釋有助於更好的理解。

github倉庫地址:https://github.com/shuirongsh...

網址演示效果地址:http://ashuai.work:8888/#/myTree

樹元件說明

關於樹結構形式的效果,在工作中很常見,如,選單樹、許可權樹、關係組織樹等一些展示層級關係的功能。本文使用元件遞迴的思想,實現一個樹元件,我們先看一下效果圖:

樹元件的功能

我們知道,樹元件三大核心功能:

  • 基本功能
  • 勾選相關功能
  • 樹節點懶載入功能

考慮到篇幅和閱讀成本原因(作者懶),本文只講述基本的樹功能,後續樹的勾選和懶載入以後,不忙了,再去發文章。

樹的基本功能需要實現的效果

  1. 根據JSON樹結構的資料遞迴元件展示層級關係(樹節點要有縮排問題,樹層級越深,越往右靠)
  2. 點選樹的某個節點給樹節點加上一個聚焦狀態(點選加上背景色,點選別的地方背景色消失)
  3. 點選小圖示收起樹的子節點(展開與摺疊)
  4. 統一控制樹的展開或者收起(統一展開與統一折疊)
  5. 樹元件小圖示的更改(可以使用插槽,這裡筆者使用的類名變數更改的,思想是一樣的)
  6. 沒有資料時,樹元件做一個提示沒資料(加個判斷即可)
  7. 事件的傳遞(點選小圖示摺疊樹節點、點選樹節點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屬性,官方文件有很多說明,大家只需要記住以下幾點常用的即可:

  1. tabindex值為整數,有正數、負數、0三種情況
  2. 若是設定負值,一般設定負一,讓元素可以用聚焦(不能透過鍵盤導航來訪問到該元素),某些情況下,特別好用
  3. 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-ifv-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

相關文章