Vue和React对比学习之路由角色权限(页面、按钮权限控制)

4,341 阅读31分钟

简介

哈喽大家好,我是苏苏同学,前面我们聊到了VueReact的路由拦截鉴权,有很多同学跟我反馈说太简单了,只能判断有没有登录,对于有用户角色权限的系统就不适用了。所以我们今天再深入聊一下VueReact路由的角色权限。

角色权限

在聊之前我们先来说说角色权限。

角色权限,简单来说就是登录的用户能看到系统的哪些页面,不能看到系统的哪些页面。能看到系统的哪些按钮,不能看到系统的哪些按钮。

一般是后台管理系统才会涉及到如此复杂的角色权限。

一、Vue 实现页面权限控制

对于 vue 技术栈,实现角色权限一般有两种方式。

第一种是在 route 里面配置 roles,然后利用 beforeEach 全局前置守卫 实时检测用户权限。

第二种是利用 addRoutes 方法 动态添加该用户所拥有的路由。

我们先来看看第一种

1.通过beforeEach实时监测用户权限

这种方案的核心就是首先在 routes 里面事先定义好路由的权限,然后在 beforeEach 全局前置守卫里面进行权限逻辑判断。看用户所拥有的角色和我们配置在路由里面的 roles 是否相匹配。匹配则允许进入,不匹配则重定向到无权限提示页面。

定义 routes

首先我们定义好系统的路由,对于非首页,我们一般都会使用路由懒加载。

meta 里面可以定义我们需要的元数据,这里需要加上我们路由的角色roles。也就是说进入该路由用户所需要具备的角色权限。如果没定义则代表任意角色都能进入。

// router/routes.js

import Home from "../views/Home.vue";

const routes = [
  {
    path: "/",
    name: "Home",
    component: Home,
    meta: {
      needLogin: false, // 不需要登录
      title: "首页",
    },
  },
  {
    path: "/about", 
    name: "About",
    component: () =>
      import(/* webpackChunkName: "about" */ "../views/About.vue"), // 路由懒加载
    meta: {
      needLogin: true, // 需要登录
      title: "关于",
      roles: ["admin", "manage"], // 该页面只有admin和普通管理员才能进入
    },
  },
  {
    path: "/nopermission", // 没权限就进入该页面
    name: "NoPermission",
    component: () =>
      import(
        /* webpackChunkName: "nopermission" */ "../views/NoPermission.vue"
      ), // 路由懒加载
    meta: {
      needLogin: true, // 需要登录
      title: "暂无权限",
    },
  },
  {
    path: "/userlist", 
    name: "UserList",
    component: () =>
      import(/* webpackChunkName: "userlist" */ "../views/UserList.vue"), // 路由懒加载
    meta: {
      needLogin: true, // 需要登录
      title: "用户管理",
      roles: ["admin"], // 该页面只有admin才能进入
    },
  },
  {
    path: "/login",
    name: "Login",
    component: () =>
      import(/* webpackChunkName: "login" */ "../views/Login.vue"), // 路由懒加载
    meta: {
      needLogin: false, // 不需要登录
      title: "登录",
    },
  },
];

export default routes;

实例化 Router

然后创建路由,vue2vue3创建路由的时候稍有区别,但是在路由鉴权那块是通用的。

// router/index.js

// vue2 写法
import VueRouter from "vue-router";
import routes from "./routes"

const router = new VueRouter({
  mode: "history",
  base: process.env.BASE_URL,
  routes,
});

// vue3 写法
import { createRouter, createWebHistory } from "vue-router";
import routes from "./routes"

const router = createRouter({
  history: createWebHistory(process.env.BASE_URL),
  routes,
});

定义路由拦截鉴权逻辑

创建好路由后,我们可以来定义路由拦截的逻辑了,主要通过 beforeEach 全局前置守卫。因为只要页面发生跳转都会进入 beforeEach 全局前置守卫。

这里的核心逻辑就是判断前往的页面是否需要登录,需要登录就进一步判断当前系统是否有token,没有token则重定向到登录页

如果有token,则进一步判断是否有用户信息,如果没有用户信息就获取用户信息

有了用户信息后再判断进入页面需要的角色是否和用户信息里面的角色相匹配,匹配则进入页面,不匹配则进入系统的无权限提示页面。

// router/index.js

// vue2和vue3通用
router.beforeEach(async (to, from, next) => {
  // 如果需要登录
  if (to.meta.needLogin) {
    // 获取token
    const token = localStorage.getItem("token");

    // 如果有token 则直接放行
    if (token) {
      // 获取用户信息,从store里面获取
      let userInfo = store.getters["getUserInfo"];
      // 如果没有用户信息就获取用户信息
      if (!userInfo) {
        userInfo = await store.dispatch("getUserInfoAction");
      }
      
      // 如果页面需要权限,并且用户角色不满足则去无权限提示页
      if (to.meta.roles && !to.meta.roles.includes(userInfo.role)) {
        return next("/nopermission");
      }

      next();
    } else {
      // 否则去登录页
      next("/login");
    }
  } else {
    // 不需要登录则直接放行
    next();
  }
});

// 修改标题的工作可以放在全局后置守卫
router.afterEach((to, from) => {
  if (to.meta.title) {
    document.title = to.meta.title;
  }
});

我们再来看看 store 的逻辑

import { createStore } from "vuex";

export default createStore({
  state: {
    userInfo: null,
  },
  getters: {
    getUserInfo: (state) => state.userInfo,
  },
  mutations: {
    setUserInfo(state, payload) {
      state.userInfo = payload;
    },
  },
  actions: {
    // 获取用户信息的action
    async getUserInfoAction({ commit }) {
      // 模拟后端获取用户信息的api
      const getUserInfoApi = () => {
        return Promise.resolve({ role: "manage", name: "jack" }); // 假设角色为 manage
      };

      const userInfo = await getUserInfoApi();

      commit("setUserInfo", userInfo);

      return userInfo;
    },
  },
});

使用

创建完路由后需要在 main.js 导入使用

import router from "./router";

// vue2写法
new Vue({
  router,
  render: (h) => h(App),
}).$mount("#app");


// vue3写法
const app = createApp(App);
app.use(router).mount("#app");

我们来看看效果

在没登录的情况下,去aboutuserlist页面都会重定向到登录页。

给本地添加token 模拟登录完成后,因为用户信息角色是 manage,根据 routes 的配置去 about 是没问题的,去 userlist 则会重定向到没有权限页面。

2.通过addRoutes动态添加路由

这种方案相较第一种方案更灵活。

这种方案的核心就是调用后端接口,返回当前用户角色所拥有的菜单,将菜单格式化成路由后通过 addRoutes 方法将这些路由动态添加到系统。

定义 routes

因为路由从后端获取,所以这里我们只需要定义基本通用路由,也就是不涉及到权限的路由。比如登录页、没权限页、首页等。

import Home from "../views/Home.vue";

const routes = [
  {
    path: "/",
    name: "Home",
    component: Home,
    meta: {
      title: "首页",
    },
  },
  {
    path: "/nopermission", // 没权限就进入该页面
    name: "NoPermission",
    component: () =>
      import(
        /* webpackChunkName: "nopermission" */ "../views/NoPermission.vue"
      ), // 路由懒加载
    meta: {
      title: "暂无权限",
    },
  },
  {
    path: "/login",
    name: "Login",
    component: () =>
      import(/* webpackChunkName: "login" */ "../views/Login.vue"), // 路由懒加载
    meta: {
      title: "登录",
    },
  },
];

export default routes;

实例化 Router

然后创建路由,vue2vue3创建路由的时候稍有区别。

// router/index.js

// vue2 写法
import VueRouter from "vue-router";
import routes from "./routes"

const router = new VueRouter({
  mode: "history",
  base: process.env.BASE_URL,
  routes,
});

// vue3 写法
import { createRouter, createWebHistory } from "vue-router";
import routes from "./routes"

const router = createRouter({
  history: createWebHistory(process.env.BASE_URL),
  routes,
});

定义路由拦截鉴权逻辑

创建好路由后,我们就可以定义动态添加路由的逻辑啦。

这里的核心逻辑就是判断前往的页面是否在白名单内,不在白名单就进一步判断当前系统是否有 token,没有 token 则重定向到登录页

如果有token,则进一步判断是否有用户菜单信息,如果没有用户菜单信息就获取用户菜单信息

有了用户菜单信息后,就先将菜单信息转换成前端路由需要的格式,然后通过addRoutes方法将路由动态添加到系统。

当用户前往不存在的路由时,路由的 name 属性会是 undefined,因此就重定向到无权限提示页面

// router/index.js

// vite创建的项目 使用这种方法实现动态加载
const pages = import.meta.glob("../views/*.vue");

// 将后端返回的菜单转换成 vue-router 需要的格式
const transformRoute = (menus) => {
  return menus.map((menu) => {
    return {
      path: menu.path,
      name: menu.name,
      component: modules[menu.component],
      meta: {
        title: menu.title,
      },
      // 如果有子路由,则递归处理
      children: menu.children ? transformRoute(menu.children) : [],
    };
  });
};

// vue3需要手动实现该方法
const addRoutes = (routes) => {
  routes.forEach((route) => {
    router.addRoute(route);
  });
};

// 白名单页面,不需要权限
const whiteLists = ["/login", "/"];

router.beforeEach(async (to, from, next) => {
  // 是否是白名单
  if (!whiteLists.includes(to.path)) {
    // 获取token
    const token = localStorage.getItem("token");

    // 如果有token
    if (token) {
      // 获取用户菜单信息,从store里面获取
      let userMenus = store.getters["getUserMenus"];
      // 如果没有用户菜单信息就获取用户菜单信息
      if (!userMenus) {
        userMenus = await store.dispatch("getUserMenuAction");
        // 菜单转成路由
        const userRoute = transformRoute(userMenus);
        
        // 动态添加路由 vue2 和 vue3 有细微差别
        // vue2 可以直接调用router.addRoutes()方法
        // router.addRoutes(userRoute)
        // next({ ...to , replace: true});

        // vue3
        // 因为 vue3 移除了 router.addRoutes()方法,所以需要手动实现addRoutes方法。
        addRoutes(userRoute);
        return next({ ...to });
      }

      // 有name说明路由存在,否则说明没有该路由
      if (to.name) {
        next();
      } else {
        // 去无权限页面
        next("/nopermission");
      }
    } else {
      // 否则去登录页
      next("/login");
    }
  } else {
    // 是白名单则直接进入
    next();
  }
});

// 修改标题的工作可以放在全局后置守卫
router.afterEach((to, from) => {
  if (to.meta.title) {
    document.title = to.meta.title;
  }
});

这里需要注意,因为笔者使用的 vite 创建的项目,在动态加载这块使用的是const modules = import.meta.glob("../views/*.vue");。如果你使用的是vue cli,使用的应该是 () => import()

我们再来看看 store 的逻辑,基本上就是调用后端获取用户菜单接口,然后将菜单信息存在store中。

import { createStore } from "vuex";

export default createStore({
  state: {
    userMenus: null,
  },
  getters: {
    getUserMenus: (state) => state.userMenus,
  },
  mutations: {
    setUserMenus(state, payload) {
      state.userMenus = payload;
    },
  },
  actions: {
    async getUserMenuAction({ commit }) {
      // 模拟后端获取用户菜单信息api
      const getUserMenuApi = () => {
        // 假设当前用户只有 about、parent、child1菜单
        return Promise.resolve([
          {
            path: "/about",
            name: "About",
            component: "../views/About.vue",
            title: "关于",
          },
          {
            path: "/parent",
            name: "Parent",
            component: "../views/Parent.vue",
            title: "父页面",
            children: [
              {
                path: "child1",
                name: "Child1",
                component: "../views/Child1.vue",
                title: "child1页面",
              },
            ],
          },
        ]);
      };

      const userMenus = await getUserMenuApi();

      commit("setUserMenus", userMenus);

      return userMenus;
    },
  },
});

使用

创建完路由后需要在 main.js 导入使用

import router from "./router";

// vue2写法
new Vue({
  router,
  render: (h) => h(App),
}).$mount("#app");


// vue3写法
const app = createApp(App);
app.use(router).mount("#app");

我们来看看效果

71a2672b-2138-4e43-95b4-334bc88e493a.gif

在没登录的情况下,去aboutuserlistparent页面会重定向到登录页。

给本地添加token,模拟登录完成后,因为用户有aboutparentchild1的菜单,所以去这些页面是没问题的,但是去 userlistchild2 页面则会重定向到没有权限页面。

退出登录记得移除菜单

感谢评论区牧也233同学的指出,退出登录的时候需要移除当前用户的菜单,这点笔者没考虑到。

移除也很简单,调用 router.removeRoute(xxx) 方法移除菜单就可以了。

总结

第一种通过 beforeEach 实时监测用户权限的方式优势是实现简单,路由的权限在 metaroles 里面配置。用户信息里面只需要包含当前用户的角色。缺点呢也很明显,系统所拥有的角色必须事先知道,然后以死代码的形式配置在 routes 里面,不支持动态添加角色。笔者觉得如果是做角色固定的系统是非常合适的,比如图书管理系统、教师管理系统等。

第二种通过 通过 addRoutes 动态添加路由实现角色权限的方式优势是系统角色可以不固定,支持动态添加角色。很适用于后台大型管理系统,可以在后台实时创建角色分配菜单。当然、缺点就是实现起来比较复杂,需要后端一起参与设计。

比如笔者做的低代码后台管理系统就是使用的第二种方案,里面就有实时创建菜单、角色、用户等一系列功能。

总体流程就是先创建菜单

image.png

然后将菜单绑定到某个角色上,这个角色就被赋予了这些菜单的权限。

image.png

最后创建用户,用户再绑定这个角色,这个用户就有了这个角色的菜单权限了。

image.png

二、Vue 实现按钮权限控制

实现按钮权限也需要分两种情况来讨论,

如果你系统角色是固定的,也就是页面权限的实现是通过第一种方式实现的。我们可以将按钮权限类似角色一样配置在routes 里面。

如果你系统角色不是固定的,也就是页面权限的实现是通过第二种方式实现的。这种方式我们需要后端在返回页面菜单的时候同时将该用户所拥有的按钮一并返回。然后在路由格式化的时候将按钮放到路由的meta里面。

1.将角色按钮配置在routes里面

这种方案的核心就是将用户角色按钮事先配置在 routes 里面。

定义 routes

首先我们定义好系统的路由,在meta里面定义好每个角色所拥有的按钮列表。

// router/routes.js

import Home from "../views/Home.vue";

const routes = [
  {
    path: "/",
    name: "Home",
    component: Home,
    meta: {
      needLogin: false, // 不需要登录
      title: "首页",
    },
  },
  {
    path: "/userlist", 
    name: "UserList",
    component: () =>
      import(/* webpackChunkName: "userlist" */ "../views/UserList.vue"), // 路由懒加载
    meta: {
      needLogin: true, // 需要登录
      title: "用户管理",
      roles: ["admin", "manage"], // 该页面只有admin才能进入
      btns: {
        admin: ["add", "delete", "edit", "select"], // admin 有增删改查按钮
        manage: ["select"], // 普通管理员只有查看按钮
      }
    },
  },
  {
    path: "/login",
    name: "Login",
    component: () =>
      import(/* webpackChunkName: "login" */ "../views/Login.vue"), // 路由懒加载
    meta: {
      needLogin: false, // 不需要登录
      title: "登录",
    },
  },
];

export default routes;

这里我们给 admin 定义了增删改查按钮,给普通管理员定义了查看按钮。

使用

我们可以使用 v-if 或者自定义指令的方式来控制按钮的显示与隐藏。

如果你想简单点就可以使用v-if,如果你觉得太 low,想逼格高点,就可以使用自定义指令。

// vue3 自定义全局指令
app.directive("action", {
  created: function (el, binding, vnode) {
    const actionName = binding.arg;
    const btns = binding.value;
    if (btns && btns.includes(actionName)) {
      el.style.display = "inline-block"; // 这里看情况给样式,不是none就可以
    } else {
      el.style.display = "none";
    }
  },
  beforeUpdate(el, binding, vnode, prevVnode) {
    const actionName = binding.arg;
    const btns = binding.value;
    if (btns && btns.includes(actionName)) {
      el.style.display = "inline-block"; // 这里看情况给样式,不是none就可以
    } else {
      el.style.display = "none";
    }
  },
});

因为 各角色的按钮都事先存到了 meta 里面,所以在页面里面我们只需要根据用户角色获取到当前角色的按钮,然后进行显示或隐藏。

<template>
  <div class="user-list">
    <h1>User List</h1>
    <h3>v-if 控制</h3>
    <button
      v-if="
        !$route?.meta.btns || $route?.meta.btns[userInfo.role].includes('add')
      "
    >
      add
    </button>
    <button
      v-if="
        !$route?.meta.btns ||
        $route?.meta.btns[userInfo.role].includes('delete')
      "
    >
      delete
    </button>
    <button
      v-if="
        !$route?.meta.btns || $route?.meta.btns[userInfo.role].includes('edit')
      "
    >
      edit
    </button>
    <button
      v-if="
        !$route?.meta.btns ||
        $route?.meta.btns[userInfo.role].includes('select')
      "
    >
      select
    </button>

    <h3>自定义指令控制 directive</h3>
    <button v-action:add="$route.meta.btns[userInfo.role]">add</button>
    <button v-action:delete="$route.meta.btns[userInfo.role]">delete</button>
    <button v-action:edit="$route.meta.btns[userInfo.role]">edit</button>
    <button v-action:select="$route.meta.btns[userInfo.role]">select</button>

    <div style="margin-top: 20px">
      <button @click="changeRole">改变角色</button>
    </div>
  </div>
</template>

<script>
import { defineComponent, computed } from "vue";
import { useStore } from "vuex";

export default defineComponent({
  setup() {
    const store = useStore();

    // 改变用户的角色
    const changeRole = () => {
      store.commit("setUserInfo", { role: "admin", name: "jack" });
    };

    return {
      userInfo: computed(() => store.getters.getUserInfo), // 获取用户信息
      changeRole,
    };
  },
});
</script>

我们来看看效果

08797623-24db-4240-a009-02329aa45484.gif

开始的时候用户角色为 manage,所以根据配置信息只有 select 按钮。当我们改变角色,变成 admin 的时候,增删改查的按钮就都有了。

2.后端返回用户的菜单和按钮

这种方式需要搭配前面的 addRoutes 动态添加路由一起使用。

核心就是在格式化路由的时候,顺带将该页面的按钮存放到 meta 中。

将用户按钮添加到meta中

假设菜单接口根据用户返回了菜单和该菜单的按钮

async getUserMenuAction({ commit }) {
  // 模拟后端获取用户信息api
  const getUserMenuApi = () => {
    // 假设当前用户只有 userlist 菜单
    return Promise.resolve([
      {
        path: "/userlist",
        name: "UserList",
        component: "../views/UserList.vue",
        title: "关于",
        btns: ["select", "add"], // 假设该用户只有 select、 add 按钮
      },
    ]);
  };

  const userMenus = await getUserMenuApi();

  commit("setUserMenus", userMenus);

  return userMenus;
},

然后在菜单转路由这里,需要将btns存到meta里面

// 转换成 vue-router 需要的格式
const transformRoute = (menus) => {
  return menus.map((menu) => {
    return {
      path: menu.path,
      name: menu.name,
      component: modules[menu.component],
      meta: {
        title: menu.title,
        btns: menu.btns, // 保存当前用户在当前路由的按钮
      },
      // 如果有子路由,则递归处理
      children: menu.children ? transformRoute(menu.children) : [],
    };
  });
};

使用

因为路由在格式化的时候已经将按钮存到了meta里面,所以在页面里面我们只需要判断meta里面是否包含对应的按钮。

<template>
  <div class="user-list">
    <h1>User List</h1>

    <!-- 第二种方式 后端返回按钮 -->
    <h3>v-if 控制</h3>
    <button v-if="!$route?.meta.btns || $route?.meta.btns.includes('add')">
      add
    </button>
    <button v-if="!$route?.meta.btns || $route?.meta.btns.includes('delete')">
      delete
    </button>
    <button v-if="!$route?.meta.btns || $route?.meta.btns.includes('edit')">
      edit
    </button>
    <button v-if="!$route?.meta.btns || $route?.meta.btns.includes('select')">
      select
    </button>

    <h3>自定义指令控制 directive</h3>
    <button v-action:add="$route?.meta.btns">add</button>
    <button v-action:delete="$route?.meta.btns">delete</button>
    <button v-action:edit="$route?.meta.btns">edit</button>
    <button v-action:select="$route?.meta.btns">select</button>
  </div>
</template>

我们来看看效果,只显示了addselect按钮。

image.png

总结

对于页面按钮权限的控制,笔者这里介绍了两种方案,这两种方案是搭配前面介绍的页面权限使用的。

当然按钮权限并不是只有这两种方案。比如你还可以在每个页面添加请求,去实时获取用户的按钮权限,当用户有了某按钮的权限后再将某按钮显示出来,否则隐藏。(限于篇幅原因,笔者就不再详细介绍了)

按钮权限的控制相对页面权限的控制来说相对灵活些,具体用哪种方案得根据自身项目需求来,这里笔者起一个抛砖引玉的作用。

三、React 实现页面权限控制

对于React技术栈实现页面权限控制,我们分React-Router4/5React-Router6 两个模块来讲解。

1.React-Router4/5通过高阶组件实时检测用户权限

这种方案的核心就是首先在 routes 里面事先定义好路由的权限,然后在高阶组件里面进行权限逻辑判断。看用户所拥有的角色和我们配置在路由里面的 roles 是否相匹配。匹配则允许进入,不匹配则重定向到无权限提示页面。

定义 routes

老样子,我们先定义项目的 routes

// router/routes.js

import Home from "../views/Home";
import About from "../views/About";
import Login from "../views/Login";
import Child1 from "../views/Child1";
import Child2 from "../views/Child2";
import Error404 from "../views/Error404";
import NoPermission from "../views/NoPermission";

const routes = [
  {
    component: Home,
    path: "/home",
    meta: {
      title: "首页",
      needLogin: false, // 不需要登录
    },
    routes: [
      {
        path: "/home/child1",
        component: Child1,
        meta: {
          title: "子页面1",
          needLogin: false, // 不需要登录
        },
      },
      {
        path: "/home/child2",
        component: Child2,
        meta: {
          title: "子页面2",
          needLogin: true, // 需要登录
          roles: ["admin", "manage"], // admin和manage角色才能看
        },
      },
    ],
  },
  {
    path: "/about",
    component: About,
    meta: {
      title: "关于",
      needLogin: true, // 需要登录
      roles: ["admin"], // admin角色才能看
    },
  },
  {
    path: "/login",
    component: Login,
    meta: {
      title: "登录",
      needLogin: false, // 不需要登录
    },
  },
  {
    path: "/nopermission", // 没有权限提示页面
    component: NoPermission,
    meta: {
      title: "暂无权限",
      needLogin: false,
    },
  },
  // 放后面
  {
    path: "/",
    redirect: "/home",
    exact: true,
  },
  // 放最后
  {
    path: "*",
    component: Error404,
  },
];

export default routes;

定义高阶组件 Auth

然后我们定义一个Auth高阶组件,用来处理权限相关逻辑。

// router/Auth.js

import { Route, Redirect } from "react-router-dom";
import { useSelector } from "react-redux";

function Auth(props) {
  const {
    component: Component,
    path,
    meta,
    routes,
    redirect,
    exact,
    strict,
  } = props;
  
  // 获取用户信息
  const userInfo = useSelector((state) => state.user.userinfo);
  
  // 设置网页标题
  if (meta && meta.title) {
    document.title = meta.title;
  }

  // 重定向
  if (redirect) {
    return <Redirect to={redirect} />;
  }

  // 判断是否需要登录
  if (meta && meta.needLogin) {
    const token = localStorage.getItem("token");
    // 没登录去登录页
    if (!token) {
      return <Redirect to="/login" />;
    }
  }
  // 路由需要角色、并且当前有用户信息 并且角色不匹配则去没有权限页面
  if (meta && meta.roles && userInfo && !meta.roles.includes(userInfo.role)) {
    return <Redirect to="/nopermission" />;
  }

  return (
    <Route
      path={path}
      exact={exact}
      strict={strict}
      render={(props) => <Component {...props} routes={routes} />}
    ></Route>
  );
}

export default Auth;

根据 routes 结合 Auth 渲染路由

在根组件根据我们的routes配置来渲染Auth组件。

因为涉及到角色权限,所以需要获取用户信息,在用户信息中获取当前用户角色,实时判断页面权限。

// app.jsx

import { useEffect } from "react";
import routes from "./router/routes";
import { Switch } from "react-router-dom";
import { useDispatch } from "react-redux";
import { setUserInfoAction } from "./store/actions/userActions";

export default function App() {
  const dispatch = useDispatch();
  
  useEffect(() => {
    // 获取用户信息
    dispatch(setUserInfoAction()); // 调用后会设置用户信息为 { role: "manage", name: "jack" }
  }, []);

  return (
    <div className="app-wrapper">
      <Switch>
        {routes.map((route) => {
          return (
            // 路由鉴权
            <Auth key={route.path} {...route}></Auth>
          );
        })}
      </Switch>
    </div>
  );
}

我们来看看userActions里面的核心逻辑。大概逻辑就是通过接口获取用户信息,这里模拟的用户信息是{ role: "manage", name: "jack" }

// 假设这里调用后端接口获取了用户信息数据
const getUserInfo = async () => {
  const userInfo = await Promise.resolve({ role: "manage", name: "jack" });
  return userInfo;
};

export const setUserInfoAction = () => {
  return {
    type: SET_USERINFO_TYPE,
    payload: getUserInfo(),
  };
};

由于 React-Router4/5 子路由需要定义在对应父组件里面,所以父组件我们也需要根据子routes渲染Auth组件。

// Home.js
import { Switch } from "react-router-dom";

export default function Home(props) {
  return (
    <div className="home-wrapper">
      <Switch>
        {props.routes.map((route) => {
          return (
            // 路由鉴权
            <Auth key={route.path} {...route}></Auth>
          );
        })}
      </Switch>
    </div>
  );
}

使用

最后,别忘记在入口文件使用 BrowserRouter 组件。

// index.jsx

import { BrowserRouter } from "react-router-dom";

ReactDOM.createRoot(document.getElementById("root")).render(
  <BrowserRouter>
    <App />
  </BrowserRouter>
);

我们来看看效果

d853a0b6-1509-42d2-b38b-11e8002994ef.gif

这样就达到了路由鉴权的效果,当我们本地没token的时候,根据配置进入/home/child2或者/about 的时候needLogin: true,所以会重定向到/login页面的。

给本地添加token 模拟登录完成后,因为用户信息角色是 manage,根据 routes 的配置去 child2 是没问题的,去 about 则会重定向到没有权限页面。

2.React-Router4/5动态添加路由

上面的方案只适用于角色固定的系统,对于需要后台动态创建角色的系统就不适用了,那在React中可不可以实现跟Vue中一样的动态添加路由呢?

答案是可以。

定义 routes

老样子,我们先定义项目的 routes。这里我们只需要将所有用户都有的路由定义在这里,其它的路由根据登录的用户角色再返回。

// router/routes.js

import Login from "../views/Login";
import Error404 from "../views/Error404";

const routes = [
  {
    path: "/login",
    component: Login,
    meta: {
      title: "登录",
      needLogin: false, // 不需要登录
    },
  },
  // 放后面
  {
    path: "/",
    redirect: "/home",
    exact: true,
  },
  // 放最后
  {
    path: "*",
    component: Error404,
  },
];

export default routes;

定义高阶组件 Auth

然后我们定义一个Auth高阶组件,用来处理权限相关逻辑。这里逻辑相对上面的会简单点,因为菜单都是后端返回的,所以我们只需要判断需不需要登录,然后将用户有的菜单渲染出来即可。

// router/Auth.js

import { Route, Redirect } from "react-router-dom";
import { Suspense } from "react";

function Auth(props) {
  const {
    component: Component,
    path,
    meta,
    routes,
    redirect,
    exact,
    strict,
  } = props;
  
  // 设置网页标题
  if (meta && meta.title) {
    document.title = meta.title;
  }

  // 重定向
  if (redirect) {
    return <Redirect to={redirect} />;
  }

  // 判断是否需要登录
  if (meta && meta.needLogin) {
    const token = localStorage.getItem("token");
    // 没登录去登录页
    if (!token) {
      return <Redirect to="/login" />;
    }
  }

  return (
    // 动态菜单 lazy 需要添加 Suspense
    <Suspense>
      <Route
        path={path}
        exact={exact}
        strict={strict}
        render={(props) => <Component {...props} routes={routes} />}
      ></Route>
    </Suspense>
  );
}

export default Auth;

根据 menus 转换成前端路由并渲染

在根组件根据我们的menus配置来渲染Auth组件。

这里的menus是调用后端接口获取的,获取到之后我们需要转换成前端路由,然后和我们在routes文件里面定义的通用路由进行合并。

// app.jsx

import { useEffect } from "react";
import routes from "./router/routes";
import { Switch } from "react-router-dom";
import { useDispatch, useSelector } from "react-redux";
import { setUserMenusAction } from "./store/actions/userActions";

export default function App() {
  const dispatch = useDispatch();
  const menus = useSelector((state) => state.user.menus);
  
  useEffect(() => {
    // 后台动态获取菜单,
    dispatch(setUserMenusAction());
  }, []);

  return (
    <div className="app-wrapper">
      <Switch>
        {menus.map((route) => {
          return (
            // 路由鉴权
            <Auth key={route.path} {...route}></Auth>
          );
        })}
      </Switch>
    </div>
  );
}

我们来看看userActions里面setUserMenusAction的核心逻辑。逻辑就是获取到该用户所拥有的菜单后转换成前端路由,然后和我们在routes文件里面定义的通用路由进行合并返回。

import routes from "@/router/routes";

// 菜单转路由
const transformMenuToRoute = (menus) => {
  return menus.map((menu) => {
    return {
      path: menu.path,
      name: menu.name,
      component: lazy(() => import(menu.component)),
      exact: menu.exact,
      strict: menu.strict,
      redirect: menu.redirect,
      meta: menu.meta,
      // 如果有子路由,则递归处理
      routes: menu.routes ? transformMenuToRoute(menu.routes) : [],
    };
  });
};

// 假设这里调用后端接口获取了用户菜单
const getUserMenus = async () => {
  const menus = await Promise.resolve([
    {
      component: "../../views/Home.jsx",
      path: "/home",
      meta: {
        title: "首页",
        needLogin: false, // 不需要登录
      },
      routes: [
        {
          path: "/home/child1",
          component: "../../views/Child1.jsx",
          meta: {
            title: "子页面1",
            needLogin: false, // 不需要登录
          },
        },
        {
          path: "/home/child2",
          component: "../../views/Child2.jsx",
          meta: {
            title: "子页面2",
            needLogin: true, // 需要登录
          },
        },
      ],
    },
  ]);

  return transformMenuToRoute(menus).concat(routes);
};

export const setUserMenusAction = () => {
  return {
    type: SET_USERMENUS_TYPE,
    payload: getUserMenus(),
  };
};

由于 React-Router4/5 子路由需要定义在对应父组件里面,所以父组件我们也需要根据子routes渲染Auth组件。

// Home.js
import { Switch } from "react-router-dom";

export default function Home(props) {
  return (
    <div className="home-wrapper">
      <Switch>
        {props.routes.map((route) => {
          return (
            // 路由鉴权
            <Auth key={route.path} {...route}></Auth>
          );
        })}
      </Switch>
    </div>
  );
}

使用

最后,别忘记在入口文件使用 BrowserRouter 组件。

// index.jsx

import { BrowserRouter } from "react-router-dom";

ReactDOM.createRoot(document.getElementById("root")).render(
  <BrowserRouter>
    <App />
  </BrowserRouter>
);

我们来看看效果

c02fba16-dd02-4bc2-b43c-879e83fdca76.gif

这样就达到了路由鉴权的效果,当我们本地没token的时候,根据配置进入/home/child2 的时候needLogin: true,所以会重定向到/login页面的。

给本地添加token 模拟登录完成后,根据 routes 的配置去 child2 是没问题的,因为没有about菜单所以去 about 则会重定向到错误404页面。

3.React-Router6通过高阶组件实时检测用户权限

这种方案的核心就是首先在 routes 里面事先定义好路由的权限,然后在高阶组件里面进行权限逻辑判断。看用户所拥有的角色和我们配置在路由里面的 roles 是否相匹配。匹配则允许进入,不匹配则重定向到无权限提示页面。

定义 routes

老样子,我们先定义项目的 routes。

// router/routes.js

import Home from "../views/Home";
import About from "../views/About";
import Login from "../views/Login";
import Child1 from "../views/Child1";
import Child2 from "../views/Child2";
import Error404 from "../views/Error404";
import NoPermission from "../views/NoPermission";

import { Navigate } from "react-router-dom";
import Auth from "./Auth";

const routes = [
  {
    element: <Home></Home>,
    path: "/home",
    meta: {
      title: "首页",
      needLogin: false,
    },
    children: [
      {
        path: "/home/child1",
        element: <Child1></Child1>,
        meta: {
          title: "子页面1",
          needLogin: false,
        },
      },
      {
        path: "/home/child2",
        element: <Child2></Child2>,
        meta: {
          title: "子页面2",
          needLogin: true,
          roles: ["admin", "manage"], // admin和manage角色才能看
        },
      },
    ],
  },
  {
    path: "/about",
    element: <About></About>,
    meta: {
      title: "关于",
      needLogin: true,
      roles: ["admin"], // admin角色才能看
    },
  },
  {
    path: "/login",
    element: <Login></Login>,
    meta: {
      title: "登录",
      needLogin: false,
    },
  },
  {
    path: "/nopermission", // 没有权限提示页面
    element: <NoPermission></NoPermission>,
    meta: {
      title: "暂无权限",
      needLogin: false,
    },
  },
  // 放后面
  {
    path: "/",
    redirect: "/home",
  },
  // 放最后
  {
    path: "*",
    element: <Error404></Error404>,
  },
];

// HOC
const authLoad = (element, meta = {}) => {
  return <Auth meta={meta}>{element}</Auth>;
};

// 路由配置列表数据转换
export const transformRoutes = (routes) => {
  const list = [];
  routes.forEach((route) => {
    const obj = { ...route };
    if (obj.redirect) {
      obj.element = <Navigate to={obj.redirect} replace={true} />;
    }

    if (obj.element) {
      obj.element = authLoad(obj.element, obj.meta);
    }

    delete obj.redirect;
    delete obj.meta;

    if (obj.children) {
      obj.children = transformRoutes(obj.children);
    }
    list.push(obj);
  });
  return list;
};

export default routes;

定义高阶组件 Auth

然后我们定义一个Auth高阶组件,用来处理权限相关逻辑。

// router/Auth.js

import { Navigate } from "react-router-dom";
import { useSelector } from "react-redux";

export default function Auth(props) {
  // 获取用户信息
  const userInfo = useSelector((state) => state.user.userinfo);
  const { meta } = props;

  // 设置标题
  if (meta.title) {
    document.title = meta.title;
  }

  const token = localStorage.getItem("token");

  // 权限校验
  if (meta.needLogin && !token) {
    return <Navigate to="/login" replace></Navigate>;
  }
  
  // 路由需要角色、并且当前有用户信息 并且角色不匹配则去没有权限页面
  if (meta && meta.roles && userInfo && !meta.roles.includes(userInfo.role)) {
    return <Navigate to="/nopermission" replace></Navigate>;
  }

  return <>{props.children}</>;
}

根据 routes 结合 useRoutes 渲染路由

因为涉及到角色权限,所以需要获取用户信息,在用户信息中获取当前用户角色,实时判断页面权限。

store的代码我就不贴出来了,大概逻辑就是通过接口获取用户信息,这里模拟的用户信息是{ role: "manage", name: "jack" }

这里我们使用 React-Router6 的新特性,useRoutes来渲染路由,使用transformRoutes方法将路由转成useRoutes所需要的格式。

// app.jsx

import { useState, useEffect } from "react";
import { useRoutes } from "react-router-dom";
import routes, { transformRoutes } from "./router/routes";
import { useDispatch } from "react-redux";
import { setUserInfoAction } from "./store/actions/userActions";

function App() {
  const pages = useRoutes(transformRoutes(routes));
  const dispatch = useDispatch();
  
  useEffect(() => {
    // 获取用户信息
    dispatch(setUserInfoAction()); // 调用后会设置用户信息为 { role: "manage", name: "jack" }
  }, []);

  return (
    <div className="app-wrapper">
      {pages}
    </div>
  );
}

export default App;

在父组件我们不需要再遍历渲染子组件了,直接使用useOutlet hook就可以了

// Home.js
import { useOutlet } from "react-router-dom";

export default function Home(props) {
  const outlet = useOutlet();
  return (
    <div className="home-wrapper">
      {outlet}
    </div>
  );
}

使用

最后,别忘记在入口文件使用 BrowserRouter 组件。

// index.jsx

import { BrowserRouter } from "react-router-dom";

ReactDOM.createRoot(document.getElementById("root")).render(
  <BrowserRouter>
    <App />
  </BrowserRouter>
);

我们来看看效果

24e0aeea-bc53-4393-adbb-47c36f1c417b.gif

这样就达到了路由鉴权的效果,当我们本地没token的时候,根据配置进入/home/child2或者/about 的时候needLogin: true,所以会重定向到/login页面的。

给本地添加token 模拟登录完成后,因为用户信息角色是 manage,根据 routes 的配置去 child2 是没问题的,去 about 则会重定向到没有权限页面。

4.React-Router6动态添加路由

上面的方案只适用于角色固定的系统,对于需要后台动态创建角色的系统就不适用了。对于需要动态创建角色的系统这种动态添加路由的方式就很好用。

定义 routes

老样子,我们先定义项目的 routes。这里我们只需要将所有用户都有的路由定义在这里,其它的路由根据登录的用户角色再返回。

// router/routes.js

import Login from "../views/Login";
import Error404 from "../views/Error404";

import { Navigate } from "react-router-dom";
import Auth from "./Auth";

const routes = [
  {
    path: "/login",
    element: <Login></Login>,
    meta: {
      title: "登录",
      needLogin: false,
    },
  },
  // 放后面
  {
    path: "/",
    redirect: "/home",
  },
  {
    path: "*",
    element: <Error404></Error404>,
  },
];

// HOC
const authLoad = (element, meta = {}) => {
  const Component = lazy(() => import(element));
  return (
    <Suspense>
      <Auth meta={meta}>
        <Component></Component>
      </Auth>
    </Suspense>
  );
};

// 路由配置列表数据转换
export const transformRoutes = (routes) => {
  const list = [];
  routes.forEach((route) => {
    const obj = { ...route };
    if (obj.redirect) {
      obj.element = <Navigate to={obj.redirect} replace={true} />;
    }

    if (obj.element) {
      obj.element = authLoad(obj.element, obj.meta);
    }

    delete obj.redirect;
    delete obj.meta;

    if (obj.children) {
      obj.children = transformRoutes(obj.children);
    }
    list.push(obj);
  });
  return list;
};

export default routes;

定义高阶组件 Auth

然后我们定义一个Auth高阶组件,用来处理权限相关逻辑。这里逻辑相对上面的会简单点,因为菜单都是后端返回的,所以我们只需要判断需不需要登录,然后将用户有的菜单渲染出来即可。

// router/Auth.js

import { Navigate } from "react-router-dom";

export default function Auth(props) {
  const { meta } = props;

  // 设置标题
  if (meta.title) {
    document.title = meta.title;
  }

  const token = localStorage.getItem("token");

  // 权限校验
  if (meta.needLogin && !token) {
    return <Navigate to="/login" replace></Navigate>;
  }
  
  return <>{props.children}</>;
}

根据 menus 转换成前端路由并渲染

在根组件根据我们的menus配置来渲染Auth组件。

这里的menus是调用后端接口获取的,获取到之后我们需要转换成前端路由,然后和我们在routes文件里面定义的通用路由进行合并。

这里我们使用 React-Router6 的新特性,useRoutes来渲染路由。

// app.jsx

import { useState, useEffect } from "react";
import { useRoutes } from "react-router-dom";
import { useDispatch } from "react-redux";
import { setUserMenusAction } from "./store/actions/userActions";

function App() {
  const menus = useSelector((state) => state.user.menus);
  const dispatch = useDispatch();
  
  useEffect(() => {
    // 获取用户菜单并转换成路由
    dispatch(setUserMenusAction());
  }, []);

  return (
    <div className="app-wrapper">
      {useRoutes(menus)}
    </div>
  );
}

export default App;

我们来看看userActions里面setUserMenusAction的核心逻辑。逻辑就是获取到该用户所拥有的菜单后转换成前端路由,然后和我们在routes文件里面定义的通用路由进行合并返回。

import routes, { transformRoutes } from "@/router/routes";

// 假设这里调用后端接口获取了用户菜单
const getUserMenus = async () => {
  const menus = await Promise.resolve([
    {
      element: "../views/Home.jsx",
      path: "/home",
      meta: {
        title: "首页",
        needLogin: false,
      },
      children: [
        {
          path: "/home/child1",
          element: "../views/Child1.jsx",
          meta: {
            title: "子页面1",
            needLogin: false,
          },
        },
        {
          path: "/home/child2",
          element: "../views/Child2.jsx",
          meta: {
            title: "子页面2",
            needLogin: true,
          },
        },
      ],
    },
  ]);

  return transformRoutes(menus).concat(routes);
};

export const setUserMenusAction = () => {
  return {
    type: SET_USERMENUS_TYPE,
    payload: getUserMenus(),
  };
};

在父组件我们不需要再遍历渲染子组件了,直接使用useOutlet hook就可以了

// Home.js
import { useOutlet } from "react-router-dom";

export default function Home(props) {
  const outlet = useOutlet();
  return (
    <div className="home-wrapper">
      {outlet}
    </div>
  );
}

使用

最后,别忘记在入口文件使用 BrowserRouter 组件。

// index.jsx

import { BrowserRouter } from "react-router-dom";

ReactDOM.createRoot(document.getElementById("root")).render(
  <BrowserRouter>
    <App />
  </BrowserRouter>
);

我们来看看效果

28ff0e20-8172-4a32-8faf-bd3c53a120e4.gif

这样就达到了路由鉴权的效果,当我们本地没token的时候,根据配置进入/home/child2 的时候needLogin: true,所以会重定向到/login页面的。

给本地添加token 模拟登录完成后,根据 routes 的配置去 child2 是没问题的,因为没有about菜单所以去 about 则会重定向到错误404页面。

总结

因为React-Router4/5React-Router6 相差比较多,所以对于配置式路由和动态添加路由笔者都分别讲解了一遍。

第一种配置路由的方式优势是实现简单,路由的权限在 metaroles 里面配置。用户信息里面只需要包含当前用户的角色。缺点呢也很明显,系统所拥有的角色必须事先知道,然后以死代码的形式配置在 routes 里面,不支持动态添加角色。笔者觉得如果是做角色固定的系统是非常合适的,比如图书管理系统、教师管理系统等。

第二种通过 通过动态添加路由实现角色权限的方式优势是系统角色可以不固定,支持动态添加角色。很适用于后台大型管理系统,可以在后台实时创建角色分配菜单。当然、缺点就是实现起来比较复杂,需要后端一起参与设计。

四、React 实现按钮权限控制

实现按钮权限也需要分两种情况来讨论,

如果你系统角色是固定的,也就是页面权限的实现是通过第一种方式实现的。我们可以将按钮权限类似角色一样配置在routes 里面。

如果你系统角色不是固定的,也就是页面权限的实现是通过第二种方式实现的。这种方式我们需要后端在返回页面菜单的时候同时将该用户所拥有的按钮一并返回。然后在路由格式化的时候将按钮放到路由的meta里面。

1.React-Router4/5将角色按钮配置在routes里面

这种方案的核心就是将用户角色按钮事先配置在 routes 里面。

定义 routes

首先我们定义好系统的路由,在meta里面定义好每个角色所拥有的按钮列表。

这里我只拿Child2做测试,所以我只贴了Child2路由相关代码。

// router/routes.js

import Home from "@/views/Home";
import About from "../views/About";
import Login from "../views/Login";
import Child1 from "../views/Child1";
import Child2 from "../views/Child2";
import Error404 from "../views/Error404";
import NoPermission from "../views/NoPermission";

const routes = [
  {
    component: Home,
    path: "/home",
    meta: {
      title: "首页",
      needLogin: false, // 不需要登录
    },
    routes: [
      // ...
      {
        path: "/home/child2",
        component: Child2,
        meta: {
          title: "子页面2",
          needLogin: true, // 需要登录
          roles: ["admin", "manage"], // admin和manage角色才能看
          btns: {
            admin: ["add", "delete", "edit", "select"], // admin 有增删改查按钮
            manage: ["select"], // 普通管理员只有查看按钮
          },
        },
      },
    ],
  },
 // ...
];

export default routes;

这里我们给 admin 定义了增删改查按钮,给普通管理员定义了查看按钮。

在Auth组件给每个路由组件注入它相应权限的按钮

这里的核心就是通过用户信息里面的角色,给每个路由组件注入它相应权限的按钮。

// Auth.jsx

return (
  <Route
    path={path}
    exact={exact}
    strict={strict}
    render={(props) => (
      <Component
        {...props}
        routes={routes}
        btns={meta.btns && userInfo ? meta.btns[userInfo.role] : []} //注入它相应权限的按钮
      />
    )}
  ></Route>
);

使用

页面的按钮已经通过Auth组件注入进来了,所以在使用的时候,我们只需要通过props取出按钮列表与页面按钮匹配即可,能匹配上的就展示,否则就隐藏。控制显示隐藏的方式很多,这里笔者用的是&&

import React from "react";
import { useDispatch } from "react-redux";
import { setUserInfoAction2 } from "../store/actions/userActions";

export default function Child2(props) {
  const dispatch = useDispatch();
  const changeRole = () => {
    // 获取用户信息
    dispatch(setUserInfoAction2({ role: "admin", name: "randy" }));
  };
  return (
    <div>
      <div>Child2 按钮权限</div>
      <div>
        {props.btns.includes("add") && <button>add</button>}
        {props.btns.includes("delete") && <button>delete</button>}
        {props.btns.includes("edit") && <button>edit</button>}
        {props.btns.includes("select") && <button>select</button>}
      </div>

      <button onClick={changeRole}>改变角色为admin</button>
    </div>
  );
}

我们来看看效果

13a35df2-17fc-48c9-aea6-495c8c03142d.gif

开始的时候用户角色为 manage,所以根据配置信息只有 select 按钮。当我们改变角色,变成 admin 的时候,增删改查的按钮就都有了。

2.React-Router4/5后端返回用户的菜单和按钮

这种方式需要搭配前面的动态添加路由一起使用。

核心就是在格式化路由的时候,顺带将该页面的按钮存放到 meta 中。

将用户按钮添加到meta中

假设菜单接口根据用户返回了菜单和该菜单的按钮

// 假设这里调用后端接口获取了用户菜单
const getUserMenus = async () => {
  const menus = await Promise.resolve([
    {
      component: "../../views/Home.jsx",
      path: "/home",
      meta: {
        title: "首页",
        needLogin: false, // 不需要登录
      },
      routes: [
        // ...
        {
          path: "/home/child2",
          component: "../../views/Child2.jsx",
          meta: {
            title: "子页面2",
            needLogin: true, // 需要登录
            btns: ["add", "delete"], // 当前用户只有add和delete按钮
          },
        },
      ],
    },
  ]);

  // 菜单转路由
  return transformMenuToRoute(menus).concat(routes);
};

export const setUserMenusAction = () => {
  return {
    type: SET_USERMENUS_TYPE,
    payload: getUserMenus(),
  };
};

// 菜单转路由
const transformMenuToRoute = (menus) => {
  return menus.map((menu) => {
    return {
      path: menu.path,
      name: menu.name,
      component: lazy(() => import(menu.component)),
      exact: menu.exact,
      strict: menu.strict,
      redirect: menu.redirect,
      meta: menu.meta,
      // 如果有子路由,则递归处理
      routes: menu.routes ? transformMenuToRoute(menu.routes) : [],
    };
  });
};

在Auth组件给每个路由组件注入它相应权限的按钮

在Auth组件,从meta中取出按钮列表,给每个路由组件注入它相应权限的按钮。

return (
// 动态菜单 lazy 需要添加 Suspense
<Suspense>
  <Route
    path={path}
    exact={exact}
    strict={strict}
    render={(props) => (
      <Component
        {...props}
        routes={routes}
        btns={meta.btns ? meta.btns : []}
      />
    )}
  ></Route>
</Suspense>
);

使用

页面的按钮已经通过Auth组件注入进来了,所以在使用的时候,我们只需要通过props取出按钮列表与页面按钮匹配即可,能匹配上的就展示,否则就隐藏。控制显示隐藏的方式很多,这里笔者用的是&&

import React from "react";

export default function Child2(props) {

  return (
    <div>
      <div>Child2 按钮权限</div>
      <div>
        {props.btns.includes("add") && <button>add</button>}
        {props.btns.includes("delete") && <button>delete</button>}
        {props.btns.includes("edit") && <button>edit</button>}
        {props.btns.includes("select") && <button>select</button>}
      </div>
    </div>
  );
}

我们来看看效果

image.png

因为我们在child2路由配置的按钮只有adddelete,所以页面也只展示了adddelete按钮。

3.React-Router6将角色按钮配置在routes里面

这种方案的核心就是将用户角色按钮事先配置在 routes 里面。然后在Auth组件里通过用户角色将匹配的按钮传递到路由组件中。

定义 routes

首先我们定义好系统的路由,在meta里面定义好每个角色所拥有的按钮列表。

这里我只拿Child2做测试,所以我只贴了Child2路由相关代码。

// router/routes.js

import Home from "../views/Home";
import Child2 from "../views/Child2";

import { Navigate } from "react-router-dom";
import Auth from "./Auth";

const routes = [
  {
    element: <Home></Home>,
    path: "/home",
    meta: {
      title: "首页",
      needLogin: false,
    },
    children: [
      // ...
      {
        path: "/home/child2",
        element: <Child2></Child2>,
        meta: {
          title: "子页面2",
          needLogin: true,
          roles: ["admin", "manage"], // admin和manage角色才能看
          btns: {
            admin: ["add", "delete", "edit", "select"], // admin 有增删改查按钮
            manage: ["select"], // 普通管理员只有查看按钮
          },
        },
      },
    ],
  },
  // ...
];

// HOC
const authLoad = (element, meta = {}) => {

  return <Auth meta={meta}>{element}</Auth>;
};

// 路由配置列表数据转换
export const transformRoutes = (routes) => {
  const list = [];
  routes.forEach((route) => {
    const obj = { ...route };

    if (obj.element) {
      obj.element = authLoad(obj.element, obj.meta);
    }

    delete obj.meta;

    if (obj.children) {
      obj.children = transformRoutes(obj.children);
    }
    list.push(obj);
  });
  return list;
};

export default routes;

这里我们给 admin 定义了增删改查按钮,给普通管理员定义了查看按钮。

在Auth组件给每个路由组件注入它相应权限的按钮

这里的核心就是通过用户信息里面的角色,给每个路由组件注入它相应权限的按钮。

import { Navigate, useLocation } from "react-router-dom";
import { useSelector } from "react-redux";
import { cloneElement } from "react";

export default function Auth(props) {
  // 获取用户信息
  const userInfo = useSelector((state) => state.user.userinfo);
  const { meta } = props;
  const location = useLocation();
  console.log("auth", userInfo, props, location);

  // 设置标题
  if (meta.title) {
    document.title = meta.title;
  }

  const token = localStorage.getItem("token");

  // 权限校验
  if (meta.needLogin && !token) {
    return <Navigate to="/login" replace></Navigate>;
  }
  // 路由需要角色、并且当前有用户信息 并且角色不匹配则去没有权限页面
  if (meta && meta.roles && userInfo && !meta.roles.includes(userInfo.role)) {
    return <Navigate to="/nopermission" replace></Navigate>;
  }

  // 为了给jsx传递属性,使用了 cloneElement。简单点的话可以在routes里面定义组件本身而不是jsx组件。
  const _cloneElement = cloneElement(props.children, {
    btns: meta.btns && userInfo ? meta.btns[userInfo.role] : [],
  });

  return <>{_cloneElement}</>;
}

使用

页面的按钮已经通过Auth组件注入进来了,所以在使用的时候,我们只需要通过props取出按钮列表与页面按钮匹配即可,能匹配上的就展示,否则就隐藏。控制显示隐藏的方式很多,这里笔者用的是&&

import React from "react";
import { useDispatch } from "react-redux";
import { setUserInfoAction2 } from "../store/actions/userActions";

export default function Child2(props) {
  const dispatch = useDispatch();
  const changeRole = () => {
    // 获取用户信息
    dispatch(setUserInfoAction2({ role: "admin", name: "randy" }));
  };
  return (
    <div>
      <div>Child2 按钮权限</div>
      <div>
        {props.btns.includes("add") && <button>add</button>}
        {props.btns.includes("delete") && <button>delete</button>}
        {props.btns.includes("edit") && <button>edit</button>}
        {props.btns.includes("select") && <button>select</button>}
      </div>

      <button onClick={changeRole}>改变角色为admin</button>
    </div>
  );
}

我们来看看效果

e398f810-e995-4481-b1bd-41a28c3e26e6.gif

开始的时候用户角色为 manage,所以根据配置信息只有 select 按钮。当我们改变角色,变成 admin 的时候,增删改查的按钮就都有了。

4.React-Router6后端返回用户的菜单和按钮

这种方式需要搭配前面的动态添加路由一起使用。

核心就是在在动态导入组件的时候给每个路由组件注入它相应权限的按钮。

将用户按钮添加到meta中

假设菜单接口根据用户返回了菜单和该菜单的按钮,拿到菜单我们需要通过transformRoutes方法转成前端路由。

// 假设这里调用后端接口获取了用户菜单
const getUserMenus = async () => {
  const menus = await Promise.resolve([
    {
      element: "../views/Home.jsx",
      path: "/home",
      meta: {
        title: "首页",
        needLogin: false,
      },
      children: [
        {
          path: "/home/child1",
          element: "../views/Child1.jsx",
          meta: {
            title: "子页面1",
            needLogin: false,
          },
        },
        {
          path: "/home/child2",
          element: "../views/Child2.jsx",
          meta: {
            title: "子页面2",
            needLogin: true,
            btns: ["add", "select"], // 当前用户只有add和select按钮
          },
        },
      ],
    },
  ]);

  return transformRoutes(menus).concat(routes);
};

export const setUserMenusAction = () => {
  return {
    type: SET_USERMENUS_TYPE,
    payload: getUserMenus(),
  };
};

给每个路由组件注入它相应权限的按钮

在菜单转路由的时候,我们需要将组件路径转成对应路由组件,然后给每个路由组件注入它相应权限的按钮。这个逻辑我们在外面就能处理,并不需要在Auth组件里面处理。

const authLoad = (element, meta = {}) => {
  const Component = lazy(() => import(element));
  return (
    <Suspense>
      <Auth meta={meta}>
        <Component btns={meta.btns ? meta.btns : []}></Component> // 将按钮列表传递到各自组件
      </Auth>
    </Suspense>
  );
};

// 菜单配置列表转路由列表
export const transformRoutes = (routes) => {
  const list = [];
  routes.forEach((route) => {
    const obj = { ...route };
    if (obj.redirect) {
      obj.element = <Navigate to={obj.redirect} replace={true} />;
    }

    if (obj.element) {
      obj.element = authLoad(obj.element, obj.meta);
    }

    delete obj.redirect;
    delete obj.meta;

    if (obj.children) {
      obj.children = transformRoutes(obj.children);
    }
    list.push(obj);
  });
  return list;
};

使用

页面的按钮已经通过Auth组件注入进来了,所以在使用的时候,我们只需要通过props取出按钮列表与页面按钮匹配即可,能匹配上的就展示,否则就隐藏。控制显示隐藏的方式很多,这里笔者用的是&&

import React from "react";

export default function Child2(props) {

  return (
    <div>
      <div>Child2 按钮权限</div>
      <div>
        {props.btns.includes("add") && <button>add</button>}
        {props.btns.includes("delete") && <button>delete</button>}
        {props.btns.includes("edit") && <button>edit</button>}
        {props.btns.includes("select") && <button>select</button>}
      </div>
    </div>
  );
}

我们来看看效果

image.png

因为我们在child2路由配置的按钮只有addselect,所以页面也只展示了addselect按钮。

总结

对于页面按钮权限的控制,笔者这里介绍了两种方案,这两种方案是搭配前面介绍的页面权限使用的。

当然按钮权限并不是只有这两种方案。比如你还可以在每个页面添加请求,去实时获取用户的按钮权限,当用户有了某按钮的权限后再将某按钮显示出来,否则隐藏。(限于篇幅原因,笔者就不再详细介绍了)

按钮权限的控制相对页面权限的控制来说相对灵活些,具体用哪种方案得根据自身项目需求来,这里笔者起一个抛砖引玉的作用。

总结

VueReact都是支持路由权限校验和动态添加路由,只是实现方式不太一样。整体来说,笔者感觉Vue封装的更好,路由钩子和动态添加路由方法内部都已经实现好了。反观React,并没有内部实现,需要我们自己手写Hoc组件来实现,相对来说更灵活。

对于按钮权限,VueReact都没有内部方法支持,都需要我们自己手动实现。因此实现方案有很多。

系列文章

Vue和React对比学习之生命周期函数(Vue2、Vue3、老版React、新版React)

Vue和React对比学习之组件传值(Vue2 12种、Vue3 9种、React 7种)

Vue和React对比学习之Style样式

Vue和React对比学习之Ref和Slot

Vue和React对比学习之Hooks

Vue和React对比学习之路由(Vue-Router、React-Router)

Vue和React对比学习之状态管理 (Vuex和Redux)

Vue和React对比学习之条件判断、循环、计算属性、属性监听

Vue和React对比学习之路由拦截鉴权

Vue和React对比学习之路由角色权限

后记

感谢小伙伴们的耐心观看,本文为笔者个人学习笔记,如有谬误,还请告知,万分感谢!如果本文对你有所帮助,还请点个关注点个赞~,您的支持是笔者不断更新的动力!