­

自定義Element父子不關聯的穿梭樹

Element自身是有一個Transfer穿梭框組件的,這個組件是穿梭框結合checkbox複選框來實現的,功能比較單一,自己想實現這個功能也是很簡單的,只是在項目開發中,項目排期緊,沒有閑功夫來實現罷了,但這個組件只適合用來實現較為簡單的左右數據添加刪除的效果,複雜一點的樹結構穿梭框就難實現多了,當然也有造好的輪子等你使用,這裡推薦一個比較好用的穿梭樹組件el-tree-transfer

這個el-tree-transfer輪子好是好,但還是沒有達到我的需求,確切的說是沒有達到我們公司產品的需求,我們公司產品的需求在這裡vue+element-ui之tree樹形控制項有關子節點和父節點之間的各種選中關係詳解,尬笑臉… 其實之前實現我們產品的需求時我心裡就已經一萬個草泥馬呼嘯而過了,現在又要在這個基礎上添加一個穿梭框的效果,阿西吧,我除了苦笑還能幹啥?前端的同行們,我們遇到這樣奇葩的需求,除了苦笑還能幹啥?什麼?你說懟回去?有用嗎?懟回去的後果是除了別人說你愛牢騷情商低,你還能得到啥?什麼?你說尥蹶子?你老婆答應了嗎?你小孩答應了嗎?你的房貸車貸答應了嗎?消停地自己哭吧!這就是余歡水式的中年危機!!!

嘰嘰歪歪了這許多,還是趕緊看看如何實現吧,其實說白了就是把穿梭框左邊樹組件選中的數據複製一份給右邊的樹組件,這樣在vue「數據驅動視圖」的牛逼格拉斯思想下,右邊樹組件就會完美顯示左邊勾選的數據,然後再把左邊已選中的數據給刪除了就行了。說干就干,來啊,造作啊,反正有大把時光。

   //往右側添加數據的按鈕
   addRight(){
      //this.checkedKeys保存的是每次勾選的數據以及上一次勾選的數據
      this.leftCheckedKeys.push(...this.$refs.leftTree.getCheckedKeys());
      this.leftCheckedKeys = [...new Set(this.leftCheckedKeys)];
      this.rightData = this.setRightData(this.leftAllData, this.leftCheckedKeys);
      this.leftDel(this.leftData, this.$refs.leftTree.getCheckedKeys());
      this.$refs.leftTree.setCheckedKeys([]);
    },
    //設置右側樹-遞歸循環左側數據並賦值給一個空數組而後返回該數組
    setRightData(tree = [], keys = []){
      let arr = [];
      if (!!tree && tree.length !== 0) {
        keys.forEach(i => {
          tree.forEach(item => {
            let obj = {};
            if(i == item.id){
              obj.id = item.id;
              obj.label = item.label;
              obj.children = this.setRightData(item.children, keys);
              arr.push(obj);
            }
          });
        })
      }
      return arr;
    },
    //刪除選中的節點-如節點有子節點未被選中則該節點不被刪除
    leftDel(tree = [], keys = []){
      if (!!tree && tree.length !== 0) {
        keys.forEach((i, index) => {
          tree.forEach((item, idx) => {
            if(keys[index] == tree[idx].id){
              this.leftDel(item.children, keys)
              if(!tree[idx].children || tree[idx].children.length < 1){
                tree.splice(idx, 1)
              }
            }
          });
        })
      }
    },

其實右側選中的樹組件數據再添加給左側的樹組件數據通過這個套路也是基本沒有問題的,反正套路基本都一樣,但問題是右側選中的數據如何插入到左側它本來該在的位置呢?通過這個套路還能實現嗎?比如在左側選中的一個子級元素連同它的父級和祖先級一起添加到了右側,此時選中的數據組成的數組比如是[1,3,5],然後我又在左側選中了一個子級元素連同它的父級和祖先級一起添加到了右側,此時選中的數據組成的數組比如是[1,4,6],那麼問題來了,我在右側勾選了[1,4,6]這個數組數據構成的選中樹,如何把它復原到左側的樹組件中呢?還是遞歸循環?那你怎麼判斷哪個是祖先元素呢?哪個是父元素呢?那個是子元素呢?比如你如何根據數組中的1把數組中的4組合成父子關係然後再賦值給左側的樹組件數據呢?如何又根據1,4來把6再組合成祖先、父級、子級的關係再賦值給左側樹組件的數據呢?況且左側祖先級1的下邊還可能有2這個子元素呢?而且有時,我們勾選的數據不一定就是按照從上往下來依次勾選的,可能是先勾選了下邊的一個,然後又勾選了上邊的一個,然後又勾選了一個其他的數據,然後又在這個的上邊勾選了一個,比如我們如果按照從上往下的順序來依次勾選,得到的數組可能是[1,3,5,7,9],但是由於這次我們不按照從上往下依次勾選,而是打亂了順序,往數組中添加元素又基本是push進去的,所以這次得到的數據有可能就是[1,3,9,5,7],那這種情況又該如何把右側勾選的數據再完美的添加到左側呢?是不是有點暈了?其實一開始我也暈,但情況就是這麼個情況,問題就是這麼個問題,這種穿梭樹組件跟element那個簡單的基於checkbox的穿梭框不同,那個穿梭框不存在上下級的關係,直接往數組中push就OK了。

那麼這個問題是不是就進入了死胡同實現不了你呢?當然不是,在看了el-tree-transfer這個輪子的原理後,如醍醐灌頂,是恍然大悟。其實一開始能想到我文中之前的那種實現方法也是很不錯的,至少自己思考了,米蘭.昆德拉說「人類一思考,上帝就發笑。」 這當然是句玩笑,思想很重要。

但是el-tree-transfer這個輪子的實現效果是基於父子關聯的,但我們的實際需求是父子基本不關聯,即選中了一個元素,若該元素有子元素,子元素就可以不選中,若該元素有父元素和祖先元素,它的父元素和祖先元素統統都要選中,這個功能我早前已經實現了,這裡不再多說,有興趣的可以移步vue+element-ui之tree樹形控制項有關子節點和父節點之間的各種選中關係詳解。我的這個需求el-tree-transfer就玩不轉了,但它玩不轉不要緊,我還是領悟到了它的思想,在這裡還是要感謝這個輪子的作者。這個輪子用到了element樹組件的append方法,我咋就沒有想到呢?而且作者的思想也確實牛逼,具體咋牛逼,一兩句話說不清楚,直接上程式碼吧:

<template>
  <div class="tree-transfer">
    <div class="transfer-mian transfer-left">
      <p class="transfer-title">{{leftTitle}}</p>
      <el-input placeholder="輸入關鍵字進行過濾" v-model="filterLeft" v-if="filter" size="small" class="filter-tree"></el-input>
      <el-tree
        ref="leftTree"
        :data="leftData"
        show-checkbox
        :node-key="node_key"
        :default-expand-all="expandAll"
        check-on-click-node
        :check-strictly="checkStrictly"
        @node-click="nodeClick"
        :expand-on-click-node="false"
        :filter-node-method="filterNodeLeft"
        @check="leftTreeChecked"
      >
        <span class="custom-tree-node" slot-scope="{ node, data }">
          <span>{{ node.label }}</span>
        </span>
      </el-tree>
    </div>
    <div class="transfer-middle">
      <template v-if="buttonTxt">
          <p class="transfer-middle-item">
            <el-button
              type="primary"
              @click="addToRight"
              :disabled="leftDisabled"
            >
              {{ fromButton || "添加" }}
              <i class="el-icon-arrow-right"></i>
            </el-button>
          </p>
          <p class="transfer-middle-item">
            <el-button
              type="primary"
              @click="addToLeft"
              :disabled="rightDisabled"
              icon="el-icon-arrow-left"
              >{{ toButton || "移除" }}</el-button
            >
          </p>
        </template>
        <template v-else>
          <p class="transfer-middle-item">
            <el-button
              type="primary"
              @click="addToRight"
              icon="el-icon-arrow-right"
              circle
              :disabled="leftDisabled"
            ></el-button>
          </p>
          <p class="transfer-middle-item">
            <el-button
              type="primary"
              @click="addToLeft"
              :disabled="rightDisabled"
              icon="el-icon-arrow-left"
              circle
            ></el-button>
          </p>
        </template>
    </div>
    <div class="transfer-mian transfer-right">
      <p class="transfer-title">{{rightTitle}}</p>
      <el-input placeholder="輸入關鍵字進行過濾" v-model="filterRight" v-if="filter"  size="small" class="filter-tree"></el-input>
      <el-tree
        ref="rightTree"
        :data="rightData"
        show-checkbox
        :node-key="node_key"
        :default-expand-all="expandAll"
        check-on-click-node
        :check-strictly="checkStrictly"
        @node-click="nodeClick"
        :expand-on-click-node="false"
        :filter-node-method="filterNodeRight"
        @check="rightTreeChecked"
      >
        <span class="custom-tree-node" slot-scope="{ node, data }">
          <span>{{ node.label }}</span>
        </span>
      </el-tree>
    </div>
  </div>
</template>

<script>
export default {
  props: {
    // 標題
    title: {
      type: Array,
      default: () => ["源列表", "目標列表"]
    },
    // 源數據
    leftData: {
      type: Array,
      default: () => []
    },
    // 選中數據
    rightData: {
      type: Array,
      default: () => []
    },
    // 穿梭按鈕名字
    buttonTxt: Array,
    // el-tree node-key 必須唯一
    node_key: {
      type: String,
      default: "id"
    },
    // 自定義 pid參數名
    pid: {
      type: String,
      default: "pid"
    },
    defaultProps: {
      type: Object,
      default: () => {
        return { label: "label", children: "children" };
      }
    },
    // 源數據 默認選中節點
    defaultCheckedKeys: {
      type: Array,
      default: () => []
    },
    // 是否啟用篩選
    filter: {
      type: Boolean,
      default: false
    },
    // 自定義篩選函數
    filterNode: Function,
    // 是否展開所有節點
    expandAll: {
      type: Boolean,
      default: false
    },
    checkStrictly: {
      type: Boolean,
      default: false
    },
  },
  data() {
    return {
      filterLeft: '',
      filterRight: '',
      leftDisabled: true,
      rightDisabled: true,
      leftCheckedKeys: [], // 源數據選中key數組 以此屬性關聯穿梭按鈕
      rightCheckedKeys: [], // 目標數據選中key數組 以此屬性關聯穿梭按鈕
    };
  },
  created() {
    this.leftCheckedKeys = this.defaultCheckedKeys;
  },
  mounted() {
    if (this.defaultCheckedKeys.length > 0 && this.defaultTransfer) {
      this.$nextTick(() => {
        this.addToRight();
      });
    }
  },
  watch: {
    filterLeft(val) {
      this.$refs.leftTree.filter(val);
    },
    filterRight(val){
      this.$refs.rightTree.filter(val);
    },
    // 左側狀態監測
    leftCheckedKeys(val) {
      // 穿梭按鈕是否禁用
      this.leftDisabled = val.length > 0 ? false : true;
    },
    // 右側狀態監測
    rightCheckedKeys(val) {
      this.rightDisabled = val.length > 0 ? false : true;
    },
  },
  computed: {
    // 左側數據
    selfLeftData() {
      let from_array = [...this.leftData];
      if (!this.arrayToTree) {
        from_array.forEach(item => {
          item[this.pid] = 0;
        });
        return from_array;
      } else {
        return arrayToTree(from_array, {
          id: this.node_key,
          pid: this.pid,
          children: this.defaultProps.children
        });
      }
    },
    // 右側數據
    selfRightData() {
      let to_array = [...this.rightData];
      if (!this.arrayToTree) {
        to_array.forEach(item => {
          item[this.pid] = 0;
        });
        return to_array;
      } else {
        return arrayToTree(to_array, {
          id: this.node_key,
          pid: this.pid,
          children: this.defaultProps.children
        });
      }
    },
    leftTitle() {
      let [text] = this.title;
      return text;
    },
    // 右側菜單名
    rightTitle() {
      let [, text] = this.title;
      return text;
    },
  },
  methods: {
    nodeClick(data, node, e) {
      this.childNodesChange(node);
      this.parentNodesChange(node);
    },
    //勾選節點則其所有子節點及其所有孫子節點可以不選中
    childNodesChange(node){
      let len = node.childNodes.length;
      for(let i = 0; i < len; i++){
        node.childNodes[i].checked = false;
        this.childNodesChange(node.childNodes[i]);
      }
    },
    //勾選節點則其父節點及其所有祖先節點必須選中
    parentNodesChange(node){
      if(node.parent){
        for(let key in node){
          if(key == "parent"){
            node[key].checked = true;
            this.parentNodesChange(node[key]);
          }
        }
      }
    },
    addToRight(){
      let keys = this.$refs["leftTree"].getCheckedKeys();
      let arrayCheckedNodes = this.$refs["leftTree"].getCheckedNodes();
      // 獲取選中通過穿梭框的nodes - 僅用於傳送選中節點數組到父組件同後台通訊需求
      let nodes = JSON.parse(JSON.stringify(arrayCheckedNodes));
      // 自定義參數讀取設置
      let children__ = this.defaultProps.children || "children";
      let pid__ = this.pid || "pid";
      let id__ = this["node_key"] || "id";
      let selfRightData = JSON.stringify(this.selfRightData);
      // 篩選目標樹不存在的骨架節點 - 全選內的節點
      let newSkeletonCheckedNodes = [];
      nodes.forEach(item => {
        if (!inquireIsExist(item)) {
          newSkeletonCheckedNodes.push(item);
        }
      });
      // 篩選到目標樹不存在的骨架後在處理每個骨架節點-非末端葉子節點 - 全選節點
      newSkeletonCheckedNodes.forEach(item => {
        if (item[children__] && item[children__].length > 0) {
          item[children__] = [];
          [0, "0"].includes(item[pid__])
            ? this.$refs["rightTree"].append(item)
            : this.$refs["rightTree"].append(item, item[pid__]);
        }
      });

      // 第三步 處理末端葉子元素 - 聲明新盒子篩選出所有末端葉子節點
      let leafCheckedNodes = arrayCheckedNodes.filter(
        item => !item[children__] || item[children__].length == 0
      );
      // 末端葉子插入目標樹
      leafCheckedNodes.forEach(item => {
        if (!inquireIsExist(item)) {
          this.$refs["rightTree"].append(item, item[pid__]);
        }
      });

      // 遞歸查詢data內是否存在item函數
      function inquireIsExist(item, strData = selfRightData) {
        // 將樹形數據格式化成一維字元串 然後通過匹配來判斷是否已存在
        let strItem =
          typeof item[id__] == "number"
            ? `"${id__}":${item[id__]},`
            : `"${id__}":"${item[id__]}"`;
        let reg = RegExp(strItem);
        let existed = reg.test(strData);
        return existed;
      }

      // 左側刪掉選中數據
      this.leftDel(this.leftData, this.$refs["leftTree"].getCheckedKeys());

      // 處理完畢按鈕恢復禁用狀態
      this.leftCheckedKeys = [];

      // 目標數據節點展開
      if (this.transferOpenNode && !this.lazy) {
        this.to_expanded_keys = keys;
      }

      // 處理完畢取消選中
      this.$refs["leftTree"].setCheckedKeys([]);

      // 傳遞資訊給父組件
      this.$emit("addBtn", this.selfLeftData, this.selfRightData, {
        keys,
        nodes,
      });
    },
    addToLeft(){
      let keys = this.$refs["rightTree"].getCheckedKeys();
      // 獲取選中通過穿梭框的nodes 選中節點數據
      let arrayCheckedNodes = this.$refs["rightTree"].getCheckedNodes();
      // 獲取選中通過穿梭框的nodes - 僅用於傳送選中節點數組到父組件同後台通訊需求
      let nodes = JSON.parse(JSON.stringify(arrayCheckedNodes));
      // 自定義參數讀取設置
      let children__ = this.defaultProps.children || "children";
      let pid__ = this.pid || "pid";
      let id__ = this["node_key"] || "id";
      let selfLeftData = JSON.stringify(this.selfLeftData);
      // 篩選目標樹不存在的骨架節點 - 全選內的節點
      let newSkeletonCheckedNodes = [];
      nodes.forEach(item => {
        if (!inquireIsExist(item)) {
          newSkeletonCheckedNodes.push(item);
        }
      });
      // 篩選到目標樹不存在的骨架後在處理每個骨架節點-非末端葉子節點 - 全選節點
      newSkeletonCheckedNodes.forEach(item => {
        if (item[children__] && item[children__].length > 0) {
          item[children__] = [];
          [0, "0"].includes(item[pid__])
            ? this.$refs["leftTree"].append(item)
            : this.$refs["leftTree"].append(item, item[pid__]);
        }
      });

      // 第三步 處理末端葉子元素 - 聲明新盒子篩選出所有末端葉子節點
      let leafCheckedNodes = arrayCheckedNodes.filter(
        item => !item[children__] || item[children__].length == 0
      );
      // 末端葉子插入目標樹
      leafCheckedNodes.forEach(item => {
        if (!inquireIsExist(item)) {
          this.$refs["leftTree"].append(item, item[pid__]);
        }
      });

      // 遞歸查詢data內是否存在item函數
      function inquireIsExist(item, strData = selfLeftData) {
        // 將樹形數據格式化成一維字元串 然後通過匹配來判斷是否已存在
        let strItem =
          typeof item[id__] == "number"
            ? `"${id__}":${item[id__]},`
            : `"${id__}":"${item[id__]}"`;
        let reg = RegExp(strItem);
        let existed = reg.test(strData);
        return existed;
      }

      // 右側刪掉選中數據
      this.leftDel(this.rightData, this.$refs["rightTree"].getCheckedKeys());

      // 處理完畢按鈕恢復禁用狀態
      this.rightCheckedKeys = [];

      // 目標數據節點展開
      if (this.transferOpenNode && !this.lazy) {
        this.from_expanded_keys = keys;
      }

      // 處理完畢取消選中
      this.$refs["rightTree"].setCheckedKeys([]);

      // 傳遞資訊給父組件
      this.$emit("removeBtn", this.selfLeftData, this.selfRightData, {
        keys,
        nodes,
      });
    },
    //刪除選中的節點-如節點有子節點未被選中則該節點不被刪除
    leftDel(tree = [], keys = []){
      if (!!tree && tree.length !== 0) {
        keys.forEach((i, index) => {
          tree.forEach((item, idx) => {
            if(keys[index] == tree[idx].id){
              this.leftDel(item.children, keys)
              if(!tree[idx].children || tree[idx].children.length < 1){
                tree.splice(idx, 1)
              }
            }
          });
        })
      }
    },
    // 源樹選中事件 - 是否禁用穿梭按鈕
    leftTreeChecked(nodeObj, treeObj) {
      this.leftCheckedKeys = treeObj.checkedNodes;
    },
    // 目標樹選中事件 - 是否禁用穿梭按鈕
    rightTreeChecked(nodeObj, treeObj) {
      this.rightCheckedKeys = treeObj.checkedNodes;
    },
    // 源數據 篩選
    filterNodeLeft(value, data) {
      if(this.filterNode){
        return this.filterNode(value, data, 'form')
      }
      if (!value) return true;
      return data[this.defaultProps.label].indexOf(value) !== -1;
    },
    // 目標數據篩選
    filterNodeRight(value, data) {
      if(this.filterNode){
        return this.filterNode(value, data, 'to')
      }
      if (!value) return true;
      return data[this.defaultProps.label].indexOf(value) !== -1;
    },
  }
};
</script>

<style scoped>
/*此處是實現點擊選中子節點的checkbox時也選中父節點,點擊取消選中父節點的checkbox時也取消子節點選中的關鍵之一*/
.custom-tree-node{
  position: relative;
}
.custom-tree-node:before{
  content:'';
  width:20px;
  height: 20px;
  display: block;
  position:absolute;
  top:8px;
  left:-24px;
  z-index:999;
}
.tree-transfer{position:relative;height:500px;}
.transfer-mian{float:left;width:40%;height:100%;border:1px solid #ebeef5;border-radius:5px;}
.transfer-left {
  position: absolute;
  top: 0;
  left: 0;
}
.transfer-right {
  position: absolute;
  top: 0;
  right: 0;
}
.transfer-middle{
  position: absolute;
  top: 50%;
  left: 40%;
  width: 20%;
  transform: translateY(-50%);
  text-align: center;
}
.transfer-middle-item {
  padding: 10px;
  overflow: hidden;
}
.transfer-title {
  border-bottom: 1px solid #ebeef5;
  padding: 0 15px;
  height: 40px;
  line-height: 40px;
  color: #333;
  font-size: 16px;
  background-color: #f5f7fa;
}
.filter-tree {
  margin:10px auto;
  display:block;
  width:95%;
}
/*此處是實現點擊選中子節點的checkbox時也選中父節點,點擊取消選中父節點的checkbox時也取消子節點選中的關鍵之一*/
.custom-tree-node{
  position: relative;
}
.custom-tree-node:before{
  content:'';
  width:20px;
  height: 20px;
  display: block;
  position:absolute;
  top:8px;
  left:-24px;
  z-index:999;
}
</style>

使用這個組件:

<template>
  <div>
    <tree-transfer :leftData='leftData' :rightData='rightData' :defaultProps="{label:'label'}" :checkStrictly='strictly' :expandAll='expandAll' @addBtn='add' @removeBtn='remove' filter />
  </div>
</template>
<script>
import treeTransfer from './TreeTransferTpl'

export default {
  components: {
    treeTransfer,
  },
  data() {
    return {
      strictly: true,
      expandAll: true,
      leftData: [
        {
          id: "1",
          pid: 0,
          label: "一級 1",
          children: [
            {
              id: "1-1",
              pid: "1",
              label: "二級 1-1",
              children: []
            },
            {
              id: "1-2",
              pid: "1",
              label: "二級 1-2",
              children: [
                {
                  id: "1-2-1",
                  pid: "1-2",
                  children: [
                    {
                      id: "1-2-1-1",
                      pid: "1-2-1",
                      children: [],
                      label: "二級 1-2-1-1"
                    },
                  ],
                  label: "二級 1-2-1"
                },
                {
                  id: "1-2-2",
                  pid: "1-2",
                  children: [],
                  label: "二級 1-2-2"
                }
              ]
            }
          ]
        },
      ],
      rightData: [],
    };
  },
  methods: {
    add(leftData, rightData, obj){
      console.log("leftData:", leftData);
      console.log("rightData:", rightData);
      console.log("obj:", obj);
    },
    // 監聽穿梭框組件移除
    remove(leftData, rightData, obj){
      console.log("leftData:", leftData);
      console.log("rightData:", rightData);
      console.log("obj:", obj);
    },
  }
};
</script>

程式碼有點多,這不重要,重要的是作者的思想。這裡是封裝成了一個公共組件來使用,可以在頁面中的不同地方來調用。我這裡只是實現了基於我們自己的需求的功能,相看完整實現的朋友可以移步el-tree-transfer這個輪子,再次感謝這個輪子的作者,謝謝!

本文參考://github.com/hql7/tree-transfer