Vue3+Vant4实战:手把手教你封装一个带搜索和全选的移动端树形选择器
Vue3Vant4实战构建企业级移动端树形选择组件在移动端H5开发中组织架构选择、多级分类筛选等场景对交互体验提出了极高要求。传统的下拉选择器难以应对复杂层级数据的展示与操作这正是我们需要构建一个功能完备的树形选择组件的原因。本文将带你从零开始基于Vue3的组合式API和Vant4的移动端组件库打造一个支持多选/单选、关键词搜索、父子联动和全选功能的企业级树形选择器。1. 组件架构设计与基础搭建1.1 技术选型与设计思路我们选择Vue3Vant4的组合主要基于以下考量Vant4专为移动端优化的UI组件库提供Popup、Field、Checkbox等高质量基础组件Vue3 Composition API更好的逻辑复用和组织特别适合复杂交互组件TypeScript支持Vue3原生TS支持为组件提供类型安全组件核心功能模块划分├── TreeSelect (主入口) │ ├── SearchBar (搜索模块) │ ├── Tree (递归树形结构) │ ├── ControlBar (全选/状态切换) │ └── ConfirmButton (确认操作)1.2 基础组件结构搭建首先创建主组件框架使用Vant的Popup作为容器template van-field v-modeldisplayText is-link readonly :placeholderplaceholder clickshowPopup / van-popup v-model:showisVisible positionbottom round :style{ height: 70vh } div classtree-select-container !-- 搜索区域 -- van-search v-modelsearchKeyword placeholder请输入关键词搜索 searchhandleSearch / !-- 控制栏 -- div classcontrol-bar van-checkbox v-ifmultiple v-modelisAllSelected clicktoggleSelectAll 全选 /van-checkbox /div !-- 树形内容 -- div classtree-wrapper Tree reftreeRef :nodesfilteredData :multiplemultiple changehandleSelectionChange / /div !-- 确认按钮 -- van-button v-ifmultiple typeprimary block clickconfirmSelection 确定 /van-button /div /van-popup /template2. 递归树形组件的实现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 classtree-node v-fornode in visibleNodes :keynode.id div classnode-content :style{ paddingLeft: ${depth * 20}px } !-- 选择控件 -- van-checkbox v-ifmultiple v-modelnode.checked click.stoptoggleCheck(node) / van-radio v-else :namenode.id :model-valueselectedId click.stopselectNode(node) / !-- 节点标签 -- span classnode-label{{ node.label }}/span !-- 展开/收起图标 -- van-icon v-ifhasChildren(node) :namenode.expanded ? arrow-up : arrow-down click.stoptoggleExpand(node) / /div !-- 递归子节点 -- Tree v-ifnode.expanded hasChildren(node) :nodesnode.children :depthdepth 1 :multiplemultiple change$emit(change, $event) / /div /template script setup langts const props defineProps({ nodes: { type: Array as PropTypeTreeNode[], 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) } /script3. 核心功能实现细节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 refNodeJS.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 classtree-scroller :itemsflattenedVisibleNodes :item-size50 key-fieldid template #default{ item } TreeNode :nodeitem :depthitem.depth togglehandleToggle / /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: ArrayTreeNode { depth: number } [] flattenVisibleNodes(props.nodes, result, 0) return result }) const flattenVisibleNodes ( nodes: TreeNode[], result: ArrayTreeNode { 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) } } }) } /script4.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: Arraystring | number // 选中的节点ID treeData: TreeNode[] // 树形数据 placeholder?: string multiple?: boolean // 是否多选 searchable?: boolean // 是否可搜索 checkStrictly?: boolean // 是否严格模式(不联动) expandAll?: boolean // 默认展开所有节点 showCheckbox?: boolean // 是否显示复选框 defaultExpandLevel?: number // 默认展开层级 } interface TreeSelectEmits { (e: update:modelValue, value: Arraystring | 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-modelselectedDepartments :tree-datadepartmentTree placeholder请选择部门 multiple searchable changehandleDepartmentChange / /template在实际项目中使用时我们还需要考虑与后端API的对接、错误处理、空状态展示等细节。一个健壮的树形选择组件应该能够处理各种边界情况如异步加载子节点、节点禁用状态、自定义节点渲染等需求。