🚫🔨 不用重构!教你用 Vue3 + TSX 🧹优雅收纳后台页面一堆操作按钮

69 阅读5分钟

前言

在后台管理系统开发中,操作栏(ActionBar)是一个极为常见的 UI 组件,用于展示各种操作按钮。随着业务复杂度提升,操作按钮数量往往会越来越多,如何优雅地处理空间限制、提升界面美观性,成为前端开发者经常面临的挑战。本文将介绍一个基于 Vue 3 + TSX 的智能操作栏组件,它能够自动检测按钮的可见性,并在空间不足时自动折叠到下拉菜单中,极大提升了界面灵活性和用户体验。

设计背景

理想情况下,我们完全可以一开始就设计一个高度动态、可配置的操作栏组件,通过配置项统一传入按钮、权限、插槽等信息,实现一劳永逸的动态渲染和权限控制。

但实际开发中,往往并非如此。很多时候,项目已经在不同页面、不同场景下零散地写了大量操作按钮,权限控制也直接绑定在按钮自身。就在大家以为一切都已稳定时,产品突然提出新需求:

"这些按钮太多了,能不能只显示一部分,剩下的收进'更多'里?"

这时,如果让你推翻重构所有页面的按钮渲染逻辑,显然不现实。我们需要一个**"无侵入、低成本、兼容现有写法"**的解决方案,能够快速适配已有代码,将杂乱的操作按钮优雅地收纳起来。这正是本组件设计的初衷和最大价值所在。

组件设计思路

核心需求

  1. 自适应显示:根据容器宽度自动调整显示的按钮数量
  2. 智能折叠:超出显示限制的按钮自动折叠到下拉菜单
  3. 动态检测:实时检测按钮的可见性状态
  4. 类型安全:基于 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)
    }
  })
})

关键步骤:

  1. 动态渲染检测:将每个插槽内容渲染到临时 div 中
  2. 可见性检测:通过 getComputedStyle 检测元素的 display 属性
  3. 过滤有效内容:只保留可见的按钮元素

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!)
  }
})

折叠逻辑:

  1. 数量判断:当按钮数量超过 baseNum 时触发折叠
  2. 分组显示:前 N 个按钮直接显示,其余按钮放入下拉菜单
  3. 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 组件可以无缝接入现有页面,无需大规模重构,即可实现操作栏的美观与智能收纳。其核心价值在于:

  1. 自动化:无需手动管理按钮显示逻辑
  2. 智能化:自动检测按钮可见性,适应动态内容
  3. 类型安全:完整的 TypeScript 支持
  4. 易用性:简单的 API 设计,开箱即用
  5. 低侵入性:最大程度兼容现有代码和权限逻辑

这种设计思路可以应用到其他类似的场景中,比如导航栏、工具栏等需要自适应显示的组件。


技术栈: 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>