5

之前已经写过一篇关于vue权限路由实现方式总结的文章,经过一段时间的踩坑和总结,下面说说目前我认为比较“完美”的一种方案:菜单与路由完全由后端提供

菜单与路由完全由后端返回

这种方案前文也有提过,现在更加具体的说一说。

很多人喜欢把路由处理成菜单,或者把菜单处理成路由(我之前也是这样做的),最后发现挖的坑越来越深。

应用的菜单可能是两级,可能是三级,甚至是四到五级,而路由一般最多不会超过三级。如果应用的菜单达到五级,而用两级路由就可以就解决的情况下,为了能根据路由生成相应的菜单,有的人会弄出个五级路由出来。。。

所以墙裂建议,菜单数据与路由数据独立开,只要能根据菜单跳转到相应的路由即可。

菜单与路由都由后端提供,就需要就菜单与路由做相应的的维护功能。菜单上一些属性也是必须的,比如标题、跳转路径(也可以用跳转名称,对应路由名称即可,因为vue路由能根据名称进行跳转)。路由数据维护vue路由所需字段即可。

当然,做权限控制还得在菜单和路由上都维护相应的权限码,后端根据用户的权限过滤出用户能访问的菜单与路由。

下面是一份由后端返回的菜单和路由例子

let permissionMenu = [
    {
        title: "系统",
        path: "/system",
        icon: "folder-o",
        children: [
            {
                title: "系统设置",
                icon: "folder-o",
                children: [
                    {
                        title: "菜单管理",
                        path: "/system/menu",
                        icon: "folder-o"
                    },
                    {
                        title: "路由管理",
                        path: "/system/route",
                        icon: "folder-o"
                    }
                ]
            },
            {
                title: "权限管理",
                icon: "folder-o",
                children: [
                    {
                        title: "功能管理",
                        path: "/system/function",
                        icon: "folder-o"
                    },
                    {
                        title: "角色管理",
                        path: "/system/role",
                        icon: "folder-o"
                    },
                    {
                        title: "角色权限管理",
                        path: "/system/rolepermission",
                        icon: "folder-o"
                    },
                    {
                        title: "角色用户管理",
                        path: "/system/roleuser",
                        icon: "folder-o"
                    },
                    {
                        title: "用户角色管理",
                        path: "/system/userrole",
                        icon: "folder-o"
                    }
                ]
            },
            {
                title: "组织架构",
                icon: "folder-o",
                children: [
                    {
                        title: "部门管理",
                        path: "",
                        icon: "folder-o"
                    },
                    {
                        title: "职位管理",
                        path: "",
                        icon: "folder-o"
                    }
                ]
            },
            {
                title: "用户管理",
                icon: "folder-o",
                children: [
                    {
                        title: "用户管理",
                        path: "/system/user",
                        icon: "folder-o"
                    }
                ]
            }
        ]
    }
]

let permissionRouter = [
    {
        name: "系统设置",
        path: "/system",
        component: "layoutHeaderAside",
        componentPath:'layout/header-aside/layout',
        meta: {
            title: '系统设置'
        },
        children: [
            {
                name: "菜单管理",
                path: "/system/menu",
                meta: {
                    title: '菜单管理'
                },
                component: "menu",
                componentPath:'pages/sys/menu/index',
            },
            {
                name: "路由管理",
                path: "/system/route",
                meta: {
                    title: '路由管理'
                },
                component: "route",
                componentPath:'pages/sys/menu/index',
            }
        ]
    },
    {
        name: "权限管理",
        path: "/system",
        component: "layoutHeaderAside",
        componentPath:'layout/header-aside/layout',
        meta: {
            title: '权限管理'
        },
        children: [
            {
                name: "功能管理",
                path: "/system/function",
                meta: {
                    title: '功能管理'
                },
                component: "function",
                componentPath:'pages/sys/menu/index',
            },
            {
                name: "角色管理",
                path: "/system/role",
                meta: {
                    title: '角色管理'
                },
                component: "role",
                componentPath:'pages/sys/menu/index',
            },
            {
                name: "角色权限管理",
                path: "/system/rolepermission",
                meta: {
                    title: '角色权限管理'
                },
                component: "rolePermission",
                componentPath:'pages/sys/menu/index',
            },
            {
                name: "角色用户权限管理",
                path: "/system/roleuser",
                meta: {
                    title: '角色用户管理'
                },
                component: "roleUser",
                componentPath:'pages/sys/menu/index',
            },
            {
                name: "用户角色权限管理",
                path: "/system/userrole",
                meta: {
                    title: '用户角色管理'
                },
                component: "userRole",
                componentPath:'pages/sys/menu/index',
            }
        ]
    },
    {
        name: "用户管理",
        path: "/system",
        component: "layoutHeaderAside",
        componentPath:'layout/header-aside/layout',
        meta: {
            title: '用户管理'
        },
        children: [
            {
                name: "用户管理",
                path: "/system/user",
                meta: {
                    title: '用户管理'
                },
                component: "user",
                componentPath:'pages/sys/menu/index',
            }
        ]
    }
]

可以看到菜单最多达到三级,路由只有两级,通过菜单上的path与路由的path相对应,当点击菜单的时候就能正确的跳转。

有个小技巧:在路由的meta上维护一个title属性,在页面切换的时候,如果需要动态改变浏览器标签页的标题,可以直接从当前路由上取到,不需要到菜单上取。

菜单数据可以作为左侧菜单的数据源,也可以是顶部菜单的数据源。有的系统内容比较多,顶部可能是系统模块,左侧是模块下的菜单,切换顶部不同模块,左侧菜单要动态进行切换。做类似功能的时候,因为菜单数据与路由分开,只要关注与菜单即可,比如在菜单上加上模块属性。

当前的路由数据是完全符合vue路由声明规则的,但是直接使用添加路由的方法addRoutes动态添加路由是不行的。因为vue路由的component属性必须是一个组件,比如

{
    name: "login",
    path: "/login",
    component: () => import("@/pages/Login.vue")
}

而目前我们得到的路由数据中component属性是一个字符串。需要根据这个字符串将component属性处理成真正的组件。在路由数据中除了component这个属性不符合vue路由要求,还多了componentPath这个属性。下面介绍两种分别根据这两个属性处理路由的方法。

处理路由

使用routerMapComponents

这个名称是我取的,其实就是维护一个js文件,将组件按照key-value的规则导出,比如:

import layoutHeaderAside from '@/layout/header-aside'
export default {
    "layoutHeaderAside": layoutHeaderAside,
    "menu": () => import(/* webpackChunkName: "menu" */'@/pages/sys/menu'),
    "route": () => import(/* webpackChunkName: "route" */'@/pages/sys/route'),
    "function": () => import(/* webpackChunkName: "function" */'@/pages/permission/function'),
    "role": () => import(/* webpackChunkName: "role" */'@/pages/permission/role'),
    "rolePermission": () => import(/* webpackChunkName: "rolepermission" */'@/pages/permission/rolePermission'),
    "roleUser": () => import(/* webpackChunkName: "roleuser" */'@/pages/permission/roleUser'),
    "userRole": () => import(/* webpackChunkName: "userrole" */'@/pages/permission/userRole'),
    "user": () => import(/* webpackChunkName: "user" */'@/pages/permission/user')
}

这里的key就是与后端返回的路由数据的component属性对应。所以拿到后端返回的路由数据后,使用这份规则将路由数据处理一下即可:

const formatRoutes = function (routes) {
    routes.forEach(route => {
      route.component = routerMapComponents[route.component]
      if (route.children) {
        formatRoutes(route.children)
      }
    })
  }
formatRoutes(permissionRouter)
router.addRoutes(permissionRouter);

而且,规则列表里维护的组件都会被webpack打包成单独的js文件,即使处理路由数据的时候没有被使用到(没有被routerMapComponents[route.component]匹配出来)。当我们需要给一个页面做多种布局的时候,只需要在菜单维护界面上将component修改为routerMapComponents中相应的key即可。

标准的异步组件

按照vue官方文档的异步组件的写法,得到两种处理路由的方法,并且用到了路由数据中的componentPath:

第一种写法:

const formatRoutesByComponentPath = function (routes) {
    routes.forEach(route => {
      route.component = function (resolve) {
        require([`../${route.componentPath}.vue`], resolve)
      }
      if (route.children) {
        formatRoutesByComponentPath(route.children)
      }
    })
  }
formatRoutesByComponentPath(permissionRouter);
router.addRoutes(permissionRouter);

第二种写法:

const formatRoutesByComponentPath = function (routes) {
    routes.forEach(route => {
      route.component = () => import(`../${route.componentPath}.vue`)
      if (route.children) {
        formatRoutesByComponentPath(route.children)
      }
    })
  }
formatRoutesByComponentPath(permissionRouter);
router.addRoutes(permissionRouter);

其实在大多数人的认知里(包括我),这样的代码webpack应该是处理不了的,毕竟componentPath是运行时才确定,而webpack是“编译”时进行静态处理的。

为了验证这样的代码能不能正常运行,写了个简单的demo,感兴趣的可以下载到本地运行。

测试的结果是:上面的两种写法程序都可以正常运行。

观察打包后的代码,发现所有的组件都被打包,不管是否被使用(之前routerMapComponents方式中,只有维护进列表中的组件才会打包)。

所有的组件都被打包了,但是两种方法打包后的代码却是天差地别。

使用

route.component = function (resolve) {
    require([`../${route.componentPath}.vue`], resolve)
}

处理路由,打包后

0开头的文件是page404.vue打包后的代码,1开头的是home.vue的。这两个组件能分别打包,是因为main.js中显式的使用的这两个组件:

...
let routers = [
  {
    name: "home",
    path: "/",
    component: () => import(/* webpackChunkName: "home" */"@/pages/home.vue")
  },
  {
    name: "404",
    path: "*",
    component: () => import(/* webpackChunkName: "page404" */"@/pages/page404.vue")
  }
];

let router = new Router({
  // mode: 'history', // require service support
  scrollBehavior: () => ({ y: 0 }),
  routes: routers
});
...

而4开头的文件就是其它全部组件打包后的,而且额外带了点东西:

webpackJsonp([4, 0], {
    "/EbY": function(e, t, n) {
        var r = {
            "./App.vue": "M93x",
            "./pages/dynamic.vue": "fJxZ",
            "./pages/home.vue": "vkyI",
            "./pages/nouse.vue": "HYpT",
            "./pages/page404.vue": "GVrJ"
        };
        function i(e) {
            return n(a(e))
        }
        function a(e) {
            var t = r[e];
            if (! (t + 1)) throw new Error("Cannot find module '" + e + "'.");
            return t
        }
        i.keys = function() {
            return Object.keys(r)
        },
        i.resolve = a,
        e.exports = i,
        i.id = "/EbY"
    },
    GVrJ: function(e, t, n) {
        "use strict";
        Object.defineProperty(t, "__esModule", {
            value: !0
        });
        var r = {
            render: function() {
                var e = this.$createElement,
                t = this._self._c || e;
                return t("div", [this._v("\n  404\n  "), t("div", [t("router-link", {
                    attrs: {
                        to: "/"
                    }
                },
                [this._v("返回首页")])], 1)])
            },
            staticRenderFns: []
        };
        var i = n("VU/8")({
            name: "page404"
        },
        r, !1,
        function(e) {
            n("tqPO")
        },
        "data-v-5b14313a", null);
        t.
    default = i.exports
    },
    HYpT: function(e, t, n) {
        "use strict";
        Object.defineProperty(t, "__esModule", {
            value: !0
        });
        var r = {
            render: function() {
                var e = this.$createElement;
                return (this._self._c || e)("div", [this._v("\n  从未使用的组件\n")])
            },
            staticRenderFns: []
        };
        var i = n("VU/8")({
            name: "nouse"
        },
        r, !1,
        function(e) {
            n("v4yi")
        },
        "data-v-d4fde316", null);
        t.
    default = i.exports
    },
    WMa5: function(e, t) {},
    fJxZ: function(e, t, n) {
        "use strict";
        Object.defineProperty(t, "__esModule", {
            value: !0
        });
        var r = {
            render: function() {
                var e = this.$createElement,
                t = this._self._c || e;
                return t("div", [t("div", [this._v("动态路由页")]), this._v(" "), t("router-link", {
                    attrs: {
                        to: "/"
                    }
                },
                [this._v("首页")])], 1)
            },
            staticRenderFns: []
        };
        var i = n("VU/8")({
            name: "dynamic"
        },
        r, !1,
        function(e) {
            n("WMa5")
        },
        "data-v-71726d06", null);
        t.
    default = i.exports
    },
    tqPO: function(e, t) {},
    v4yi: function(e, t) {}
});

dynamic.vue,nouse.vue都被打包进去了,而且page404.vue又被打包了一次(???)。

而且有点东西:

var r = {
            "./App.vue": "M93x",
            "./pages/dynamic.vue": "fJxZ",
            "./pages/home.vue": "vkyI",
            "./pages/nouse.vue": "HYpT",
            "./pages/page404.vue": "GVrJ"
        };

这应该就是运行时使用componentPath处理路由,程序也能正常运行的关键点。

为了弄清楚page404.vue为什么又被打包了一次,我加了个simple.vue,而且在main.js也显式的import进去了,打包后发现simple.vue也是单独打包的,唯独page404.vue被打包了两次。暂时无解。。。

使用

route.component = () => import(`../${route.componentPath}.vue`)

处理路由,打包后

0开头的文件是page404.vue打包后的代码,1开头的是home.vue的,4开头是nouse.vue的,5开头是dynamic.vue的。

所有的组件都被单独打包了,而且home.vue打包后的代码还多了写东西:

webpackJsonp([1], {
    "rF/f": function(e, t) {},
    sTBc: function(e, t, n) {
        var r = {
            "./App.vue": ["M93x"],
            "./pages/dynamic.vue": ["fJxZ", 5],
            "./pages/home.vue": ["vkyI"],
            "./pages/nouse.vue": ["HYpT", 4],
            "./pages/page404.vue": ["GVrJ", 0]
        };
        function i(e) {
            var t = r[e];
            return t ? Promise.all(t.slice(1).map(n.e)).then(function() {
                return n(t[0])
            }) : Promise.reject(new Error("Cannot find module '" + e + "'."))
        }
        i.keys = function() {
            return Object.keys(r)
        },
        i.id = "sTBc",
        e.exports = i
    },
    vkyI: function(e, t, n) {
        "use strict";
        Object.defineProperty(t, "__esModule", {
            value: !0
        });
        var r = {
            name: "home",
            methods: {
                addRoutes: function() {
                    this.$router.addRoutes([{
                        name: "dynamic",
                        path: "/dynamic",
                        component: function() {
                            return n("sTBc")("./" +
                            function() {
                                return "pages/dynamic"
                            } + ".vue")
                        }
                    }]),
                    alert("路由添加成功!")
                }
            }
        },
        i = {
            render: function() {
                var e = this.$createElement,
                t = this._self._c || e;
                return t("div", [t("div", [this._v("这是首页")]), this._v(" "), t("a", {
                    attrs: {
                        href: "javascript:void(0)"
                    },
                    on: {
                        click: this.addRoutes
                    }
                },
                [this._v("动态添加路由")]), this._v("  \n  "), t("router-link", {
                    attrs: {
                        to: "/dynamic"
                    }
                },
                [this._v("前往动态路由")])], 1)
            },
            staticRenderFns: []
        };
        var s = n("VU/8")(r, i, !1,
        function(e) {
            n("rF/f")
        },
        "data-v-25e45483", null);
        t.
    default = s.exports
    }
});

可以看到

var r = {
    "./App.vue": ["M93x"],
    "./pages/dynamic.vue": ["fJxZ", 5],
    "./pages/home.vue": ["vkyI"],
    "./pages/nouse.vue": ["HYpT", 4],
    "./pages/page404.vue": ["GVrJ", 0]
};

跑里面去了,可能是因为是在home.vue里使用了route.component = () => import(../${route.componentPath}.vue)

低版本的vue-cli创建的项目,打包后的代码和前一种方式一样,并不是所有的组件都单独打包,不知道是webpack(webpack2出现这种情况),还是vue-loader的问题

小结

  • 使用routerMapComponents的方式处理路由,后端返回的路由数据上需要标识组件字段,使用此字段能匹配上前端维护的路由-组件列表(routerMapComponents.js)中的组件。使用此方式,只有维护进了路由-组件列表(routerMapComponents.js)中的组件才会被打包。
  • 使用
route.component = function (resolve) {
    require([`../${route.componentPath}.vue`], resolve)
}

方式处理路由,后端返回的路由数据上需要标识组件在前端项目目录中的具体位置(上文一直使用的componentPath字段)。使用此方式,编译时就已经显示import的组件会被单独打包,而其它全部组件会被打包在一起(不管运行时是否使用到相应的组件),404路由对应的组件会被打包两次。

  • 使用
route.component = () => import(`../${route.componentPath}.vue`)

方式处理路由,后端返回的路由数据上也需要标识组件在前端项目目录中的具体位置。使用此方式,所有的组件会被单独打包,不管是否使用。

所以,处理后端返回的路由,推荐使用第一种和第三种方式。

第一种方式,前端需要维护一份路由-组件列表(routerMapComponents.js),当相关人员维护路由的时候,前端开发需要将相应的key给出,当然也可以由维护路由的人确定key后交由前端开发。

第三种方式,前端不需要维护任何东西,只需要告诉维护路由的人相应的组件在前端项目中的路径即可,这可能会导致泄露前端项目结构,因为在打包后的代码总是可以看到的。

总结

菜单与路由完全由后端提供,菜单与路由数据分离,菜单与路由上分别标上权限标识,后端根据用户权限筛选出用户所能访问的菜单与路由,前端拿到路由数据后作相应的处理,使得路由正确的匹配上相应的组件。这应该是一种比较“完美”的vue权限路由实现方案。

有的人可能会说,既然已经前后端分离,为什么还要那么依赖于后端?

菜单与路由不由后端提供,权限过滤的时候,不还是需要后端返回的权限列表,而且权限标识还写死在菜单和路由上。

而菜单与路由完全由后端提供,并不是说前端开发要与后端开发需要更多的交流(扯皮)。菜单与路由可以做相应的维护功能,比如支持批量导出与导入,添加新菜单或路由的时候,在页面功能上进行操作即可。唯一的沟通成本就是维护路由的时候需要知道前端维护组件列表的key或者组件对应的路径,但路由也完全可以由前端开发去维护,权限标识可以待前后端确认后再维护(当然,页面上元素级别的权限控制的权限标识,还是得提前确认)。而如果菜单与路由写死在前端,一开始前后端就得确认相应的权限标识。

demo代码地址


若邪
1.5k 声望64 粉丝

划水摸鱼糊屎