在 Vue 中,如何实现 RBAC(权限系统)?
“权限管理”一般在大家的印象中都属于后端的责任,但这两年随着 SPA 应用的兴起,很多应用都采用了前后端分离的方式进行开发,但是纯前端的开发方式就导致,以前很多由后端模板语言硬件解决的问题,现在势必要重新造一次轮子。而如果要重造轮子,前端er 就要根据对应的公司业务需求和后端er 一起配合,基于需求和安全性来考虑,来实现这个动态路由了。
所以考虑到以上的痛点和具体的业务需求。今天我会模拟前端请求后端,获取用户权限,实现RBAC,带你从登录到验证、鉴权,最终实现动态 的功能。这样一来,你会解决大部分的后台痛点。(这一讲我们用到的技术栈是 vue2.6 + vue- + vuex + -ui)
首先我们来看下实现后的动态演示图:
那么,我们的实现步骤应该是怎么样的呢?
我们希望在进行页面导航的时候,能先根据登录用户所具有的权限,判断用户是否能访问该页面。 实现可见页面的局部 UI 组件的可使用性或可见性控制,即基于自定义的权限,对比声明的接口或资源是否已经授权。 实现发送请求时,传输 token,对待请求接口进行权限检查,如果用户不具有访问该后端接口的权限,则不发送请求,而是友好地提示用户。
其中要实现步骤 1 这个目标,我们的方案是:获得登录用户的可访问前端页面的 path 列表,在 进行导航的 前置钩子中,处理当前用户所能请求的页面及路由可见性。
接下来我们就一步步具体来实现吧!
新建项目及脚手架和插件安装
首先,需要在个人电脑上安装好 (我的是 14.15.1),利用 自带的 npm 包管理器安装好 vue (我的是 @vue/cli 4.5.12)。
然后在命令行中,通过以下指令,在指定目录下安装脚手架 vue-cli。
npm install -g @vue/cli
紧接着,我们使用 vue 的创建项目命令,vue xxx (其中 xxx 是指项目名称),在这里会有两步。
第一步:选择项目所需要的插件。
? Check the features needed for your project: ◉ Choose Vue version // 选择vue版本◉ Babel // 支持babel◯ TypeScript // 支持使用 TypeScript 书写源码◯ Progressive Web App (PWA) Support // PWA 支持◉ Router // 支持 vue-router◉ Vuex // 支持 vuex◯ CSS Pre-processors // 支持 CSS 预处理器◉ Linter / Formatter // 支持代码风格检查和格式化◯ Unit Testing // 支持单元测试◯ E2E Testing // 支持 E2E 测试// 注意:你要集成什么就选就行了(注:空格键是选中与取消,A键是全选)
第二步:选择 vue 的版本(由于 vue3 目前只出来了 8 个月左右,受众面不广,所以选择 vue 2.x 版本)。
配置决定好了以后,按确定即可,最终输出如下图所示。
到这里,我们的项目就搭建完成了,你可以 CD 打开项目,跑起来。
不过由于我们做的是管理系统,所以为了节约时间,可以使用 ui 框架(-ui)。为了页面加载和跳转更友好一点,我们还需要添加 、.css。而且由于 vue-cli 3 也学习了 的零配置思路,所以项目初始化后,没有了以前熟悉的 build 目录,也就没有了 .base..js、.dev..js 、.prod..js 等配置文件。
但是有些内容需要进行相关的配置,所以我们还是要创建一个 vue..js 来进行数据修改,比如代理什么的,具体如下。
先安装 -ui、 和 .css。
npm install element-ui nprogress normalize.css
由于 -ui 使用到了 sass-,所以我们还需要安装 sass- 插件。
npm install sass-loader
这里有个坑,如果sass-版本过高,会报错。
基本上都是 sass- 的版本过高导致的编译错误,所需要降低版本
解决办法:cd 到项目文件里面运行下面
npm uninstall sass-loader(卸载当前版本)npm install sass-loader@7.0.3 --save-dev
当前项目插件如下:
然后配置 vue..js。vue..js是一个可选的配置文件,如果项目的 (和.json同级的) 根目录中存在这个文件,那么它会被@vue/cli-自动加载。当然,你也可以使用.json中的vue字段,但是注意这种写法需要你严格遵照 JSON 的格式来写。
我们在根目录中创建 vue..js。
由于 vue-cli 3 也学习了 的零配置思路,所以项目初始化后,没有了以前熟悉的 build 目录,也就没有了 .base..js、.dev..js 、.prod..js 等配置文件。
等于是把以前的 放进去了 vue..js 里面,进行统一配置处理。
但是有些内容需要进行相关的配置,所以我们还是要创建一个 vue..js 来进行数据修改,比如代理啥的。
vue..js 文件是一个可选的配置文件,存放在根目录中,要是有这个文件,在@vue/cli- 启动的时候会自动加载,所以我们在修改里面的内容之后,要进行项目重新加载。
至于,vue 为什么要依赖 ?
因为 vue 在工程化开发的时候依赖于 ,而 是将所有的资源整合到一块后形成一个 html 文件 一堆 js 文件, 如果将 vue 实现多页面应用,就需要对它的依赖进行重新配置,也就是修改 的配置文件。
vue 的开发有两种,一种是直接的在 标签里引入 vue.js 文件即可,个人感觉做小型的多页面会比较舒坦,一旦做大型一点的项目,还是离不开 。所以另一种方法也就是基于 和 vue-cli 的工程化开发。
其中关于 的配置说明, 有个特性,基本的功能都是可以用配置去实现的,所以就造成一个特点“很容易忘记它”,所以就很有必要记录一下。我这里简述一下:
webpack 分为Entry:入口Output: 出口Module 模块Plugin 插件DevServer 服务器配置
具体详细的配置说明在代码注释里,你可以看看。
'use strict'const path = require('path')function resolve(di r) {return path.join(__dirname, dir)} // All configuration item explanations can be find in https://cli.vuejs.org/config/ module.exports = { publicPath: '/', // 部署应用包时的基本 URL,用法和 webpack 本身的 output.publicPath 一致 outputDir: 'dist', // 构建输出目录(打包位置) assetsDir: 'static',// 放置生成的静态资源(js,css,img,fonts)的(相对于outputDir)的目录 lintOnSave: false, // 是否校验语法 productionSourceMap: false, // 如果你不需要生产环境的 source map,可以将其设置为 false 以加速生产环境构建devServer: {port: 8888,open: true,},configureWebpack: { // 绝对路径resolve: {alias: {'@': resolve('src')}}} }
以上内容就是我们新建项目之后,项目所需要的脚手架和插件安装。接下来我们要开始实现 RBAC 动态路由了,为了让你对 RBAC 有个大概的印象,不至于看代码时云里雾里,所以我先讲一下实现 RBAC 的思路。
如何实现 RBAC 的具体功能?
实现 RBAC 动态权限功思路
实现 RBAC 动态权限功能的相关思路,主要有三步:
前端在本地写好路由表,以及每个路由对应的角色,也就是哪些角色可以看到这个菜单 / 路由; 登录时,向后端请求得到登录用户的角色(管理者,普通用户); 利用路由守卫者(.),根据取到的用户角色,跟本地的路由表进行对比,过滤出用户对应的路由,并利用路由进行菜单渲染。
前端每次跳转路由时,做以下判断:
接下来我再从技术栈的角度补充几点技术实现目标,这样你可以更容易掌握这个知识点:
实现 RBAC 动态路由的两种方式
然而,关于RBAC 动态路由是有多种思路的。
第一种方式:
有些开发者喜欢完整的静态路由都在前端里面,然后根据 的 meta 属性,写上对应 user 的 role。登录时,再根据后端返回的权限,去过滤比对权限,把该用户角色所对应的路由处理好,渲染处理。这也是主流的一种处理方式,因为前后端是相辅相成的,而不是前端仅负责渲染即可。
这种方式等于是把所有的路由和权限业务处理都放在了前端,一旦上线发布后,想要修改就需要重新打包处理,而且不能经由后台动态新增删除,比如:
//代码位置:router/index.js{path: '',component: layout, //整体页面的布局(包含左侧菜单跟主内容区域)children: [{path: 'main',component: main,meta: {title: '首页', //菜单名称roles: ['user', 'admin'], //当前菜单哪些角色可以看到}}]}
第二种方式:
前端把用户输入的账号密码传递给后端,后端根据用户输入的账号密码,做鉴权和路由角色处理,然后把匹配了对应用户的路由返回给前端,再由前端去渲染路由和页面。这样的做法就是把所有的计算和处理都让后端去实现了,前端运算量不会太大,而且易于修改和后期维护以及动态的增删改查,我个人也是倾向这种实现,所以这一讲咱们就采取这种形式。
接下来,我们以第二种方式来实现动态路由。
具体实现步骤
第一步:根据路由表,判断哪些用户可以看到对应的路由和权限。
大家可以看一下这段示例代码, 里面就是模拟的后端数据,一般的后台数据库里面,就是分为一个 user 用户表,一个 role 权限路由表,这里不涉及后端,所以只给出最后后端输出的数据源。
一个较完整的后端数据示例如下:
const dynamicUser = [{name: "管理员",avatar: "https://sf3-ttcdn-tos.pstatp.com/img/user-avatar/ccb565eca95535ab2caac9f6129b8b7a~300x300.image",desc: "管理员 - admin",username: "admin",password: "654321",token: "rtVrM4PhiFK8PNopqWuSjsc1n02oKc3f",routes: [{ id: 1, name: "/", path: "/", component: "Layout", redirect: "/index", hidden: false, children: [{ name: "index", path: "/index", meta: { title: "index" }, component: "index/index" },]},{ id: 2, name: "/form", path: "/form", component: "Layout", redirect: "/form/index", hidden: false, children: [{ name: "/form/index", path: "/form/index", meta: { title: "form" }, component: "form/index" }]},{ id: 3, name: "/example", path: "/example", component: "Layout", redirect: "/example/tree", meta: { title: "example" }, hidden: false, children: [{ name: "/tree", path: "/example/tree", meta: { title: "tree" }, component: "tree/index" },{ name: "/copy", path: "/example/copy", meta: { title: "copy" }, component: "tree/copy" }] },{ id: 4, name: "/table", path: "/table", component: "Layout", redirect: "/table/index", hidden: false, children: [{ name: "/table/index", path: "/table/index", meta: { title: "table" }, component: "table/index" }] },{ id: 5, name: "/admin", path: "/admin", component: "Layout", redirect: "/admin/index", hidden: false, children: [{ name: "/admin/index", path: "/admin/index", meta: { title: "admin" }, component: "admin/index" }] },{ id: 6, name: "/people", path: "/people", component: "Layout", redirect: "/people/index", hidden: false, children: [{ name: "/people/index", path: "/people/index", meta: { title: "people" }, component: "people/index" }] }]},{name: "普通用户",avatar: "https://sf1-ttcdn-tos.pstatp.com/img/user-avatar/6364348965908f03e6a2dd188816e927~300x300.image",desc: "普通用户 - people",username: "people",password: "123456",token: "4es8eyDwznXrCX3b3439EmTFnIkrBYWh",routes: [{ id: 1, name: "/", path: "/", component: "Layout", redirect: "/index", hidden: false, children: [{ name: "index", path: "/index", meta: { title: "index" }, component: "index/index" },]},{ id: 2, name: "/form", path: "/form", component: "Layout", redirect: "/form/index", hidden: false, children: [{ name: "/form/index", path: "/form/index", meta: { title: "form" }, component: "form/index" }]},{ id: 3, name: "/example", path: "/example", component: "Layout", redirect: "/example/tree", meta: { title: "example" }, hidden: false, children: [{ name: "/tree", path: "/example/tree", meta: { title: "tree" }, component: "tree/index" },{ name: "/copy", path: "/example/copy", meta: { title: "copy" }, component: "tree/copy" }] },{ id: 4, name: "/table", path: "/table", component: "Layout", redirect: "/table/index", hidden: false, children: [{ name: "/table/index", path: "/table/index", meta: { title: "table" }, component: "table/index" }] },{ id: 6, name: "/people", path: "/people", component: "Layout", redirect: "/people/index", hidden: false, children: [{ name: "/people/index", path: "/people/index", meta: { title: "people" }, component: "people/index" }] }]}]export default dynamicUser
由此可以看出,一般登录后,返回的数据里,包含了一个用户的姓名、头像、简述、token( 和 只是用来模拟登录用到的数据,因为在正常业务流中,后端不可能直接把账号密码带出来的,只会把当前用户的 jwt 的 token 令牌带出来,做鉴权,这意味着我们只需要把 token 缓存下来用于跟后端做鉴权即可 )。
在 的 字段里, 就是 admin 管理员和 普通用户的差异化动态路由了,admin 多了一个 admin 的页面,而 是没有的。
第二步:模拟用户登录,获取用户的权限和路由。
首先我们在 main.js 里面,引入该页面,用于做路由守卫者。
import Vue from "vue" import App from "./App.vue" import router from "./router" import store from "./store" import ElementUI from "element-ui" import 'element-ui/lib/theme-chalk/index.css' import "./router/router-config" // 路由守卫,做动态路由的地方Vue.config.productionTip = falseVue.use(ElementUI) //new Vue({ router, store, render: (h) => h(App),}).$mount("#app")
本来我是写了 mock 数据模拟用户登录,请求后端角色的接口,奈何 mock 挂了,所以我就直接模拟了:取到用户角色,存放进 ,然后跳转主页。
html部分,由于用了 -ui 的 form 表单提交,所以直接this.$refs..(-ui的form表单提交)
这里的 是 出来的 mock 数据流,一般后端是直接返回对应的结果,可由于 容易挂掉,所以就直接手写 mock来模拟后端返回的数据了。
其中用户登录的具体实现如下。由于我们是 mock 出来的数据,所以 是用户角色的数组形式,所以需要定义一个变量( flag )用以做登录校验匹配,如果循环都找不到对应的 和 的话,就告诉用户,该账号密码错误,登录失败。
可如果有一次是成功的,那么 flag 就是为 !0 (true)的,并且返回对应的用户信息,用户路由等,最后还会进行路由的跳转初始化页面(首页),并进行动态路由加载和路由跳转。(在这里,必须要先做路由跳转,然后 的 才能捕获到路由的变化状态,进行路由的动态加载。)
import dynamicUser from "../../mock" import { Message } from "element-ui"login() {this.$refs.userForm.validate((valid) => {if (valid) {let flag = !1window.localStorage.removeItem("userInfo")dynamicUser.forEach(item => {if (item["username"] == this.user['username'] && item["password"] == this.user['password']) {flag = !0Message({ type: 'success', message: "登录成功", showClose: true, duration: 3000 })window.localStorage.setItem("userInfo", JSON.stringify(item))// 这里用catch捕获错误,而且不打印,解释在下方 this.$router.replace({ path: "/" }).catch(() => {}) }})if (!flag) Message({ type: 'warning', message: "账号密码错误,请重试!", showClose: true, duration: 3000 })} else return false}) }
在vue-.1.0版本以上,路由跳转失败抛出该错误,但并影响程序的功能。
从图中我们可以看到,如果不捕获 catch 错误,而且不打印的话,就会出现如图所示的错误。这是因为 vue- 路由版本更新产生的,导致路由跳转失败抛出该错误,但并不影响程序功能。那我们怎么解决这个问题呢?有三个方案:
在使用编程式导航跳转时,每次使用,后面都跟上 .catch 方法,捕获错误信息this.$.push("/xxx").catch(() => {})。全局解决,替换路由的 Push 和 方法,放在 src//index.js 中。
const originalPush = VueRouter.prototype.pushVueRouter.prototype.push = function push(location, onResolve, onReject) { if (onResolve || onReject) return originalPush.call(this, location, onResolve, onReject) return originalPush.call(this, location).catch(err => err) }
3. 对 vue- 的版本降低到 3.0.7,手动修改,然后删除 ,改完再 npm 。
修改前:
修改后:
第三步:根据登录返回的数据,实现路由守卫。
以上是登录鉴权部分,接下来我们开始实现路由守卫。路由守卫者拦截 ,并动态渲染出路由表(这一部分是重点)。
创建对应的路由守卫者文件
在 文件夹下,创建 ..js文件,用来做路由守卫者的拦截页面。
2.引入 ,, 三个插件。
说明 是引用 /index.js 里面导出的 在 /index.js 里面, 是 new vue-,相当于 vue- 对象
说明这是页面的大体框架,具体页面详情如下 :
是进度条插件。
import router from "./index" import Layout from "../layout/index" import NProgress from 'nprogress' // progress barimport 'nprogress/nprogress.css' // progress bar style
引入插件后,我们就开始实现路由守卫者了。
在 的 的这个 api 里面分别有三个参数 (to、form 和 next),分别对应着(“去哪儿”“从哪儿来”“下一步”)。
接下来,根据“去哪儿(to)”, 需要判断路由指向是否需要过滤到路由地址数组里,如果在,则直接进入页面,无需判断,例如登录页面、注册页面、 找回密码等(具体看业务需求)。
const filterRoutes = ["/login"]if (filterRoutes.indexOf(to.path) !== -1) { // 如果是无需权限的静态路由,可以直接跳走 next() return false }
3. 实现路由守卫者
路由守卫者的实现,首先判断当前路由栈的数量,如果路由栈的数量等于你在 /index.js 里面的静态路由的数量,那么表明当前仍未加载动态路由,需要处理路由了,反之,则可以让它直接进入循环。
// 由于我目前的课程里,只是做了一个 login 的登录页面,所以静态页面也是仅有一个而已if (router.options.routes.length == 1) { // 此处动态加载路由} else next() // 表明路由已加载,可直接进入页面
当路由未加载时,就需要获取登录时缓存的 token 和路由栈。由于刷新时,vuex 的数据无法持久化,所以建议最好 和 token 都放在缓存 里(当然, 里面也是可以的,可是这样的话,浏览器一旦关闭,那么下次打开就需要重新登录了)。
// 获取token和原始路由数组// 这里需要做空值合并操作,防止路由存在时,可token已失效,然后JSON.parse转义失败的情况导致的报错const userInfo = JSON.parse(window.localStorage.getItem("userInfo")) ?? "" // 当token和原始路由都存在的时候// 进入路由执行路由过滤和跳转封装函数 // 否则,跳回登录页面 if(userInfo.token && userInfo.routes) onFilterRoutes(to, next, userInfo.routes) else next({ path: "/login", replace: true })
以上是路由守卫的基础实现,接下来,我对路由守卫这个知识点可能会遇到的坑以及一些用到的知识点总结如下,你有空可以暂停看下。
空值合并操作符( ?? )
只有当左侧为 null 和 时,才会返回右侧的数。
空值合并操作符(??)是一个逻辑操作符,当左侧的操作数为null或者时,返回其右侧操作数,否则返回左侧操作数。当进入路由过滤和跳转封装的时:
(1) 先执行异步请求,确保路由过滤和路径补全已完成。先把传入递归函数(),用于做路径的补全和的判断并赋值,并且当存在(子级路由)的时候,路由需要再次回调递归函数(),最后并把处理好的路由栈,返回给路由过滤函数
(2)根据异步请求返回的,进行路由的排序,毕竟当用户动态处理了路由后,展示出来的顺序跟处理时的顺序不一致,那就不太好了。
(3)路由都处理完成后,把路由循环,并动态添加进..里面,而且路由里面,要使用(item),把路由一点点添加进路由表里。
(4)最后执行路由跳转,跳回当前需要跳转的页面。
具体代码:
// 路由拼接 function loadView(view) { return () => import(`@/views/${ view }`) } // 路由过滤和跳转 async function onFilterRoutes(to, next, e) { const routes = await filterASyncRoutes(e) // 路由过滤 routes.sort((a, b) => a['id'] - b['id']) routes.forEach(item => { router.options.routes.push(item) router.addRoute(item) }) next({ ...to, replace: true }) } // 路由过滤 遍历路由 转换为组件对象和路径 function filterASyncRoutes(data) { const routes = data.filter(item => { if(item["component"] === "Layout") item.component = Layout else item["component"] = loadView(item["component"]) // 路由递归,转换组件对象和路径 if(item["children"] && item["children"].length > 0) item["children"] = filterASyncRoutes(item.children) return true }) return routes }
到此,我已经把动态路由的最重要部分,路由守卫者实现了,我们来再看看效果吧。
当然,你可能会有一些疑问,以下是我曾经遇到过的问题和思考,大家可以参考一下。
为什么使用.,而不使用.
因为新版本.已废弃:使用 .() 代替。官方的解释是 . 接受的是一个路由规则,也就是一个对象,或者接受一个字符串和一个对象。
为什么要使用() => (@/views/${ view })来做路由拼接?
因为懒加载:又叫延时加载,即在需要的时候进行加载,随用即载。
【相关问题】() 懒加载使用变量报错解决:
和 的区别是什么?
node 编程中最重要的思想就是模块化,和都是被模块化所使用。
遵循规范
是 AMD规范引入方式
是es6的一个语法标准,如果要兼容浏览器的话必须转化成es5的语法
调用时间
是运行时调用,所以理论上可以运用在代码的任何地方
是编译时调用,所以必须放在文件开头
本质
是赋值过程,其实的结果就是对象、数字、字符串、函数等,再把的结果赋值给某个变量
是解构过程,但是目前所有的引擎都还没有实现,我们在node中使用babel支持ES6,也仅仅是将ES6转码为ES5再执行,语法会被转码为
以下是我们这个课程的动态路由中路由守卫的完整代码:
import router from "./index" import Layout from "../layout/index" import NProgress from 'nprogress' // progress barNProgress.configure({ showSpinner: false }) // NProgress Configurationconst filterRoutes = ["/login"] // 需要过滤掉的路由 router.beforeEach((to, from, next) => { // start progress bar NProgress.start() // 获取路由 meta 中的title,并设置给页面标题 document.title = "动态路由(" + to.meta.title + ")" // 判断路由指向是否在需要过滤的路由地址数组里 // 如果在,则直接跳进页面,无需判断 if(filterRoutes.indexOf(to.path) !== -1) { next() return false } if(router.options.routes.length == 1) { // 获取token和原始路由数组 const userInfo = JSON.parse(window.localStorage.getItem('userInfo')) ?? "" // 当token和原始路由都存在的时候 if(userInfo.token && userInfo.routes) onFilterRoutes(to, next, userInfo.routes) // 执行路由过滤和跳转 else next({ path: "/login", replace: true }) } else next()})router.afterEach(() => { // finish progress bar NProgress.done()}) // 路由拼接 function loadView(view) { return () => import(`@/views/${ view }`)} // 路由过滤和跳转 async function onFilterRoutes(to, next, e) { const routes = await filterASyncRoutes(e) // 路由过滤 routes.sort((a, b) => a['id'] - b['id']) routes.forEach(item => { router.options.routes.push(item) router.addRoute(item) }) next({ ...to, replace: true })} // 路由过滤 遍历路由 转换为组件对象和路径 function filterASyncRoutes(data) { const routes = data.filter(item => { if(item["component"] === "Layout") item.component = Layout else item["component"] = loadView(item["component"]) // 路由递归,转换组件对象和路径 if(item["children"] && item["children"].length > 0) item["children"] = filterASyncRoutes(item.children) return true }) return routes }
你要注意哪些误区?
讲完今天的内容之后,你还需要注意哪些内容呢?我总结了一下。
根据路由进行菜单展示
(代码位置:/src///.vue。)先看下 菜单组件,把一些基础的参数先了解一下。这里我把菜单渲染写成了一个组件:用到了递归属性,保证可以生成多级菜单,我建议不熟悉的,你可以用组件先模拟着写一个包含跳转功能、icon 展示的菜单,然后再看我写的组件。
用户怎么退出系统
(代码位置:/src///index.vue。)退出的时候,记得清除掉存在 的用户角色,然后利用 this.$.({ path: "/login" })跳转到登录页。
为什么要用 .()?这样会把之前 的路由清除掉,确保下个用户登陆后,重新渲染正确的菜单。
// 退出登录 handleLogout(key) { if(key == "logout") { window.localStorage.removeItem("userInfo") Message({ type: 'success', message: "退出登录", showClose: true, duration: 3000 }) this.$router.replace({ path: "/login" }) location.reload() }}
3. 为什么不用 vuex?
本来确实是打算用 vuex 来做路由的处理的,可是后来发现,当浏览器手动刷新或者被动刷新的时候,vuex 无法做数据持久化。简而言之,就是 vuex 里面的 state 的值会被清空,所以为了稳妥起见,我是选择了缓存 来处理路由问题。
小结
今天这一讲,我主要是从前端的角度去思考和实现 RBAC 动态路由,并从原理和思路上进行分析,而不是盲目地跟随网上的一些代码实现。最重要是去思考为什么要这么做,这么做的原因是什么,能不能基于公司的业务角度和与后端的配合角度,去实现这 RBAC 动态路由,或者是否有更简单、更完美的方式去实现类似的功能。
只有在一次次的实践中,我们才能真正提高自己,也才能更好地理解这些开源项目好用背后的根本逻辑。希望你在学完以后,能有所收获。
源代码和示例:
源码地址(gitee):何小玍/vue2动态路由
项目地址:#/login
源码文件(百度云盘): 密码:3y5i