第29期 | 穿梭框是什么,如何实现?
前言
一般当项目中遇到需要对产品进行选择添加的应用场景时,我们就会考虑使用穿梭框来实现,那么自己如何实现一个穿梭框呢?今天就来看一下element-plus的Transfer穿梭框的具体实现~
收获清单
- h函数
- 源码分析
- 穿梭框的实现
- ts函数重载等
组件介绍
穿梭框是绑定从给定数据选中数据的选择框,支持传入的属性较多,这里只放一下部分截图,具体的可以自行去官网查阅
- 属性
源码下载
git clone https://github.com/element-plus/element-plus.git
cd element-plus
pnpm install
// 本地打开文档
pnpm docs:dev
// 本地运行例子
pnpm run dev
- 利用vue-devtools打开源码位置
可以将官网例子复制到
play\src\App.vue
,运行pnpm run dev
后利用vue-devtools
打开源码所在位置
源码分析
transfer.vue
<template>
<div :class="ns.b()">
<!-- 左侧源数据选择框 -->
<transfer-panel ref="leftPanel" :data="sourceData" :option-render="optionRender"
:placeholder="panelFilterPlaceholder" :title="leftPanelTitle" :filterable="filterable" :format="format"
:filter-method="filterMethod" :default-checked="leftDefaultChecked" :props="props.props"
@checked-change="onSourceCheckedChange">
<!-- 底部插槽 -->
<slot name="left-footer" />
</transfer-panel>
<!-- 操作按钮 -->
<div :class="ns.e('buttons')">
<el-button type="primary" :class="[ns.e('button'), ns.is('with-texts', hasButtonTexts)]"
:disabled="isEmpty(checkedState.rightChecked)" @click="addToLeft">
<el-icon>
<arrow-left />
</el-icon>
<span v-if="!isUndefined(buttonTexts[0])">{{ buttonTexts[0] }}</span>
</el-button>
<el-button type="primary" :class="[ns.e('button'), ns.is('with-texts', hasButtonTexts)]"
:disabled="isEmpty(checkedState.leftChecked)" @click="addToRight">
<span v-if="!isUndefined(buttonTexts[1])">{{ buttonTexts[1] }}</span>
<el-icon>
<arrow-right />
</el-icon>
</el-button>
</div>
<!-- 右侧目标列选择框 -->
<transfer-panel ref="rightPanel" :data="targetData" :option-render="optionRender"
:placeholder="panelFilterPlaceholder" :filterable="filterable" :format="format" :filter-method="filterMethod"
:title="rightPanelTitle" :default-checked="rightDefaultChecked" :props="props.props"
@checked-change="onTargetCheckedChange">
<!-- 底部插槽 -->
<slot name="right-footer" />
</transfer-panel>
</div>
</template>
<script lang="ts" setup>
// 引入相关方法
import { computed, h, reactive, ref, useSlots, watch } from 'vue'
import { debugWarn, isEmpty, isUndefined } from '@element-plus/utils'
import { useLocale, useNamespace } from '@element-plus/hooks'
import { ElButton } from '@element-plus/components/button'
import { ElIcon } from '@element-plus/components/icon'
import { useFormItem } from '@element-plus/components/form'
import { ArrowLeft, ArrowRight } from '@element-plus/icons-vue'
import { transferEmits, transferProps } from './transfer'
import {
useCheckedChange,
useComputedData,
useMove,
usePropsAlias,
} from './composables'
// 选择框面板组件
import TransferPanel from './transfer-panel.vue'
// 类型定义
import type {
TransferCheckedState,
TransferDataItem,
TransferDirection,
} from './transfer'
import type { TransferPanelInstance } from './transfer-panel'
// 定义组件名称
defineOptions({
name: 'ElTransfer',
})
// 支持属性及插槽
const props = defineProps(transferProps)
const emit = defineEmits(transferEmits)
const slots = useSlots()
// 语言切换、类名命名空间、表单校验
const { t } = useLocale()
const ns = useNamespace('transfer')
const { formItem } = useFormItem()
// 左右侧选择框选中状态数据
const checkedState = reactive<TransferCheckedState>({
leftChecked: [],
rightChecked: [],
})
// 自定义数据源的字段别名
const propsAlias = usePropsAlias(props)
// 源数据、目标数据
const { sourceData, targetData } = useComputedData(props)
// 源数据、目标数据选中函数
const { onSourceCheckedChange, onTargetCheckedChange } = useCheckedChange(
checkedState,
emit
)
// 左右数据切换函数
const { addToLeft, addToRight } = useMove(props, checkedState, emit)
// 选择面板
const leftPanel = ref<TransferPanelInstance>()
const rightPanel = ref<TransferPanelInstance>()
// 清空某个面板的搜索关键词
const clearQuery = (which: TransferDirection) => {
switch (which) {
case 'left':
leftPanel.value!.query = ''
break
case 'right':
rightPanel.value!.query = ''
break
}
}
// 是否有自定义按钮文案
const hasButtonTexts = computed(() => props.buttonTexts.length === 2)
// 左侧面板标题
const leftPanelTitle = computed(
() => props.titles[0] || t('el.transfer.titles.0')
)
// 右侧面板标题
const rightPanelTitle = computed(
() => props.titles[1] || t('el.transfer.titles.1')
)
// 面板筛选提示文案
const panelFilterPlaceholder = computed(
() => props.filterPlaceholder || t('el.transfer.filterPlaceholder')
)
// 监听绑定数据触发表单验证
watch(
() => props.modelValue,
() => {
if (props.validateEvent) {
formItem?.validate?.('change').catch((err) => debugWarn(err))
}
}
)
// 自定义数据项渲染函数
const optionRender = computed(() => (option: TransferDataItem) => {
if (props.renderContent) return props.renderContent(h, option)
if (slots.default) return slots.default({ option })
return h(
'span',
option[propsAlias.value.label] || option[propsAlias.value.key]
)
})
// 向外暴露clearQuery方法以及穿梭框面板
defineExpose({
/** @description clear the filter keyword of a certain panel */
clearQuery,
/** @description left panel ref */
leftPanel,
/** @description left panel ref */
rightPanel,
})
</script>
分析完transfer.vue文件,我们接着看一下一些hooks及关键函数的实现~
- debugWarn
export function debugWarn(err: Error): void
export function debugWarn(scope: string, message: string): void
export function debugWarn(scope: string | Error, message?: string): void {
if (process.env.NODE_ENV !== 'production') {
const error: Error = isString(scope)
? new ElementPlusError(`[${scope}] ${message}`)
: scope
// eslint-disable-next-line no-console
console.warn(error)
}
}
这里虽然只是简单打印警告函数,但主要利用ts的函数重载【即允许在同一范围内指定多个同名函数】来实现根据参数的类型和数量为函数提供不同的语义的写法值得我们在实际使用ts开发时借鉴使用~
- useCheckedChange
export const useCheckedChange = (
checkedState: TransferCheckedState,
emit: SetupContext<TransferEmits>['emit']
) => {
const onSourceCheckedChange = (
val: TransferKey[],
movedKeys?: TransferKey[]
) => {
checkedState.leftChecked = val
if (!movedKeys) return
emit(LEFT_CHECK_CHANGE_EVENT, val, movedKeys)
}
const onTargetCheckedChange = (
val: TransferKey[],
movedKeys?: TransferKey[]
) => {
checkedState.rightChecked = val
if (!movedKeys) return
emit(RIGHT_CHECK_CHANGE_EVENT, val, movedKeys)
}
return {
onSourceCheckedChange,
onTargetCheckedChange,
}
}
位于packages\components\transfer\src\composables\use-checked-change.ts
的useCheckedChange
函数的作用很简单:
- 改变源数据列/目标数据列的选中状态的数据
- 若有移动数据,则通过check-change把选中的数据以及被移动的数据向父组件传递
- usePropsAlias
// 文件位置packages\components\transfer\src\composables\use-props-alias.ts
export const usePropsAlias = (props: { props: TransferPropsAlias }) => {
const initProps: Required<TransferPropsAlias> = {
label: 'label',
key: 'key',
disabled: 'disabled',
}
return computed(() => ({
...initProps,
...props.props,
}))
}
这里主要是自定义数据源label、key、disabled的字段别名
- useComputedData
// 文件位置packages\components\transfer\src\composables\use-computed-data.ts
export const useComputedData = (props: TransferProps) => {
const propsAlias = usePropsAlias(props)
const dataObj = computed(() =>
props.data.reduce((o, cur) => (o[cur[propsAlias.value.key]] = cur) && o, {})
)
const sourceData = computed(() =>
props.data.filter(
(item) => !props.modelValue.includes(item[propsAlias.value.key])
)
)
const targetData = computed(() => {
if (props.targetOrder === 'original') {
return props.data.filter((item) =>
props.modelValue.includes(item[propsAlias.value.key])
)
} else {
return props.modelValue.reduce(
(arr: TransferDataItem[], cur: TransferKey) => {
const val = dataObj.value[cur]
if (val) {
arr.push(val)
}
return arr
},
[]
)
}
})
return {
sourceData,
targetData,
}
}
useComputedData函数主要根据选中项绑定值获取源数据跟目标数据:
- 左侧数据源列为不包含选中项的数据
- 右侧目标列则为包含选中项的数据,并且要根据右侧列表元素的排序策略来获取正确排序的数据
- useMove
// 文件位置 packages\components\transfer\src\composables\use-move.ts
export const useMove = (
props: TransferProps,
checkedState: TransferCheckedState,
emit: SetupContext<TransferEmits>['emit']
) => {
const propsAlias = usePropsAlias(props)
const _emit = (
value: TransferKey[],
direction: TransferDirection,
movedKeys: TransferKey[]
) => {
emit(UPDATE_MODEL_EVENT, value)
emit(CHANGE_EVENT, value, direction, movedKeys)
}
const addToLeft = () => {
const currentValue = props.modelValue.slice()
checkedState.rightChecked.forEach((item) => {
const index = currentValue.indexOf(item)
if (index > -1) {
currentValue.splice(index, 1)
}
})
_emit(currentValue, 'left', checkedState.rightChecked)
}
const addToRight = () => {
let currentValue = props.modelValue.slice()
const itemsToBeMoved = props.data
.filter((item: TransferDataItem) => {
const itemKey = item[propsAlias.value.key]
return (
checkedState.leftChecked.includes(itemKey) &&
!props.modelValue.includes(itemKey)
)
})
.map((item) => item[propsAlias.value.key])
currentValue =
props.targetOrder === 'unshift'
? itemsToBeMoved.concat(currentValue)
: currentValue.concat(itemsToBeMoved)
if (props.targetOrder === 'original') {
currentValue = props.data
.filter((item) => currentValue.includes(item[propsAlias.value.key]))
.map((item) => item[propsAlias.value.key])
}
_emit(currentValue, 'right', checkedState.leftChecked)
}
return {
addToLeft,
addToRight,
}
}
useMove函数主要是暴露左右两侧数据交换的函数
- addToLeft函数利用splice将右侧数据移动到左侧数据,并将移动的值向父级传递出去,这里的两个emit由于有相同的参数还封装了_emit函数
- addToRight函数由于涉及到排序策略处理会复杂一些,但归根结底也是将选中的值移动到右侧而已
- h渲染函数
h(
'span',
option[propsAlias.value.label] || option[propsAlias.value.key]
)
h函数的作用是创建虚拟 DOM 节点 (vnode),这里主要是创建内容为label或key的span,h函数接受三个参数,第一个参数既可以是一个字符串 (用于原生元素,必传) 也可以是一个 Vue 组件定义。第二个参数是要传递的 prop,第三个参数是子节点;接着咱们来看一下transfer-panel.vue
文件
transfer-panel.vue
<template>
<div :class="ns.b('panel')">
<!-- 标题及全选 -->
<p :class="ns.be('panel', 'header')">
<el-checkbox v-model="allChecked" :indeterminate="isIndeterminate" :validate-event="false"
@change="handleAllCheckedChange">
{{ title }}
<span>{{ checkedSummary }}</span>
</el-checkbox>
</p>
<div :class="[ns.be('panel', 'body'), ns.is('with-footer', hasFooter)]">
<!-- 筛选框 -->
<el-input v-if="filterable" v-model="query" :class="ns.be('panel', 'filter')" size="default"
:placeholder="placeholder" :prefix-icon="Search" clearable :validate-event="false" />
<!-- 多选框组 -->
<el-checkbox-group v-show="!hasNoMatch && !isEmpty(data)" v-model="checked" :validate-event="false"
:class="[ns.is('filterable', filterable), ns.be('panel', 'list')]">
<el-checkbox v-for="item in filteredData" :key="item[propsAlias.key]" :class="ns.be('panel', 'item')"
:label="item[propsAlias.key]" :disabled="item[propsAlias.disabled]" :validate-event="false">
<!-- 自定义选项内容 -->
<option-content :option="optionRender?.(item)" />
</el-checkbox>
</el-checkbox-group>
<!-- 空数据展示 -->
<p v-show="hasNoMatch || isEmpty(data)" :class="ns.be('panel', 'empty')">
{{ hasNoMatch ? t('el.transfer.noMatch') : t('el.transfer.noData') }}
</p>
</div>
<!-- 自定义底部 -->
<p v-if="hasFooter" :class="ns.be('panel', 'footer')">
<slot />
</p>
</div>
</template>
<script lang="ts" setup>
// 引入相关函数及组件
import { computed, reactive, toRefs, useSlots } from 'vue'
import { isEmpty } from '@element-plus/utils'
import { useLocale, useNamespace } from '@element-plus/hooks'
import { ElCheckbox, ElCheckboxGroup } from '@element-plus/components/checkbox'
import { ElInput } from '@element-plus/components/input'
import { Search } from '@element-plus/icons-vue'
import { transferPanelEmits, transferPanelProps } from './transfer-panel'
import { useCheck, usePropsAlias } from './composables'
// 定义类型
import type { VNode } from 'vue'
import type { TransferPanelState } from './transfer-panel'
// 组件名
defineOptions({
name: 'ElTransferPanel',
})
// 定义props、emit及引用插槽
const props = defineProps(transferPanelProps)
const emit = defineEmits(transferPanelEmits)
const slots = useSlots()
// 利用h函数生成的自定义选项
const OptionContent = ({ option }: { option: VNode | VNode[] }) => option
// 语言切换及命名空间
const { t } = useLocale()
const ns = useNamespace('transfer')
// 定义面板状态
const panelState = reactive<TransferPanelState>({
checked: [],
allChecked: false,
query: '',
checkChangeByUser: true,
})
// 自定义数据源的字段别名
const propsAlias = usePropsAlias(props)
// 变量
const {
filteredData,
checkedSummary,
isIndeterminate,
handleAllCheckedChange,
} = useCheck(props, panelState, emit)
// 筛选无数据
const hasNoMatch = computed(
() => !isEmpty(panelState.query) && isEmpty(filteredData.value)
)
// 是否是自定义底部
const hasFooter = computed(() => !isEmpty(slots.default!()[0].children))
const { checked, allChecked, query } = toRefs(panelState)
// 将筛选关键词暴露出去
defineExpose({
/** @description filter keyword */
query,
})
</script>
看完.vue文件,然后看一下该文件使用的相关函数
- useCheck
import { computed, watch } from 'vue'
import { isFunction } from '@element-plus/utils'
import { CHECKED_CHANGE_EVENT } from '../transfer-panel'
import { usePropsAlias } from './use-props-alias'
import type { SetupContext } from 'vue'
import type { CheckboxValueType } from '@element-plus/components/checkbox'
import type { TransferKey } from '../transfer'
import type {
TransferPanelEmits,
TransferPanelProps,
TransferPanelState,
} from '../transfer-panel'
export const useCheck = (
props: TransferPanelProps,
panelState: TransferPanelState,
emit: SetupContext<TransferPanelEmits>['emit']
) => {
// 数据源的字段别名
const propsAlias = usePropsAlias(props)
// 过滤数据
const filteredData = computed(() => {
return props.data.filter((item) => {
if (isFunction(props.filterMethod)) {
return props.filterMethod(panelState.query, item)
} else {
const label = String(
item[propsAlias.value.label] || item[propsAlias.value.key]
)
return label.toLowerCase().includes(panelState.query.toLowerCase())
}
})
})
// 数据项是否可选
const checkableData = computed(() =>
filteredData.value.filter((item) => !item[propsAlias.value.disabled])
)
// 选中的总数
const checkedSummary = computed(() => {
const checkedLength = panelState.checked.length
const dataLength = props.data.length
const { noChecked, hasChecked } = props.format
if (noChecked && hasChecked) {
return checkedLength > 0
? hasChecked
.replace(/\${checked}/g, checkedLength.toString())
.replace(/\${total}/g, dataLength.toString())
: noChecked.replace(/\${total}/g, dataLength.toString())
} else {
return `${checkedLength}/${dataLength}`
}
})
// 设置全选框的不确定状态,仅负责样式控制
const isIndeterminate = computed(() => {
const checkedLength = panelState.checked.length
return checkedLength > 0 && checkedLength < checkableData.value.length
})
// 是否全选
const updateAllChecked = () => {
const checkableDataKeys = checkableData.value.map(
(item) => item[propsAlias.value.key]
)
panelState.allChecked =
checkableDataKeys.length > 0 &&
checkableDataKeys.every((item) => panelState.checked.includes(item))
}
// 全选状态改变
const handleAllCheckedChange = (value: CheckboxValueType) => {
panelState.checked = value
? checkableData.value.map((item) => item[propsAlias.value.key])
: []
}
// 监听选中状态更新多选框的值以及手动触发时更新移动的数据项
watch(
() => panelState.checked,
(val, oldVal) => {
updateAllChecked()
if (panelState.checkChangeByUser) {
const movedKeys = val
.concat(oldVal)
.filter((v) => !val.includes(v) || !oldVal.includes(v))
emit(CHECKED_CHANGE_EVENT, val, movedKeys)
} else {
emit(CHECKED_CHANGE_EVENT, val)
panelState.checkChangeByUser = true
}
}
)
// 监听可选数据更新数据项选中状态
watch(checkableData, () => {
updateAllChecked()
})
// 监听源数据初始化值
watch(
() => props.data,
() => {
const checked: TransferKey[] = []
const filteredDataKeys = filteredData.value.map(
(item) => item[propsAlias.value.key]
)
panelState.checked.forEach((item) => {
if (filteredDataKeys.includes(item)) {
checked.push(item)
}
})
panelState.checkChangeByUser = false
panelState.checked = checked
}
)
// 监听默认选中数据
watch(
() => props.defaultChecked,
(val, oldVal) => {
if (
oldVal &&
val.length === oldVal.length &&
val.every((item) => oldVal.includes(item))
)
return
const checked: TransferKey[] = []
const checkableDataKeys = checkableData.value.map(
(item) => item[propsAlias.value.key]
)
val.forEach((item) => {
if (checkableDataKeys.includes(item)) {
checked.push(item)
}
})
panelState.checkChangeByUser = false
panelState.checked = checked
},
{
immediate: true,
}
)
return {
filteredData,
checkableData,
checkedSummary,
isIndeterminate,
updateAllChecked,
handleAllCheckedChange,
}
}
useCheck主要是通过监听数据及相应操作更新数据选中状态
总结
至此,穿梭框的大致实现就告一段落,当现实项目中涉及到较多交互时我们可以参考el-transfer
的实现把相应的操作函数按功能命名并抽成相应的hooks,同时在渲染自定义组件时除了使用插槽,也可以使用渲染函数h函数,也可以利用函数重载为函数提供多个函数类型定义等。最后,不啻微芒,方可造炬成阳!
转载自:https://juejin.cn/post/7248062581276393532