前端四种布局模式切换实现方案
前端四种布局模式切换实现方案
引言
企业级后台管理系统中,不同用户对页面布局有不同的偏好:有人喜欢经典的左侧菜单+顶部导航,有人偏好横向菜单布局以获得更多纵向空间,还有人习惯分栏布局以便同时查看多个模块。支持多种布局模式并允许用户自由切换,是提升用户体验的重要手段。
本文分享在 Vue 3 项目中实现四种布局模式(默认/经典/横向/分栏)切换的完整方案,包括布局组件设计、动态切换机制、用户级配置持久化,以及无需刷新页面即可切换的技术实现。
核心内容
一、四种布局模式概览
flowchart TB
A[四种布局模式] --> B[默认布局
Default]
A --> C[经典布局
Classic]
A --> D[横向布局
Transverse]
A --> E[分栏布局
Columns]
B --> B1[顶部导航栏
左侧菜单
右侧内容区]
C --> C1[顶部Logo+导航
左侧菜单
右侧内容区]
D --> D1[顶部导航+菜单
右侧内容区
无侧边栏]
E --> E1[左侧分栏菜单
右侧内容区
分栏可折叠]
四种布局的结构差异:
| 布局模式 | 顶部栏 | 侧边栏 | 特点 |
|---|---|---|---|
| 默认 | 有(含Logo+操作区) | 有(可折叠) | 最常用,功能完整 |
| 经典 | 有(仅操作区) | 有(含Logo+菜单) | 传统后台风格 |
| 横向 | 有(含Logo+菜单+操作区) | 无 | 纵向空间最大化 |
| 分栏 | 有(含操作区) | 有(分栏常驻+子菜单) | 多级菜单快速导航 |
二、布局组件架构
flowchart TB
A[App.vue] --> B[LayoutContainer]
B --> C{当前布局模式}
C -->|default| D[DefaultLayout]
C -->|classic| E[ClassicLayout]
C -->|transverse| F[TransverseLayout]
C -->|columns| G[ColumnsLayout]
D --> H[LayoutHeader]
D --> I[LayoutSidebar]
D --> J[LayoutMain]
E --> K[LayoutSidebar
含Logo]
E --> L[LayoutHeader
不含Logo]
E --> M[LayoutMain]
F --> N[LayoutHeader
含菜单]
F --> O[LayoutMain]
G --> P[LayoutColumns
分栏菜单]
G --> Q[LayoutMain]
J --> R[LayoutTabs]
M --> R
O --> R
Q --> R
R --> S[RouterView]
三、布局状态管理
使用 Pinia 管理布局状态,实现响应式切换:
/**
* 布局状态管理
* 管理布局模式、侧边栏状态、主题等全局布局配置
*/
import { defineStore } from 'pinia'
export const useLayoutStore = defineStore('layout', {
state: () => ({
/** 当前布局模式 */
layoutMode: 'default',
/** 侧边栏是否折叠 */
isCollapse: false,
/** 分栏布局-左侧分栏是否折叠 */
isColumnsCollapse: false,
/** 侧边栏宽度(展开) */
sidebarWidth: 210,
/** 侧边栏宽度(折叠) */
sidebarCollapseWidth: 64,
/** 是否显示标签页 */
showTabs: true,
/** 是否显示页脚 */
showFooter: true,
}),
getters: {
/** 当前侧边栏实际宽度 */
currentSidebarWidth: (state) => {
return state.isCollapse ? state.sidebarCollapseWidth : state.sidebarWidth
},
/** 是否有侧边栏 */
hasSidebar: (state) => {
return state.layoutMode !== 'transverse'
},
},
actions: {
/** 切换布局模式 */
setLayoutMode(mode) {
const validModes = ['default', 'classic', 'transverse', 'columns']
if (!validModes.includes(mode)) return
this.layoutMode = mode
// 横向布局时折叠侧边栏
if (mode === 'transverse') {
this.isCollapse = true
}
// 持久化到用户配置
this.persistLayoutConfig()
},
/** 切换侧边栏折叠状态 */
toggleCollapse() {
this.isCollapse = !this.isCollapse
},
/** 持久化布局配置 */
persistLayoutConfig() {
const config = {
layoutMode: this.layoutMode,
isCollapse: this.isCollapse,
showTabs: this.showTabs,
showFooter: this.showFooter,
}
localStorage.setItem('layout-config', JSON.stringify(config))
},
/** 从本地存储恢复布局配置 */
restoreLayoutConfig() {
try {
const config = JSON.parse(localStorage.getItem('layout-config'))
if (config) {
this.layoutMode = config.layoutMode || 'default'
this.isCollapse = config.isCollapse || false
this.showTabs = config.showTabs !== false
this.showFooter = config.showFooter !== false
}
} catch (e) {
console.warn('恢复布局配置失败', e)
}
},
},
})
四、布局容器组件
布局容器是所有布局模式的入口,根据当前模式动态渲染对应布局:
<template>
<component :is="layoutComponent" />
</template>
<script setup>
import { computed, onMounted } from 'vue'
import { useLayoutStore } from '@/store/modules/layout'
import DefaultLayout from './layouts/DefaultLayout.vue'
import ClassicLayout from './layouts/ClassicLayout.vue'
import TransverseLayout from './layouts/TransverseLayout.vue'
import ColumnsLayout from './layouts/ColumnsLayout.vue'
const layoutStore = useLayoutStore()
/** 布局模式与组件的映射 */
const layoutMap = {
default: DefaultLayout,
classic: ClassicLayout,
transverse: TransverseLayout,
columns: ColumnsLayout,
}
/** 当前布局组件 */
const layoutComponent = computed(() => {
return layoutMap[layoutStore.layoutMode] || DefaultLayout
})
/** 应用启动时恢复用户布局配置 */
onMounted(() => {
layoutStore.restoreLayoutConfig()
})
</script>
五、各布局模式实现
5.1 默认布局
<!-- DefaultLayout.vue -->
<template>
<el-container class="layout-default">
<!-- 顶部导航栏 -->
<el-header class="layout-header" height="50px">
<LayoutHeader />
</el-header>
<el-container>
<!-- 左侧菜单 -->
<el-aside :width="layoutStore.currentSidebarWidth + 'px'" class="layout-sidebar">
<LayoutSidebar />
</el-aside>
<!-- 右侧内容区 -->
<el-main class="layout-main">
<LayoutTabs v-if="layoutStore.showTabs" />
<router-view v-slot="{ Component }">
<transition name="fade" mode="out-in">
<component :is="Component" />
</transition>
</router-view>
</el-main>
</el-container>
</el-container>
</template>
<script setup>
import { useLayoutStore } from '@/store/modules/layout'
import LayoutHeader from '../components/LayoutHeader.vue'
import LayoutSidebar from '../components/LayoutSidebar.vue'
import LayoutTabs from '../components/LayoutTabs.vue'
const layoutStore = useLayoutStore()
</script>
<style scoped>
.layout-default {
height: 100vh;
overflow: hidden;
}
.layout-header {
padding: 0;
border-bottom: 1px solid var(--el-border-color-light);
transition: background-color 0.3s;
}
.layout-sidebar {
transition: width 0.3s ease;
overflow: hidden;
border-right: 1px solid var(--el-border-color-light);
}
.layout-main {
padding: 0;
overflow: auto;
background-color: var(--el-bg-color-page);
}
</style>
5.2 经典布局
<!-- ClassicLayout.vue -->
<template>
<el-container class="layout-classic">
<el-container>
<!-- 左侧:Logo + 菜单 -->
<el-aside :width="layoutStore.currentSidebarWidth + 'px'" class="layout-sidebar">
<div class="sidebar-logo">
<img src="@/assets/logo.png" alt="Logo" />
<span v-show="!layoutStore.isCollapse" class="logo-text">管理系统</span>
</div>
<LayoutSidebar />
</el-aside>
<!-- 右侧:顶部栏 + 内容 -->
<el-container>
<el-header height="50px" class="layout-header">
<LayoutHeader :show-logo="false" />
</el-header>
<el-main class="layout-main">
<LayoutTabs v-if="layoutStore.showTabs" />
<router-view />
</el-main>
</el-container>
</el-container>
</el-container>
</template>
5.3 横向布局
<!-- TransverseLayout.vue -->
<template>
<el-container class="layout-transverse">
<!-- 顶部:Logo + 菜单 + 操作区 -->
<el-header height="50px" class="layout-header">
<div class="header-left">
<div class="header-logo">
<img src="@/assets/logo.png" alt="Logo" />
<span class="logo-text">管理系统</span>
</div>
<!-- 横向菜单 -->
<LayoutHorizontalMenu />
</div>
<div class="header-right">
<LayoutHeaderActions />
</div>
</el-header>
<!-- 内容区 -->
<el-main class="layout-main">
<LayoutTabs v-if="layoutStore.showTabs" />
<router-view />
</el-main>
</el-container>
</template>
<style scoped>
.layout-transverse .layout-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 20px;
}
.header-left {
display: flex;
align-items: center;
gap: 20px;
}
</style>
5.4 分栏布局
<!-- ColumnsLayout.vue -->
<template>
<el-container class="layout-columns">
<!-- 左侧常驻分栏(一级菜单图标) -->
<el-aside width="64px" class="layout-columns-aside">
<div class="columns-logo">
<img src="@/assets/logo.png" alt="Logo" width="32" />
</div>
<LayoutColumnsMenu :menu-list="rootMenuList" />
</el-aside>
<!-- 二级菜单 -->
<el-aside
v-show="!layoutStore.isColumnsCollapse"
width="180px"
class="layout-columns-sub"
>
<div class="sub-menu-title">{{ currentRootMenu?.meta?.title }}</div>
<LayoutSidebar :menu-list="currentSubMenuList" />
</el-aside>
<!-- 右侧内容区 -->
<el-container>
<el-header height="50px" class="layout-header">
<LayoutHeader />
</el-header>
<el-main class="layout-main">
<LayoutTabs v-if="layoutStore.showTabs" />
<router-view />
</el-main>
</el-container>
</el-container>
</template>
<script setup>
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import { useLayoutStore } from '@/store/modules/layout'
import { useMenuStore } from '@/store/modules/menu'
const route = useRoute()
const layoutStore = useLayoutStore()
const menuStore = useMenuStore()
/** 一级菜单列表 */
const rootMenuList = computed(() => menuStore.menuList)
/** 当前激活的一级菜单 */
const currentRootMenu = computed(() => {
const matched = route.matched
return rootMenuList.value.find(m => matched.some(r => r.path === m.path))
})
/** 当前一级菜单的子菜单列表 */
const currentSubMenuList = computed(() => {
return currentRootMenu.value?.children || []
})
</script>
六、布局切换组件
提供可视化的布局切换入口,让用户一键切换:
<template>
<div class="layout-switcher">
<el-popover placement="bottom" :width="300" trigger="click">
<template #reference>
<el-button :icon="Grid" circle />
</template>
<div class="layout-options">
<h4>布局模式</h4>
<div class="layout-grid">
<div
v-for="item in layoutOptions"
:key="item.value"
class="layout-item"
:class="{ active: layoutStore.layoutMode === item.value }"
@click="switchLayout(item.value)"
>
<!-- 布局缩略图 -->
<div class="layout-preview" :class="'preview-' + item.value">
<div class="preview-header"></div>
<div class="preview-body">
<div v-if="item.hasSidebar" class="preview-sidebar"></div>
<div class="preview-content"></div>
</div>
</div>
<span class="layout-label">{{ item.label }}</span>
</div>
</div>
</div>
</el-popover>
</div>
</template>
<script setup>
import { Grid } from '@element-plus/icons-vue'
import { useLayoutStore } from '@/store/modules/layout'
const layoutStore = useLayoutStore()
const layoutOptions = [
{ value: 'default', label: '默认', hasSidebar: true },
{ value: 'classic', label: '经典', hasSidebar: true },
{ value: 'transverse', label: '横向', hasSidebar: false },
{ value: 'columns', label: '分栏', hasSidebar: true },
]
const switchLayout = (mode) => {
layoutStore.setLayoutMode(mode)
}
</script>
<style scoped>
.layout-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
}
.layout-item {
cursor: pointer;
padding: 8px;
border-radius: 6px;
border: 2px solid transparent;
transition: all 0.2s;
}
.layout-item.active {
border-color: var(--el-color-primary);
}
.layout-item:hover {
background: var(--el-fill-color-light);
}
/* 布局缩略图样式 */
.layout-preview {
height: 60px;
border: 1px solid var(--el-border-color);
border-radius: 4px;
overflow: hidden;
display: flex;
flex-direction: column;
}
.preview-header {
height: 12px;
background: var(--el-color-primary-light-5);
}
.preview-body {
flex: 1;
display: flex;
}
.preview-sidebar {
width: 20px;
background: var(--el-color-primary-light-7);
}
.preview-content {
flex: 1;
background: var(--el-fill-color-lighter);
}
/* 横向布局缩略图 */
.preview-transverse .preview-body .preview-content {
width: 100%;
}
.layout-label {
display: block;
text-align: center;
margin-top: 4px;
font-size: 12px;
}
</style>
七、用户级配置持久化
布局偏好属于用户个人设置,需要持久化到服务端,换设备也能恢复:
flowchart TB
A[用户切换布局] --> B[更新 Pinia 状态]
B --> C[localStorage 即时保存]
C --> D[异步同步到服务端]
D --> E[PUT /api/user/layout-config]
E --> F[保存到用户配置表]
subgraph 下次登录恢复
G[用户登录] --> H[GET /api/user/layout-config]
H --> I[恢复 Pinia 状态]
I --> J[渲染对应布局]
end
7.1 持久化服务
/**
* 布局配置持久化服务
* 本地缓存 + 服务端持久化双保险
*/
import { useLayoutStore } from '@/store/modules/layout'
import { getUserConfig, updateUserConfig } from '@/api/system/user-config'
const CACHE_KEY = 'layout-config'
export const LayoutPersistService = {
/** 保存到本地缓存 */
saveToLocal(config) {
localStorage.setItem(CACHE_KEY, JSON.stringify(config))
},
/** 从本地缓存读取 */
loadFromLocal() {
try {
return JSON.parse(localStorage.getItem(CACHE_KEY))
} catch {
return null
}
},
/** 同步到服务端 */
async syncToServer(config) {
try {
await updateUserConfig({ type: 'layout', content: config })
} catch (e) {
console.warn('布局配置同步服务端失败', e)
}
},
/** 从服务端加载 */
async loadFromServer() {
try {
const res = await getUserConfig({ type: 'layout' })
return res.data?.content || null
} catch {
return null
}
},
/** 初始化布局配置(优先服务端,降级本地) */
async init() {
const store = useLayoutStore()
// 优先从服务端获取
const serverConfig = await this.loadFromServer()
if (serverConfig) {
store.setLayoutMode(serverConfig.layoutMode)
store.restoreFromConfig(serverConfig)
this.saveToLocal(serverConfig)
return
}
// 降级到本地缓存
const localConfig = this.loadFromLocal()
if (localConfig) {
store.restoreFromConfig(localConfig)
}
},
}
八、布局切换过渡动画
切换布局时添加过渡动画,避免突兀的页面跳变:
/* 布局切换过渡 */
.layout-container {
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
/* 侧边栏宽度过渡 */
.layout-sidebar {
transition: width 0.3s ease, transform 0.3s ease;
}
/* 内容区路由切换过渡 */
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
结论与建议
核心要点
- 组件化布局架构:每种布局模式独立组件,通过动态组件切换,互不干扰
- Pinia 集中管理状态:布局模式、侧边栏状态等统一管理,响应式更新
- 无需刷新页面切换:利用 Vue 的动态组件
<component :is>实现无缝切换 - 用户级持久化:本地缓存 + 服务端存储双保险,换设备也能恢复偏好
最佳实践建议
| 建议 | 说明 |
|---|---|
| 布局组件职责单一 | 每种布局只负责结构编排,业务逻辑放在子组件中 |
| 公共组件复用 | Header、Sidebar、Tabs 等组件在不同布局间复用 |
| 过渡动画适度 | 布局切换动画不宜过长,300ms 以内为佳 |
| 移动端适配 | 横向布局在移动端自动降级为默认布局 |
| 性能优化 | 布局组件使用 keep-alive 缓存,避免重复渲染 |
| 配置项可扩展 | 预留更多布局配置项(如侧边栏宽度、头部高度等) |