vue项目与iview3实现可折叠动态菜单。
菜单实现一下效果:
- 动态获取项目路由生成动态三级菜单导航
- 可折叠展开
- 根据路由name默认打开子目录,选中当前项
- 自动过滤需要隐藏的路由(例:登陆)
- 在手机端首次进入自动收起全部的导航栏,pc端进入导航栏展开
争议之处:当一级菜单项只有一个子元素时,只会显示一级菜单项不会展开下拉列表,设置子元素的显示(hidden)将无效。例如:主页
demo效果如图显示,
菜单使用iview3实现,菜单组件sider.vue的代码如下:
<template> <Menu ref="asideMenu" width="100%" accordion :theme="theme1" :open-names="openItem" :active-name="activeName"> <!-- 动态菜单 --> <div v-for="(item, index) in menuItems" :key="index" v-if="!app.isCollapsed && !item.meta.hidden"> <Submenu v-if="item.children && item.children.length>1 && !app.isCollapsed" :name="item.name"> <template slot="title"> <Icon :size="item.size" :type="item.type"/> <span>{{item.text}}</span> </template> <div v-for="(subItem, i) in item.children" :key="index + i"> <Submenu v-if="subItem.children" :name="subItem.name"> <template slot="title"> <Icon :size="subItem.size" :type="subItem.type"/> <span class="text-over">{{subItem.text}}</span> </template> <MenuItem class="menu-level-3" v-for="(threeItem, k) in subItem.children" :name="threeItem.name" :to="item.path+ '/' + subItem.path+ '/' + threeItem.path" :key="index + i + k"> <Icon :size="threeItem.size" :type="threeItem.type"/> <span>{{threeItem.text}}</span> </MenuItem> </Submenu> <MenuItem v-else :name="subItem.name" :to="item.path+ '/' + subItem.path"> <Icon :size="subItem.size" :type="subItem.type"/> <span class="text-over">{{subItem.text}}</span> </MenuItem> </div> </Submenu> <MenuItem v-else :name="getName(item)" :to="item.path"> <Icon :size="item.size" :type="item.type" /> <span>{{item.text}}</span> </MenuItem> </div> <!-- 折叠菜单 --> <div class="center-right" v-if="app.isCollapsed"> <div v-for="(item,index) in menuItems" :key = "index"> <Tooltip :content="item.text" placement="right" theme="light"> <Dropdown style="margin-left: 20px" trigger="click" placement="right-end" @on-click="toRoute"> <div class="collapsed-icon" @click="goRoute(item)"><Icon :type="item.type" size="18"></Icon></div> <DropdownMenu slot="list" class="" v-if="item.children && item.children.length>1"> <div v-for="(secItem,i) in item.children" :key="i"> <Dropdown placement="right-start" v-if="secItem.children&& secItem.children.length>0"> <DropdownItem name=''> {{secItem.text}} <Icon type="ios-arrow-forward"></Icon> </DropdownItem> <DropdownMenu slot="list"> <DropdownItem v-for="(tt, t) in secItem.children" :key="t" :name="tt.name" >{{tt.text}}</DropdownItem> </DropdownMenu> </Dropdown> <DropdownItem v-else :name="secItem.name">{{secItem.text}}</DropdownItem> </div> </DropdownMenu> </Dropdown> </Tooltip> </div> </div> </Menu> </template> <script> import { mapState, mapGetters, mapMutations } from 'vuex' export default { data() { return { isShowAsideTitle: true, theme1: 'dark', openItem: [], activeName: '' } }, computed: { ...mapState(['app', 'user']), menuItems(state) { return this.showMemuList(this.$router.options.routes) }, // 获取的store中isCollapsed getIsCollapsed() { return this.$store.state.app.isCollapsed } }, watch: { // 监听isCollapsed,当菜单展开时,默认当前打开,选中 getIsCollapsed() { if (!this.app.isCollapsed) { this.$nextTick(() => { this.$refs.asideMenu.updateOpened() this.$refs.asideMenu.updateActiveName() }) } }, // 监听路由,展开子目录,更新当前选择项 $route() { this.openSideList() this.activeName = this.$route.name }, // 监听展开的子目录,更新 openItem() { this.$nextTick(() => { this.$refs.asideMenu.updateOpened() }) } }, methods: { // 筛选需要显示的列表 showMemuList(list) { let newArr = []; list.map((item, index, arr) => { // console.log(item, index, arr) if (!item.meta.hidden) { // 显示 this.filterObj(item) newArr.push(item) } }) return newArr }, filterObj(obj) { if (obj.children && obj.children.length > 1) { obj.children = this.showMemuList(obj.children) } }, toRoute(name) { if (name !== '') { this.$router.push({ name: name }) } }, goRoute(item) { if (item.children && item.children.length == 1) { this.$router.push({ path: item.path }) } }, getName(item) { return item.children[0].name }, // 获取到展开的子目录 例:['/component'] openSideList() { this.openItem = [] if (this.$route.matched.length > 2) { this.openItem.push(this.$route.matched[0].name) this.openItem.push(this.$route.matched[1].name) } else this.openItem.push(this.$route.matched[0].name) } }, mounted() { this.openSideList() this.activeName = this.$route.name } } </script> <style> .center-right { float: right; } .collapsed-icon { width: 78px; height: 78px; text-align: center; line-height: 78px; } .text-over { display: inline-block; width: 90px; white-space: nowrap; text-overflow: ellipsis; overflow: hidden; vertical-align: middle; } /* sider */ .layout { border: 1px solid #d7dde4; background: #f5f7f9; position: relative; border-radius: 4px; overflow: hidden; } .layout-header-bar { background: #fff; box-shadow: 0 1px 1px rgba(0, 0, 0, 0.1); } .layout-logo-left { width: 90%; height: 30px; background: #5b6270; border-radius: 3px; margin: 15px auto; } .menu-icon { transition: all 0.3s; } .rotate-icon { transform: rotate(-90deg); } .ivu-menu { white-space: nowrap; } .ivu-menu-item span { display: inline-block; overflow: hidden; width: 69px; text-overflow: ellipsis; white-space: nowrap; vertical-align: bottom; transition: width 0.2s ease 0.2s; } .ivu-menu-item i { transform: translateX(0px); transition: font-size 0.2s ease, transform 0.2s ease; vertical-align: middle; font-size: 16px; } .ivu-layout-sider-collapsed .ivu-menu-submenu-title span, .ivu-layout-sider-collapsed .ivu-menu-item span { display: inline-block; width: 0px; overflow: hidden; transition: width 0.2s ease; } .ivu-layout-sider-collapsed .ivu-menu-submenu-title .ivu-menu-submenu-title-icon { display: none; } .ivu-layout-sider-collapsed .ivu-menu-submenu-title i, .ivu-layout-sider-collapsed .ivu-menu-item i { transform: translateX(5px) translateY(-15px) scale(1.4); transition: font-size 0.2s ease 0.2s, transform 0.2s ease 0.2s; vertical-align: middle; font-size: 22px; } </style>
在sider.vue中,展开的菜单与折叠起来的菜单是分开写的,然后根据store中的状态判断是否展开收起。通过showMemuList()和filterObj()两个函数将不需要显示的路由过滤隐藏。
在layout.vue整体布局文件中引用sider组件,内容如下:
<style lang="scss"> .height100 { height: 100%; } .main-bg { width: 100%; height: 100%; } .main-header-bg { overflow: hidden; position: relative; .header-theme { position: absolute; z-index: 9; height: 40px; line-height: 40px; top: 12px; right: 30px; display: flex; justify-content: flex-end; .theme { width: 40px; height: 40px; text-align: center; // @include bg_color($background_color_theme); } } } </style> <template> <div class="layout height100"> <Layout class="height100"> <Sider ref="side1" hide-trigger collapsible :collapsed-width="width" v-model="app.isCollapsed"> <!-- 引入菜单组件 --> <SideMenu></SideMenu> </Sider> <Layout> <Header :style="{padding: 0}" class="layout-header-bar"> <Row class="main-header-bg"> <Icon @click.native="collapsedSider" :class="rotateIcon" :style="{margin: '0 20px'}" type="md-menu" size="24"></Icon> <i-col :xs="3" class="header-theme"> <div class="theme" @click="toRoute('theme',{})">theme</div> </i-col> </Row> </Header> <Content :style="{margin: '20px', background: '#fff', minHeight: '360px',overflow:'auto'}"> <div class="main-bg"> <router-view></router-view> </div> </Content> <Footer>博客园地址:https://i.cnblogs.com/posts</Footer> </Layout> </Layout> </div> </template> <script> import { mapState, mapGetters, mapMutations, mapActions } from 'vuex' import SideMenu from '@/components/layout/sider' export default { data() { return { } }, components: { SideMenu }, computed: { ...mapState(['app']), ...mapGetters(['rotateIcon', 'menuitemClasses']), width(state) { console.log(state.app.pc ? 78 : 0, state.app.pc) return state.app.pc ? 78 : 0 } }, methods: { ...mapMutations(['collapsed']), collapsedSider() { this.collapsed() } }, mounted(){ } } </script>
在layout.vue文件中,重要的部分是组件引入部分以及收起展开的逻辑部分。导航菜单的展开和收起通过操作。动画效果通过store中getters实现,所以在layout.vue中引入了辅助函数。width()方法通过得到store中的pc值判断收起时的宽度。
store中使用modules中app.js,代码如下:
const moduleApp = { state: { isCollapsed: false, // 侧边栏是否折叠,默认不折叠 pc:true // 是否pc端打开 }, mutations: { collapsed (state) { // 这里的 `state` 对象是模块的局部状态 state.isCollapsed = !state.isCollapsed }, // 判断是否pc端,若不是pc端,将自动收起菜单 isPC(state,boo){ state.pc = boo; if(!boo + '' == 'true'){ state.isCollapsed = true } } }, getters: { rotateIcon (state, getters, rootState) { return [ 'menu-icon', state.isCollapsed ? 'rotate-icon' : '' ]; }, menuitemClasses (state, getters, rootState) { return [ 'menu-item', state.isCollapsed ? 'collapsed-menu' : '' ] } }, actions:{ // 测试actions incrementIfOddOnRootSum ({ state, commit, rootState },param) { console.log(state,rootState,param) commit('increment',param.num) } } } export default moduleApp
在创建vuex实例时,通过模块化引入app.js为app。
导航菜单是由路由动态生成,以下是router.js路由文件的代码:
import layout from '@/pages/layout' import home from '@/pages/home' let routes = [ { path: '/', name: 'layout', size:18, // 图标大小 type: 'md-home', // icon类型 text: '主页', // 文本内容 component: layout, redirect: '/page1', meta:{ hidden:false }, children: [ { path: 'page1', name: 'page1', size:18, type: 'ios-paper', text:'首页', meta: { hidden:true }, component: () => import('@/components/HelloWorld.vue') } ] }, { path:'/login', name:'login', meta:{ hidden:true }, component:()=>import('@/components/HelloWorld.vue') }, { path: '/component', name: 'component', size:18, // 图标大小 type: 'md-cube', // icon类型 text: '组件', // 文本内容 component: layout, meta: { hidden:false }, children: [ { path: 'other', name: 'other', // size:18, // 图标大小 type: 'ios-aperture', // icon类型 text: '二级菜单', // 文本内容 component: home, meta: { hidden:false }, children: [ { path: 'theme', name: 'theme', // size:18, // 图标大小 type: 'ios-brush', // icon类型 text: 'theme', // 文本内容 meta: { hidden:false }, component: () => import('@/components/theme.vue') }, ] }, { path: 'page2', name: 'page2', // size:18, // 图标大小 type: 'md-cafe', // icon类型 text: '单选框自定义样式', // 文本内容 meta: { hidden:false }, component: () => import('@/components/input.vue') } ] } ] export default routes
某个路由是否在导航菜单中显示,通过meta中hidden控制,true表示隐藏,false表示不隐藏。layout和hone为模板页,layout是上面layout.vue文件,layout是一级、二级菜单的模板页,home是三级菜单的模板。引入一般的显示页面通过路由懒加载的方式,当要打开对应页面时,在加载页面。当一级菜单项只有一个子元素时,只会显示一级菜单项不会展开下拉列表,设置子元素的显示(hidden)将无效。例如:主页的children只有一个子元素,这时,设置这个子元素的hidden为false,页面中也不会出现子菜单显示此项。因为判断时一级菜单项的子元素长度需要大于一才会出现子菜单。
另外,我在app.vue中判断是否pc端,然后修改store中的值。
<template> <div id="app"> <router-view/> </div> </template> <script> import * as util from '@/libs/util' import { mapMutations } from 'vuex' export default { name: 'App', methods: { ...mapMutations(['isPC']), browserRedirect(state) { var sUserAgent = navigator.userAgent.toLowerCase(); var bIsIpad = sUserAgent.match(/ipad/i) == "ipad"; var bIsIphoneOs = sUserAgent.match(/iphone os/i) == "iphone os"; var bIsMidp = sUserAgent.match(/midp/i) == "midp"; var bIsUc7 = sUserAgent.match(/rv:1.2.3.4/i) == "rv:1.2.3.4"; var bIsUc = sUserAgent.match(/ucweb/i) == "ucweb"; var bIsAndroid = sUserAgent.match(/android/i) == "android"; var bIsCE = sUserAgent.match(/windows ce/i) == "windows ce"; var bIsWM = sUserAgent.match(/windows mobile/i) == "windows mobile"; if (bIsIpad || bIsIphoneOs || bIsMidp || bIsUc7 || bIsUc || bIsAndroid || bIsCE || bIsWM) { this.isPC(false) } else { this.isPC(true) } } }, mounted() { this.browserRedirect() } } </script> <style lang='scss'> html, body { width: 100%; height: 100%; } #app { font-family: "Avenir", Helvetica, Arial, sans-serif; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; height: 100%; } </style>
当然,还可添加其他操作,例如添加权限,动态从后端获取路由等。
- 总结
其中在实现导航菜单展开收起(isCollapsed)操作时,我选择将isCollapsed的值存储在store中。目的是,如果以后要进入某一个页面需要将菜单收起来时,可以直接操作store中的值实现。如果不会有这种需求或者对store还不太熟悉的话,可以直接将isCollapsed当做参数传进菜单组件。
vue的一个很大的特点是组件化,上面关于展开和收起的菜单我写在了一个组件中。也可以将它们拆成组件,这样不显的累赘。
多有考虑不到之处,欢迎多多指教。