Vue3 + Vant 移动端树状多选组件封装实战:从需求到完整代码的保姆级教程

Vue3 + Vant 移动端树状多选组件封装实战:从需求到完整代码的保姆级教程 Vue3 Vant 移动端树状多选组件封装实战从需求到完整代码的保姆级教程在移动端开发中树状多选功能的需求越来越常见尤其是在需要层级化展示数据的场景下。本文将带你从零开始基于Vue3和Vant UI库封装一个高性能、易用的移动端树状多选组件。1. 需求分析与设计思路在开始编码之前我们需要明确组件的核心功能和设计目标核心功能支持多层级树状结构展示支持单选和多选模式支持父子节点联动选择支持搜索过滤功能支持异步加载子节点设计目标保持Vant UI的设计风格一致性高性能渲染支持大数据量良好的API设计易于集成完善的类型提示关键决策点数据结构设计采用扁平化树形双数据结构存储提升查找效率使用id作为唯一标识符而非数组索引状态管理使用Vue3的响应式系统管理选中状态避免深度监听优化性能组件通信采用provide/inject实现跨层级通信使用v-model实现双向绑定2. 基础组件搭建首先我们创建基础的组件结构和样式template van-popup v-model:showvisible positionbottom round :style{ height: 70% } div classtree-select-container van-search v-modelsearchText placeholder搜索 searchhandleSearch / div classtree-wrapper tree-node v-fornode in filteredTree :keynode.id :nodenode :depth0 / /div div classactions van-button typeprimary block clickhandleConfirm确定/van-button /div /div /van-popup /template script setup import { ref, computed, provide } from vue import TreeNode from ./TreeNode.vue const props defineProps({ modelValue: { type: Array, default: () [] }, treeData: { type: Array, required: true }, multiple: { type: Boolean, default: true } }) const emit defineEmits([update:modelValue, confirm]) const visible ref(false) const searchText ref() const selectedNodes ref([...props.modelValue]) // 扁平化树数据 const flattenTree computed(() { const result [] const flatten (nodes, parent null) { nodes.forEach(node { result.push({ ...node, parentId: parent?.id || null }) if (node.children?.length) { flatten(node.children, node) } }) } flatten(props.treeData) return result }) // 提供全局状态 provide(treeState, { selectedNodes, multiple: props.multiple, toggleSelect: (node) { // 处理选中逻辑 } }) /script style scoped .tree-select-container { display: flex; flex-direction: column; height: 100%; } .tree-wrapper { flex: 1; overflow-y: auto; padding: 12px; } .actions { padding: 12px; border-top: 1px solid #f5f5f5; } /style3. 树节点组件实现接下来实现核心的树节点组件template div classtree-node :style{ paddingLeft: ${depth * 20 12}px } div classnode-content clicktoggleExpand van-checkbox v-ifmultiple v-modelisChecked click.stophandleSelect / span classnode-label{{ node.name }}/span van-icon v-ifhasChildren :nameisExpanded ? arrow-down : arrow-up classexpand-icon / /div div v-ifisExpanded hasChildren classchildren tree-node v-forchild in node.children :keychild.id :nodechild :depthdepth 1 / /div /div /template script setup import { computed, inject } from vue const props defineProps({ node: { type: Object, required: true }, depth: { type: Number, default: 0 } }) const { selectedNodes, multiple, toggleSelect } inject(treeState) const isExpanded ref(true) const hasChildren computed(() props.node.children?.length 0) const isChecked computed({ get: () selectedNodes.value.includes(props.node.id), set: (val) { if (val) { selectedNodes.value.push(props.node.id) } else { const index selectedNodes.value.indexOf(props.node.id) if (index -1) { selectedNodes.value.splice(index, 1) } } } }) const toggleExpand () { if (hasChildren.value) { isExpanded.value !isExpanded.value } } const handleSelect () { toggleSelect(props.node) } /script style scoped .tree-node { margin-bottom: 8px; } .node-content { display: flex; align-items: center; padding: 8px 0; cursor: pointer; } .node-label { margin: 0 8px; flex: 1; } .expand-icon { color: #969799; } /style4. 核心功能实现4.1 父子节点联动选择实现父子节点联动是树状多选组件的关键功能// 在treeState中完善toggleSelect方法 const toggleSelect (node) { if (multiple) { // 处理当前节点 const index selectedNodes.value.indexOf(node.id) const isSelected index -1 if (isSelected) { selectedNodes.value.splice(index, 1) // 取消选择所有子节点 unselectChildren(node) } else { selectedNodes.value.push(node.id) // 选择所有子节点 selectChildren(node) } // 检查父节点状态 checkParentStatus(node) } else { // 单选模式 selectedNodes.value [node.id] } } const selectChildren (node) { if (node.children?.length) { node.children.forEach(child { if (!selectedNodes.value.includes(child.id)) { selectedNodes.value.push(child.id) } selectChildren(child) }) } } const unselectChildren (node) { if (node.children?.length) { node.children.forEach(child { const index selectedNodes.value.indexOf(child.id) if (index -1) { selectedNodes.value.splice(index, 1) } unselectChildren(child) }) } } const checkParentStatus (node) { const parent flattenTree.value.find(item item.id node.parentId) if (parent) { const allChildrenSelected parent.children.every(child selectedNodes.value.includes(child.id) ) const parentIndex selectedNodes.value.indexOf(parent.id) if (allChildrenSelected parentIndex -1) { selectedNodes.value.push(parent.id) } else if (!allChildrenSelected parentIndex -1) { selectedNodes.value.splice(parentIndex, 1) } // 递归检查上级 checkParentStatus(parent) } }4.2 搜索过滤功能实现高效的树状数据搜索const filteredTree computed(() { if (!searchText.value) return props.treeData const searchTerm searchText.value.toLowerCase() const filter (nodes) { return nodes .map(node ({ ...node })) .filter(node { // 检查当前节点是否匹配 const isMatch node.name.toLowerCase().includes(searchTerm) // 如果有子节点递归过滤 if (node.children?.length) { const filteredChildren filter(node.children) node.children filteredChildren return isMatch || filteredChildren.length 0 } return isMatch }) } return filter(props.treeData) }) const handleSearch () { // 展开所有匹配的节点 expandMatchingNodes() } const expandMatchingNodes () { if (!searchText.value) return const expandParents (nodeId) { const node flattenTree.value.find(item item.id nodeId) if (node node.parentId) { const parent props.treeData.find(item item.id node.parentId) if (parent) { parent.isExpanded true expandParents(parent.id) } } } flattenTree.value .filter(node node.name.toLowerCase().includes(searchText.value.toLowerCase()) ) .forEach(node expandParents(node.id)) }5. 性能优化与边界处理5.1 大数据量优化// 使用虚拟滚动优化性能 import { VirtualList } from vant // 修改模板中的tree-wrapper部分 virtual-list classtree-wrapper :datavisibleNodes :item-height48 :heightcalc(100% - 108px) template #default{ item } tree-node :keyitem.id :nodeitem.node :depthitem.depth / /template /virtual-list // 计算可见节点 const visibleNodes computed(() { const result [] const traverse (nodes, depth 0) { nodes.forEach(node { result.push({ node, depth }) if (node.isExpanded node.children?.length) { traverse(node.children, depth 1) } }) } traverse(filteredTree.value) return result })5.2 边界情况处理// 在组件props中添加边界处理 const props defineProps({ modelValue: { type: Array, default: () [], validator: (value) Array.isArray(value) value.every(id typeof id string || typeof id number) }, treeData: { type: Array, required: true, validator: (value) { const validateNode (node, parentPath []) { if (!node.id) { console.error(每个节点必须包含id属性, node) return false } if (parentPath.includes(node.id)) { console.error(检测到循环引用, [...parentPath, node.id]) return false } if (node.children?.length) { return node.children.every(child validateNode(child, [...parentPath, node.id]) ) } return true } return value.every(node validateNode(node)) } } }) // 添加数据变化监听 watch(() props.treeData, (newVal) { // 重置状态 selectedNodes.value [...props.modelValue] }, { deep: true }) watch(() props.modelValue, (newVal) { // 同步外部值变化 selectedNodes.value [...newVal] })6. 完整组件集成与使用示例6.1 组件最终封装template div van-field readonly clickable :model-valueselectedLabels :placeholderplaceholder clickvisible true / tree-select-popup v-modelvisible v-model:selectedselectedNodes :tree-datatreeData :multiplemultiple confirmhandleConfirm / /div /template script setup import { computed, ref } from vue import TreeSelectPopup from ./TreeSelectPopup.vue const props defineProps({ modelValue: { type: Array, default: () [] }, treeData: { type: Array, required: true }, multiple: { type: Boolean, default: true }, placeholder: { type: String, default: 请选择 } }) const emit defineEmits([update:modelValue, change]) const visible ref(false) const selectedNodes ref([...props.modelValue]) const selectedLabels computed(() { const getNodeName (id) { const node flattenTree.value.find(item item.id id) return node?.name || } return selectedNodes.value.map(id getNodeName(id)).join(, ) }) const handleConfirm () { emit(update:modelValue, [...selectedNodes.value]) emit(change, [...selectedNodes.value]) visible.value false } /script6.2 使用示例template div h2部门选择/h2 tree-select v-modelselectedDepartments :tree-datadepartmentTree multiple placeholder请选择部门 / h2单个分类选择/h2 tree-select v-modelselectedCategory :tree-datacategoryTree :multiplefalse placeholder请选择分类 / /div /template script setup import { ref } from vue import TreeSelect from ./components/TreeSelect.vue const departmentTree [ { id: 1, name: 总公司, children: [ { id: 2, name: 技术部, children: [ { id: 3, name: 前端组 }, { id: 4, name: 后端组 } ] }, { id: 5, name: 市场部 } ] } ] const categoryTree [ { id: a, name: 电子产品, children: [ { id: a1, name: 手机, children: [ { id: a11, name: 智能手机 }, { id: a12, name: 功能手机 } ] }, { id: a2, name: 电脑 } ] } ] const selectedDepartments ref([3, 5]) const selectedCategory ref(a11) /script7. 高级功能扩展7.1 异步加载子节点// 在props中添加异步加载方法 const props defineProps({ // ...其他props loadData: { type: Function, default: null } }) // 在树节点组件中添加加载方法 const isLoading ref(false) const hasLoaded ref(false) const loadChildren async () { if (!props.loadData || hasLoaded.value || !props.node.id) return isLoading.value true try { const children await props.loadData(props.node) if (children?.length) { props.node.children children // 更新扁平化树数据 updateFlattenTree() } hasLoaded.value true } catch (error) { console.error(加载子节点失败:, error) } finally { isLoading.value false } } // 在点击展开时触发加载 const toggleExpand async () { if (hasChildren.value) { isExpanded.value !isExpanded.value } else if (props.loadData) { await loadChildren() isExpanded.value true } }7.2 自定义节点渲染template div classtree-node :style{ paddingLeft: ${depth * 20 12}px } div classnode-content clicktoggleExpand slot namecheckbox :nodenode :checkedisChecked van-checkbox v-ifmultiple v-modelisChecked click.stophandleSelect / /slot slot namelabel :nodenode span classnode-label{{ node.name }}/span /slot slot nameicon :nodenode :expandedisExpanded van-icon v-ifhasChildren :nameisExpanded ? arrow-down : arrow-up classexpand-icon / /slot /div div v-ifisExpanded hasChildren classchildren tree-node v-forchild in node.children :keychild.id :nodechild :depthdepth 1 template v-for(_, slot) in $slots #[slot]scope slot :nameslot v-bindscope / /template /tree-node /div /div /template // 使用示例 tree-select v-modelselected :tree-datatreeData template #label{ node } span :style{ color: node.important ? red : inherit } {{ node.name }} /span /template /tree-select