Vue2 穿梭框组件封装实战:功能增强与灵活扩展

一、引言

        穿梭框(Transfer)是前端开发中常用的组件,用于在两个列表之间移动数据项。虽然 Element UI 提供了基础的 el-transfer 组件,但在实际项目中我们往往需要更丰富的功能和更灵活的定制。本文将详细讲解如何基于 Vue2 和 Element UI 封装一个功能强大的穿梭框组件,并分析其特点、优势及使用方法。

二、先上效果图

三、组件封装详解

想跳过步骤直接使用的,可以直接划到底部看 使用指南 和 完整组件代码.

1. 基础结构搭建

首先我们来看组件的基础模板结构:

<template>
  <div class="transfer">
    <!-- 左框 -->
    <div class="transfer_left" :style="{ width: props.leftWidth }">
      <!-- 左侧内容 -->
    </div>
    
    <!-- 按钮 -->
    <div class="transfer_center">
      <el-button @click="leftClick"></el-button>
      <el-button @click="rightClick"></el-button>
    </div>
    
    <!-- 右框 -->
    <div class="transfer_right" :style="{ width: props.rightWidth }">
      <!-- 右侧内容 -->
    </div>
  </div>
</template>

这个结构创建了一个包含左右两个面板和中间操作按钮的基础布局。通过 props.leftWidthprops.rightWidth 可以动态设置两侧面板的宽度。

2. 数据管理

组件的数据管理是核心功能:

data() {
  return {
    allCheck_left: false, // 左侧全选状态
    allCheck_right: false, // 右侧全选状态

    checked_left: [], // 左侧已选数据
    checked_right: [], // 右侧已选数据

    leftList: [], // 左侧总数据列表
    rightList: [], // 右侧总数据列表

    left_search: "", // 左侧搜索词
    right_search: "" // 右侧搜索词
  };
}

组件通过 data 属性管理所有状态,包括选择状态、数据列表和搜索条件。

这里比较核心的是左右两侧的数据解耦,毕竟我就是为了解决这个问题才封装的这个组件,在element-ui 的 el-transfer 中因左右框共用一套数据的原因,不能在一侧框单独添加检索条件,数据解耦完美的解决了这个问题。

3. 属性配置

组件提供了丰富的配置选项:

props: {
    props: {
      type: Object,
      default() {
        return {
          label: "name", // 显示文本内容字段
          key: "id", // 唯一标识字段
          disabled: "disabled",  // 多选框禁用字段
          leftWidth: "300px", // 左侧宽度
          rightWidth: "300px", // 右侧宽度
        };
      },
    },
    // 源数据
    data: {
      type: Array,
      default() {
        return [];
      },
    },
    // 已选数据
    value: {
      type: Array,
      default() {
        return [];
      },
    },
    // 是否可搜索
    filterable: {
      type: Boolean,
      default() {
        return false;
      },
    },
    // 搜索框提示文字
    filterPlaceholder: {
      type: String,
      default() {
        return "请输入关键字";
      },
    },
    // 两侧标题
    titles: {
      type: Array,
      default() {
        return ["未选数据", "已选数据"];
      },
    },
    // 自定义搜索条件
    search: {
      type: Object,
      default() {
        return {
          left: [],
          right: [],
        };
      },
    },
}

这些属性使得组件可以高度定制化,适应各种业务场景。

为减少学习成本,这里封装的和 el-transfer 相同的属性保持了对齐。

有额外定制需求,也可以在这里继续添加定制化属性。

4. 核心功能实现

数据移动功能
// 将右边选中的数据移动到左边
leftClick() {
  let arr = [];
  this.rightList = this.rightList.filter((item) => {
    if (this.checked_right.includes(item[this.props.key])) {
      this.leftList.push(item);
      arr.push(item);
      return false;
    } else {
      return true;
    }
  });
  this.$emit(
    "change",
    this.rightList.map((item) => item[this.props.key]),
    "left",
    this.checked_right
  );
  this.checked_right = [];
}

// 将左边选中的数据移动到右边
rightClick() {
  let arr = [];
  this.leftList = this.leftList.filter((item) => {
    if (this.checked_left.includes(item[this.props.key])) {
      this.rightList.push(item);
      arr.push(item);
      return false;
    } else {
      return true;
    }
  });
  this.$emit(
    "change",
    this.rightList.map((item) => item[this.props.key]),
    "right",
    this.checked_left
  );
  this.checked_left = [];
}

这两个方法实现了数据在左右面板之间的移动,并在移动后触发 change 事件通知父组件。

搜索过滤功能
computed: {
  // 左边要显示的数据列表
  leftList_bak() {
    let arr = this.leftList.filter((item) =>
      item[this.props.label].includes(this.left_search)
    );
    let array = [arr];
    if (this.search.left && this.search.left.length) {
      array.push(...this.search.left);
    }
    return this.findIntersectionById(...array);
  },
  
  // 右边要显示的数据列表
  rightList_bak() {
    let arr = this.rightList.filter((item) =>
      item[this.props.label].includes(this.right_search)
    );
    let array = [arr];
    if (this.search.right && this.search.right.length) {
      array.push(...this.search.left);
    }
    return this.findIntersectionById(...array);
  }
},
methods: {
    // 求查询交集
    findIntersectionById(...arrays) {
      // 使用第一个数组作为初始累积值
      return arrays.reduce((acc, currentArray) => {
        const currentIds = new Set(currentArray.map((item) => item.id));
        return acc.filter((item) => currentIds.has(item.id));
      }, arrays[0] || []); // 确保初始值为第一个数组或空数组
    },
}

这些计算属性实现了基于搜索条件的数据过滤,支持文本搜索和自定义搜索条件的组合。

这里的 left_search 和 right_search 是封装自带的 filterable 的名称过滤功能的过滤结果,this.search.left 和 this.search.right 是分别是左右两侧增加的搜索条件的过滤结果,这里调用 findIntersectionById 方法求最后过滤的交集得到最终查询结果。

5. 文本溢出处理

<template>
    <el-checkbox
        v-for="(item, index) in rightList_bak"
        :key="item[props.key]"
        :label="item[props.key]"
        :disabled="item[props.disabled]"
    >
        <el-tooltip v-if="item.isOverflow" :content="item[props.label]" placement="top">
            <span :ref="'text_right' + index" class="check_text">{{item[props.label]}}</span>
        </el-tooltip>
        <span :ref="'text_right' + index" class="check_text" v-else>{{item[props.label]}}</span>
        <slot name="right" :option="item"></slot>
    </el-checkbox>
</template>

methods: {
  checkOverflowLeft() {
    this.leftList_bak.forEach((item, index) => {
      const el = this.$refs["text_left" + index][0];
      if (el) this.$set(item, "isOverflow", el.scrollWidth > el.clientWidth);
    });
  },
  
  checkOverflowRight() {
    this.rightList_bak.forEach((item, index) => {
      const el = this.$refs["text_right" + index][0];
      if (el) this.$set(item, "isOverflow", el.scrollWidth > el.clientWidth);
    });
  }
}

这些方法通过判断 el.scrollWidth 是否大于 el.clientWidth 检测文本是否溢出,通过控制 isOverflow 属性来控制 toolTip 的显隐,如果溢出则显示 tooltip 提示完整内容。

6. 数量展示

<span>{{ chechCount_left }}/{{ checkTotal_left }}</span>

computed: {
    checkTotal_left() {
      return this.leftList.length;
    },
    checkTotal_right() {
      return this.rightList.length;
    },
    // 未选设备-已选数量
    chechCount_left() {
      return this.checked_left.length;
    },
    // 已选设备-已选数量
    chechCount_right() {
      return this.checked_right.length;
    },
}

7. 全选/半选状态判断

watch: {
    checked_left(val) {
      if (val.length && val.length == this.leftList_bak.length) {
        this.allCheck_left = true;
      } else {
        this.allCheck_left = false;
      }
    },
    checked_right(val) {
      if (val.length && val.length == this.rightList_bak.length) {
        this.allCheck_right = true;
      } else {
        this.allCheck_right = false;
      }
    },
}

通过对左右框的多选框 绑定数据的长度变化 监听去和 两侧总数 对比是否相等,来判断是否展示 全选状态

computed: {
    indeterminate_left() {
      return (
        this.checked_left.length > 0 &&
        this.checked_left.length < this.leftList_bak.length
      );
    },
    indeterminate_right() {
      return (
        this.checked_right.length > 0 &&
        this.checked_right.length < this.rightList_bak.length
      );
    },
}

使用计算属性对左右框的多选框 绑定数据的长度变化 监听去和 两侧总数 对比大小,来判断是否展示 半选状态

四、组件特点

  1. 高度可定制:通过 props 可以自定义显示样式、宽度、标题、选中/总数、过滤条件等
  2. 增强的搜索功能:支持文本搜索和自定义搜索条件的组合
  3. 灵活的插槽:提供了 文本插槽过滤插槽,用于扩展功能
  4. 丰富的交互:支持全选、部分选择、文本溢出提示等
  5. 完善的API:提供了 change 事件和 v-model 支持

    五、使用指南

    基本使用

    <Transfer
      v-model="selectedData"
      :data="sourceData"
      :props="{ key: 'id', label: 'name' }"
      @change="handleChange"
    />

    高级使用(带搜索和扩展)(结果如效果图所示)

    <Transfer
      filterable
      filter-placeholder="设备名称"
      :titles="['未选设备', '已选设备']"
      v-model="treeValue"
      :props="transferProp"
      :data="treeList"
      :search="searchObj"
      @change="transferChange"
    >
      <template #search_left>
        <el-select v-model="areaVal" placeholder="所属区域">
          <!-- 选项 -->
        </el-select>
        <el-select v-model="layerVal" placeholder="所在图层">
          <!-- 选项 -->
        </el-select>
      </template>
      
      <template #right="{option}">
        <div class="selectBox">
          <el-select v-model="option.preset_point">
            <!-- 选项 -->
          </el-select>
          <el-radio v-model="option.is_main_device" :label="true">
            主相机
          </el-radio>
        </div>
      </template>
    </Transfer>
    
    data() {
        treeValue: [],
        treeList: [],
        transferProp: {
            label: "resource_name",
            key: "id",
            leftWidth: "400px",
            rightWidth: "500px",
        },
    },
    methods: {
        searchObj() {
            return {
                // treeList 根据上面“所属区域”和“所属图层”过滤后的数组
                left: [this.treeList_area, this.treeList_layer],
                // 如果右侧框也有自定义过滤条件,则添加 right: [...]
            };
        },
        // 点击切换按钮的change回调,同el-transfer,参数为(当前值、数据移动的方向('left' / 'right')、发生移动的数据 key 数组)
        transferChange(val, type, list) {},
    }

    属性说明

    属性名类型默认值说明
    dataArray[]源数据列表
    v-modelArray[]已选数据
    propsObject

    {
            label:'name',

            key:'id',

            disabled: "disabled",

            leftWidth: "300px",

            rightWidth: "300px",    

    }

    显示文本内容字段
    唯一标识字段
    多选框禁用字段
    左侧宽度
    右侧宽度
    filterableBooleanfalse是否可搜索
    filterPlaceholderString'请输入关键字'搜索框提示
    titlesArray['未选数据','已选数据']两侧标题
    searchObject

    {

            left:[],

            right:[]

    }

    自定义搜索结果数据

    例:

    {

            left: [

                    左侧过滤条件1过滤结果,

                    左侧过滤条件2的过滤结果,

                    ...

                    ],

            right: [

                      右侧过滤条件1过滤结果,

                      右侧过滤条件2的过滤结果,

                       ...

                      ],

    }

    事件说明

    事件名参数说明
    change(value, direction, checkedKeys)数据变化时触发

    插槽

    name说明
    left左侧文本插槽(参数为option)
    right右侧文本插槽(参数为option)

    search_left

    左侧条件检索区域插槽

    search_right

    右侧条件检索区域插槽

    六、完整组件代码

    <template>
      <div class="transfer">
        <!-- 左框 -->
        <div class="transfer_left" :style="{ width: props.leftWidth }">
          <header>
            <el-checkbox
              v-model="allCheck_left"
              @change="allCheck_left_change"
              :indeterminate="indeterminate_left"
              >{{ titles[0] }}</el-checkbox
            >
            <span>{{ chechCount_left }}/{{ checkTotal_left }}</span>
          </header>
          <section>
            <div class="transfer_search" v-if="filterable">
              <el-input
                :placeholder="filterPlaceholder"
                prefix-icon="el-icon-search"
                v-model="left_search"
                size="small"
                clearable
              >
              </el-input>
              <slot name="search_left"></slot>
            </div>
            <el-checkbox-group v-model="checked_left">
              <el-checkbox
                v-for="(item, index) in leftList_bak"
                :key="item[props.key]"
                :label="item[props.key]"
                :disabled="item[props.disabled]"
              >
                <el-tooltip
                  v-if="item.isOverflow"
                  :content="item[props.label]"
                  placement="top"
                >
                  <span :ref="'text_left' + index" class="check_text">{{
                    item[props.label]
                  }}</span>
                </el-tooltip>
                <span :ref="'text_left' + index" class="check_text" v-else>{{
                  item[props.label]
                }}</span>
                <slot name="left" :option="item"></slot>
              </el-checkbox>
            </el-checkbox-group>
          </section>
        </div>
        <!-- 按钮 -->
        <div class="transfer_center">
          <el-button
            icon="el-icon-arrow-left"
            type="primary"
            :disabled="!checked_right.length"
            @click="leftClick"
          ></el-button>
          <el-button
            icon="el-icon-arrow-right"
            type="primary"
            :disabled="!checked_left.length"
            @click="rightClick"
          ></el-button>
        </div>
        <!-- 右框 -->
        <div class="transfer_right" :style="{ width: props.rightWidth }">
          <header>
            <el-checkbox
              v-model="allCheck_right"
              @change="allCheck_right_change"
              :indeterminate="indeterminate_right"
              >{{ titles[1] }}</el-checkbox
            >
            <span>{{ chechCount_right }}/{{ checkTotal_right }}</span>
          </header>
          <section>
            <div class="transfer_search" v-if="filterable">
              <el-input
                :placeholder="filterPlaceholder"
                prefix-icon="el-icon-search"
                v-model="right_search"
                size="small"
                clearable
              >
              </el-input>
              <slot name="search_right"></slot>
            </div>
            <el-checkbox-group v-model="checked_right">
              <el-checkbox
                v-for="(item, index) in rightList_bak"
                :key="item[props.key]"
                :label="item[props.key]"
                :disabled="item[props.disabled]"
              >
                <el-tooltip
                  v-if="item.isOverflow"
                  :content="item[props.label]"
                  placement="top"
                >
                  <span :ref="'text_right' + index" class="check_text">{{
                    item[props.label]
                  }}</span>
                </el-tooltip>
                <span :ref="'text_right' + index" class="check_text" v-else>{{
                  item[props.label]
                }}</span>
                <slot name="right" :option="item"></slot>
              </el-checkbox>
            </el-checkbox-group>
          </section>
        </div>
      </div>
    </template>
    
    <script>
    export default {
      name: "transfer",
      data() {
        return {
          allCheck_left: false, // 未选设备-是否全选
          allCheck_right: false, // 已选设备-是否全选
          checked_left: [], // 未选设备-已选数据
          checked_right: [], // 已选设备-已选数据
          leftList: [], // 未选设备-数据
          rightList: [], // 已选设备-数据
          left_search: "",
          right_search: "",
        };
      },
      props: {
        props: {
          type: Object,
          default() {
            return {
              label: "name",
              key: "id",
              disabled: "disabled",
              leftWidth: "300px",
              rightWidth: "300px",
            };
          },
        },
        data: {
          type: Array,
          default() {
            return [];
          },
        },
        value: {
          type: Array,
          default() {
            return [];
          },
        },
        filterable: {
          type: Boolean,
          default() {
            return false;
          },
        },
        filterPlaceholder: {
          type: String,
          default() {
            return "请输入关键字";
          },
        },
        titles: {
          type: Array,
          default() {
            return ["未选数据", "已选数据"];
          },
        },
        search: {
          type: Object,
          default() {
            return {
              left: [],
              right: [],
            };
          },
        },
      },
      components: {},
      computed: {
        checkTotal_left() {
          return this.leftList.length;
        },
        checkTotal_right() {
          return this.rightList.length;
        },
        // 未选设备-已选数量
        chechCount_left() {
          return this.checked_left.length;
        },
        // 已选设备-已选数量
        chechCount_right() {
          return this.checked_right.length;
        },
        indeterminate_left() {
          return (
            this.checked_left.length > 0 &&
            this.checked_left.length < this.leftList_bak.length
          );
        },
        indeterminate_right() {
          return (
            this.checked_right.length > 0 &&
            this.checked_right.length < this.rightList_bak.length
          );
        },
        // 左边要显示的数据列表
        leftList_bak() {
          let arr = this.leftList.filter((item) =>
            item[this.props.label].includes(this.left_search)
          );
          let array = [arr];
          if (this.search.left && this.search.left.length) {
            array.push(...this.search.left);
          }
          return this.findIntersectionById(...array);
        },
        // 右边要显示的数据列表
        rightList_bak() {
          let arr = this.rightList.filter((item) =>
            item[this.props.label].includes(this.right_search)
          );
          let array = [arr];
          if (this.search.right && this.search.right.length) {
            array.push(...this.search.left);
          }
          return this.findIntersectionById(...array);
        },
      },
      watch: {
        checked_left(val) {
          if (val.length && val.length == this.leftList_bak.length) {
            this.allCheck_left = true;
          } else {
            this.allCheck_left = false;
          }
        },
        checked_right(val) {
          if (val.length && val.length == this.rightList_bak.length) {
            this.allCheck_right = true;
          } else {
            this.allCheck_right = false;
          }
        },
        data: {
          handler(val) {
            console.log(val, this.value, "this.value");
            this.leftList = [];
            this.rightList = [];
            val.forEach((item) => {
              if (this.value.includes(item[this.props.key])) {
                this.rightList.push(item);
              } else {
                this.leftList.push(item);
              }
            });
            this.$nextTick(() => {
              this.checkOverflowLeft();
              this.checkOverflowRight();
            });
          },
          immediate: true,
        },
        rightList(val) {
          this.$emit(
            "input",
            val.map((item) => item[this.props.key])
          );
        },
      },
      mounted() {},
      methods: {
        allCheck_left_change(val) {
          this.checked_left = val
            ? this.leftList_bak.map((item) => item[this.props.key])
            : [];
        },
        allCheck_right_change(val) {
          this.checked_right = val
            ? this.rightList_bak.map((item) => item[this.props.key])
            : [];
        },
        checkOverflowLeft() {
          this.leftList_bak.forEach((item, index) => {
            const el = this.$refs["text_left" + index][0];
            if (el) this.$set(item, "isOverflow", el.scrollWidth > el.clientWidth);
          });
        },
        checkOverflowRight() {
          this.rightList_bak.forEach((item, index) => {
            const el = this.$refs["text_right" + index][0];
            if (el) this.$set(item, "isOverflow", el.scrollWidth > el.clientWidth);
          });
        },
        leftClick() {
          let arr = [];
          this.rightList = this.rightList.filter((item) => {
            if (this.checked_right.includes(item[this.props.key])) {
              this.leftList.push(item);
              arr.push(item);
              return false;
            } else {
              return true;
            }
          });
          this.$emit(
            "change",
            this.rightList.map((item) => item[this.props.key]),
            "left",
            this.checked_right
          );
          this.checked_right = [];
        },
        // 将左边选中的数据移动到右边
        rightClick() {
          let arr = [];
          this.leftList = this.leftList.filter((item) => {
            if (this.checked_left.includes(item[this.props.key])) {
              this.rightList.push(item);
              arr.push(item);
              return false;
            } else {
              return true;
            }
          });
          this.$emit(
            "change",
            this.rightList.map((item) => item[this.props.key]),
            "right",
            this.checked_left
          );
          this.checked_left = [];
        },
        // 求查询交集
        findIntersectionById(...arrays) {
          // 使用第一个数组作为初始累积值
          return arrays.reduce((acc, currentArray) => {
            const currentIds = new Set(currentArray.map((item) => item.id));
            return acc.filter((item) => currentIds.has(item.id));
          }, arrays[0] || []); // 确保初始值为第一个数组或空数组
        },
      },
    };
    </script>
    <style scoped lang="less">
    .transfer {
      display: flex;
      .transfer_left,
      .transfer_right {
        border: 1px solid #0270c1;
        border-radius: 3px;
        height: 370px;
        header {
          display: flex;
          justify-content: space-between;
          align-items: center;
          height: 40px;
          padding: 0 15px;
          border-bottom: 1px solid #0270c1;
          background: #0270c16b;
          > span {
            color: #00ffff;
          }
        }
        section {
          width: 100%;
          height: calc(100% - 40px);
          overflow-y: auto;
          padding: 15px;
          .transfer_search {
            display: flex;
            margin-bottom: 5px;
            .el-input,
            .el-select {
              flex: 1;
              &:not(:last-child) {
                margin-right: 5px;
              }
            }
          }
          .el-checkbox-group {
            width: 100%;
            ::v-deep .el-checkbox {
              width: 100%;
              height: 40px;
              display: block;
              display: flex;
              align-items: center;
              .el-checkbox__label {
                width: calc(100% - 30px);
                vertical-align: text-top;
                display: flex;
                align-items: center;
                .check_text {
                  display: inline-block;
                  overflow: hidden;
                  text-overflow: ellipsis;
                  flex: 1;
                }
              }
            }
          }
        }
      }
      .transfer_center {
        width: 180px;
        text-align: center;
        line-height: 400px;
      }
    }
    </style>
    

    七、总结

            这个自定义的 Transfer 组件在 Element UI 原生组件的基础上进行了多方面的增强,提供了更灵活的配置选项、更强大的搜索功能和更丰富的扩展能力。通过插槽机制,开发者可以在左右面板中添加自定义内容,满足各种复杂业务场景的需求。组件还提供了完善的 API 和事件系统,便于与其他组件协同工作。

            在实际项目中,这种高度可定制的组件可以显著提高开发效率,同时保持统一的交互体验。通过本文的讲解,希望读者能够理解其实现原理,并能够根据自身需求进行进一步的定制和优化。

    评论
    添加红包

    请填写红包祝福语或标题

    红包个数最小为10个

    红包金额最低5元

    当前余额3.43前往充值 >
    需支付:10.00
    成就一亿技术人!
    领取后你会自动成为博主和红包主的粉丝 规则
    hope_wisdom
    发出的红包
    实付
    使用余额支付
    点击重新获取
    扫码支付
    钱包余额 0

    抵扣说明:

    1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
    2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

    余额充值