【设计】基于 Vue 3.x + TypeScript 实现一个穿梭框组件

211 阅读4分钟

前言

什么是穿梭框组件

穿梭框 (Transfer) 是一种在一组备选项中进行多选并且左右移动选择的组件。

为什么需要穿梭框组件

穿梭框组件通常用于在两个列表之间进行数据的选择和移动,例如在用户管理中,需要从一组用户中选择一部分用户进行操作,这时就可以使用穿梭框组件。

穿梭框组件的用途

穿梭框组件通常用于以下场景:

  • 用户管理:在用户管理中,需要从一组用户中选择一部分用户进行操作,例如分配权限、删除用户等。
  • 数据筛选:在数据筛选中,需要从一组数据中选择一部分数据进行展示,例如在表格中筛选数据。

本次用到的技术栈

技术栈名称技术栈说明
Vue3.0Vue.js v3 版本,基于 <script setup>
TypeScriptJavaScript 模拟强类型语言的超集
Functional Programming函数式编程

首先,我们需要什么?

  1. 需要对 Vue3.0 组件库封装, TypeScript 使用,Functional Programming 有一定的了解
  2. 需要一个 Vue3.0 + TypeScript 的工程 (本文使用的是 vite 工程)

温馨提示

如果没有 Vue3 项目,可以参考 以往的文档

参数 & 配置

Props

属性名说明类型默认值
data数据源, 定义参考TransferItemTransferItem[][]
modelValue / v-model数据源中的唯一标识字段名(string | number)[][]
allowSearch是否显示搜索框booleanfalse
searchPlaceholder搜索框占位符 (仅在 allowSearch = true 下生效)string'请输入搜索内容'
showTitle是否展示盒子标题栏booleantrue
titles标题栏内容string[] | string['源数据', '目标数据']
disabled是否禁用穿梭框booleanfalse

Emits

属性名说明类型默认值
update:modelValue数据变化时触发的事件处理函数

程序设计

程序设计包含以下几个部分:

  1. 组件设计
  2. 模块设计
  3. 类型设计

组件设计

Transfer 的组件结构如下:

Transfer/src
  ├── Transfer          # Transfer 组件
    ├── TransferBox     # Transfer 左右盒子
      ├── TransferTitle # Transfer 标题栏
      ├── TransferList  # Transfer 列表
    ├── TransferEmpty     # Transfer 数据为空展示的内容

  ├── TransferCtrl      # Transfer 控制栏
    ├── TransferSearch  # Transfer 搜索栏

模块设计

Transfer/modules
  ├── constant  # Transfer 组件的配置常量
  ├── hooks     # Transfer 组件的 hooks
  ├── utils     # Transfer 组件的工具函数

类型设计

TransferItem 定义

interface TransferItem {
  key: string | number;
  label: string;
  disabled?: boolean;
}

整体结构输出

Transfer
  Transfer/src
    ├── Transfer          # Transfer 组件
      ├── TransferBox     # Transfer 左右盒子
        ├── TransferTitle # Transfer 标题栏
        ├── TransferList  # Transfer 列表
      ├── TransferEmpty     # Transfer 数据为空展示的内容

    ├── TransferCtrl      # Transfer 控制栏
      ├── TransferSearch  # Transfer 搜索栏

  Transfer/modules
    ├── constant  # Transfer 组件的配置常量
    ├── hooks     # Transfer 组件的 hooks
    ├── utils     # Transfer 组件的工具函数

代码实现

1. 常量定义

// modules/constant.ts

import { PropType } from 'vue';
import {
  TransferItem,
  TransferDirectionType,
  TransferCheckAllType,
} from '../types';

export const transferProps = {
  /**
   * @description 数据源
   */
  data: {
    type: Array as PropType<TransferItem[]>,
    default: () => [],
  },
  /**
   * @description 数据源中的唯一标识字段名
   */
  modelValue: {
    type: Array as PropType<(string | number)[]>,
    default: () => [],
  },
  /**
   * @description 是否显示搜索框
   */
  allowSearch: {
    type: Boolean,
    default: false,
  },
  /**
   * @description 搜索框占位符 (仅在 `allowSearch = true` 下生效)
   */
  searchPlaceholder: {
    type: [String, Array] as PropType<string | [string, string]>,
    default: '请输入搜索内容',
  },
  /**
   * @description 是否展示盒子标题栏
   */
  showTitle: {
    type: Boolean,
    default: true,
  },
  /**
   * @description 标题栏内容
   */
  titles: {
    type: Array as PropType<string[] | string>,
    default: () => ['源数据', '目标数据'],
  },
  /**
   * @description 是否禁用
   */
  disabled: {
    type: Boolean,
    default: false,
  },
} as const;

export const transferEmits = {
  /**
   * @description 数据变化时触发
  */
  'update:modelValue': (value: (string | number)[]) =>
    typeof value === 'string' || Array.isArray(value),
  change: (value: (string | number)[]) =>
    typeof value === 'string' || Array.isArray(value),
} as const;

export const transfrerBoxEmits = {
  change: (
    direction: TransferDirectionType,
    value: boolean,
    item: TransferItem
  ) => (
    typeof direction === 'string'
      && typeof value === 'boolean'
      && typeof item === 'object' && item !== null
  ),
  'change-search-value': (newValue: string, direction: TransferDirectionType) =>
    typeof newValue === 'string' && typeof direction === 'string',
  'checked-all-change': (field: TransferCheckAllType) => typeof field === 'string',
} as const;

2. 类型定义

// types.ts

import type { ComputedRef, ExtractPropTypes, Ref } from 'vue';
import { transferProps } from './modules/constant';

export interface TransferItem {
  key: string | number;
  label: string;
  disabled?: boolean;
}

export interface TransferGroupItem {
  label: string;
  children: TransferItem[];
}

export type TransferDirectionType = 'left' | 'right';

export type TransferProps = ExtractPropTypes<typeof transferProps>;

export type TransferType = (
  | 'legacy'
  | 'group'
);

export interface UseShowDataOptions {
  selectedKeys: Ref<(string | number)[]>;
  data: Ref<TransferItem[]>;
  direction: TransferDirectionType;
  allowSearch: ComputedRef<boolean>;
  searchValue: Ref<string>;
}

export type UseAllShowDataOptions = Omit<UseShowDataOptions, 'direction' | 'searchValue'> & {
  leftSearchValue: Ref<string>;
  rightSearchValue: Ref<string>;
};

export interface UseCheckedValueControllerOptions {
  direction: TransferDirectionType;
  data: Ref<TransferItem[]>;
}

export type UseAllCheckedValueControllersOptions = (
  & Omit<UseCheckedValueControllerOptions, 'direction'>
  & UseAllShowDataOptions
);

export enum TransferCheckAllType {
  // 全选
  ALL = 'all',
  // 全不选
  NONE = 'none',
  // 部分选中
  PART = 'part',
}

3. 组件定义

我们需要把组件都定义出来

  • src/Transfer.vue
<script lang="ts" setup>
  defineOptions({ name: 'MyTransfer' });

  const const ns = 'my-transfer';
</script>

<template></template>
  • src/Sub/TransferBox.vue
<script setup lang="ts">
  import { computed } from 'vue';

  import MyInput from '../../../Input/src/Input.vue';
  import MyCheckbox from '../../../Checkbox/src/Checkbox.vue';

  import { transfrerBoxEmits } from '../../modules/constant';
  import {
    TransferCheckAllType,
    TransferDirectionType,
    TransferItem,
  } from '../../types';

  import MyTransferEmpty from './TransferEmpty.vue';

  const ns = 'my-transfer-box';

  interface TransferBoxProps {
    disabled: boolean;
    direction: TransferDirectionType;
    showTitle: boolean;
    titles: string | string[];
    checkedAllValue: boolean;
    checkedValue: (string | number)[];
    showData: TransferItem[];
    allowSearch: boolean;
    searchValue: string;
    allowClear: boolean;
    searchPlaceholder: string | [string, string];
  }

  defineOptions({ name: 'MyTransferBox' });

  const props = defineProps<TransferBoxProps>();

  const navTitle = computed(() => {
    switch (props.direction) {
      case 'left':
        if (Array.isArray(props.titles)) {
          return props.titles?.[0] || '列表1';
        }
        if (typeof props.titles === 'string') {
          return props.titles;
        }
        return '列表1';
      case 'right':
        if (Array.isArray(props.titles)) {
          return props.titles?.[1] || props.titles?.[0] || '列表2';
        }
        if (typeof props.titles === 'string') {
          return props.titles;
        }
        return '列表2';
      default:
        return '-';
    }
  });

  const emit = defineEmits(transfrerBoxEmits);

  const indeterminate = computed(() => {
    if (props.checkedValue.length && !props.checkedAllValue) {
      return true;
    }
    return false;
  });

  function handleValueChange(
    direction: TransferDirectionType,
    value: boolean,
    item: TransferItem,
  ) {
    emit('change', direction, value, item);
  }

  function handleSearchValueChange(value: string) {
    emit('change-search-value', value, props.direction);
  }

  function handleCheckedAllValueChange(newValue: boolean) {
    if (newValue) {
      emit('checked-all-change', TransferCheckAllType.ALL);
    } else {
      emit('checked-all-change', TransferCheckAllType.NONE);
    }
  }

</script>

<template>
  <div :class="[ns, `${ns}-left`]">
    <div :class="`${ns}-top`">
      <nav :class="`${ns}-nav`" v-if="props.showTitle">
        <my-checkbox
          :class="`${ns}-nav-title`"
          :disabled="props.disabled"
          :label="navTitle"
          :model-value="props.checkedAllValue"
          :indeterminate="indeterminate"
          @change="handleCheckedAllValueChange"
        />
        <span :class="`${ns}-nav-stats`">
          ( {{ `${checkedValue.length} / ${showData.length}` }} )
        </span>
      </nav>
      <div :class="`${ns}-search`" v-if="props.allowSearch">
        <my-input
          :class="`${ns}-search-input`"
          :disabled="props.disabled"
          :model-value="props.searchValue"
          @update:model-value="handleSearchValueChange"
          type="text"
          allow-clear
          :placeholder="
            typeof props.searchPlaceholder === 'string'
              ? props.searchPlaceholder
              : props.searchPlaceholder?.[0]
          "
        />
        <i class="iconfont icon-search" />
      </div>
    </div>
    <div v-if="!showData.length" style="height: 200px; text-align: center; padding: 20px; box-sizing: border-box;">
      <my-transfer-empty />
    </div>
    <template v-else>
      <ul :class="`${ns}-list`">
        <li
          v-for="item in showData"
          :key="item.key"
          :class="[`${ns}-list-item`]"
          :draggable="!(item.disabled || props.disabled)"
        >
          <my-checkbox
            :disabled="item.disabled || props.disabled"
            :label="item.label"
            :model-value="
              checkedValue.includes(item.key)
              || props.direction === 'right' && item.disabled
            "
            @change="value => handleValueChange(props.direction, value, item)"
          />
          <i class="iconfont icon-right" />
        </li>
      </ul>
    </template>
  </div>
</template>
  • src/Sub/TransferCtrl.vue
<script setup lang="ts">
  import { TransferDirectionType } from '../../types';
  import { RightArrowIcon, LeftArrowIcon } from '../../../Icon';

  interface TransferCtrlProps {
    disabled: boolean;
    leftButtonDisabled: boolean;
    rightButtonDisabled: boolean;
  }

  const ns = 'my-transfer-ctrl';

  const props = defineProps<TransferCtrlProps>();

  const emit = defineEmits({
    change: (direction: TransferDirectionType) =>
      ['left', 'right'].includes(direction),
  });

  function handleSwitchButtonClick(direction: TransferDirectionType) {
    emit('change', direction);
  }

</script>

<template>
  <div :class="ns">
    <button
      :class="[
        `${ns}-btn`,
        props.leftButtonDisabled ? `${ns}-btn-disabled` : ''
      ]"
      data-direction="right"
      :disabled="props.leftButtonDisabled || props.disabled"
      @click="handleSwitchButtonClick('right')"
    >
      <right-arrow-icon class="transfer-button-icon" />
    </button>
    <button
      :class="[
        `${ns}-btn`,
        props.rightButtonDisabled ? `${ns}-btn-disabled` : ''
      ]"
      data-direction="left"
      :disabled="props.rightButtonDisabled || props.disabled"
      @click="handleSwitchButtonClick('left')"
    >
      <left-arrow-icon class="transfer-button-icon" />
    </button>
  </div>
</template>
  • src/Sub/TransferEmpty.vue
<script setup lang="ts">
  defineOptions({ name: 'MyTransferEmpty' });
</script>

<template>
  <div class="my-empty">
    <svg
      class="empty-icon"
      width="64"
      height="64"
      viewBox="0 0 24 24"
      fill="none"
      xmlns="http://www.w3.org/2000/svg"
    >
      <path
        d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8zm-1-13h2v6h-2zm0 8h2v2h-2z"
        fill="#BDBDBD"
      />
    </svg>
    <p class="empty-text">暂无数据</p>
  </div>
</template>

<style scoped>
  .my-empty {
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    padding: 24px;
  }

  .empty-icon {
    margin-bottom: 16px;
  }

  .empty-text {
    font-size: 14px;
    color: #757575;
  }
</style>

4. 模块实现

  1. 准备工具函数
// modules/utils.ts

export function getFilteredData<T extends { label: string }>(
  data: T[],
  searchValue: string,
): T[] {
  return data.filter(item => {
    const itemLabel = item.label.toLowerCase();
    if (!searchValue.trim().length) return true;
    return itemLabel.includes(searchValue.toLowerCase());
  });
}

export function filterDataByKeys<T extends { key: string | number }>(
  data: T[],
  keys: (string | number)[],
  field: 'selected' | 'unselected' = 'selected'
): T[] {
  if (field === 'unselected') {
    return data.filter(item => keys.every(key => key !== item.key));
  }
  return data.filter(item => keys.some(key => key === item.key));
}

export function addElementToArray<T>(result: T[], value: T | T[]) {
  if (Array.isArray(value)) {
    for (let item of value) {
      if (result.includes(item)) continue;
      result.push(item);
    }
  } else {
    if (result.includes(value)) return;
    result.push(value);
  }
  return result;
}

export function removeElementFromArray<T>(result: T[], value: T | T[]) {
  let newResult: T[] = [];
  if (Array.isArray(value)) {
    newResult = result.filter((item) => !value.includes(item));
  } else {
    newResult = result.filter((item) => item !== value);
  }
  return newResult;
}
  1. 编写组建的逻辑
  • 本文采用函数式编程!
  • 这里将组件的数据视图驱动写到了 hooks 中,最后在根组件中接入这些 hooks 就行了
import { computed, onMounted, ref } from 'vue';
import type { ComputedRef, Ref } from 'vue';
import type {
  TransferItem,
  UseAllShowDataOptions,
  UseShowDataOptions,
  UseCheckedValueControllerOptions,
  UseAllCheckedValueControllersOptions,
  TransferDirectionType,
} from '../types';
import { TransferCheckAllType } from '../types';
import {
  addElementToArray,
  removeElementFromArray,
  filterDataByKeys,
  getFilteredData,
} from './utils';

export function useTransferData<T extends { data: TransferItem[] }>(props: T) {
  const transferData = ref<TransferItem[]>([]);

  onMounted(() => {
    transferData.value = props.data;
  });

  return transferData;
}

export function useSelectedKeys<T extends { modelValue: (string | number)[] }>(props: T): Ref<(string | number)[]> {
  const selectedKeys = ref<(string | number)[]>([]);

  onMounted(() => {
    selectedKeys.value = [...props.modelValue];
  });

  return selectedKeys;
}


export function useAllSearchValue(): [
  Ref<string>,
  Ref<string>,
  (newValue: string, direction: TransferDirectionType) => void,
] {
  const [leftSearchValue, rightSearchValue] = [ref(''), ref('')];

  function changeSearchValue(newValue: string, direction: TransferDirectionType) {
    switch (direction) {
      case 'left':
        leftSearchValue.value = newValue;
        break;
      case 'right':
        rightSearchValue.value = newValue;
        break;
      default:
        break;
    }
  }

  return [leftSearchValue, rightSearchValue, changeSearchValue];
}

export function useShowData({
  direction,
  data,
  selectedKeys,
  allowSearch,
  searchValue,
}: UseShowDataOptions) {
  return computed<TransferItem[]>(() => {
    switch (direction) {
      case 'left':
        if (!selectedKeys.value.length) {
          if (allowSearch.value) {
            return getFilteredData(data.value, searchValue.value);
          } else {
            return data.value;
          }
        }
        if (allowSearch.value) {
          const filteredData = getFilteredData(data.value, searchValue.value);
          return filterDataByKeys(filteredData, selectedKeys.value, 'unselected');
        }
        return filterDataByKeys(data.value, selectedKeys.value, 'unselected');
      case 'right':
        if (!selectedKeys.value.length) return [];
        if (allowSearch.value) {
          const filteredData = getFilteredData(data.value, searchValue.value);
          return filterDataByKeys(filteredData, selectedKeys.value);
        }
        return filterDataByKeys(data.value, selectedKeys.value);
      default:
        return [];
    }
  });
}

export function useAllShowData({
  data,
  selectedKeys,
  allowSearch,
  leftSearchValue,
  rightSearchValue,
}: UseAllShowDataOptions
): [ComputedRef<TransferItem[]>, ComputedRef<TransferItem[]>] {
  const leftShowData = useShowData({
    direction: 'left',
    data,
    selectedKeys,
    allowSearch,
    searchValue: leftSearchValue,
  });
  const rightShowData = useShowData({
    direction: 'right',
    data,
    selectedKeys,
    allowSearch,
    searchValue: rightSearchValue,
  });
  return [leftShowData, rightShowData];
}

export function useCheckedValueController({
  direction, // 方向
  data, // 数据
}: UseCheckedValueControllerOptions) {
  const checkedValue = ref<(number | string)[]>([]);

  const checkedAllValue = computed(() => {
    if (!data.value.length || !checkedValue.value.length) return false;
    // return data.value.filter(item => !item.disabled).length <= checkedValue.value.length;
    return data.value.every(item => checkedValue.value.includes(item.key));
  })

  const clearCheckedValue = () => {
    checkedValue.value = [];
  }

  const addCheckedValue = (value: number | string | (string | number)[]) => {
    const result = checkedValue.value;
    addElementToArray(result, value);
    checkedValue.value = [...result];
  }

  const removeCheckedValue = (value: number | string | (string | number)[]) => {
    let result = checkedValue.value;
    result = removeElementFromArray(result, value);
    checkedValue.value = [...result];
  }

  const operateAll = (type: TransferCheckAllType) => {
    switch (type) {
      case TransferCheckAllType.ALL:
        checkedValue.value = data.value.map((item) => item.key);
        break;
      case TransferCheckAllType.NONE:
        checkedValue.value = [];
        break;
      default:
        break;
    }
  }

  return {
    checkedValue,
    checkedAllValue,
    direction,
    clear: clearCheckedValue,
    add: addCheckedValue,
    remove: removeCheckedValue,
    operateAll,
  };
}

export function useAllCheckedValueControllers({
  data,
  selectedKeys,
  allowSearch,
  leftSearchValue,
  rightSearchValue,
}: UseAllCheckedValueControllersOptions): [
  ReturnType<typeof useCheckedValueController>,
  ReturnType<typeof useCheckedValueController>
] {
  const [leftShowData, rightShowData] = useAllShowData({
    data,
    selectedKeys,
    allowSearch,
    leftSearchValue,
    rightSearchValue,
  });

  return [
    useCheckedValueController({ direction: 'left', data: leftShowData }),
    useCheckedValueController({ direction: 'right', data: rightShowData }),
  ];
}

5. 将模块接入到组件

  • src/Transfer.vue
<script lang="ts" setup>
  import { computed } from 'vue';

  import * as constant from '../modules/constant';
  import {
    useTransferData,
    useSelectedKeys,
    useAllSearchValue,
    useAllShowData,
    useAllCheckedValueControllers,
  } from '../modules/hooks';
  import { TransferDirectionType, TransferItem } from '../types';
  import { addElementToArray, removeElementFromArray } from '../modules/utils';

  import MyTransferCtrl from './Sub/TransferCtrl.vue';
  import MyTransferBox from './Sub/TransferBox.vue';

  defineOptions({ name: 'MyTransfer' });

  const ns = 'my-transfer';
  const props = defineProps(constant.transferProps);
  const emit = defineEmits(constant.transferEmits);

  const transferData = useTransferData(props);
  const selectedKeys = useSelectedKeys(props);

  const [leftSearchValue, rightSearchValue, changeSearchValue] = useAllSearchValue();

  const [leftShowData, rightShowData] = useAllShowData({
    data: transferData,
    selectedKeys,
    allowSearch: computed(() => props.allowSearch),
    leftSearchValue,
    rightSearchValue,
  });

  const [leftCheckedController, rightCheckedController] = useAllCheckedValueControllers({
    data: transferData,
    selectedKeys,
    allowSearch: computed(() => props.allowSearch),
    leftSearchValue,
    rightSearchValue,
  });

  const leftButtonDisabled = computed(() =>
    !leftShowData.value.length || !leftCheckedController.checkedValue.value.length
  );

  const rightButtonDisabled = computed(() =>
    !rightShowData.value.length || !rightCheckedController.checkedValue.value.length
  );

  function handleValueChange(
    direction: TransferDirectionType,
    value: boolean,
    item: TransferItem,
  ) {
    switch (direction) {
      case 'left':
        !!value ? leftCheckedController.add(item.key) : leftCheckedController.remove(item.key);
        break;
      case 'right':
        !!value ? rightCheckedController.add(item.key) : rightCheckedController.remove(item.key);
        break;
      default:
        break;
    }
  }

  function handleSwitchButtonClick(direction: TransferDirectionType) {
    switch (direction) {
      case 'right':
        {
          // 添加元素
          const addKeys: (string | number)[] = leftCheckedController.checkedValue.value;
          const selectedKeysValue = selectedKeys.value;
          addElementToArray(selectedKeysValue, addKeys);
          selectedKeys.value = selectedKeysValue;
          leftCheckedController.clear();
        }
        break;
      case 'left':
        {
          // 删除元素
          const removeKeys: (string | number)[] = rightCheckedController.checkedValue.value;
          let selectedKeysValue = selectedKeys.value;
          selectedKeysValue = removeElementFromArray(selectedKeysValue, removeKeys);
          selectedKeys.value = selectedKeysValue;
          rightCheckedController.clear();
        }
        break;
      default:
        break;
    }
  }

</script>

<template>
  <div :class="[ns, props.disabled ? `${ns}-disabled` : '']">
    <my-transfer-box
      direction="left"
      :disabled="props.disabled"
      :show-title="props.showTitle"
      :titles="props.titles"
      :checked-all-value="leftCheckedController.checkedAllValue.value"
      :checked-value="leftCheckedController.checkedValue.value"
      :show-data="leftShowData"
      :allow-search="props.allowSearch"
      :search-placeholder="props.searchPlaceholder"
      :search-value="leftSearchValue"
      :allow-clear="true"
      @change="handleValueChange"
      @change-search-value="changeSearchValue"
      @checked-all-change="leftCheckedController.operateAll"
    />
    <my-transfer-ctrl
      :disabled="props.disabled"
      :left-button-disabled="leftButtonDisabled"
      :right-button-disabled="rightButtonDisabled"
      @change="handleSwitchButtonClick"
    />
    <my-transfer-box
      direction="right"
      :disabled="props.disabled"
      :show-title="props.showTitle"
      :titles="props.titles"
      :checked-all-value="rightCheckedController.checkedAllValue.value"
      :checked-value="rightCheckedController.checkedValue.value"
      :show-data="rightShowData"
      :allow-search="props.allowSearch"
      :search-placeholder="props.searchPlaceholder"
      :search-value="rightSearchValue"
      :allow-clear="true"
      @change="handleValueChange"
      @change-search-value="changeSearchValue"
      @checked-all-change="rightCheckedController.operateAll"
    />
  </div>
</template>

到了这一步,就算是大功告成了!

效果

image.png

完整代码

参考链接 🔗Transfer