Vue3+Vant4实战:构建企业级移动端树形选择组件
在移动端H5开发中,组织架构选择、多级分类筛选等场景对交互体验提出了极高要求。传统的下拉选择器难以应对复杂层级数据的展示与操作,这正是我们需要构建一个功能完备的树形选择组件的原因。本文将带你从零开始,基于Vue3的组合式API和Vant4的移动端组件库,打造一个支持多选/单选、关键词搜索、父子联动和全选功能的企业级树形选择器。
1. 组件架构设计与基础搭建
1.1 技术选型与设计思路
我们选择Vue3+Vant4的组合主要基于以下考量:
- Vant4:专为移动端优化的UI组件库,提供Popup、Field、Checkbox等高质量基础组件
- Vue3 Composition API:更好的逻辑复用和组织,特别适合复杂交互组件
- TypeScript支持:Vue3原生TS支持为组件提供类型安全
组件核心功能模块划分:
├── TreeSelect (主入口) │ ├── SearchBar (搜索模块) │ ├── Tree (递归树形结构) │ ├── ControlBar (全选/状态切换) │ └── ConfirmButton (确认操作)1.2 基础组件结构搭建
首先创建主组件框架,使用Vant的Popup作为容器:
<template> <van-field v-model="displayText" is-link readonly :placeholder="placeholder" @click="showPopup" /> <van-popup v-model:show="isVisible" position="bottom" round :style="{ height: '70vh' }" > <div class="tree-select-container"> <!-- 搜索区域 --> <van-search v-model="searchKeyword" placeholder="请输入关键词搜索" @search="handleSearch" /> <!-- 控制栏 --> <div class="control-bar"> <van-checkbox v-if="multiple" v-model="isAllSelected" @click="toggleSelectAll" > 全选 </van-checkbox> </div> <!-- 树形内容 --> <div class="tree-wrapper"> <Tree ref="treeRef" :nodes="filteredData" :multiple="multiple" @change="handleSelectionChange" /> </div> <!-- 确认按钮 --> <van-button v-if="multiple" type="primary" block @click="confirmSelection" > 确定 </van-button> </div> </van-popup> </template>2. 递归树形组件的实现
2.1 树节点数据结构设计
良好的数据结构是树形组件的基础,我们采用以下格式:
interface TreeNode { id: string | number label: string children?: TreeNode[] parentId?: string | number | null checked?: boolean expanded?: boolean visible?: boolean disabled?: boolean }2.2 递归组件核心实现
创建Tree.vue组件处理递归渲染:
<template> <div class="tree-node" v-for="node in visibleNodes" :key="node.id"> <div class="node-content" :style="{ paddingLeft: `${depth * 20}px` }"> <!-- 选择控件 --> <van-checkbox v-if="multiple" v-model="node.checked" @click.stop="toggleCheck(node)" /> <van-radio v-else :name="node.id" :model-value="selectedId" @click.stop="selectNode(node)" /> <!-- 节点标签 --> <span class="node-label">{{ node.label }}</span> <!-- 展开/收起图标 --> <van-icon v-if="hasChildren(node)" :name="node.expanded ? 'arrow-up' : 'arrow-down'" @click.stop="toggleExpand(node)" /> </div> <!-- 递归子节点 --> <Tree v-if="node.expanded && hasChildren(node)" :nodes="node.children" :depth="depth + 1" :multiple="multiple" @change="$emit('change', $event)" /> </div> </template> <script setup lang="ts"> const props = defineProps({ nodes: { type: Array as PropType<TreeNode[]>, required: true }, depth: { type: Number, default: 0 }, multiple: { type: Boolean, default: true }, selectedId: { type: [String, Number], default: null } }) const emit = defineEmits(['change']) const hasChildren = (node: TreeNode) => { return node.children && node.children.length > 0 } const toggleExpand = (node: TreeNode) => { node.expanded = !node.expanded } const toggleCheck = (node: TreeNode) => { node.checked = !node.checked // 处理父子联动逻辑 updateChildNodes(node, node.checked) updateParentNodes(node) emitSelectionChange() } const emitSelectionChange = () => { const selectedNodes = flattenTree(props.nodes) .filter(node => node.checked) emit('change', selectedNodes) } </script>3. 核心功能实现细节
3.1 父子联动选择逻辑
实现父子节点间的联动选择是树形组件的关键:
// 更新所有子节点的选中状态 const updateChildNodes = (node: TreeNode, checked: boolean) => { if (node.children) { node.children.forEach(child => { child.checked = checked updateChildNodes(child, checked) }) } } // 更新父节点的选中状态 const updateParentNodes = (node: TreeNode) => { if (!node.parentId) return const parent = findNodeById(props.nodes, node.parentId) if (!parent) return const allChildrenChecked = parent.children?.every(child => child.checked) const someChildrenChecked = parent.children?.some(child => child.checked) parent.checked = allChildrenChecked ? true : someChildrenChecked ? null // 半选状态 : false updateParentNodes(parent) } // 辅助函数:根据ID查找节点 const findNodeById = (nodes: TreeNode[], id: string | number): TreeNode | null => { for (const node of nodes) { if (node.id === id) return node if (node.children) { const found = findNodeById(node.children, id) if (found) return found } } return null }3.2 关键词搜索与筛选
实现高效的树形搜索需要考虑性能和平滑体验:
const searchKeyword = ref('') const searchTimeout = ref<NodeJS.Timeout>() const handleSearch = () => { clearTimeout(searchTimeout.value) searchTimeout.value = setTimeout(() => { filterTreeNodes() }, 300) } const filterTreeNodes = () => { if (!searchKeyword.value) { resetNodeVisibility(props.nodes) return } const keyword = searchKeyword.value.toLowerCase() // 先隐藏所有节点 setAllNodesVisibility(props.nodes, false) // 显示匹配节点及其祖先 props.nodes.forEach(node => { if (node.label.toLowerCase().includes(keyword)) { showNodeAndAncestors(node) } if (node.children) { searchInChildren(node.children, keyword) } }) } const showNodeAndAncestors = (node: TreeNode) => { node.visible = true if (node.parentId) { const parent = findNodeById(props.nodes, node.parentId) if (parent) { parent.expanded = true showNodeAndAncestors(parent) } } } const searchInChildren = (nodes: TreeNode[], keyword: string) => { nodes.forEach(node => { if (node.label.toLowerCase().includes(keyword)) { showNodeAndAncestors(node) } if (node.children) { searchInChildren(node.children, keyword) } }) }4. 性能优化与体验提升
4.1 大数据量优化策略
当处理大型组织架构时,我们需要特别关注性能:
虚拟滚动实现方案
<template> <RecycleScroller class="tree-scroller" :items="flattenedVisibleNodes" :item-size="50" key-field="id" > <template #default="{ item }"> <TreeNode :node="item" :depth="item.depth" @toggle="handleToggle" /> </template> </RecycleScroller> </template> <script setup> import { RecycleScroller } from 'vue-virtual-scroller' import 'vue-virtual-scroller/dist/vue-virtual-scroller.css' const flattenedVisibleNodes = computed(() => { const result: Array<TreeNode & { depth: number }> = [] flattenVisibleNodes(props.nodes, result, 0) return result }) const flattenVisibleNodes = ( nodes: TreeNode[], result: Array<TreeNode & { depth: number }>, depth: number ) => { nodes.forEach(node => { if (node.visible !== false) { result.push({ ...node, depth }) if (node.expanded && node.children) { flattenVisibleNodes(node.children, result, depth + 1) } } }) } </script>4.2 动画与交互优化
提升移动端体验的关键细节:
/* 平滑展开动画 */ .tree-node { transition: all 0.3s ease; .node-content { display: flex; align-items: center; padding: 12px 16px; .node-label { flex: 1; margin: 0 12px; transition: color 0.2s; } &:active { background-color: #f5f5f5; } } } /* 半选状态样式 */ .van-checkbox--indeterminate .van-checkbox__icon { background-color: var(--van-primary-color); border-color: var(--van-primary-color); &::before { content: ''; width: 50%; height: 2px; background-color: white; position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); } }5. 完整组件集成与API设计
5.1 组件Props与Events设计
完善的API设计让组件更易用:
interface TreeSelectProps { modelValue: Array<string | number> // 选中的节点ID treeData: TreeNode[] // 树形数据 placeholder?: string multiple?: boolean // 是否多选 searchable?: boolean // 是否可搜索 checkStrictly?: boolean // 是否严格模式(不联动) expandAll?: boolean // 默认展开所有节点 showCheckbox?: boolean // 是否显示复选框 defaultExpandLevel?: number // 默认展开层级 } interface TreeSelectEmits { (e: 'update:modelValue', value: Array<string | number>): void (e: 'change', nodes: TreeNode[]): void (e: 'search', keyword: string): void (e: 'expand-change', node: TreeNode, isExpanded: boolean): void }5.2 组件方法与使用示例
暴露实用方法并展示典型用法:
<script setup> const treeSelectRef = ref() // 暴露的方法 defineExpose({ expandAll: () => treeSelectRef.value.expandAll(), collapseAll: () => treeSelectRef.value.collapseAll(), getSelectedNodes: () => treeSelectRef.value.getSelectedNodes(), filter: (keyword: string) => treeSelectRef.value.filter(keyword) }) // 使用示例 const selectedDepartments = ref([]) const departmentTree = ref([]) fetch('/api/departments').then(res => { departmentTree.value = res.data }) </script> <template> <TreeSelect v-model="selectedDepartments" :tree-data="departmentTree" placeholder="请选择部门" multiple searchable @change="handleDepartmentChange" /> </template>在实际项目中使用时,我们还需要考虑与后端API的对接、错误处理、空状态展示等细节。一个健壮的树形选择组件应该能够处理各种边界情况,如异步加载子节点、节点禁用状态、自定义节点渲染等需求。