前言
在后台管理系统开发中,操作栏(ActionBar)是一个极为常见的 UI 组件,用于展示各种操作按钮。随着业务复杂度提升,操作按钮数量往往会越来越多,如何优雅地处理空间限制、提升界面美观性,成为前端开发者经常面临的挑战。本文将介绍一个基于 Vue 3 + TSX 的智能操作栏组件,它能够自动检测按钮的可见性,并在空间不足时自动折叠到下拉菜单中,极大提升了界面灵活性和用户体验。
设计背景
理想情况下,我们完全可以一开始就设计一个高度动态、可配置的操作栏组件,通过配置项统一传入按钮、权限、插槽等信息,实现一劳永逸的动态渲染和权限控制。
但实际开发中,往往并非如此。很多时候,项目已经在不同页面、不同场景下零散地写了大量操作按钮,权限控制也直接绑定在按钮自身。就在大家以为一切都已稳定时,产品突然提出新需求:
"这些按钮太多了,能不能只显示一部分,剩下的收进'更多'里?"
这时,如果让你推翻重构所有页面的按钮渲染逻辑,显然不现实。我们需要一个**"无侵入、低成本、兼容现有写法"**的解决方案,能够快速适配已有代码,将杂乱的操作按钮优雅地收纳起来。这正是本组件设计的初衷和最大价值所在。
组件设计思路
核心需求
- 自适应显示:根据容器宽度自动调整显示的按钮数量
- 智能折叠:超出显示限制的按钮自动折叠到下拉菜单
- 动态检测:实时检测按钮的可见性状态
- 类型安全:基于 TypeScript 提供完整的类型支持
技术选型
- Vue 3 Composition API:使用最新的组合式 API
- TSX:在 Vue 中直接使用 JSX 语法,提供更好的类型推导
- Arco Design Vue:使用成熟的 UI 组件库
- 动态渲染:通过
render
函数实现动态内容渲染
实现原理
1. 组件结构设计
<template>
<div ref="RootDivRef">
<div v-show="false" ref="DivRef"></div>
</div>
</template>
组件采用双层 div 结构:
RootDivRef
:最终渲染的容器DivRef
:用于检测按钮可见性的隐藏容器
2. 核心实现逻辑
onMounted(() => {
const finallyRenderVNodes = [] as VNode[]
// 遍历所有插槽内容
;(slots?.default?.() ?? []).forEach((item) => {
const div = document.createElement('div')
render(item, div)
// 检测元素是否可见
if (div.firstElementChild?.nodeType === 1) {
nextTick(() => {
if (getComputedStyle(div.firstElementChild!).getPropertyValue('display') !== 'none') {
finallyRenderVNodes.push(item)
}
})
DivRef.value?.appendChild(div)
}
})
})
关键步骤:
- 动态渲染检测:将每个插槽内容渲染到临时 div 中
- 可见性检测:通过
getComputedStyle
检测元素的display
属性 - 过滤有效内容:只保留可见的按钮元素
3. 智能折叠实现
nextTick(() => {
const finallyDiv = document.createElement('div')
let vnode: VNode
if (finallyRenderVNodes.length > baseNum.value) {
vnode = createVNode(
<Space>
<>{finallyRenderVNodes.slice(0, baseNum.value)}</>
<Dropdown>
{{
default: () => (
<Space style={{ cursor: 'pointer' }}>
<span>更多</span>
<IconDown />
</Space>
),
content: () => {
return finallyRenderVNodes.slice(baseNum.value).map((item) => {
return (
<Doption>
<>{item}</>
</Doption>
)
})
}
}}
</Dropdown>
</Space>
)
render(vnode, finallyDiv)
RootDivRef.value?.appendChild(finallyDiv.firstElementChild!)
}
})
折叠逻辑:
- 数量判断:当按钮数量超过
baseNum
时触发折叠 - 分组显示:前 N 个按钮直接显示,其余按钮放入下拉菜单
- TSX 渲染:使用 JSX 语法创建复杂的组件结构
使用方式
基础用法
<template>
<ActionBar :base-show-num="3">
<a-button type="primary">新增</a-button>
<a-button>编辑</a-button>
<a-button>删除</a-button>
<a-button>导出</a-button>
<a-button>导入</a-button>
</ActionBar>
</template>
动态按钮
<template>
<ActionBar :base-show-num="3">
<a-button v-if="hasPermission('create')" type="primary">新增</a-button>
<a-button v-if="hasPermission('edit')">编辑</a-button>
<a-button v-if="hasPermission('delete')" danger>删除</a-button>
<a-button v-if="hasPermission('export')">导出</a-button>
<a-button v-if="hasPermission('import')">导入</a-button>
</ActionBar>
</template>
技术亮点
1. 智能可见性检测
组件能够自动检测按钮的可见性状态,只统计实际显示的按钮数量,避免了因条件渲染导致的布局问题。
2. TSX 语法优势
使用 TSX 语法在 Vue 中编写复杂组件结构,既保持了 Vue 的响应式特性,又获得了更好的类型推导和开发体验。
3. 动态渲染机制
通过 render
函数实现动态内容渲染,支持任意复杂的插槽内容,包括条件渲染、循环渲染等场景。
4. 类型安全
完整的 TypeScript 支持,提供良好的开发体验和代码提示。
扩展思考
1. 响应式适配
可以考虑添加响应式断点支持,在不同屏幕尺寸下自动调整显示数量:
const responsiveConfig = {
xs: 1,
sm: 2,
md: 3,
lg: 4,
xl: 5
}
2. 自定义折叠策略
支持更灵活的折叠策略,比如按优先级折叠、按功能分组等。
3. 动画效果
为折叠/展开过程添加平滑的动画效果,提升用户体验。
总结
这个 ActionBar 组件的最大意义在于:它不是为理想化的"全新项目"而生,而是为"已经写了一堆按钮、权限分散在各处、临时要美化"的真实开发场景而生。
通过巧妙的设计和实现,ActionBar 组件可以无缝接入现有页面,无需大规模重构,即可实现操作栏的美观与智能收纳。其核心价值在于:
- 自动化:无需手动管理按钮显示逻辑
- 智能化:自动检测按钮可见性,适应动态内容
- 类型安全:完整的 TypeScript 支持
- 易用性:简单的 API 设计,开箱即用
- 低侵入性:最大程度兼容现有代码和权限逻辑
这种设计思路可以应用到其他类似的场景中,比如导航栏、工具栏等需要自适应显示的组件。
技术栈: Vue 3 + TypeScript + TSX + Arco Design Vue
适用场景: 后台管理系统、数据展示页面、操作密集型界面,尤其适合"后加需求"场景
希望这篇文章能够帮助大家更好地理解和使用这个智能操作栏组件!如果有任何问题或建议,欢迎在评论区讨论。
📦 完整 ActionBar 组件源码
<!-- src/components/ActionBar/index.vue -->
<template>
<div ref="RootDivRef">
<div v-show="false" ref="DivRef"></div>
</div>
</template>
<script setup lang="tsx">
/* @jsxImportSource vue */
import { VNode, computed, createVNode, nextTick, onMounted, ref, render, useSlots } from 'vue'
import { Doption, Dropdown, Space } from '@arco-design/web-vue'
import { IconDown } from '@arco-design/web-vue/es/icon'
const Props = defineProps<{
baseShowNum?: number
}>()
const baseNum = computed(() => Props.baseShowNum ?? 3)
const slots = useSlots()
const RootDivRef = ref<HTMLDivElement>()
const DivRef = ref<HTMLDivElement>()
onMounted(() => {
const finallyRenderVNodes = [] as VNode[]
;(slots?.default?.() ?? []).forEach((item) => {
const div = document.createElement('div')
render(item, div)
if (div.firstElementChild?.nodeType === 1) {
nextTick(() => {
if (getComputedStyle(div.firstElementChild!).getPropertyValue('display') !== 'none') {
finallyRenderVNodes.push(item)
}
})
DivRef.value?.appendChild(div)
}
})
nextTick(() => {
const finallyDiv = document.createElement('div')
let vnode: VNode
if (finallyRenderVNodes.length > baseNum.value) {
vnode = createVNode(
<Space>
<>{finallyRenderVNodes.slice(0, baseNum.value)}</>
<Dropdown>
{{
default: () => (
<Space style={{ cursor: 'pointer' }}>
<span>更多</span>
<IconDown />
</Space>
),
content: () => {
return finallyRenderVNodes.slice(baseNum.value).map((item) => {
return (
<Doption>
<>{item}</>
</Doption>
)
})
}
}}
</Dropdown>
</Space>
)
render(vnode, finallyDiv)
RootDivRef.value?.appendChild(finallyDiv.firstElementChild!)
}
})
})
</script>
<style scoped lang="scss"></style>