为了实现权限控制,我手搓一个 vite 插件【15-权限篇】

前言

权限控制,就前端而言有两种

  • 页面权限
  • 按钮权限

页面权限的话,还好说,我们可以通过拦截器,来捕获路由信息,判断是否有权限跳转

而按钮权限,web 端是 vue 是通过自定义指令实现的,但是 uniapp 或者准确点来说是微信小程序端,没有自定义指令这个功能

那我们要怎么实现这个功能呢?

为了与 web 使用方式类似,可以通过 vite 插件系统来实现类似自定义指令的效果,具体实现方式可以看 按钮权限控制 部分

路由权限控制

typescript 复制代码
// src/interceptors/router.ts
import { useUserStore } from '@/store'
import { getPermissionKeysByPath } from '@/utils'

const loginRoute = '/pages/routerDemo/login'

// 获取用户权限标识
const getUserPermissionKeys = () => {
  const userStore = useUserStore()
  return userStore.getUserPermissionKeys
}

/**
 * 实现原理:
 * 1. 或者所有需要登录的路由,存到黑名单,需要在 route-block 配置标识 key,根据 isNeedLogin 判断是否需要登录
 * 2. 在路由跳转前,判断是否需要登录,如果需要登录,判断是否已经登录,如果没有登录,跳转到登录页
 **/
const navigateToInterceptor = {
  invoke({ url }: { url: string }) {
    // 'pages.json' 里面的 path 是 'pages/index/index'
    // url 如果带参数则是 /pages/route-interceptor/index?name=uni-plus,所以需要去掉带的参数、去掉前面的 /
    const path = url.split('?')[0].slice(1) // pages/index/index
    // 用户已有权限
    const userPermissionKeys = getUserPermissionKeys() // ['logined', 'vip']
    // 页面所需权限
    const pageRoutePaths = getPermissionKeysByPath(path) // ['vip']
    // 两个权限取交集,然后交集再与用户权限取补集
    const LackPermissionKeys = pageRoutePaths.filter(item => !userPermissionKeys.includes(item))

    // 如果有权限就放行
    if (LackPermissionKeys.length === 0) {
      return true
    }

    /* ---------- 无权限,对缺少各种权限做处理 --------- */
    // 如果缺少登录权限,跳转到登录页
    if (LackPermissionKeys.includes('logined')) {
      const redirectRoute = `${loginRoute}?redirect=${encodeURIComponent(url)}`
      uni.navigateTo({ url: redirectRoute })
    }
    // 如果缺少其他权限,提示无权限,需要其他权限特殊判定可以继续添加
    else {
      uni.showToast({
        title: '无权限访问',
        icon: 'none'
      })
    }
    return false
  }
}

export const routerInterceptor = {
  install() {
    uni.addInterceptor('navigateTo', navigateToInterceptor)
    uni.addInterceptor('reLaunch', navigateToInterceptor)
    uni.addInterceptor('redirectTo', navigateToInterceptor)
    uni.addInterceptor('switchTab', navigateToInterceptor)
  }
}

这里主要实现路由权限,实现了黑名单拦截,权限不够就拦截

比如现在用户权限是 ['logined', 'vip'] 那么他能进入就只能是 permissionKeys['logined', 'vip'] 范围里面的路由

如果页面 permissionKeys['logined', 'vip', 'admin'],那么这个页面就无法被当前用户访问

如果页面 permissionKeys['logined']、\['logined', 'vip'],或者没有 permissionKeys 那么这个页面可以被当前用户访问

用户权限 ,通过 pinia 去拿,为了保证每次都是拿到最新数据,我们通过一个函数 getUserPermissionKeys 进行获取

typescript 复制代码
const getUserPermissionKeys = () => {
  const userStore = useUserStore()
  return userStore.getUserPermissionKeys
}

页面权限 ,通过 getPermissionKeysByPath() 去拿

这个函数,我们在 src/utils/router.ts 中实现

typescript 复制代码
import { pages, subPackages } from '@/pages.json'

export const getPermissionKeysByPath = (path: string): string[] => {
  let navigateToPage
  if (path.split('/')[0] === 'pages') {
    navigateToPage = pages.find(page => page.path === path)
  } else {
    navigateToPage = subPackages.find((page: Record<string, any>) => page.path === path)
  }
  return navigateToPage ? navigateToPage.permissionKeys || [] : []
}

其中页面权限的定义,我们通过 route-block 进行实现,也就是这样

js 复制代码
<route lang="json5" type="page">
{
  style: {
    navigationBarTitleText: '页面1'
  },
  permissionKeys: ['vip']
}
</route>

然后,跳转到每个页面前先拦截判断,用户权限是否符合页面权限,如果符合,就可以进入页面啦~

typescript 复制代码
const navigateToInterceptor = {
  invoke({ url }: { url: string }) {
    // 'pages.json' 里面的 path 是 'pages/index/index'
    // url 如果带参数则是 /pages/route-interceptor/index?name=uni-plus,所以需要去掉带的参数、去掉前面的 /
    const path = url.split('?')[0].slice(1) // pages/index/index
    // 用户已有权限
    const userPermissionKeys = getUserPermissionKeys() // ['logined', 'vip']
    // 页面所需权限
    const pageRoutePaths = getPermissionKeysByPath(path) // ['vip']
    // 两个权限取交集,然后交集再与用户权限取补集
    const LackPermissionKeys = pageRoutePaths.filter(item => !userPermissionKeys.includes(item))

    // 如果有权限就放行
    if (LackPermissionKeys.length === 0) {
      return true
    }

    /* ---------- 无权限,对缺少各种权限做处理 --------- */
    // 如果缺少登录权限,跳转到登录页
    if (LackPermissionKeys.includes('logined')) {
      const redirectRoute = `${loginRoute}?redirect=${encodeURIComponent(url)}`
      uni.navigateTo({ url: redirectRoute })
    }
    // 如果缺少其他权限,提示无权限,需要其他权限特殊判定可以继续添加
    else {
      uni.showToast({
        title: '无权限访问',
        icon: 'none'
      })
    }
    return false
  }
}

拦截的核心就是这个函数,对 用户权限与页面权限取交集,交集再与页面权限取补集

typescript 复制代码
const LackPermissionKeys = pageRoutePaths.filter(item => !userPermissionKeys.includes(item))

比如

  • 用户权限:['vip', 'logined', 'admin']
  • 页面权限:['vip', 'logined']

用户权限与页面权限 取交集:['vip', 'logined']

页面权限与交集 取补集:[]

也就是 LackPermissionKeys[]

typescript 复制代码
// 如果有权限就放行
if (LackPermissionKeys.length === 0) {
  return true
}

在拦截器函数中返回 true,页面就会跳转,返回 false 就不会跳转

无权限的处理,我们可以通过,判断 LackPermissionKeys 有哪些权限,也即用户还需要哪些权限进行单独处理

typescript 复制代码
/* ---------- 无权限,对缺少各种权限做处理 --------- */
// 如果缺少登录权限,跳转到登录页
if (LackPermissionKeys.includes('logined')) {
  const redirectRoute = `${loginRoute}?redirect=${encodeURIComponent(url)}`
  uni.navigateTo({ url: redirectRoute })
}
// 如果缺少其他权限,提示无权限,需要其他权限特殊判定可以继续添加
else {
  uni.showToast({
    title: '无权限访问',
    icon: 'none'
  })
}
return false

现在只实现对登录的权限控制,缺少登录权限的话,就跳转到登录页,并在跳转路由上加入重定向路径

登录完成后,跳回登录前的页面

现在还需要在 src/utils/router.ts 中实现,重定向的功能

typescript 复制代码
// src/utils/router.ts
const getLastPage = () => {
  const pages = getCurrentPages()
  return pages[pages.length - 1]
}

const getUrlObj = (url: string) => {
  const [path, queryStr] = url.split('?')

  if (!queryStr) {
    return {
      path,
      query: {}
    }
  }
  const query: Record<string, string> = {}
  queryStr.split('&').forEach(item => {
    const [key, value] = item.split('=')
    query[key] = ensureDecodeURIComponent(value) // 这里需要统一 decodeURIComponent 一下,可以兼容h5和微信y
  })
  return { path, query }
}

export const redirectRouteTo = () => {
  // 获取上一页路由路径
  const lastPage = getLastPage()
  const currRoute = (lastPage as any).$page
  const { fullPath } = currRoute as { fullPath: string }
  // 获取上一页路由路径的 query 参数
  const { query } = getUrlObj(fullPath)
  // 获取上一页路由路径的 query 参数中的 redirect 参数
  const { redirect } = query
  if (redirect) {
    uni.redirectTo({
      url: redirect
    })
    return true
  } else {
    return false
  }
}

根据当前路由路径,判定是否有重定向路径 redirect,如果有则跳转,没有则返回 false(不做任何操作)

我们来测试一下,写下 index.vue、login.vue、page1.vue、page2.vue、page3.vue 用于测试

js 复制代码
<!-- src/routerDemo/index.vue -->
<route lang="json5" type="home">
{
  style: {
    navigationBarTitleText: '页面路由权限控制'
  }
}
</route>

<template>
  <div class="w-100vw flex justify-center items-center m-y-20rpx">--------用户权限----------</div>
  <div class="w-100vw flex justify-center items-center m-y-20rpx">{{ getUserPermissionKeys() }}</div>
  <div class="w-100vw flex justify-center items-center m-y-20rpx">--------页面权限----------</div>
  <button @click="goToPage1">['vip']</button>
  <button @click="goToPage2">['vip', 'admin']</button>
  <button @click="goToPage3">['logined']</button>
</template>

<script setup lang="ts">
import { useUserStore } from '@/store'

const getUserPermissionKeys = () => {
  const userStore = useUserStore()
  return userStore.getUserPermissionKeys
}

// 跳转到页面1
const goToPage1 = () => {
  uni.navigateTo({
    url: '/pages/routerDemo/page1'
  })
}

// 跳转到页面2
const goToPage2 = () => {
  uni.navigateTo({
    url: '/pages/routerDemo/page2'
  })
}

// 跳转到页面3
const goToPage3 = () => {
  uni.navigateTo({
    url: '/pages/routerDemo/page3'
  })
}
</script>
js 复制代码
<!-- src/routerDemo/login.vue -->
<route lang="json5" type="page">
{
  style: {
    navigationBarTitleText: '登录页'
  }
}
</route>

<template>
  <button @click="login">使用假数据模拟登录</button>
</template>

<script setup lang="ts">
import { useUserStore } from '@/store'
import { redirectRouteTo } from '@/utils'
const userStore = useUserStore()

/* 假登录 */
const login = () => {
  userStore.setUserInfo({
    accessToken: 'fakeAccessToken',
    refreshToken: 'fakeRefreshToken'
  })
  // 如果有重定向路径,跳转才有效
  redirectRouteTo()
}
</script>
js 复制代码
<!-- src/routerDemo/page1.vue -->
<route lang="json5" type="page">
{
  style: {
    navigationBarTitleText: '页面1'
  },
  permissionKeys: ['vip']
}
</route>

<template>
  <h1>页面1</h1>
</template>
js 复制代码
<!-- src/routerDemo/page2.vue -->
<route lang="json5" type="page">
{
  style: {
    navigationBarTitleText: '页面2'
  },
  permissionKeys: ['vip', 'admin']
}
</route>

<template>
  <h1>页面2</h1>
</template>
js 复制代码
<!-- src/routerDemo/page3.vue -->
<route lang="json5" type="page">
{
  style: {
    navigationBarTitleText: '页面3'
  },
  permissionKeys: ['logined']
}
</route>

<template>
  <h1>页面3</h1>
</template>

我们来运行一下看下效果

原本我们的用户权限只有 ['vip'] 所以,只能访问 第一个按钮所跳转的页面

第三个按钮是需要登录权限,点击进去因为没有权限,跳转到 登录页,登录完成后,重定向到页面3

回到首页,可以看到用户权限已经变成 ['logined', 'vip'],点击第三个按钮就能直接跳转到页面3了

按钮权限控制

web 端的按钮权限控制,想必很多同学都用过

vue 复制代码
<button v-perms="['operation:user:create']">按钮权限</button>

从后端获取权限列表,然后通过自定义指令 v-perms 获取到权限字段 operation:user:create

如果存在的话就显示元素,否则就隐藏元素

typescript 复制代码
// 从后端的获取权限列表,通常获取后存在缓存中,我们使用 pinia 获取
userBtnPermission: ["operation:user:create", "operation:user:update"]

由于微信小程序限制,我们没法使用自定义指令,所以我们可以通过 vite 插件的形式

<template> 中所有 v-perms="['operation:user:create']" 都转换成 v-if="perms(['operation:user:create'])"

checkBtnPermission 是一个全局函数,该函数主要功能是将 "用户按钮权限" 与 "按钮所需权限" 做对比

先在 src/utils/router.ts 中编写下 checkBtnPermission

typescript 复制代码
import { useUserStore } from '@/store'

export const checkBtnPermission = (btnPermissions: string[]) => {
  const store = useUserStore() // 放函数内是为了保证,每次调用都获取最新的用户权限
  const userBtnPermissions = store.getUserBtnPermission // 用户已有按钮权限
  const intersectionPermissions = btnPermissions.filter(item => userBtnPermissions.includes(item)) // 用户已有按钮权限与当前按钮权限的交集
  return !!(intersectionPermissions.length === btnPermissions.length)
}

判断其实也很简单哈

就是 "用户按钮权限" 与 "按钮所需权限" 做交集

再判断 "交集的长度" 与 "按钮权限长度" 是否一致

如果挂载到全局呢?可以通过 app.config.globalProperties.$permsperms 挂载到全局

typescript 复制代码
import { createSSRApp } from 'vue'
import { checkBtnPermission } from './utils'
import App from './App.vue'

export function createApp() {
  const app = createSSRApp(App)
  app.config.globalProperties.$perms = checkBtnPermission
  return {
    app
  }
}

接下来就是最关键的部分,vite 插件的实现

vite 都是放在 plugins 配置下导入,而这个插件需要最优化度高一些,那么得传入一些参数,比如 指令名称

src/vite-plugin/vite-plugin-directives.ts 中我们编写下vite 插件

typescript 复制代码
import { Plugin } from 'vite'
import { parse } from 'vue/compiler-sfc'

const vitePluginDirectives = ({ directives = 'v-perms' }): Plugin => {
  return {
    name: 'vite-plugin-directives',
    // code 是源码,path 是文件路径
    transform(code, path) {
      try {
        if (!/.vue$/.test(path)) return null //  如果不是vue文件,则返回null
        const parseCode = parse(code) // parse 的作用是将源码解析成 ast
        if (!parseCode) return null //  如果解析失败,则返回null
        if (!parseCode.descriptor?.template?.content) return null // 如果没有模板内容,则返回null

        // 正则匹配 (根据传入的指令名称) /v-perms="([^"]+)"/g
        const reg = new RegExp(`${directives}="([^"]+)"`, 'g') // 匹配指令
        const checkPermsName = directives.slice(2) // 检查函数名称 与 指令名称一致
        const $code = parseCode.descriptor.template.content.replace(reg, `v-if="$${checkPermsName}($1)"`) // 模板内容
        return {
          code: code.replace(parseCode.descriptor.template.content, $code)
        }
      } catch (error) {
        return null
      }
    }
  }
}

export default vitePluginDirectives

大家有没有发现,说的高大上一点就是 vite 插件

通俗的说不就是一个函数,然后函数里返回一些配置嘛

  • name 为插件名称
  • transform 代码转译,这个函数的功能类似于 webpackloader

transfrom 参数

  • code 源码
  • path 是文件路径
typescript 复制代码
if (!/.vue$/.test(path)) return null //  如果不是vue文件,则返回null
const parseCode = parse(code) // parse 的作用是将源码解析成 ast
if (!parseCode) return null //  如果解析失败,则返回null
if (!parseCode.descriptor?.template?.content) return null // 如果没有模板内容,则返回null

先对文件进行判断,我们只需要 .vue 结尾的文件,通过正则对路径判断

parse 将 源码转成 ast 树,方便我们查找

path、code、parseCode 内容结构如下

typescript 复制代码
// 正则匹配 (根据传入的指令名称) /v-perms="([^"]+)"/g
const reg = new RegExp(`${directives}="([^"]+)"`, 'g') // 匹配指令
const checkPermsName = directives.slice(2) // 检查函数名称 与 指令名称一致
const $code = parseCode.descriptor.template.content.replace(reg, `v-if="$${checkPermsName}($1)"`) // 模板内容
return {
  code: code.replace(parseCode.descriptor.template.content, $code)
}

我们只需对 tempalte 中的内容进行判断,并通过正则将 自定义指令 转为 v-if 进行判断

并且我们传入了 directives 也是就自定义指令的名称,我们通过 new RegExp 加到正则中

因为 directivesv-xxx 的结构,替换为 v-if=$xxx() 中的函数,需要去掉 v-

所以,通过 slice 只拿 v- 后面的内容

最后,把 替换后的源码 返回

至此,我们的按钮权限就完成了,我们来测试下

我们用户已有按钮权限是 userBtnPermission: ['operation:user:create', 'operation:user:update']

而我们定义了两个按钮

  • 第一个按钮权限为 operation:user:create ,这权限我们有,所以显示在页面上
  • 第二个按钮权限为 operation:user:delete ,这权限我们没有,所以页面上没有显示
vue 复制代码
<button v-perms="['operation:user:create']">有按钮权限: operation:user:create</button>
<button v-perms="['operation:user:delete']">无按钮权限: operation:user:delete</button>

总结

页面权限,我们主要通过拦截器获取到页面的 url

然后去拿 route-block 中页面权限,然后和用户已有的页面权限进行对比

之后,通过拦截器函数,返回 true 放行,返回 false 拦截

并且,对缺少了登录权限进行特殊处理

如果页面缺失了登录权限,就会跳转到登录页,并加上重定向路径

编写重定向函数,登录完成后再重定向回去

后续,可以在这个基础,继续增加缺失特殊权限的处理,比如,页面缺少 vip 权限,那就跳到购买 vip 的页面等等

按钮权限的话,因为微信小程序没有自定义指令,为了实现这个功能

定义全局判断函数 checkBtnPermission,并在 main.ts 中注册成全局函数

编写自定义 vite 插件

介绍了 匹配文件、源码转 ast、源码替换

最终将源码中 v-perms="['operation:user:create']" 都转换成 v-if="perms(['operation:user:create'])"

专栏更新

这是我编写的专栏 零基础入门《前端工程化》到年薪百万【15-权限篇】

这个专栏主要教你如何,从零开始编写一个 uniapp 开发模板,并且搭配对应的模板下载脚手架~

这个 uniapp 开发模板,包含了,代码规范配置、VScode插件、Layout、国际化、请求封装、权限控制、AI辅助等一些系列功能

每一个部分都会写一篇文章,这些系列的文章都会归入 零基础入门《前端工程化》到年薪百万 这个专栏中

一个超超超好用的 uniapp 开发框架【uni-plus】

相关推荐
天宇&嘘月2 小时前
web第三次作业
前端·javascript·css
小王不会写code2 小时前
axios
前端·javascript·axios
发呆的薇薇°3 小时前
vue3 配置@根路径
前端·vue.js
luckyext3 小时前
HBuilderX中,VUE生成随机数字,vue调用随机数函数
前端·javascript·vue.js·微信小程序·小程序
小小码农(找工作版)4 小时前
JavaScript 前端面试 4(作用域链、this)
前端·javascript·面试
前端没钱4 小时前
前端需要学习 Docker 吗?
前端·学习·docker
前端郭德纲4 小时前
前端自动化部署的极简方案
运维·前端·自动化
海绵宝宝_4 小时前
【HarmonyOS NEXT】获取正式应用签名证书的签名信息
android·前端·华为·harmonyos·鸿蒙·鸿蒙应用开发
码农土豆5 小时前
chrome V3插件开发,调用 chrome.action.setIcon,提示路径找不到
前端·chrome
鱼樱前端5 小时前
深入JavaScript引擎与模块加载机制:从V8原理到模块化实战
前端·javascript