說在前面
本篇記錄學習了vue-element-admin中的多級菜單的實現 [傳送門]
@vue/cli 4.2.2;vuex;scss;組件嵌套
正文
創建項目
npm create 項目名 //或npm init webpack 項目名
安裝element-ui
npm add element-ui //或npm i element-ui
安裝vuex
npm add vuex //或npm i vuex
安裝完vuex后會出現src/store目錄,同時在src/main.js中vue實例添加了store(這里是關於vuex的知識先放一下)
首先側邊欄的內容哪來?需要根據路由表來展示。
所以我們需要
一、 構造子頁面並配置路由
1 在src/views目錄建兩個目錄和三個vue文件book/read.vue,book/write.vue和 movie/watch.vue (template+script構造頁面)
2 接着配置這三個頁面的路由如下
const routes = [ { path: '/book', component: Layout, redirect: '/book/write', children: [ { path: '/book/write', component: () => import('@/views/book/write'), name: 'book', meta: { title: '寫書', icon: 'edit', roles: ['admin'] } }, { path: '/book/read', component: () => import('@/views/book/read'), name: 'book', meta: { title: '讀書', icon: 'edit' } } ] }, { path: '/movie', component: Layout, redirect: '/movie/watch', children: [ { path: '/movie/watch', component: () => import('@/views/movie/watch'), name: 'movie', meta: { title: '看電影', icon: 'edit' } } ] } ]
這里面的component:layout是什么呢?
二、構造主頁面(准備引用菜單欄)
簡單說layout它是一個整體的頁面結構,比如一個頁面有側邊欄也有正文內容,還有頂部底部等,他們都在這里被引入。
接下來就來實現它:
建立一個目錄和主文件src/layout/index.vue,再建立一個目錄src/layout/components存放整體結構下的一些部件,比如側邊欄、設置按鈕等。
這里的index.vue
<template> <div :class="classObj"> <sidebar class="sidebar-container" />
<!--頁面的其他元素--> </div> </template> <script> import { Sidebar } from './components' import { mapState } from 'vuex' export default { name: 'Layout', components: { Sidebar }, mixins: [ResizeMixin], computed: { ...mapState({ sidebar: state => state.app.sidebar }), classObj() { return { hideSidebar: !this.sidebar.opened, openSidebar: this.sidebar.opened, withoutAnimation: this.sidebar.withoutAnimation } } }, methods: { handleClickOutside() { this.$store.dispatch('app/closeSideBar', { withoutAnimation: false }) } } } </script>
其中...mapState({})的作用是將store中的getter映射到局部計算屬性 [文檔傳送門]
handleClickOutside()是控制側邊欄折疊,通過封裝element-ui原生代碼實現,可刪去不細講。
sidebar就是我們從./components引入的組件,也就是本篇的主角。
三、構造菜單欄
首先要了解element的菜單欄是怎么樣子的 [文檔傳送門]
可以發現最外層是el-menu,內層可以是el-submenu或者el-menu-item,
若為el-submenu則其內部包含el-menu-item-group,內部可以繼續包含el-submenu或者el-menu-item,如此形成多級菜單。
但要根據路由來實現,就要獲取路由,可以通過vuex中的mapGetters來獲取。
所以Sidebar/index.vue如下
<template>
<div>
<el-scrollbar wrap-class="scrollbar-wrapper">
<el-menu
:default-active="activeMenu"
:collapse="isCollapse"
:background-color="variables.menuBg"
:text-color="variables.menuText"
:unique-opened="false"
:active-text-color="variables.menuActiveText"
:collapse-transition="false"
mode="vertical"
>
<sidebar-item v-for="route in permission_routes" :key="route.path" :item="route" :base-path="route.path" />
</el-menu>
</el-scrollbar>
</div>
</template>
<script>
import { mapGetters } from 'vuex'
import SidebarItem from './SidebarItem'
import variables from '@/styles/variables.scss'
export default {
components: { SidebarItem },
computed: {
...mapGetters([
'permission_routes',
'sidebar'
]),
activeMenu() {
const route = this.$route
const { meta, path } = route
// if set path, the sidebar will highlight the path you set
if (meta.activeMenu) {
return meta.activeMenu
}
return path
},
showLogo() {
return this.$store.state.settings.sidebarLogo
},
variables() {
return variables
},
isCollapse() {
return !this.sidebar.opened
}
}
}
</script>
el-scrollbar是element的隱藏屬性,表示如果側邊欄過長會產生自定義的滾動條。
其中mapGetters方法前面加了三個點(對象展開運算符),使得它可以混入computed屬性 (官方解釋:使用對象展開運算符將 getter 混入 computed 對象中)
此處mapGetters的作用是將store中的getter映射到局部計算屬性。
至此Sidebar/index.vue獲得了路由表,進一步將它傳給了內部的sidebar-item組件。
接着SidebarItem.vue文件如下,這里就是最精華的地方(組件嵌套自己)
<template> <div v-if="!item.hidden"><!--來自於路由配置表--> <template v-if="hasOneShowingChild(item.children,item) && (!onlyOneChild.children||onlyOneChild.noShowingChildren)&&!item.alwaysShow"> <app-link 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> </app-link> </template> <!--三個條件:1有且只有一個子項目,2子菜單是否還包含子菜單,3是否必須展示--> <!--app-link在meta存在時展示,相當於添加點擊功能,即a標簽(處理內鏈還是外鏈)--> <!--item是實際展示,該模版只有一個render()方法來渲染(渲染圖標,標題等)--> <!--若child小於2執行以上步驟--> <!--若child大於2執行以下步驟(我調我自己sidebar-item)--> <!--插槽title展示父路由(render方法渲染)--> <!--sidebar-item遍歷child(is-nest控制isNest,決定submenu-title-noDropdown即子菜單各項目的樣式是否展示,bath-path處理該菜單各項目的鏈接)--> <el-submenu v-else ref="subMenu" :index="resolvePath(item.path)" popper-append-to-body> <template slot="title"> <item v-if="item.meta" :icon="item.meta && item.meta.icon" :title="item.meta.title" /> </template> <sidebar-item v-for="child in item.children" :key="child.path" :is-nest="true" :item="child" :base-path="resolvePath(child.path)" class="nest-menu" /> </el-submenu> </div> </template> <script> import path from 'path' import { isExternal } from '@/utils/validate' import Item from './Item' import AppLink from './Link' export default { name: 'SidebarItem', components: { Item, AppLink }, props: { // route object item: { type: Object, required: true }, isNest: { type: Boolean, default: false }, basePath: { type: String, default: '' } }, data() { // To fix https://github.com/PanJiaChen/vue-admin-template/issues/237 // TODO: refactor with render function this.onlyOneChild = null return {} }, methods: { hasOneShowingChild(children = [], parent) { const showingChildren = children.filter(item => {//把需要顯示的children存入showingChildren if (item.hidden) { return false } else { // Temp set(will be used if only has one showing child) this.onlyOneChild = item return true } }) // When there is only one child router, the child router is displayed by default if (showingChildren.length === 1) { return true } // Show parent if there are no child router to display if (showingChildren.length === 0) { this.onlyOneChild = { ... parent, path: '', noShowingChildren: true }//沒有子菜單,則用parent覆蓋onlyOneChild來展示 return true } return false//child大於2時,則展示ELSubmenu }, resolvePath(routePath) { if (isExternal(routePath)) { return routePath } if (isExternal(this.basePath)) { return this.basePath } return path.resolve(this.basePath, routePath) } } } </script>
首先要明確上面這段html分文兩部分:v-if 和 v-else
v-if部分
這里的item來源於Sidebar/index.vue中sidebar-item標簽的:item屬性,即遍歷路由表時的當前路由。
1 若有定義hidden屬性將直接不顯示;
2 template有三個條件的判斷;
3 app-link是增加點擊跳轉的功能,給菜單項添加跳轉路徑(自定義的,詳見Sidebar/Link.vue);
4 el-menu-item就是element的菜單欄最小單元,再往里面就是文字內容了
5 item是自定義的,為的是將icon和文字合在一起,所以這里又要定義一個自定義組件。(自定義的,詳見Sidebar/Item.vue)
<template>
<!-- eslint-disable vue/require-component-is -->
<component v-bind="linkProps(to)"> <!--相當於:is :href :target :rel都寫在linkProps中了,因為這里有兩套屬性,所以寫在方法中-->
<slot />
</component>
</template>
<script>
import { isExternal } from '@/utils/validate'
export default {
props: {
to: {
type: String,
required: true
}
},
methods: {
linkProps(url) {
if (isExternal(url)) {//判斷路由是否包含http,即外鏈。一般內鏈(路由)是類似相對路徑的寫法
return {
is: 'a',
href: url,
target: '_blank',
rel: 'noopener'
}
}
return {//若是內鏈(路由),則顯示routerLink
is: 'router-link',
to: url
}
}
}
}
</script>
<script> export default { name: 'MenuItem', functional: true, props: { icon: { type: String, default: '' }, title: { type: String, default: '' } }, render(h, context) { const { icon, title } = context.props const vnodes = [] if (icon) { vnodes.push(<svg-icon icon-class={icon}/>) } if (title) { vnodes.push(<span slot='title'>{(title)}</span>) } return vnodes } } </script>
Link中主要是插槽slot,和對外鏈內鏈的區分;
Item中主要是用render函數渲染。
至此完成了一級菜單,接下來是子菜單的實現
v-else部分
el-submenu下面套用了Sidebar-item,也就是自己調用了自己。
這里與index下面調用的Sidebar-item區別在於多了is-nest(見注釋)
如此遞歸調用,遍歷子菜單時,還會繼續檢測子菜單是否有子菜單,有的話繼續遞歸,這樣就實現了無限層級的菜單。
下面補充vuex的內容
src/store/index.js(代碼默認生成如下,結構由State、Getters、Mutation、Actions這四種組成)
import Vue from 'vue' import Vuex from 'vuex' Vue.use(Vuex) export default new Vuex.Store({ state: { }, mutations: { }, actions: { }, modules: { } })
可以理解為Store是一個容器,Store里面的狀態與單純的全局變量是不一樣的,因為無法直接改變store中的狀態。想要改變store中的狀態,只有一個辦法,顯式地提交mutation。
當我們需要不止一個store的時候,為了便於維護,可以建立一個目錄存放不同的store模塊,再把它引入到store/index.js中。[兩步:1建立目錄,2引入]
所以我們在src/store目錄下增加一個getters.js(映射)和建立modules目錄(該目錄用於存放不同store模塊,所謂store模塊就是一個個的形如src/store/index.js的代碼,如上,但具體內容與作用不同),
getter.js(申明一個getters)
const getters = { sidebar: state => state.app.sidebar, permission_routes: state => state.permission.routes, } export default getters
接着建立app.js和permission.js
分別寫入如下內容
import Cookies from 'js-cookie' const state = { sidebar: { opened: Cookies.get('sidebarStatus') ? !!+Cookies.get('sidebarStatus') : true, withoutAnimation: false } } const mutations = { TOGGLE_SIDEBAR: state => { state.sidebar.opened = !state.sidebar.opened if (state.sidebar.opened) { Cookies.set('sidebarStatus', 1) } else { Cookies.set('sidebarStatus', 0) } }, CLOSE_SIDEBAR: (state, withoutAnimation) => { Cookies.set('sidebarStatus', 0) state.sidebar.opened = false } } const actions = { toggleSideBar({ commit }) { commit('TOGGLE_SIDEBAR') }, closeSideBar({ commit }, { withoutAnimation }) { commit('CLOSE_SIDEBAR', withoutAnimation) } } export default { namespaced: true, state, mutations, actions }
import { asyncRoutes, constantRoutes } from '@/router'
/**
* Use meta.role to determine if the current user has permission
* @param roles
* @param route
*/
function hasPermission(roles, route) {
if (route.meta && route.meta.roles) {//若有配置權限,進行some判斷(只要符合一條即為true),判斷傳過來的角色是否存在
return roles.some(role => route.meta.roles.includes(role))//傳過來的roles符合該route所要求的用戶權限,返回true,否則false
} else {//若未配置權限則默認可以訪問,返回true
return true
}
}
/**
* Filter asynchronous routing tables by recursion
* @param routes asyncRoutes
* @param roles
*/
export function filterAsyncRoutes(routes, roles) {
const res = []
routes.forEach(route => {
const tmp = { ...route }//淺拷貝routes
if (hasPermission(roles, tmp)) {//判斷是否有權限訪問當前遍歷到的route
if (tmp.children) {
tmp.children = filterAsyncRoutes(tmp.children, roles)//過濾不該顯示的子routes,並更新children
}
res.push(tmp)//存入空數組res
}
})
return res
}
const state = {
routes: [],
addRoutes: []
}
const mutations = {
SET_ROUTES: (state, routes) => {
state.addRoutes = routes//更新state中的addRoutes,暫時不用
state.routes = constantRoutes.concat(routes)//將constantRoutes和新的routes進行合並(側邊欄直接用state.routes)
}
}
const actions = {
generateRoutes({ commit }, roles) {
return new Promise(resolve => {
let accessedRoutes
if (roles.includes('admin')) {//角色包含admin
accessedRoutes = asyncRoutes || []
} else {//角色不包含admin
accessedRoutes = filterAsyncRoutes(asyncRoutes, roles)
}
commit('SET_ROUTES', accessedRoutes)//調用上方mutation中的SET_ROUTES,保存accessedRoutes
resolve(accessedRoutes)
})
}
}
export default {
namespaced: true,
state,
mutations,
actions
}
最后改寫src/store/index.js
import Vue from 'vue' import Vuex from 'vuex' Vue.use(Vuex)
/****************這一段用來生成modules,見下面解釋*******************/ // https://webpack.js.org/guides/dependency-management/#requirecontext const modulesFiles = require.context('./modules', true, /\.js$/) //多文件情況下整個文件夾統一導入 //(你創建了)一個module文件夾下面(不包含子目錄),能被require請求到,所有文件名以 `.js` 結尾的文件形成的上下文(模塊) // you do not need `import app from './modules/app'` // it will auto require all vuex module from modules file const modules = modulesFiles.keys().reduce((modules, modulePath) => { // set './app.js' => 'app' const moduleName = modulePath.replace(/^\.\/(.*)\.\w+$/, '$1') const value = modulesFiles(modulePath) modules[moduleName] = value.default return modules }, {})
/*****************這一段用來生成modules,見下面解釋*******************/ export default new Vuex.Store({ modules, getters }) export default store
那么這里是如何把modules引入呢
1 其中require.context()會返回一個webpack的回調函數對象,
2 調用該對象內的keys()則會把各個路徑以數組方式列出,
3 對該數組進行reduce()合成為一個總對象,
這一波操作最后會生成一個包含modules內所有模塊的總對象,完成引入(由於我們modules是空的,所以會打印為空。)
這里為了舉例用了vue-element-admin寫好的,大概如下。(這里可以看出modules下面有app.js,errorLog.js等等不同作用的store,每一個store都有它自己的四件套State、Getters、Mutation、Actions)

到這里就設置好了自己項目的vuex,隨着項目增大,需要的內容就會更多,小項目不用vuex更好。
