当前位置: 首页 > news >正文

十三、vue3后台项目系列——sidebar的实现,递归组件

一、sidebar组件的封装

这里的我的sidebar组件由sidebaritem、logo、link、item组件组成

  • sidebaritem为每一个菜单项
  • logo为sidebar顶部的logo
  • link为点击菜单跳转的路由,因为可能需要放置外部链接作为跳转,进行封装
  • item为每一项跳转菜单的标题和icon图标配置组件

按照顺序从sidebar最外层组件进行开始:

<template><div :class="{'has-logo': showLogo}"><Logo v-if="showLogo" :collapse="isCollapse" /><el-scrollbar wrap-class="scrollbar-wrapper"><el-menu:default-active="activeMenu":collapse="isCollapse":background-color="`var(--menuBg)`":text-color="`var(--menuText)`":unique-opened="false":active-text-color="`var(--menuActiveText)`":collapse-transition="false"mode="vertical"><SidebarItem v-for="route in permissionRoutes" :key="route.path" :item="route" :base-path="route.path" /></el-menu></el-scrollbar></div>
</template><script setup>
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import { useAppStore,useSettingsStore,usePermissionStore } from '@/store'
import Logo from './components/Logo/index.vue'
import SidebarItem from './components/SidebarItem/index.vue'// 获取路由实例
const route = useRoute()// 获取Pinia store
const appStore = useAppStore();
const settingStore = useSettingsStore()
const PermissionStore = usePermissionStore()// 计算属性
const permissionRoutes = computed(() => PermissionStore.routes)
const sidebar = computed(() => appStore.sidebar)const activeMenu = computed(() => {const { meta, path } = route// 如果设置了activeMenu,则高亮对应的路径if (meta.activeMenu) {return meta.activeMenu}return path
})const showLogo = computed(() => settingStore.sidebarLogo)const isCollapse = computed(() => !sidebar.value.opened)
</script>

logo组件:

<template><div class="sidebar-logo-container" :class="{'collapse': collapse}"><transition name="sidebarLogoFade"><!-- 折叠状态的 Logo 链接 --><router-link v-if="collapse" key="collapse" class="sidebar-logo-link" to="/"><img v-if="logo" :src="logo" class="sidebar-logo"><h1 v-else class="sidebar-title">{{ title }}</h1></router-link><!-- 展开状态的 Logo 链接 --><router-link v-else key="expand" class="sidebar-logo-link" to="/"><img v-if="logo" :src="logo" class="sidebar-logo"><h1 class="sidebar-title">{{ title }}</h1></router-link></transition></div>
</template><script setup>
import settings from '@/settings'
const { title, logo } = settings
// 定义组件接收的 props
const props = defineProps({// 控制 Logo 区域是否折叠collapse: {type: Boolean,required: true}
})// 组件内部状态
// const title = 'Vue Element Admin'
// const logo = 'https://wpimg.wallstcn.com/69a1c46c-eb1c-4b46-8bd4-e9e686ef5251.png'
</script><style lang="scss" scoped>
.sidebarLogoFade-enter-active {transition: opacity 1.5s;
}.sidebarLogoFade-enter,
.sidebarLogoFade-leave-to {opacity: 0;
}.sidebar-logo-container {position: relative;width: 100%;height: 50px;line-height: 50px;background: #2b2f3a;text-align: center;overflow: hidden;& .sidebar-logo-link {height: 100%;width: 100%;& .sidebar-logo {width: 32px;height: 32px;vertical-align: middle;margin-right: 12px;}& .sidebar-title {display: inline-block;margin: 0;color: #fff;font-weight: 600;line-height: 50px;font-size: 14px;font-family: Avenir, Helvetica Neue, Arial, Helvetica, sans-serif;vertical-align: middle;}}// 折叠状态下的样式调整&.collapse {.sidebar-logo {margin-right: 0px;}}
}
</style>

sidebaritem组件

<template><!-- 如果当前菜单项不被隐藏,则渲染 --><div v-if="!item.hidden"><!-- 情况一:只有一个需要显示的子菜单,且这个子菜单没有自己的子菜单或者被标记为 noShowingChildren,同时父菜单没有强制要求显示此时直接渲染一个无子菜单的菜单项--><template v-if="hasOneShowingChild(item.children, item) && (!onlyOneChild.children || onlyOneChild.noShowingChildren) && !item.alwaysShow"><AppLink v-if="onlyOneChild.meta" :to="resolvePath(onlyOneChild.path)"><el-menu-item :index="resolvePath(onlyOneChild.path)" :class="{'submenu-title-noDropdown': !isNest}"><Item :icon="onlyOneChild.meta.icon || (item.meta && item.meta.icon)" :title="onlyOneChild.meta.title" /></el-menu-item></AppLink></template><!-- 情况二:不符合上述条件(有多个子菜单,或有一个子菜单但它还有子菜单)此时渲染一个可展开的子菜单容器--><el-sub-menu v-else ref="subMenu" :index="resolvePath(item.path)" popper-append-to-body><!-- 子菜单的标题 --><template #title><Item v-if="item.meta" :icon="item.meta && item.meta.icon" :title="item.meta.title" /></template><!-- 递归调用自己(SidebarItem)来渲染所有子菜单项这就是实现无限级嵌套菜单的核心--><SidebarItemv-for="child in item.children":key="child.path":is-nest="true":item="child":base-path="resolvePath(child.path)"class="nest-menu"/></el-sub-menu></div>
</template><script setup>
import { ref,onMounted } from 'vue'
import path from 'path-browserify' // Vue3 项目推荐使用 path-browserify
import { isExternal } from '@/utils/validate'// 导入子组件和组合式函数
import Item from '../Item/index.vue'
import AppLink from '../Link/index.vue'
import FixiOSBug from '@/composables/fixIOSBug.js'
import SidebarItem from '../SidebarItem/index.vue'// 定义组件 props
const props = defineProps({item: {type: Object,required: true},isNest: {type: Boolean,default: false},basePath: {type: String,default: ''}
})onMounted(() => {// 解决 iOS 设备上 el-submenu 点击不展开的问题FixiOSBug()
})// 使用 ref 创建响应式变量,替代 Vue2 的 this.onlyOneChild
const onlyOneChild = ref(null)/*** 判断一个菜单是否只有一个需要显示的子菜单* @param {Array} children - 子菜单数组* @param {Object} parent - 父菜单对象* @returns {Boolean} - 是否只有一个显示的子菜单*/
const hasOneShowingChild = (children = [], parent) => {const showingChildren = children.filter(item => {if (item.hidden) {return false} else {// 临时存储这个子菜单,供后续使用onlyOneChild.value = itemreturn true}})// 如果只有一个子菜单,返回 trueif (showingChildren.length === 1) {return true}// 如果没有子菜单,创建一个“假”的子菜单指向父级自己if (showingChildren.length === 0) {onlyOneChild.value = { ...parent, path: '', noShowingChildren: true }return true}return false
}/*** 解析并拼接正确的路由路径* @param {String} routePath - 要解析的路径* @returns {String} - 拼接后的完整路径*/
const resolvePath = (routePath) => {if (isExternal(routePath)) {return routePath}if (isExternal(props.basePath)) {return props.basePath}return path.resolve(props.basePath, routePath)
}
</script>

link组件:

<template><!-- 动态组件:根据 type 的值渲染不同的标签- 外部链接渲染为 <a> 标签- 内部路由渲染为 <router-link> 组件v-bind="linkProps":将计算出的属性绑定到组件上<slot />:分发组件内部的内容(如文字、图标等)--><component :is="type" v-bind="linkProps"><slot /></component>
</template><script setup>
import { computed } from 'vue'
// 导入判断是否为外部链接的工具函数
import { isExternal } from '@/utils/validate.js'// 定义组件接收的 props
const props = defineProps({// 链接目标地址(必填项)to: {type: String,required: true}
})// 计算属性:判断是否为外部链接(如 http://xxx 或 https://xxx)
const isExternalLink = computed(() => {return isExternal(props.to)
})// 计算属性:决定渲染的组件类型
const type = computed(() => {// 如果是外部链接,使用原生 a 标签// 否则使用 vue-router 提供的 router-link 组件return isExternalLink.value ? 'a' : 'router-link'
})// 计算属性:根据链接类型生成对应的属性
const linkProps = computed(() => {if (isExternalLink.value) {// 外部链接需要的属性:// href: 链接地址// target: '_blank' 表示在新窗口打开// rel: 'noopener' 增强安全性,防止新页面篡改当前页面return {href: props.to,target: '_blank',rel: 'noopener'}} else {// 内部路由只需要 router-link 组件的 to 属性return {to: props.to}}
})
</script>

item组件:

<template><span class="menu-item"><!-- 图标 --><i v-if="icon && icon.includes('el-icon')" :class="[icon, 'sub-el-icon']"></i><svg-icon v-else-if="icon" :icon-class="icon"></svg-icon><!-- 标题 --><span v-if="title" class="title">{{ title }}</span></span>
</template><script setup>
// 导入图标组件
import SvgIcon from '@/components/SvgIcon/index.vue'// 定义组件接收的 props
const props = defineProps({icon: {type: String,default: ''},title: {type: String,default: ''}
})
</script><style scoped>
.menu-item {display: inline-flex;align-items: center;
}.sub-el-icon {color: currentColor;width: 1em;height: 1em;margin-right: 8px; /* 给图标和文字之间加一点间距 */
}</style>

在这里遇到一个警告:

Vue received a Component that was made a reactive object. This can lead to unnecessary performance overhead and should be avoided by marking the component with markRaw or using shallowRef instead of ref.

原因就是在动态添加路由时,把一个 Vue 组件(如 import xxx from 'xxx.vue' 得到的对象)放进了 reactive 或 ref,导致它被 Vue 响应式系统代理。这样会有性能隐患,Vue 官方建议用 markRaw 或 shallowRef 避免组件对象被深度代理。

所以我们更新了permissionStore里面的方法,在动态添加路由时更换下方法,为所有路由的 component 应用 markRaw标记一下防止警告的出现。

permissionStore.js3import { defineStore } from 'pinia'
import { markRaw } from 'vue' // 1. 引入 markRaw
import { asyncRoutes, constantRoutes } from '@/router'function hasPermission(roles, route) {if (route.meta && route.meta.roles) {return roles.some(role => route.meta.roles.includes(role))}return true
}// 2. 创建处理函数,递归为所有路由的 component 应用 markRaw
function markRouteComponentsAsRaw(routes) {return routes.map(route => {const processedRoute = {...route,component: route.component ? markRaw(route.component) : null}if (route.children && route.children.length) {processedRoute.children = markRouteComponentsAsRaw(route.children)}return processedRoute})
}function filterAsyncRoutes(routes, roles) {const res = []routes.forEach(route => {const tmp = { ...route }if (hasPermission(roles, tmp)) {if (tmp.children) {tmp.children = filterAsyncRoutes(tmp.children, roles)}res.push(tmp)}})return res
}export const usePermissionStore = defineStore('permission', {state: () => ({routes: [],addRoutes: []}),actions: {generateRoutes(roles) {return new Promise(resolve => {let accessedRoutesif (roles.includes('admin')) {accessedRoutes = asyncRoutes || []} else {accessedRoutes = filterAsyncRoutes(asyncRoutes, roles)}// 1. 处理路由用于存入 state (应用 markRaw)const finalAddRoutes = markRouteComponentsAsRaw(accessedRoutes)const finalRoutes = markRouteComponentsAsRaw(constantRoutes.concat(accessedRoutes))this.addRoutes = finalAddRoutesthis.routes = finalRoutes// 这样 router.beforeEach 就只会添加新的路由resolve(accessedRoutes)})},resetRoutes() {this.routes = []this.addRoutes = []}}
})

二、更新相关样式

btn.scss

@import './variables.scss';@mixin colorBtn($color) {background: $color;&:hover {color: $color;&:before,&:after {background: $color;}}
}.blue-btn {@include colorBtn($blue)
}.light-blue-btn {@include colorBtn($light-blue)
}.red-btn {@include colorBtn($red)
}.pink-btn {@include colorBtn($pink)
}.green-btn {@include colorBtn($green)
}.tiffany-btn {@include colorBtn($tiffany)
}.yellow-btn {@include colorBtn($yellow)
}.pan-btn {font-size: 14px;color: #fff;padding: 14px 36px;border-radius: 8px;border: none;outline: none;transition: 600ms ease all;position: relative;display: inline-block;&:hover {background: #fff;&:before,&:after {width: 100%;transition: 600ms ease all;}}&:before,&:after {content: '';position: absolute;top: 0;right: 0;height: 2px;width: 0;transition: 400ms ease all;}&::after {right: inherit;top: inherit;left: 0;bottom: 0;}
}.custom-button {display: inline-block;line-height: 1;white-space: nowrap;cursor: pointer;background: #fff;color: #fff;-webkit-appearance: none;text-align: center;box-sizing: border-box;outline: 0;margin: 0;padding: 10px 15px;font-size: 14px;border-radius: 4px;
}

element-ui.scss

// cover some element-ui styles.el-breadcrumb__inner,
.el-breadcrumb__inner a {font-weight: 400 !important;
}.el-upload {input[type="file"] {display: none !important;}
}.el-upload__input {display: none;
}.cell {.el-tag {margin-right: 0px;}
}.small-padding {.cell {padding-left: 5px;padding-right: 5px;}
}.fixed-width {.el-button--mini {padding: 7px 10px;min-width: 60px;}
}.status-col {.cell {padding: 0 10px;text-align: center;.el-tag {margin-right: 0px;}}
}// to fixed https://github.com/ElemeFE/element/issues/2461
.el-dialog {transform: none;left: 0;position: relative;margin: 0 auto;
}// refine element ui upload
.upload-container {.el-upload {width: 100%;.el-upload-dragger {width: 100%;height: 200px;}}
}// dropdown
.el-dropdown-menu {a {display: block}
}// fix date-picker ui bug in filter-item
.el-range-editor.el-input__inner {display: inline-flex !important;
}// to fix el-date-picker css style
.el-range-separator {box-sizing: content-box;
}

element-variables.scss

/**
* I think element-ui's default theme color is too light for long-term use.
* So I modified the default color and you can modify it to your liking.
**//* theme color */
$--color-primary: #1890ff;
$--color-success: #13ce66;
$--color-warning: #ffba00;
$--color-danger: #ff4949;
// $--color-info: #1E1E1E;$--button-font-weight: 400;// $--color-text-regular: #1f2d3d;$--border-color-light: #dfe4ed;
$--border-color-lighter: #e6ebf5;$--table-border: 1px solid #dfe6ec;/* icon font path, required */
$--font-path: "~element-ui/lib/theme-chalk/fonts";@import "~element-ui/packages/theme-chalk/src/index";// the :export directive is the magic sauce for webpack
// https://www.bluematador.com/blog/how-to-share-variables-between-js-and-sass
:export {theme: $--color-primary;
}

index.scss

@import './variables.scss';
@import './mixin.scss';
@import './transition.scss';
@import './element-ui.scss';
@import './sidebar.scss';
@import './btn.scss';body {height: 100%;-moz-osx-font-smoothing: grayscale;-webkit-font-smoothing: antialiased;text-rendering: optimizeLegibility;font-family: Helvetica Neue, Helvetica, PingFang SC, Hiragino Sans GB, Microsoft YaHei, Arial, sans-serif;
}label {font-weight: 700;
}html {height: 100%;box-sizing: border-box;
}#app {height: 100%;
}*,
*:before,
*:after {box-sizing: inherit;
}.no-padding {padding: 0px !important;
}.padding-content {padding: 4px 0;
}a:focus,
a:active {outline: none;
}a,
a:focus,
a:hover {cursor: pointer;color: inherit;text-decoration: none;
}div:focus {outline: none;
}.fr {float: right;
}.fl {float: left;
}.pr-5 {padding-right: 5px;
}.pl-5 {padding-left: 5px;
}.block {display: block;
}.pointer {cursor: pointer;
}.inlineBlock {display: block;
}.clearfix {&:after {visibility: hidden;display: block;font-size: 0;content: " ";clear: both;height: 0;}
}aside {background: #eef1f6;padding: 8px 24px;margin-bottom: 20px;border-radius: 2px;display: block;line-height: 32px;font-size: 16px;font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif;color: #2c3e50;-webkit-font-smoothing: antialiased;-moz-osx-font-smoothing: grayscale;a {color: #337ab7;cursor: pointer;&:hover {color: rgb(32, 160, 255);}}
}//main-container全局样式
.app-container {padding: 20px;
}.components-container {margin: 30px 50px;position: relative;
}.pagination-container {margin-top: 30px;
}.text-center {text-align: center
}.sub-navbar {height: 50px;line-height: 50px;position: relative;width: 100%;text-align: right;padding-right: 20px;transition: 600ms ease position;background: linear-gradient(90deg, rgba(32, 182, 249, 1) 0%, rgba(32, 182, 249, 1) 0%, rgba(33, 120, 241, 1) 100%, rgba(33, 120, 241, 1) 100%);.subtitle {font-size: 20px;color: #fff;}&.draft {background: #d0d0d0;}&.deleted {background: #d0d0d0;}
}.link-type,
.link-type:focus {color: #337ab7;cursor: pointer;&:hover {color: rgb(32, 160, 255);}
}.filter-container {padding-bottom: 10px;.filter-item {display: inline-block;vertical-align: middle;margin-bottom: 10px;}
}//refine vue-multiselect plugin
.multiselect {line-height: 16px;
}.multiselect--active {z-index: 1000 !important;
}

mixin.scss

// 清除浮动
@mixin clearfix {&:after {content: "";display: table;clear: both;}
}// 给页面滚动条设置样式
@mixin scrollBar {&::-webkit-scrollbar-track-piece {background: #d3dce6;}&::-webkit-scrollbar {width: 6px;}&::-webkit-scrollbar-thumb {background: #99a9bf;border-radius: 20px;}
}// 相对定位(父元素)
@mixin relative {position: relative;width: 100%;height: 100%;
}// 快速设置 “指定宽度 + 居中”
@mixin pct($pct) {width: #{$pct};position: relative;margin: 0 auto;
}// 快速弄个三角形出来
@mixin triangle($width, $height, $color, $direction) {$width: $width/2;$color-border-style: $height solid $color;$transparent-border-style: $width solid transparent;height: 0;width: 0;@if $direction==up {border-bottom: $color-border-style;border-left: $transparent-border-style;border-right: $transparent-border-style;}@else if $direction==right {border-left: $color-border-style;border-top: $transparent-border-style;border-bottom: $transparent-border-style;}@else if $direction==down {border-top: $color-border-style;border-left: $transparent-border-style;border-right: $transparent-border-style;}@else if $direction==left {border-right: $color-border-style;border-top: $transparent-border-style;border-bottom: $transparent-border-style;}
}// 绝对定位居中(支持水平/垂直/全居中)
@mixin absoluteCenter($type: both) {position: absolute;@if $type == both { // 水平+垂直居中top: 50%;left: 50%;transform: translate(-50%, -50%);} @else if $type == horizontal { // 仅水平居中left: 50%;transform: translateX(-50%);} @else if $type == vertical { // 仅垂直居中top: 50%;transform: translateY(-50%);}
}// 内容在容器中完全居中(水平+垂直居中,不换行)
@mixin flex-center() {display: flex;justify-content: center; // 主轴居中align-items: center;     // 交叉轴居中flex-wrap: nowrap;       // 不换行
}// 内容左对齐,垂直居中(比如列表项、导航栏)
@mixin flex-start-center() {display: flex;justify-content: flex-start; // 主轴左对齐align-items: center;         // 交叉轴居中flex-wrap: nowrap;
}// 内容右对齐,垂直居中(比如顶部导航的用户菜单)
@mixin flex-end-center() {display: flex;justify-content: flex-end; // 主轴右对齐align-items: center;       // 交叉轴居中flex-wrap: nowrap;
}// 两端对齐(左右靠边,中间留白),垂直居中(比如卡片标题和操作按钮)
@mixin flex-between-center() {display: flex;justify-content: space-between; // 主轴两端对齐align-items: center;            // 交叉轴居中flex-wrap: nowrap;
}// 水平居中,垂直靠上(比如多行文字顶部居中)
@mixin flex-center-start() {display: flex;justify-content: center;   // 主轴居中align-items: flex-start;   // 交叉轴居上flex-wrap: nowrap;
}// 内容平均分布,垂直居中,超出换行(比如标签列表、图片网格)
@mixin flex-around-center-wrap() {display: flex;justify-content: space-around; // 主轴平均分布(两侧留白相等)align-items: center;           // 交叉轴居中flex-wrap: wrap;               // 换行
}// 左对齐,子元素拉伸占满高度,超出换行(比如表单项目)
@mixin flex-start-stretch-wrap() {display: flex;justify-content: flex-start; // 主轴左对齐align-items: stretch;        // 交叉轴拉伸(子元素高度相等)flex-wrap: wrap;             // 换行
}// 通用 flex 混入:可自定义主轴、交叉轴、换行方式
@mixin flex($justify: center,    // 主轴默认左对齐$align: center,         // 交叉轴默认拉伸$wrap: nowrap            // 默认不换行
) {display: flex;justify-content: $justify;align-items: $align;flex-wrap: $wrap;
}// 文字省略:单行或多行
@mixin textEllipsis($line: 1) {overflow: hidden;text-overflow: ellipsis;white-space: nowrap; // 单行不换行@if $line > 1 { // 多行省略(需浏览器支持)white-space: normal;display: -webkit-box;-webkit-line-clamp: $line; // 显示行数-webkit-box-orient: vertical;}
}

sidebar.scss

#app {.main-container {min-height: 100%;transition: margin-left .28s;margin-left: $sideBarWidth;position: relative;}.sidebar-container {transition: width 0.28s;width: $sideBarWidth !important;background-color: $menuBg;height: 100%;position: fixed;font-size: 0px;top: 0;bottom: 0;left: 0;z-index: 1001;overflow: hidden;// reset element-ui css.horizontal-collapse-transition {transition: 0s width ease-in-out, 0s padding-left ease-in-out, 0s padding-right ease-in-out;}.scrollbar-wrapper {overflow-x: hidden !important;}.el-scrollbar__bar.is-vertical {right: 0px;}.el-scrollbar {height: 100%;}&.has-logo {.el-scrollbar {height: calc(100% - 50px);}}.is-horizontal {display: none;}a {display: inline-block;width: 100%;overflow: hidden;}.svg-icon {margin-right: 16px;}.sub-el-icon {margin-right: 12px;margin-left: -2px;}.el-menu {border: none;height: 100%;width: 100% !important;}// menu hover.submenu-title-noDropdown,.el-submenu__title {&:hover {background-color: $menuHover !important;}}.is-active>.el-submenu__title {color: $subMenuActiveText !important;}& .nest-menu .el-submenu>.el-submenu__title,& .el-submenu .el-menu-item {min-width: $sideBarWidth !important;background-color: $subMenuBg !important;&:hover {background-color: $subMenuHover !important;}}}.hideSidebar {.sidebar-container {width: 54px !important;}.main-container {margin-left: 54px;}.submenu-title-noDropdown {padding: 0 !important;position: relative;.el-tooltip {padding: 0 !important;.svg-icon {margin-left: 20px;}.sub-el-icon {margin-left: 19px;}}}.el-submenu {overflow: hidden;&>.el-submenu__title {padding: 0 !important;.svg-icon {margin-left: 20px;}.sub-el-icon {margin-left: 19px;}.el-submenu__icon-arrow {display: none;}}}.el-menu--collapse {.el-submenu {&>.el-submenu__title {&>span {height: 0;width: 0;overflow: hidden;visibility: hidden;display: inline-block;}}}}}.el-menu--collapse .el-menu .el-submenu {min-width: $sideBarWidth !important;}// mobile responsive.mobile {.main-container {margin-left: 0px;}.sidebar-container {transition: transform .28s;width: $sideBarWidth !important;}&.hideSidebar {.sidebar-container {pointer-events: none;transition-duration: 0.3s;transform: translate3d(-$sideBarWidth, 0, 0);}}}.withoutAnimation {.main-container,.sidebar-container {transition: none;}}
}// when menu collapsed
.el-menu--vertical {&>.el-menu {.svg-icon {margin-right: 16px;}.sub-el-icon {margin-right: 12px;margin-left: -2px;}}.nest-menu .el-submenu>.el-submenu__title,.el-menu-item {&:hover {// you can use $subMenuHoverbackground-color: $menuHover !important;}}// the scroll bar appears when the subMenu is too long>.el-menu--popup {max-height: 100vh;overflow-y: auto;&::-webkit-scrollbar-track-piece {background: #d3dce6;}&::-webkit-scrollbar {width: 6px;}&::-webkit-scrollbar-thumb {background: #99a9bf;border-radius: 20px;}}
}

transition.scss

// global transition css/* fade */
.fade-enter-active,
.fade-leave-active {transition: opacity 0.28s;
}.fade-enter,
.fade-leave-active {opacity: 0;
}/* fade-transform */
.fade-transform-leave-active,
.fade-transform-enter-active {transition: all .5s;
}.fade-transform-enter {opacity: 0;transform: translateX(-30px);
}.fade-transform-leave-to {opacity: 0;transform: translateX(30px);
}/* breadcrumb transition */
.breadcrumb-enter-active,
.breadcrumb-leave-active {transition: all .5s;
}.breadcrumb-enter,
.breadcrumb-leave-active {opacity: 0;transform: translateX(20px);
}.breadcrumb-move {transition: all .5s;
}.breadcrumb-leave-active {position: absolute;
}

variables.scss

// -------------------------- 基础颜色变量 --------------------------
// 深蓝色(常用于主色调、标题等)
$blue: #324157;
// 浅蓝色(常用于次要强调、边框等)
$light-blue: #3A71A8;
// 红色(常用于错误提示、删除按钮等)
$red: #C03639;
// 粉红色(常用于特殊标记、提醒等)
$pink: #E65D6E;
// 绿色(常用于成功提示、确认按钮等)
$green: #30B08F;
// 蒂芙尼蓝(常用于特殊模块、高亮等)
$tiffany: #4AB7BD;
// 黄色(常用于警告提示、重要标记等)
$yellow: #FEC171;
// 潘通绿(和上面的绿色类似,可能用于特定场景的区分)
$panGreen: #30B08F;$theme: $blue; // 主题色(可以根据需要修改为其他颜色变量)// -------------------------- 侧边栏专用变量 --------------------------
// 侧边栏菜单文字默认颜色
$menuText: #bfcbd9;
// 侧边栏菜单选中时的文字颜色
$menuActiveText: #409EFF;
// 侧边栏子菜单选中时的文字颜色
$subMenuActiveText: #f4f4f5;// 侧边栏菜单背景色
$menuBg: #304156;
// 侧边栏菜单项 hover(鼠标经过)时的背景色
$menuHover: #263445;// 侧边栏子菜单背景色
$subMenuBg: #1f2d3d;
// 侧边栏子菜单项 hover(鼠标经过)时的背景色
$subMenuHover: #001528;// 侧边栏宽度(用于布局计算,比如内容区宽度 = 100% - 侧边栏宽度)
$sideBarWidth: 210px;// -------------------------- 供 JS 访问的变量 --------------------------
// 作用:让 JS 能读取 SCSS 中的变量(比如在 Vue 组件的 JS 部分动态计算布局时使用)
:root {--menuText: #{$menuText};           // 导出菜单文字颜色--menuActiveText: #{$menuActiveText}; // 导出菜单选中文字颜色--subMenuActiveText: #{$subMenuActiveText}; // 导出子菜单选中文字颜色--menuBg: #{$menuBg};               // 导出菜单背景色--menuHover: #{$menuHover};         // 导出菜单 hover 背景色--subMenuBg: #{$subMenuBg};         // 导出子菜单背景色--subMenuHover: #{$subMenuHover};   // 导出子菜单 hover 背景色--sideBarWidth: #{$sideBarWidth};   // 导出侧边栏宽度--theme: #{$theme};                 // 导出主题色
}

三、组件递归

主要是运用于sidebaritem中,实现多级路由嵌套。

<template><!-- 如果当前菜单项不被隐藏,则渲染 --><div v-if="!item.hidden"><!-- 情况一:只有一个需要显示的子菜单,且这个子菜单没有自己的子菜单或者被标记为 noShowingChildren,同时父菜单没有强制要求显示此时直接渲染一个无子菜单的菜单项--><template v-if="hasOneShowingChild(item.children, item) && (!onlyOneChild.children || onlyOneChild.noShowingChildren) && !item.alwaysShow"><AppLink v-if="onlyOneChild.meta" :to="resolvePath(onlyOneChild.path)"><el-menu-item :index="resolvePath(onlyOneChild.path)" :class="{'submenu-title-noDropdown': !isNest}"><Item :icon="onlyOneChild.meta.icon || (item.meta && item.meta.icon)" :title="onlyOneChild.meta.title" /></el-menu-item></AppLink></template><!-- 情况二:不符合上述条件(有多个子菜单,或有一个子菜单但它还有子菜单)此时渲染一个可展开的子菜单容器--><el-sub-menu v-else ref="subMenu" :index="resolvePath(item.path)" popper-append-to-body><!-- 子菜单的标题 --><template #title><Item v-if="item.meta" :icon="item.meta && item.meta.icon" :title="item.meta.title" /></template><!-- 递归调用自己(SidebarItem)来渲染所有子菜单项这就是实现无限级嵌套菜单的核心--><SidebarItemv-for="child in item.children":key="child.path":is-nest="true":item="child":base-path="resolvePath(child.path)"class="nest-menu"/></el-sub-menu></div>
</template>

四、抽离组件递归思想

组件递归是 Vue 中一个非常强大且优雅的模式,特别适合处理具有任意层级嵌套结构的数据,如树形菜单、文件夹目录、评论楼等。

什么是组件递归?

简而言之,组件递归就是一个组件在它自己的模板中调用它自己

核心实现:name 选项

要实现组件递归,关键在于给组件定义一个 name 选项。Vue 会通过这个名字在模板中找到并渲染它自己。


实战示例:实现一个可无限嵌套的菜单

假设我们有这样一个嵌套的路由 / 菜单数据:

// menu-data.js
export const menuItems = [{name: 'Dashboard',path: '/dashboard',icon: 'el-icon-s-home',},{name: 'Permission',path: '/permission',icon: 'el-icon-s-tools',children: [{name: 'Page Permission',path: '/permission/page',},{name: 'Directive Permission',path: '/permission/directive',},],},{name: 'Menu Management',path: '/menu',icon: 'el-icon-s-menu',children: [{name: 'Menu 1',path: '/menu/1',children: [{name: 'Menu 1-1',path: '/menu/1/1',},{name: 'Menu 1-2',path: '/menu/1/2',},],},],},
];

现在,我们来创建递归组件 RecursiveMenu.vue

<!-- components/RecursiveMenu.vue -->
<template><ul class="menu"><li v-for="item in items" :key="item.path" class="menu-item"><!-- 渲染当前菜单项 --><div class="menu-link"><i :class="item.icon" v-if="item.icon"></i><span>{{ item.name }}</span></div><!-- 递归核心:如果有子项,则再次调用自身组件 --><RecursiveMenuv-if="item.children && item.children.length":items="item.children"/></li></ul>
</template><script>
export default {// 1. 关键:组件必须有名字,才能被自己调用name: 'RecursiveMenu',props: {// 2. 接收一个数组作为菜单数据items: {type: Array,required: true,default: () => [],},},
};
</script><style scoped>
.menu {list-style: none;padding-left: 16px;
}
.menu-item {margin: 8px 0;
}
.menu-link {display: flex;align-items: center;gap: 8px;cursor: pointer;
}
</style>

如何使用这个递归组件:

<!-- App.vue -->
<template><div id="app"><h1>递归菜单示例</h1><RecursiveMenu :items="menuItems" /></div>
</template><script setup>
import RecursiveMenu from './components/RecursiveMenu.vue';
import { menuItems } from './menu-data.js';
</script>

工作原理

  1. 第一次渲染:父组件 App.vue 调用 <RecursiveMenu :items="menuItems">。组件接收到顶层的 menuItems 数组,v-for 循环渲染出 "Dashboard", "Permission", "Menu Management" 三个列表项。
  2. 触发递归:当循环到 "Permission" 项时,v-if="item.children && item.children.length" 条件成立。于是,组件在它自己的模板里再次渲染了 <RecursiveMenu :items="item.children">
  3. 递归深入:这个新创建的 RecursiveMenu 实例接收 Permission 的 children 数组作为 props,它会渲染出 "Page Permission" 和 "Directive Permission"。因为这两项没有 children,递归停止。
  4. 继续处理:渲染流程回到父级循环,继续处理 "Menu Management",同样的逻辑会深入到它的 children,实现无限层级的渲染。

注意事项与优化

  1. 必须有终止条件

    • 核心:递归必须有一个明确的终止条件,否则会导致无限递归,最终栈溢出。
    • 本例中的终止条件v-if="item.children && item.children.length"。当一个菜单项没有 children 或 children 为空数组时,递归调用不会发生,渲染链条在这里中断。
  2. 性能考虑

    • 避免不必要的渲染:如果菜单数据非常庞大或会频繁变动,考虑使用 v-memo 或 computed 属性来优化渲染性能,避免整棵树在数据微小变化时全部重新渲染。
    • 大数据量:对于超大规模的树结构(如成千上万节点),纯递归渲染可能导致首屏加载慢和卡顿,此时应考虑虚拟滚动(Virtual Scrolling)技术。
  3. 与 <script setup> 配合在 <script setup> 中,组件的 name 选项默认是文件名。如果你想显式指定,或者需要在模板中递归调用,需要使用 defineOptions(Vue 3.3+):

    vue

    <script setup>
    import { defineProps, defineOptions } from 'vue';// 显式定义组件名,用于递归
    defineOptions({name: 'RecursiveMenu',
    });const props = defineProps({items: {type: Array,required: true,default: () => [],},
    });
    </script>
    

常见应用场景

  • 树形控件 (Tree View)
  • 级联选择器 (Cascader)
  • 嵌套评论区
  • 文件 / 文件夹目录
  • 组织架构图
http://www.dtcms.com/a/392390.html

相关文章:

  • LeetCode 383 - 赎金信
  • compose multiplatform reader3
  • Redis 入门与实践
  • 【OpenGL】texture 纹理
  • agentscope以STUDIO方式调用MCP服务
  • 无公网 IP 访问群晖 NAS:神卓 N600 的安全解决方案(附其他方法风险对比)
  • Redis最佳实践——性能优化技巧之Pipeline 批量操作
  • Java-130 深入浅出 MySQL MyCat 深入解析 核心配置文件 server.xml 使用与优化
  • 业主信息查询功能测试指南
  • WinDivert学习文档之四-————卸载
  • 分布式链路追踪关键指标实战:精准定位服务调用 “慢节点” 全指南(二)
  • DuckDB客户端API之ADBC官方文档翻译
  • 区块链技术应用开发:智能合约进阶与多链生态落地实践
  • 分布式专题——13 RabbitMQ之高级功能
  • 神经风格迁移(Neural Style Transfer)
  • Chromium 138 编译指南 Ubuntu 篇:源码获取与版本管理(四)
  • R 语言入门实战|第九章 循环与模拟:用自动化任务解锁数据科学概率思维
  • [论文阅读] 人工智能 + 软件工程 | 4907个用户故事验证!SEEAgent:解决敏捷估计“黑箱+不协作”的终极方案
  • 鸿蒙Next ArkTS卡片开发指南:从入门到实战
  • 【绕过disable_function】
  • 使用云手机运行手游的注意事项有哪些?
  • 【数据结构】利用堆解决 TopK 问题
  • 2025陇剑杯现场检测
  • openharmony之充电空闲状态定制开发
  • 【开题答辩全过程】以 python的线上订餐系统为例,包含答辩的问题和答案
  • (附源码)基于Spring Boot的校园心理健康服务平台的设计与实现
  • 微信小程序开发教程(十八)
  • 寰宇光锥舟架构图
  • Spring Bean生命周期全面解析
  • [vibe code追踪] 侧边栏UI管理器 | showSidebarContent