結構示意圖
├── index.html├── main.js├── router│ └── index.js # 路由配置文件├── components # 組件目錄│ ├── App.vue # 根組件│ ├── Home.vue # 大的框架結構組件│ ├── TreeView.vue│ ├── TreeViewItem.vue│ └── TreeDetail.vue├── store├── index.js # 我們組裝模塊並導出 store 的地方├── modules # 模塊目錄└── menusModule.js # 菜單模塊
這個多級菜單實現的功能如下:
- 1、可展示多級菜單,理論上可以展無限級菜單
- 2、當前菜單高亮功能
- 3、刷新后依然會自動定位到上一次點擊的菜單,即使這個是子菜單,並且父菜單會自動展開
- 4、子菜單的顯示隱藏有收起、展開,同時帶有淡入效果
這個例子用到的知識點:路由、狀態管理、組件。
狀態管理安裝:
npm install --save vuex
更多關於 vuex 的介紹可以看官方文檔:https://vuex.vuejs.org/zh-cn/。
我們先來看看效果演示圖:

程序員是用代碼來溝通的,所以費話不多說,直接上碼:
index.html
<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1.0"><title>Vue 實現樹形菜單(多級菜單)功能模塊- 雲庫網</title></head><body><div id="app"></div></body></html>
main.js
import Vue from 'vue'import App from './components/App'import router from './router'import store from './store/index'Vue.config.productionTip = false/* eslint-disable no-new */new Vue({el: '#app',router,store,components: {App},template: '<App/>'})
在 main.js 中引入 路由和狀態管理配置
App.vue
<template><div id="app"><Home></Home></div></template><script>import Home from "./Home";export default {components: {Home},name: "App"};</script><style>* {padding: 0;margin: 0;}#app {font-family: "Avenir", Helvetica, Arial, sans-serif;-webkit-font-smoothing: antialiased;-moz-osx-font-smoothing: grayscale;color: #2c3e50;}html,body,#app,.home {height: 100%;}html,body {overflow: hidden;}</style>
Home.vue
<template><div class="home"><div class="side-bar"><Tree-view></Tree-view></div><div class="continer"><router-view></router-view></div></div></template><script>import TreeView from "./TreeView";export default {components: {TreeView},name: "Home"};</script><style scoped>.side-bar {width: 300px;height: 100%;overflow-y: auto;overflow-x: hidden;font-size: 14px;position: absolute;top: 0;left: 0;}.continer {padding-left: 320px;}</style>
這個 Home.vue 主要是用來完成頁面的大框架結構。
TreeView.vue
<template><div class="tree-view-menu"><Tree-view-item :menus='menus'></Tree-view-item></div></template><script>import TreeViewItem from "./TreeViewItem";const menusData = [];export default {components: {TreeViewItem},name: "TreeViewMenu",data() {return {menus: this.$store.state.menusModule.menus};}};</script><style scoped>.tree-view-menu {width: 300px;height: 100%;overflow-y: auto;overflow-x: hidden;}.tree-view-menu::-webkit-scrollbar {height: 6px;width: 6px;}.tree-view-menu::-webkit-scrollbar-trac {-webkit-box-shadow: inset 0 0 6px rgba(0, 0, 0, 0.3);box-shadow: inset 0 0 6px rgba(0, 0, 0, 0.3);}.tree-view-menu::-webkit-scrollbar-thumb {background-color: #6e6e6e;outline: 1px solid #333;}.tree-view-menu::-webkit-scrollbar {height: 4px;width: 4px;}.tree-view-menu::-webkit-scrollbar-track {-webkit-box-shadow: inset 0 0 6px rgba(0, 0, 0, 0.3);box-shadow: inset 0 0 6px rgba(0, 0, 0, 0.3);}.tree-view-menu::-webkit-scrollbar-thumb {background-color: #6e6e6e;outline: 1px solid #708090;}</style>
這個組件也非常地簡單,拿到菜單數據,傳給子組件,並把菜單的滾動條樣式修改了下。
TreeViewItem.vue
<template><div class="tree-view-item"><div class="level" :class="'level-'+ menu.level" v-for="menu in menus" :key="menu.id"><div v-if="menu.type === 'link'"><router-link class="link" v-bind:to="menu.url" @click.native="toggle(menu)">{{menu.name}}</router-link></div><div v-if="menu.type === 'button'"><div class="button heading" :class="{selected: menu.isSelected,expand:menu.isExpanded}" @click="toggle(menu)">{{menu.name}}<div class="icon"><svg xmlns="http://www.w3.org/2000/svg" focusable="false" viewBox="0 0 24 24"><path d="M8.59 16.34l4.58-4.59-4.58-4.59L10 5.75l6 6-6 6z "></path></svg></div></div><transition name="fade"><div class="heading-children" v-show="menu.isExpanded" v-if="menu.subMenu"><Tree-view-item :menus='menu.subMenu'></Tree-view-item></div></transition></div></div></div></template><script>export default {name: "TreeViewItem",props: ["menus"],created() {this.$store.commit("firstInit", { url: this.$route.path });},methods: {toggle(menu) {this.$store.commit("findParents", { menu });}}};</script><style scoped>a {text-decoration: none;color: #333;}.link,.button {display: block;padding: 10px 15px;transition: background-color 0.2s ease-in-out 0s, color 0.3s ease-in-out 0.1s;-moz-user-select: none;-webkit-user-select: none;-ms-user-select: none;-khtml-user-select: none;user-select: none;}.button {position: relative;}.link:hover,.button:hover {color: #1976d2;background-color: #eee;cursor: pointer;}.icon {position: absolute;right: 0;display: inline-block;height: 24px;width: 24px;fill: currentColor;transition: -webkit-transform 0.15s;transition: transform 0.15s;transition: transform 0.15s, -webkit-transform 0.15s;transition-timing-function: ease-in-out;}.heading-children {padding-left: 14px;overflow: hidden;}.expand {display: block;}.collapsed {display: none;}.expand .icon {-webkit-transform: rotate(90deg);transform: rotate(90deg);}.selected {color: #1976d2;}.fade-enter-active {transition: all 0.5s ease 0s;}.fade-enter {opacity: 0;}.fade-enter-to {opacity: 1;}.fade-leave-to {height: 0;}</style>
上面的這個組件才是這個樹型結構重點代碼,用了遞歸的思想來實現這個樹型菜單。
TreeViewDetail.vue
<template><h3>這里是{{currentRoute}}導航詳情</h3></template><script>export default {name: "TreeViewDetail",data() {return {currentRoute: this.$route.path};},watch: {//監聽路由,只要路由有變化(路徑,參數等變化)都有執行下面的函數$route: {handler: function(val, oldVal) {this.currentRoute = val.name;},deep: true}}};</script><style scoped>h3 {margin-top: 10px;font-weight: normal;}</style>
router/index.js
import Vue from 'vue';import Router from 'vue-router';import App from '@/components/App';import TreeViewDetail from '@/components/TreeViewDetail';Vue.use(Router)export default new Router({linkActiveClass: 'selected',routes: [{path: '/',name: 'App',component: App},{path: '/detail/quickstart',name: 'quickstart',component: TreeViewDetail},{path: '/detail/tutorial',name: 'tutorial',component: TreeViewDetail},{path: '/detail/toh-pt1',name: 'toh-pt1',component: TreeViewDetail},{path: '/detail/toh-pt2',name: 'toh-pt2',component: TreeViewDetail},{path: '/detail/toh-pt3',name: 'toh-pt3',component: TreeViewDetail},{path: '/detail/toh-pt4',name: 'toh-pt4',component: TreeViewDetail},{path: '/detail/toh-pt5',name: 'toh-pt5',component: TreeViewDetail},{path: '/detail/toh-pt6',name: 'toh-pt6',component: TreeViewDetail},{path: '/detail/architecture',name: 'architecture',component: TreeViewDetail},{path: '/detail/displaying-data',name: 'displaying-data',component: TreeViewDetail},{path: '/detail/template-syntax',name: 'template-syntax',component: TreeViewDetail},{path: '/detail/lifecycle-hooks',name: 'lifecycle-hooks',component: TreeViewDetail},{path: '/detail/component-interaction',name: 'component-interaction',component: TreeViewDetail},{path: '/detail/component-styles',name: 'component-styles',component: TreeViewDetail},{path: '/detail/dynamic-component-loader',name: 'dynamic-component-loader',component: TreeViewDetail},{path: '/detail/attribute-directives',name: 'attribute-directives',component: TreeViewDetail},{path: '/detail/structural-directives',name: 'structural-directives',component: TreeViewDetail},{path: '/detail/pipes',name: 'pipes',component: TreeViewDetail},{path: '/detail/animations',name: 'animations',component: TreeViewDetail},{path: '/detail/user-input',name: 'user-input',component: TreeViewDetail},{path: '/detail/forms',name: 'forms',component: TreeViewDetail},{path: '/detail/form-validation',name: 'form-validation',component: TreeViewDetail},{path: '/detail/reactive-forms',name: 'reactive-forms',component: TreeViewDetail},{path: '/detail/dynamic-form',name: 'dynamic-form',component: TreeViewDetail},{path: '/detail/bootstrapping',name: 'bootstrapping',component: TreeViewDetail},{path: '/detail/ngmodule',name: 'ngmodule',component: TreeViewDetail},{path: '/detail/ngmodule-faq',name: 'ngmodule-faq',component: TreeViewDetail},{path: '/detail/dependency-injection',name: 'dependency-injection',component: TreeViewDetail},{path: '/detail/hierarchical-dependency-injection',name: 'hierarchical-dependency-injection',component: TreeViewDetail},{path: '/detail/dependency-injection-in-action',name: 'dependency-injection-in-action',component: TreeViewDetail},{path: '/detail/http',name: 'http',component: TreeViewDetail},{path: '/detail/router',name: 'router',component: TreeViewDetail},{path: '/detail/testing',name: 'testing',component: TreeViewDetail},{path: '/detail/cheatsheet',name: 'cheatsheet',component: TreeViewDetail},{path: '/detail/i18n',name: 'i18n',component: TreeViewDetail},{path: '/detail/language-service',name: 'language-service',component: TreeViewDetail},{path: '/detail/security',name: 'security',component: TreeViewDetail},{path: '/detail/setup',name: 'setup',component: TreeViewDetail},{path: '/detail/setup-systemjs-anatomy',name: 'setup-systemjs-anatomy',component: TreeViewDetail},{path: '/detail/browser-support',name: 'browser-support',component: TreeViewDetail},{path: '/detail/npm-packages',name: 'npm-packages',component: TreeViewDetail},{path: '/detail/typescript-configuration',name: 'typescript-configuration',component: TreeViewDetail},{path: '/detail/aot-compiler',name: 'aot-compiler',component: TreeViewDetail},{path: '/detail/metadata',name: 'metadata',component: TreeViewDetail},{path: '/detail/deployment',name: 'deployment',component: TreeViewDetail},{path: '/detail/upgrade',name: 'upgrade',component: TreeViewDetail},{path: '/detail/ajs-quick-reference',name: 'ajs-quick-reference',component: TreeViewDetail},{path: '/detail/visual-studio-2015',name: 'visual-studio-2015',component: TreeViewDetail},{path: '/detail/styleguide',name: 'styleguide',component: TreeViewDetail},{path: '/detail/glossary',name: 'glossary',component: TreeViewDetail},{path: '/detail/api',name: 'api',component: TreeViewDetail}]})
store/module/menusModule.js
let menus = [{ id: 1, level: 1, name: '快速上手', type: "link", url: "/detail/quickstart" },{id: 2,level: 1,name: '教程',type: "button",isExpanded: false,isSelected: false,subMenu: [{ id: 21, level: 2, name: '簡介', type: "link", url: "/detail/tutorial" },{ id: 22, level: 2, name: '英雄編輯器', type: "link", url: "/detail/toh-pt1" },{ id: 23, level: 2, name: '主從結構', type: "link", url: "/detail/toh-pt2" },{ id: 24, level: 2, name: '多個組件', type: "link", url: "/detail/toh-pt3" },{ id: 25, level: 2, name: '服務', type: "link", url: "/detail/toh-pt4" },{ id: 26, level: 2, name: '路由', type: "link", url: "/detail/toh-pt5" },{ id: 27, level: 2, name: 'HTTP', type: "link", url: "/detail/toh-pt6" },]},{id: 3,level: 1,name: '核心知識',type: "button",isExpanded: false,isSelected: false,subMenu: [{ id: 31, level: 2, name: '架構', type: "link", url: "/detail/architecture" },{id: 32,level: 2,name: '模板與數據綁定',type: "button",isExpanded: false,isSelected: false,subMenu: [{ id: 321, level: 3, name: '顯示數據', type: "link", url: "/detail/displaying-data" },{ id: 322, level: 3, name: '模板語法', type: "link", url: "/detail/template-syntax" },{ id: 323, level: 3, name: '生命周期鈎子', type: "link", url: "/detail/lifecycle-hooks" },{ id: 324, level: 3, name: '組件交互', type: "link", url: "/detail/component-interaction" },{ id: 325, level: 3, name: '組件樣式', type: "link", url: "/detail/component-styles" },{ id: 326, level: 3, name: '動態組件', type: "link", url: "/detail/dynamic-component-loader" },{ id: 327, level: 3, name: '屬性型指令', type: "link", url: "/detail/attribute-directives" },{ id: 328, level: 3, name: '結構型指令', type: "link", url: "/detail/structural-directives" },{ id: 329, level: 3, name: '管道', type: "link", url: "/detail/pipes" },{ id: 3210, level: 3, name: '動畫', type: "link", url: "/detail/animations" },]},{id: 33,level: 2,name: '表單',type: "button",isExpanded: false,isSelected: false,subMenu: [{ name: '用戶輸入', type: "link", url: "/detail/user-input" },{ name: '模板驅動表單', type: "link", url: "/detail/forms" },{ name: '表單驗證', type: "link", url: "/detail/form-validation" },{ name: '響應式表單', type: "link", url: "/detail/reactive-forms" },{ name: '動態表單', type: "link", url: "/detail/dynamic-form" }]},{ id: 34, level: 2, name: '引用啟動', type: "link", url: "/detail/bootstrapping" },{id: 35,level: 2,name: 'NgModules',type: "button",isExpanded: false,isSelected: false,subMenu: [{ id: 341, level: 3, name: 'NgModule', type: "link", url: "/detail/ngmodule" },{ id: 342, level: 3, name: 'NgModule 常見問題', type: "link", url: "/detail/ngmodule-faq" }]},{id: 36,level: 2,name: '依賴注入',type: "button",isExpanded: false,isSelected: false,subMenu: [{ id: 361, level: 3, name: '依賴注入', type: "link", url: "/detail/dependency-injection" },{ id: 362, level: 3, name: '多級注入器', type: "link", url: "/detail/hierarchical-dependency-injection" },{ id: 363, level: 3, name: 'DI 實例技巧', type: "link", url: "/detail/dependency-injection-in-action" }]},{ id: 37, level: 2, name: 'HttpClient', type: "link", url: "/detail/http" },{ id: 38, level: 2, name: '路由與導航', type: "link", url: "/detail/router" },{ id: 39, level: 2, name: '測試', type: "link", url: "/detail/testing" },{ id: 310, level: 2, name: '速查表', type: "link", url: "/detail/cheatsheet" },]},{id: 4,level: 1,name: '其它技術',type: "button",isExpanded: false,isSelected: false,subMenu: [{ id: 41, level: 2, name: '國際化(i18n)', type: "link", url: "/detail/i18n" },{ id: 42, level: 2, name: '語言服務', type: "link", url: "/detail/language-service" },{ id: 43, level: 2, name: '安全', type: "link", url: "/detail/security" },{id: 44,level: 2,name: '環境設置與部署',type: "button",isExpanded: false,isSelected: false,subMenu: [{ id: 441, level: 3, name: '搭建本地開發環境', type: "link", url: "/detail/setup" },{ id: 442, level: 3, name: '搭建方式剖析', type: "link", url: "/detail/setup-systemjs-anatomy" },{ id: 443, level: 3, name: '瀏覽器支持', type: "link", url: "/detail/browser-support" },{ id: 444, level: 3, name: 'npm 包', type: "link", url: "/detail/npm-packages" },{ id: 445, level: 3, name: 'TypeScript 配置', type: "link", url: "/detail/typescript-configuration" },{ id: 446, level: 3, name: '預 (AoT) 編譯器', type: "link", url: "/detail/aot-compiler" },{ id: 447, level: 3, name: '預 (AoT) 編譯器', type: "link", url: "/detail/metadata" },{ id: 448, level: 3, name: '部署', type: "link", url: "/detail/deployment" }]},{id: 45,level: 2,name: '升級',type: "button",isExpanded: false,isSelected: false,subMenu: [{ id: 451, level: 3, name: '從 AngularJS 升級', type: "link", url: "/detail/upgrade" },{ id: 452, level: 3, name: '升級速查表', type: "link", url: "/detail/ajs-quick-reference" }]},{ id: 46, level: 2, name: 'Visual Studio 2015 快速上手', type: "link", url: "/detail/visual-studio-2015" },{ id: 47, level: 2, name: '風格指南', type: "link", url: "/detail/styleguide" },{ id: 48, level: 2, name: '詞匯表', type: "link", url: "/detail/glossary" }]},{ id: 5, level: 1, name: 'API 參考手冊', type: "link", url: "/detail/api" }];let levelNum = 1;let startExpand = []; // 保存刷新后當前要展開的菜單項function setExpand(source, url) {let sourceItem = '';for (let i = 0; i < source.length; i++) {sourceItem = JSON.stringify(source[i]); // 把菜單項轉為字符串if (sourceItem.indexOf(url) > -1) { // 查找當前 URL 所對應的子菜單屬於哪一個祖先菜單if (source[i].type === 'button') { // 導航菜單為按鈕source[i].isSelected = true; // 設置選中高亮source[i].isExpanded = true; // 設置為展開startExpand.push(source[i]);// 遞歸下一級菜單,以此類推setExpand(source[i].subMenu, url);}break;}}}const state = {menus,levelNum};const mutations = {findParents(state, payload) {if (payload.menu.type === "button") {payload.menu.isExpanded = !payload.menu.isExpanded;} else if (payload.menu.type === "link") {if (startExpand.length > 0) {for (let i = 0; i < startExpand.length; i++) {startExpand[i].isSelected = false;}}startExpand = []; // 清空展開菜單記錄項setExpand(state.menus, payload.menu.url);};},firstInit(state, payload) {setExpand(state.menus, payload.url);}}export default {state,mutations};
在使用狀態管理時,我們一定要記住,一旦數據寫到了 state 中時,就不能再添加其它屬性了,什么時間?就拿上面的 menus 數據來說,比如,本來菜單數據中沒有 isExpanded 這個字段的,然后你在 mutations 的方法中給 menus 對象添加了一個 isExpanded 屬性,但你會發現屬性是不會被狀態管理追蹤到的,所以我們一開始就給這個數據添加了 isExpanded 和 isSelected 。
store/index.js
import Vue from 'vue'import Vuex from 'vuex'import menusModule from './module/menusModule'Vue.use(Vuex);const store = new Vuex.Store({modules: {menusModule}})export default store;
上面這個例子在使用狀態管理時,把菜單的相關配置封裝成模塊,然后再引入。如果把狀態管理寫成模塊的形式的話,在調用這個模塊中的狀態時就需要注意了,寫法可以參數示例中的代碼。
上面這個例子可以直接用到自己的項目中,只要你理解了其中的思想,其他的都不是問題。Vue 實現樹形菜單功能模塊之旅只能帶你到這里了。
