在移动端企业级应用中,人员组织架构选择是个高频场景。传统的下拉选择器面对多层级的部门树时,用户体验往往很糟糕——要么需要反复点开多级菜单,要么无法快速定位目标节点。我在最近的一个HR系统项目中就遇到了这个问题:当用户需要选择跨部门的200多名员工时,原生选择器的操作效率低到令人崩溃。
Vant4作为移动端UI库,虽然提供了基础的Tree组件,但直接使用会存在几个痛点:首先,默认不支持关键词搜索,用户要在几百个节点中手动展开查找;其次,缺少业务状态筛选(如在职/离职);最重要的是没有父子节点联动勾选功能,批量选择整个部门时得一个个勾选。这些正是我们需要基于Vant4二次封装的原因。
我们先搭建基础结构,使用Vue3的<script setup>语法:
javascript复制<template>
<van-field
v-model="selectedNames"
is-link readonly
@click="showTreePicker"
/>
<van-popup v-model:show="showPicker" position="bottom">
<!-- 搜索框和树形区域 -->
</van-popup>
</template>
<script setup>
import { ref, reactive } from 'vue'
const showPicker = ref(false)
const selectedNames = ref('')
const showTreePicker = () => {
showPicker.value = true
}
</script>
这里用VanField作为触发入口,点击后弹出带树形选择的Popup。这种交互模式符合移动端操作习惯,比PC端的直接展开更节省屏幕空间。
实际业务数据往往不是标准树形结构。我们需要将扁平数组转换为嵌套树,同时建立快速查询的映射表:
javascript复制const normalizeTree = (list) => {
const tree = []
const map = {}
list.forEach(item => {
map[item.id] = { ...item, children: [] }
})
list.forEach(item => {
if (item.pid && map[item.pid]) {
map[item.pid].children.push(map[item.id])
} else {
tree.push(map[item.id])
}
})
return { tree, map }
}
这个预处理步骤很关键,后续的搜索、联动勾选都依赖这个标准化后的数据结构。我在项目中还额外添加了isHide和isShowChildren字段,用于控制节点的显隐状态。
搜索功能需要同时满足两种需求:实时过滤和状态筛选。这里采用防抖优化性能:
javascript复制const searchTree = (keyword) => {
// 重置所有节点显示状态
Object.values(treeMap).forEach(node => {
node.isHide = false
})
if (!keyword) return
// 标记不匹配的节点
Object.values(treeMap).forEach(node => {
const isMatch = node.name.includes(keyword)
if (!isMatch) {
node.isHide = true
// 如果父节点被隐藏,需要显示其匹配的子节点
if (node.pid && treeMap[node.pid]?.isHide) {
showParentChain(node)
}
}
})
}
在职/离职筛选则是通过computed实现的:
javascript复制const filteredTree = computed(() => {
return treeData.filter(node => {
return filterStatus.value === 'all' ||
node.status === filterStatus.value
})
})
联动勾选是树形组件最复杂的部分,需要处理三种情况:
javascript复制const handleCheck = (node) => {
// 处理子节点
if (node.children?.length) {
node.children.forEach(child => {
child.checked = node.checked
handleCheck(child) // 递归处理
})
}
// 处理父节点
if (node.pid) {
const parent = treeMap[node.pid]
const allChildrenChecked = parent.children.every(c => c.checked)
parent.checked = allChildrenChecked
if (parent.checked) handleCheck(parent)
}
}
这个递归处理要注意性能问题,特别是在大数据量时。我在项目中加入了isUpdating标志位来避免重复计算。
当树节点超过500个时,完整渲染会导致明显卡顿。解决方案是只渲染可视区域内的节点:
javascript复制<template>
<div class="tree-container" @scroll="handleScroll">
<div :style="{ height: totalHeight + 'px' }">
<div
v-for="visibleNode in visibleNodes"
:key="visibleNode.id"
:style="{ transform: `translateY(${visibleNode.offset}px)` }"
>
<!-- 节点内容 -->
</div>
</div>
</div>
</template>
计算可见节点的核心逻辑:
javascript复制const updateVisibleNodes = () => {
const startIdx = Math.floor(scrollTop.value / NODE_HEIGHT)
const endIdx = startIdx + VISIBLE_COUNT
visibleNodes.value = allNodes.value
.slice(startIdx, endIdx)
.map((node, i) => ({
...node,
offset: (startIdx + i) * NODE_HEIGHT
}))
}
频繁操作树节点时,直接响应式更新会导致性能下降。我的解决方案是:
shallowRef替代reactive存储大树数据javascript复制const treeData = shallowRef([])
const batchUpdate = (updates) => {
const temp = [...treeData.value]
updates.forEach(({ id, changes }) => {
const node = findNode(temp, id)
Object.assign(node, changes)
})
treeData.value = temp
}
在人员选择场景中,经常需要保留已选项。我们通过localStorage实现记忆:
javascript复制const saveSelection = () => {
const selected = getCheckedNodes()
localStorage.setItem(
'lastSelection',
JSON.stringify(selected.map(n => n.id))
)
}
const loadSelection = () => {
const saved = localStorage.getItem('lastSelection')
if (saved) {
const ids = JSON.parse(saved)
ids.forEach(id => {
if (treeMap[id]) treeMap[id].checked = true
})
}
}
实际项目需要处理分页加载和权限过滤:
javascript复制const loadMore = async (parentId) => {
const res = await api.getChildren({
parentId,
page: currentPage.value,
status: filterStatus.value
})
if (parentId) {
const parent = treeMap[parentId]
parent.children = [...parent.children, ...res.data]
} else {
treeData.value = [...treeData.value, ...res.data]
}
updateTreeMap()
}
移动端树形组件需要特别注意触控体验:
active状态反馈scss复制.tree-node {
padding: 12px 16px;
&:active {
background: #f5f5f5;
}
.checkbox {
// 扩大点击区域
padding: 12px;
margin: -12px;
}
}
// 使用transform替代top/left动画
.tree-enter-active {
transition: all 0.3s ease;
transform: translateY(0);
}
.tree-enter-from {
transform: translateY(20px);
opacity: 0;
}
最后给出在父组件中的使用示例:
javascript复制<template>
<TreeSelect
v-model="selectedUsers"
:api="getDepartmentTree"
:multiple="true"
:showStatusFilter="true"
placeholder="选择参与人员"
/>
</template>
<script setup>
const getDepartmentTree = async () => {
const res = await api.get('/department/tree')
return res.data
}
</script>
在真实项目中,这个组件还需要暴露几个实用方法:
javascript复制defineExpose({
reset: () => clearSelection(),
search: (keyword) => filterTree(keyword),
getSelected: () => getCheckedNodes()
})
封装过程中最大的教训是:移动端性能优化必须从设计阶段就考虑。最初版本在低端安卓机上卡顿明显,后来通过虚拟滚动、减少响应式依赖、防抖处理等多重优化才达到流畅标准。另一个收获是组件API设计要克制,我最初加入了太多配置项,导致维护困难,后来遵循"约定优于配置"原则精简了API。