Vue3 + TypeScript 实现穿梭框功能

1,384 阅读4分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 6 月更文挑战」的第10天,点击查看活动详情

概述

因为最近项目中要使用穿梭框功能,目前使用的 UI 中不能满足需求,只好自己封装一个使用了,这里我把自己封装的和大家分享一下,希望能够得到大家的批评和建议。

我们先来看下完成的效果图

iShot_2022-06-09_23.53.49.gif

正文

首先我们新建一个项目,执行如下命令

pnpm create vite vue-transfer

选择 vue ,接着选择 vue-ts ,回车,等待项目安装完成, cd 进入项目根目录下,接着安装项目需要的依赖包

pnpm install

安装完成后,我们启动项目

pnpm run dev

控制台输出如下内容

  vite v2.9.10 dev server running at:

  > Local: http://localhost:3000/
  > Network: use `--host` to expose

  ready in 353ms.

说明项目正常启动了

这里我们还需要安装一下项目需要的一些第三方插件库,执行如下命令

pnpm install sass sass-loader

这里我们安装的 css 预处理器 sass ,当然你也可以选择 node-sass ,但是非常不建议使用, 因为 node-sass 和 nodejs 版本相关联,一旦本地 nodejs 版本升级, node-sass 就会无法工作,因为 nodes-sass 编译实际是由 libsass 完成的,而 libsass 是用 C/C++ 实现的,经常会出现安装失败的情况,又或者 nodejs 版本升级后 又需要重新安装 node-sass 情况。 如果你是在 docker 中使用 node-sass 还会经常出现因为缺少各种依赖导致 node-sass 编译失败情况,除了这些情况,使用 node-sass 还会因为经常出现需要的二进制文件下载失败的情况,导致编译失败,所以建议还是选择 sass

另外还需要说明的是 sass 不支持/deep/,要改成::v-deep 形式

穿梭框组件的 UI

安装完成后,我们删除项目里面多余的代码,在 src/components 下新建一个文件 transfer.vue, 这个文件就是我们的穿梭框组件,接着将穿梭框组件引入到 App.vue 中

<template>
   <Transfer />
</template>

<script setup lang="ts">
import { ref } from 'vue'
import Transfer from './components/transfer.vue'

</script>

transfer.vue 文件中加入如下代码,开始把穿梭框的 UI 部分先做出来

<template>
   <div class="transfer-wrap">
       <div class="transfer-item transfer-left">
           <div class="transfer-title">
               <span class="transfer-title-checkbox">
                   <input v-model="leftAllChecked" type="checkbox" class="checkbox">源项</span>
               <span>0/1</span>
           </div>
           <ul class="transfer-content">
               <li class="transfer-content-item">
                   <input v-model="leftAllChecked" type="checkbox" class="checkbox" >Options
               </li>
           </ul>
       </div>
       <div class="transfer-middle">
           <div><button type="button" class="btn  tansfer-btn">右移</button></div>
           <div><button type="button" class="btn tansfer-btn mt-10px">左移</button></div>
       </div>
       <div class="transfer-item transfer-right">
           <div class="transfer-title">
               <span class="transfer-title-checkbox">
                   <input v-model="leftAllChecked" type="checkbox" class="checkbox" > 目标项</span>
               <span>0/0</span>
           </div>
           <ul class="transfer-content">
               <li class="transfer-content-item"></li>
           </ul>
       </div>
   </div>
</template>
<script setup lang="ts">

</script>
<style scoped lang="scss">
ul,
li {
   margin: 0;
   padding: 0;
   list-style: none;
}

.transfer-wrap {
   display: flex;
   align-items: center;
   justify-content: center;
   margin: 0 auto;
   font-size: 14px;

   .transfer-title {
       display: flex;
       align-items: center;
       padding: 8px 15px;
       justify-content: space-between;
       background-color: #fafafc;
   }

   .transfer-title-checkbox {
       display: inline-flex;
       align-items: center;
   }

   .transfer-item {
       width: 230px;
       height: 350px;
       border: 1px solid #e9e9e9;
       border-radius: 4px;
       overflow: hidden;
   }

   .checkbox {
       margin-right: 8px;
       width: 18px;
       color: #e9e9e9;
   }

   .transfer-content {
       height: calc(350px - 39px);
       overflow-y: auto;
   }

   .transfer-content-item {
       padding: 8px 15px;
       text-align: left;
       display: inline-flex;
       align-items: center;
       width: 100%;
       box-sizing: border-box;
       cursor: pointer;

       &:hover {
           background-color: #f3f3f5;
       }
   }

   .transfer-middle {
       padding: 0 10px;
   }

   .btn {
       margin: 0;
       line-height: 1;
       font-family: inherit;
       padding: 10px 14px;
       width: 50px;
       display: inline-flex;
       font-size: 14px;
       border-radius: 4px;
       color: #333639;
       border: 1px solid #e0e0e6;
       background-color: #0000;
       white-space: nowrap;
       outline: none;
       justify-content: center;
       user-select: none;
       text-align: center;
       cursor: pointer;
       text-decoration: none;

       &:hover {
           border-color: #18a058;
       }
   }

   .mt-10px {
       margin-top: 10px;
   }
}
</style>



UI 部分的效果图如下所示

截屏2022-06-09 上午12.23.18.png

左侧选择功能

接下来我们先来实现左侧的选择功能,左侧显示的数据是从 props 接收的数据,我们先定义接收的源数据的数据类型

import { ref, toRefs } from 'vue'

interface DataInter {
    label: string | number;
    value: string | number
}

const props = defineProps({
    options: {
        type: Array as PropType<DataInter[]>,
        default: () => []
    },
})
const { options } = toRefs(props)

这里我们还需要声明下 PropType, 在项目的根目录下建一个文件, types/global.d.ts,加入如下内容

import type { PropType as VuePropType } from "vue";

declare global {
  declare type PropType<T> = VuePropType<T>;
}

这里是全局声明了 PropType

下一步打开 tsconfig.josn 文件,修改 inlcude 部分内容

"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue", "types/**/*.d.ts",

保存后我们发现之前报错的 PropType 问题已经解决了。

打开 App.vue 文件,这里我们先定义一个假数据,并将数据传递到 Transfer 穿梭框组件。

<template>
<Transfer :options="data" />
</template>

<script setup lang="ts">
import { ref } from 'vue'
import Transfer from './components/transfer.vue'
const data = ref([
    {
        label:"option1",
        value:"1"
    },
     {
        label:"option2",
        value:"2"
    },
     {
        label:"option3",
        value:"3"
    },
     {
        label:"option4",
        value:"4"
    },
])

</script>

在穿梭框组件中添加如下变量和函数

....

type selectedItemInter = string | number

const leftOptions = ref<DataInter[]>([])
leftOptions.value = options.value

// 左侧选中的数据
const leftSelectedData = ref<DataInter[]>([])
// checkbox 变量
const allChecked = ref(false)
// 左侧checkbox选中变量值
const leftIsCheckedData = ref<boolean[]>([])

// 左侧源数据checkbox 选中状态存储
function getLeftCheckedData(bool = false) {
    leftIsCheckedData.value = []
    for (let i = 0; i >= leftOptions.value.length; i++) {
        leftIsCheckedData.value.push(bool)
    }
}
// 初始化 leftSelectedData 
getLeftCheckedData()

// 单个checkbox 选择功能
function toggleLeftCheckboxItem(e: MouseEvent, key: DataInter) {
    if ((e.target as HTMLInputElement).checked) {
        leftSelectedData.value.push(key)
    } else {
        let index = leftSelectedData.value.findIndex((item: DataInter) => item.value === key.value)
        if (index < 0) {
            leftSelectedData.value.splice(index, 1)
        }
    }
}
// 左侧全选
function toggleLeftAllCheckbox(e: MouseEvent) {
    leftSelectedData.value = []
    getLeftCheckedData((e.target as HTMLInputElement).checked)
    if ((e.target as HTMLInputElement).checked) {
        for (let key of leftOptions.value) {
            leftSelectedData.value.push(key)
        }
    }
}

...
  • toggleLeftAllCheckbox: 顶部 checkbox 的点击事件,点击切换所有数据的选中/取消
  • toggleLeftCheckboxItem: 单个 checkbox 的点击事件,点击单个数据的选中/取消,当选中的数据数量等于所有源数据数量的时候,顶部的checkbox切换到选中状态,反之则取消
  • getLeftCheckedData: 初始化 checkbox 的选中/未选中状态事件

修改 左侧 UI 部分代码,把选择添加到相应的元素上

 <div class="transfer-item transfer-left">
        <div class="transfer-title">
            <span class="transfer-title-checkbox">
                <input v-model="allChecked" type="checkbox" class="checkbox" @click="toggleAllCheckbox">
                源项
            </span>
            <span>{{ leftSelectedData.length }}/{{ leftOptions.length }}</span>
        </div>
        <ul class="transfer-content">
            <li class="transfer-content-item" v-for="(item, index) in leftOptions" :key="item.value">
                <input v-model="leftIsCheckedData[index]" type="checkbox" class="checkbox"
                    @click="toggleLeftCheckboxItem($event, item)">
                {{ item.label }}
            </li>
        </ul>
    </div>

如此,左侧的全选/单选功能我们就实现了

left.gif

右侧选择功能

右侧的全选/单选功能和左侧的实现是一致的,这里我们就不多说了,代码如下


// 右侧选中的数据
const rightSelectedData = ref<DataInter[]>([])
//右侧数据
const rightAllChecked = ref(false)
const rightOptions = ref<DataInter[]>([])
const rightIsCheckedData = ref<boolean[]>([])

// 右侧选中checkbox初始化
function getRightCheckedData(bool = false) {
    rightIsCheckedData.value = []
    for (let i = 0; i < rightOptions.value.length; i++) {
        rightIsCheckedData.value.push(bool)
    }
}
// 右侧checkbox初始化
getRightCheckedData()
// 右侧单个checkbox选择功能
function toggleRightCheckboxItem(e: MouseEvent, key: DataInter) {
    
    if ((e.target as HTMLInputElement).checked) {
        rightSelectedData.value.push(key)
    } else {
        let index = rightSelectedData.value.findIndex((item: DataInter) => item.value === key.value)
        if (index >= 0) {
            rightSelectedData.value.splice(index, 1)
        }
    }
    
    if (rightSelectedData.value.length === rightOptions.value.length) {
        rightAllChecked.value = true
    } else {
        rightAllChecked.value = false
    }
}
// 右侧全选
function toggleRightAllCheckbox(e: MouseEvent) {
    rightSelectedData.value = []
    getRightCheckedData((e.target as HTMLInputElement).checked)
    if ((e.target as HTMLInputElement).checked) {
        for (let key of rightOptions.value) {
            rightSelectedData.value.push(key)
        }
    }
}

右侧 UI 部分代码

<div class="transfer-item transfer-right">
    <div class="transfer-title">
        <span class="transfer-title-checkbox">
            <input type="checkbox" v-model="rightAllChecked" :disabled="rightOptions.length > 0 ? false : true " class="checkbox" @click="toggleRightAllCheckbox">
            目标项
        </span>
        <span>{{rightSelectedData.length}}/{{rightOptions.length}}</span>
    </div>
    <ul class="transfer-content">
        <li class="transfer-content-item" v-for="(item, index) in rightOptions" :key="item.value">
        <input v-model="rightIsCheckedData[index]" type="checkbox" class="checkbox"
                @click="toggleRightCheckboxItem($event, item)">{{ item.label }}</li>
    </ul>
</div>

这里要说明的是我们要对右侧顶部 checkboxdisabled 状态做个判断, 没有数据的情况下使之不能选择

:disabled="rightOptions.length > 0 ? false : true " 

数据穿梭功能

function toggleRight(){
    let result:DataInter[] = [];
    leftOptions.value.forEach((item: DataInter) => {
        let index = leftSelectedData.value.findIndex((key: DataInter) => item.value === key.value)
        console.log(index);
        if (index < 0) {
            result.push(item)
        }
    })
    leftOptions.value = result
    let arr = JSON.parse(JSON.stringify(leftSelectedData.value))
    leftSelectedData.value = []
    rightOptions.value = [...rightOptions.value, ...arr]
    getLeftCheckedData()

     if (leftOptions.value.length === 0) {
        leftAllChecked.value = false
    }
}

function toggleLeft(){
    let result:DataInter[] = [];
    rightOptions.value.forEach((item: DataInter) => {
        let index = rightSelectedData.value.findIndex((key: DataInter) => item.value === key.value)
        if (index < 0) {
            result.push(item)
        }
    })
    rightOptions.value = result
    let arr = JSON.parse(JSON.stringify(rightSelectedData.value))
    rightSelectedData.value = []
    leftOptions.value = [...leftOptions.value, ...arr]
    getRightCheckedData()
    if (rightOptions.value.length === 0) {
        rightAllChecked.value = false
    }
}

上面两个函数主要实现了左侧选中数据穿梭到右边,右边选中数据穿梭到左边,需要注意的是需要判断下,如果将所有的数据都进行了穿梭,那就要将顶部 checkbox 的选中状态置为 false

绑定值更新

到这里,穿梭框大部分的功能我们已经实现了,现在就剩最后一步,更新绑定值

打开 App.vue 文件

修改组件为

<Transfer :options="data" v-model:value="value"  @update:value="value = $event" />

这里主要接收绑定值的更新。

在穿梭框组件内部,添加

const emit = defineEmits(["update:value"]);

然后分别在 toggleRight 函数和 toggleLeft 函数的底部添加代码

const value = rightOptions.value.map((item:DataInter) => item.value)
emit("update:value", value)

ok 到这里我们的穿梭框功能就已经大功告成了

结语

虽然实现的功能很简单,但是自己在实现这个功能的过程中发现对象 vue3 ,和 Typescript 的使用理解更加的明白了。