一个vuex+vue-router实现的动态树形菜单

所谓动态菜单,就是菜单数据从后台加载,前端接收到的是一个JSON,前端代码解析后渲染相应的菜单信息,相应的路由也应该是动态加载的。

TreeDetail.vue

<template>
    <!--<h3>-->
    <div>
        <list-content :resourceId="resourceId"
                      :resourceType="resourceType"
                      ref="listContent">
        </list-content>
    </div>

    <!--</h3>-->
</template>
<script>

    import listContent from '../contents/content.vue'

    export default {
        name: "TreeViewDetail",
        data() {
            return {
                isLoadList: false,
                currentRoute: this.$route.path,
                resourceId: '',
                resourceType: ''            };
        },
        created() {

        },
        mounted() {
            this.initList();
        },
        methods: {
            // 初始化列表
            initList() {
                if (this.$route.path != '' && this.$route.path != null && this.$route.path != undefined) {
                    this.resourceId = this.$route.name;
                    this.resourceType = 'link';


                }
            }

        },
        watch: {
            //监听路由,只要路由有变化(路径,参数等变化)都有执行下面的函数
            $route: {
                handler: function(val, oldVal) {
                    this.resourceId = val.name;
                    this.resourceType = 'link';
                    this.currentRoute = val.name;
                },
                deep: true
            }
        },
        components : {
            listContent
        }
    };
</script>
<style scoped>
    h3 {
        margin-top: 10px;
        font-weight: normal;
    }
</style>

TreeView.vue

<template>
    <div class="tree-view-menu">
        <Tree-view-item :menus='menus'></Tree-view-item>
    </div>
</template>
<script>
    import TreeViewItem from "./TreeViewItem.vue";

    export default {
        components: {
            TreeViewItem
        },
        mounted() {
          console.log(this.menus);
        },
        name: "TreeViewMenu",
        data() {
            return {
                menus: this.$store.state.menusModule.menus
            };
        }
    };
</script>
<style scoped>
    .tree-view-menu {
        background-color: #404655;
        width: 100%;
        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.resourceLevel" v-for="menu in menus" :key="menu.id">
            <div v-if="menu.resourceType === 'link'">
                <router-link class="link" v-bind:to="menu.resourceUrl" @click.native="toggle(menu)">{{menu.resourceName}}</router-link>
            </div>
            <div v-if="menu.resourceType === 'button'">
                <div class="button heading" :class="{selected: menu.selected,expand:menu.expanded}" @click="toggle(menu)">
                    <span class="ats-tree-node"
                            :title="handleTitleVisible(menu.resourceName)">{{menu.resourceName}}</span>
                    <!--{{menu.resourceName}}-->
                    <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.expanded" v-if="menu.childQueues">
                        <Tree-view-item :menus='menu.childQueues'></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 });
        },
        mounted() {

        },
        data() {
          return {
              treeData: [],
          }
        },
        methods: {
            toggle(menu) {
                this.$store.commit("findParents", { menu });
            },
            //超长菜单名后面用...代替,并且加上title属性
            handleTitleVisible(title){
                let titleLen = title.replace(/[^\x00-\xff]/g, '..').length;
                if(titleLen>10){
                    return title;
                }else{
                    return '';
                }
            }
        }
    };
</script>
<style scoped>

    a {
        text-decoration: none;
        color: #333;
    }

    .level-1 {
        font-size: 20px;
        font-weight: bold;
    }
    .level-2 {
        font-size: 18px;
        font-weight: lighter;
    }
    .level-3 {
        font-size: 16px;
        font-weight: lighter;
    }

    .link {
        font-size: 14px;
        font-weight: lighter;
    }
    .button {
        /*选中最靠近标题的菜单时,避免背景色一致导致视觉上连在一起*/
        margin-bottom: 2px;
    }

    .link,
    .button {
        color:#ffffff;
        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;
        /*color: #ffffff;*/
        /*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 {
        /*background-color: #0071ff;*/
        color: #0071ff;
        font-weight: bold;
    }
    .fade-enter-active {
        transition: all 0.5s ease 0s;
    }
    .fade-enter {
        opacity: 0;
    }
    .fade-enter-to {
        opacity: 1;
    }
    .fade-leave-to {
        height: 0;
    }

    .ats-tree-node{
        max-width: 130px;
        overflow: hidden;
        text-overflow: ellipsis;
        white-space: nowrap;
        display: inline-block;
    }
</style>

初始化菜单信息以及菜单点击相关逻辑menusModule.js

let menus = toArray();

function toArray() {
    var obj =  sessionStorage.getItem("menuJson");
    return JSON.parse(obj);
}


let resourceLevelNum = 1;
let startExpand = []; // 保存刷新后当前要展开的菜单项
function setExpand(source, resourceUrl) {

    let sourceItem = '';
    for (let i = 0; i < source.length; i++) {
        sourceItem = JSON.stringify(source[i]); // 把菜单项转为字符串
        // 查找当前 resourceUrl 所对应的子菜单属于哪一个祖先菜单,并且初始路径不为'/',否则会把菜单第一个选然后选中并展开
        if (sourceItem.indexOf(resourceUrl) > -1 && resourceUrl != '/') {
            // debugger;
            if (source[i].resourceType === 'button') { // 导航菜单为按钮
                source[i].selected = true; // 设置选中高亮
                source[i].expanded = true; // 设置为展开
                startExpand.push(source[i]);
                // 递归下一级菜单,以此类推
                setExpand(source[i].childQueues, resourceUrl);
            }
            break;
        }
    }
}

const state = {
    menus,
    resourceLevelNum
};
const mutations = {

    findParents(state, payload) {
        if (payload.menu.resourceType === "button") {
            payload.menu.expanded = !payload.menu.expanded;
        } else if (payload.menu.resourceType === "link") {
            if (startExpand.length > 0) {
                for (let i = 0; i < startExpand.length; i++) {
                    startExpand[i].selected = false;
                }
            }
            startExpand = []; // 清空展开菜单记录项
            setExpand(state.menus, payload.menu.resourceUrl);
        };
    },
    firstInit(state, payload) {
        setExpand(state.menus, payload.url);
    },
    // 初始化state中的菜单信息
    initStateMenus(state, payload) {
        state.menus = payload.menus;
    }
}
export default {
    state,
    mutations
};

路由index.js

// 只给初始默认路由
import Vue from 'vue';
import Router from 'vue-router';

import welcome from '../components/welcome.vue';
import error from '../components/error.vue';

Vue.use(Router)
export default new Router({
    linkActiveClass: 'selected',
    mode: 'history',
   
    routes: [{
        path: '/',
        name: 'welcome',
        component: welcome
    },
        {
            path: '/error',
            name: 'error',
            component: error
        }
    ]

})

Home.vue

<template>
    <Layout>
        <div class="home-wrapper layout">
            <div class="content-wrapper">
                <template v-if="showHome">
                    <div class="side-bar">
                        <Tree-view></Tree-view>
                    </div>
                    <div class="continer">
                        <router-view></router-view>
                    </div>
                </template>
            </div>
        </div>
        <!--<Footer class="layout-footer-center">2011-2016 &copy; TalkingData</Footer>-->
    </Layout>
</template>
<script>

    import listContent from './contents/content.vue'
    import TreeView from "./tree_menu/TreeView.vue";
    import TreeDetail from '../components/tree_menu/TreeDetail.vue';

    export default {


        name: "Home",
        data() {
            return {
                showHome: false,
                menuArr:[], // 遍历菜单menus时的临时变量
                dynamicRouters: [] // 动态路由数组

            }
        },
        created() {
            this.initMenuInfo();
        },
        mounted() {

        },
        methods: {

            refreshList : function () {
                this.$refs.listContent.refreshListPage(this.resourceId);
            },
            // 初始化菜单
            initMenuInfo() {
                // 如果本地已存在,则不去后台加载
                if (sessionStorage.getItem('menuJson')) {
                    // 添加动态路由
                    this.addDynamicRouters(JSON.parse(sessionStorage.getItem('menuJson')));
                    this.showHome = true;
                } else {
                    this.axios.get(this.GLOBAL.gatewayCsbSrc + '/listQueue/getAppMenu/1').then((response) => {
                        //本地缓存菜单信息字符串
                        sessionStorage.removeItem('menuJson');
                        sessionStorage.setItem('menuJson',JSON.stringify(response.data));
                        // 添加动态路由
                        this.addDynamicRouters(response.data);
                        // 初始化状态中的菜单信息,否则第一次加载menusModule#firstInit中的status.menus为null,会报错
                        // 只有通过store.commit才有效
                        this.$store.commit("initStateMenus", { menus:  response.data});
                        // this.$router.push("/");
                        this.showHome = true;
                    }).catch((response) => {
                        console.log(response);
                    })
                }
            },
            // 动态添加路由
            addDynamicRouters(menuObj) {
                // 组装需要渲染的动态路由
                this.assemblyRoutersArr(menuObj, this.menuArr);
                // 将404页面的路由加在最后,因为路由会从上往下匹配,上面的匹配不到最终会走到这里,匹配任意,进入404页面
                let errorRouter = {
                    path:'*',
                    redirect: '/error'
                };
                // 添加动态路由
                this.dynamicRouters.push(errorRouter);
                this.$router.addRoutes(this.dynamicRouters);
            },
            // 递归遍历菜单json,将其中type未link的提取出来拼成需要渲染的路由数组
            assemblyRoutersArr(json, arr) {
                if (json == undefined) {
                    return;
                }
                for (var i = 0; i < json.length; i++) {
                    var sonList = json[i].childQueues;
                    // 资源类型未link的,添加到路由中
                    if (json[i].resourceType == 'link') {
                        // 防止重复添加动态路由
                        if (this.containsRouter(json[i].resourceId.toString())) {
                            continue;
                        }

                        let routerObj = {
                            path: json[i].resourceUrl,
                            name: json[i].resourceId.toString(),
                            component: TreeDetail

                        };

                        this.dynamicRouters.push(routerObj);
                    }
                    if (sonList.length == 0) {
                        arr.push(json[i]);
                    } else {
                        this.assemblyRoutersArr(sonList, arr);
                    }
                }
            },

            containsRouter(resourceId) {
                let routers = this.dynamicRouters;
                if (routers.length == 0) {
                    return false;
                }
                for (var i = 0; i < routers.length; i++) {
                    if (routers.name == resourceId) {
                        return true;
                    }
                }
                return false;
            }
        },
        components: {
            TreeView,
            listContent,
            TreeDetail
        }

    };
</script>
<style scoped>
    .side-bar {
        width: 180px;
        height: 100%;
        font-size: 14px;

    }
    .continer {
        margin-left: 10px;
        width: calc(100% - 200px);
        height:100%;
    }

    .home-wrapper {
        display : flex;
        flex-direction: column;
        flex:1;
        width:100%;
        height: 100%;
    }

    .content-wrapper {
        display : flex;
        flex-direction: row;
        width:100%;
        /*height: calc(100% - 60px);*/
        height:100%;
    }

    .layout{
        border: 1px solid #d7dde4;
        background: #ffffff;
        /*background-color: red;*/
        position: relative;
        border-radius: 4px;
        overflow: hidden;
        height: 100%;
    }

</style>

静态图效果:


微信截图_20190527174725.png

参考了网上开源的一个tree组件,原文找不到了。

动态菜单需要考虑每次都去后台加载,都需要连接查询,所以后台做了优化,菜单json存到redis中。
前端也做了优化,菜单加载后使用sessionStorage存储到本地,优先从本地加载,加载不到再发起后台请求。
递归遍历菜单json后,同时将路由信息也渲染一下,this.$router.addRoutes(routerArr),参数是数组,不是对象。如果链接不在路由信息中跳到404页面,路由信息从上向下查找,所以404的通配路由信息要放在最后,也就是上面未匹配到,最后匹配进入通配路由中,所以在动态路由加载完后,单独再将该路由信息添加到最后。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,271评论 5 476
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,275评论 2 380
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,151评论 0 336
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,550评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,553评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,559评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,924评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,580评论 0 257
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,826评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,578评论 2 320
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,661评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,363评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,940评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,926评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,156评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,872评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,391评论 2 342