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这个轮子,再次感谢这个轮子的作者,谢谢!

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

05-06 23:20