普通视图

发现新文章,点击刷新页面。
昨天 — 2025年12月11日首页

从生活实例解释什么是AST抽象语法树

作者 小胖霞
2025年12月11日 09:54

AST(Abstract Syntax Tree,抽象语法树)  听起来很高深,但其实它的核心概念非常简单:把“文本”变成“结构化的数据对象” ,方便机器理解和操作。就是把字符串形式的代码转换成机器能看懂、能操作的结构化数据—— 你可以把它理解成:代码的 “说明书”/“骨架”

机器(比如 Babel、Vue 编译器)看不懂直接的字符串代码(比如const a = 1),但能看懂 AST 这种 “键值对 + 层级结构” 的 JSON-like 数据,从而实现「修改代码、转换代码、分析代码」。

为了让你彻底明白,我们分两步走:先看生活中的例子,再看 Vue 中的实际应用。


第一部分:生活中的例子 —— “点外卖”

假设你是个复杂的客户,你给服务员说了一句很长的话(这就是源代码 Source Code):

“我要一个牛肉汉堡,不要洋葱,加双份芝士,还要一杯可乐,去冰。”

1. 为什么需要 AST?

如果你直接把这句话扔给后厨的厨师,厨师可能听懵了,或者容易漏掉细节。计算机也是一样,它看不懂这一长串字符串,它需要一个清晰的清单

2. 生成 AST(解析过程)

前台服务员(编译器/解析器)听到这句话后,会在点餐系统里输入一张结构化的单子。这张单子就是 AST

它大概长这样:

{
  "类型": "订单",
  "内容": [
    {
      "商品": "牛肉汉堡",
      "配料修改": [
        { "操作": "移除", "物品": "洋葱" },
        { "操作": "添加", "物品": "芝士", "数量": 2 }
      ]
    },
    {
      "商品": "可乐",
      "属性": [
        { "温度": "去冰" }
      ]
    }
  ]
}

3. 这个例子的核心点:

  • 源代码:那句口语(字符串)。
  • AST:那张结构化的单子(JSON 对象)。
  • 作用:有了这张单子,厨师(浏览器/JS引擎)不需要去分析语法,直接看字段就能精准干活;甚至如果需要把“汉堡”换成“三明治”,改单子(修改 AST)比改口语容易得多。

二、回到代码:AST 到底解决了什么问题?

场景:你写了一行代码 const msg = 'hello',想把它改成 var message = 'hello'

  • 如果你直接改字符串:需要 “找 const→替换成 var,找 msg→替换成 message”,但代码复杂时(比如嵌套函数、多文件),手动 / 字符串替换极易出错;
  • 用 AST 改:机器先把代码转成 AST(结构化数据),再精准修改节点,最后转回代码 —— 安全、精准、可批量操作。

第一步:解析(Parse)—— 代码→AST

const msg = 'hello' 对应的 AST 简化结构:

{
  "type": "VariableDeclaration", // 节点类型:变量声明
  "kind": "const", // 变量类型:const
  "declarations": [
    {
      "type": "VariableDeclarator",
      "id": { "type": "Identifier", "name": "msg" }, // 变量名:msg
      "init": { "type": "Literal", "value": "hello" } // 变量值:hello
    }
  ]
}

此时代码不再是字符串,而是 “变量声明节点 + 变量名节点 + 值节点” 的结构化数据,每个部分都有明确标识。

第二步:转换(Transform)—— 修改 AST

机器遍历 AST,精准修改指定节点。比如我们想把const改成varmsg改成message

// 伪代码:修改 AST 节点 
ast.kind = "var"; // 把const换成var
ast.declarations[0].id.name = "message"; // 把msg换成message

修改后的 AST

{
  "type": "VariableDeclaration",
  "kind": "var", // 已修改
  "declarations": [
    {
      "type": "VariableDeclarator",
      "id": { "type": "Identifier", "name": "message" }, // 已修改
      "init": { "type": "Literal", "value": "hello" }
    }
  ]
}

第三步:生成(Generate)——AST→代码

把修改后的 AST 转回字符串代码,核心是 “遍历 AST 树,根据节点类型拼接代码”。我们可以写一个极简的生成函数模拟这个过程:

// 迷你AST生成器:遍历节点拼接代码
function generateCode(astNode) {
  // 处理变量声明节点
  if (astNode.type === "VariableDeclaration") {
    const declarations = astNode.declarations.map(decl => {
      const name = decl.id.name;
      const value = decl.init.value;
      return `${name} = '${value}'`;
    }).join(', ');
    return `${astNode.kind} ${declarations};`;
  }
}

// 执行生成
const newCode = generateCode(ast);
console.log(newCode); // 输出:var message = 'hello';

修改后的 AST 转回字符串代码:var message = 'hello'

真实场景中,Babel、Vue 编译器会用更完善的生成器(如@babel/generator),但核心逻辑都是 “节点类型→代码片段→拼接”。

Vue 中的 AST

在 Vue 中,AST 主要用于模板编译(Template Compilation)

浏览器其实只认识 HTML、CSS 和 JS。它根本不认识 Vue 的 .vue 文件,也不认识 v-if、v-for这种语法。

Vue 需要把你的 `` 变成浏览器能运行的 render 函数,中间的桥梁就是 AST

  1. 源代码(你写的 Vue 模板)
<div id="app">
  <p>你好</p>
</div>

这就好比刚才那句“我要一个汉堡...”,对浏览器来说,这只是一串普通的字符串。

2. 解析成的 AST(Vue 内部生成的树)

Vue 的编译器会把上面的 HTML 字符串“拆解”,变成下面这样的 JavaScript 对象(简化版):

const ast = {
  // 标签类型
  tag: &#34;div&#34;,
  // 属性列表
  attrs: [{ name: &#34;id&#34;, value: &#34;app&#34; }],
  // 子节点列表
  children: [
    {
      tag: &#34;p&#34;,
      // 指令被解析成了专门的属性
      if: &#34;show&#34;, 
      children: [
        {
          type: &#34;text&#34;,
          text: &#34;你好&#34;
        }
      ]
    }
  ]
};

3. 为什么要转成 AST?(Vue 拿它干什么?)

一旦变成了上面这种树形对象,Vue 就可以对代码进行**“手术”“优化”**:

  1. 识别指令:Vue 扫描这棵树,发现 p 节点有个 if: "show"。于是它知道:生成代码时,要给这行代码加个 if (show) { ... } 的判断逻辑。
  2. 静态提升(优化性能) :Vue 3 扫描 AST,发现 "你好" 是纯文本,永远不会变。Vue 就会给它打个标记:“这块不需要每次渲染都比较,直接复用”。(如果只是看字符串,很难做这种复杂的分析)。

AST 的下一步,是生成 render 函数代码(渲染函数)。

要搞懂 AST 如何转回字符串代码,核心是理解「AST 生成器(Generator)」的工作逻辑 —— 它本质是深度遍历 AST 树,根据每个节点的类型和属性,拼接出对应的代码字符串

第一阶段:AST ➡️ Render 函数代码

这就是浏览器能“认识”的第一步:因为它变成了标准的 JavaScript 代码。

浏览器虽然不懂 <p>,但它懂 JavaScript 的 if 或者三元运算符 ? :。

举个栗子

你的 Vue 模板(源代码):

<div id="app">
  <p>你好</p>
</div>

生成的 AST(中间产物,略):
(就是一个描述结构的 JSON 对象)

AST 转换后生成的 Render 函数代码(最终产物):

Vue 的编译器会根据 AST,拼接出一段 纯 JavaScript 字符串,长得像这样(为了方便阅读,我简化了 Vue 内部的函数名):

function render() {
  // _c = createElement (创建元素)
  // _v = createTextVNode (创建文本)
  // _e = createEmptyVNode (创建空节点,用于 v-if 为 false 时)

  return _c('div', { attrs: { &#34;id&#34;: &#34;app&#34; } }, [
    // 重点看这里!v-if 被变成了 JS 的三元运算符
    (show) 
      ? _c('p', [_v(&#34;你好&#34;)]) 
      : _e()
  ])
}

这里的核心变化:

  1. HTML 标签 变成了函数调用 _c('div')。
  2. v-if="show"  消失了,变成了原生的 JS 逻辑 (show) ? ... : ...。
  3. 浏览器完全认识这段代码!  这就是一段标准的 JS 函数,里面全是函数调用和逻辑判断。

第二阶段:浏览器怎么把这段代码变成画面?

你可能会问:“浏览器运行了这个函数,然后呢?屏幕上怎么就有字了?”

这里有两个步骤:生成虚拟 DOM ➡️ 转为真实 DOM

1. 运行 Render 函数,得到 虚拟 DOM (Virtual DOM)

当 Vue 运行时(Runtime)执行上面的 render 函数时,浏览器并不会立即去画界面,而是返回一个 JS 对象树,这叫做 VNode(虚拟节点)

执行 render() 后得到的返回值:

// 这是一个纯 JS 对象,不是真实的 DOM 元素
{
  tag: 'div',
  data: { attrs: { id: 'app' } },
  children: [
    {
      tag: 'p',
      children: [{ text: '你好' }]
    }
  ]
}

为什么要多这一步?
因为操作真实 DOM(网页上的元素)非常慢,而操作 JS 对象非常快。Vue 可以在这个 JS 对象上做各种计算(比如 Diff 算法),确认没问题了,再动手改网页。

2. Patch(修补/渲染)➡️ 真实 DOM

这是最后一步。Vue 的运行时系统(Runtime)会拿着上面的 VNode,调用浏览器底层的 DOM API

这时候,浏览器才真正干活:

  • Vue 看到 tag: 'div' ➡️ 调用 document.createElement('div')
  • Vue 看到 attrs: { id: 'app' } ➡️ 调用 el.setAttribute('id', 'app')
  • Vue 看到 text: '你好' ➡️ 调用 document.createTextNode('你好')
  • 最后把它们拼在一起,挂载到页面上。

总结

  • AST 是什么?
    它是代码的骨架图。它把代码从“一行行文本”变成了“层级分明的对象”。
  • Vue 里的流程:
    template (字符串) ➡️ AST (树形对象)  ➡️ render 函数 (可执行 JS) ➡️ 虚拟 DOM ➡️ 真实 DOM。

vite创建的vue项目是通过babel还是vue自己编译器编译的

在默认的 Vite + Vue 项目中,绝大多数情况下,是不需要 Babel 的,也没有用 Babel。 它的分工是这样的:

  1. .vue 文件的编译(Template -> Render函数) :完全依靠 Vue 自己的编译器(@vue/compiler-sfc)。
  2. JS/TS 语法的转译(ES6+ -> 浏览器能跑的代码) :主要依靠 Esbuild(一个用 Go 语言写的、速度极快的构建工具)。

详细拆解:谁在干活?

为了搞清楚这个问题,我们需要把你写代码时的两个“转换”动作分开看:

1. 动作一:把 Vue 模板变成 JS 代码

也就是刚才我们聊的:v-if -> render 函数。

  • 负责工头Vue Compiler (@vue/compiler-sfc)

  • 工具链:Vite 里的插件 @vitejs/plugin-vue 会调用这个 Vue 编译器。

  • AST 产生地:这里产生的 AST 是 Vue 专有的 Template AST

  • 结论:这块跟 Babel 毫无关系。哪怕你安装了 Babel,Vue 模板编译也不归 Babel 管。

    2. 动作二:把高级 JS/TS 变成浏览器能懂的 JS

比如你用了箭头函数 () => {},或者 TypeScript 的类型标注 name: string,或者最新的 ?. 语法。

  • 传统做法(Webpack 时代)
    这是 Babel 的地盘。Webpack 会用 babel-loader 把这些新语法转成老旧的 ES5 代码,为了兼容 IE 等老浏览器。

  • 现代做法(Vite 时代)
    Vite 默认认为你不需要兼容 IE(除非你专门配置)。现代浏览器(Chrome, Edge, Firefox, Safari)都已经支持 ES6 模块了。

    • 开发环境 (npm run dev)
      Vite 使用 Esbuild 来处理 JS 和 TS。
      Esbuild 比 Babel 快 10-100 倍。因为它不需要把代码转成复杂的 ES5,只需要把 TypeScript 的类型去掉,把极少数浏览器不支持的语法微调一下即可。

    • 生产打包 (npm run build)
      Vite 使用 Rollup 进行打包,同时默认使用 Esbuild 进行代码压缩和转换。

      什么时候 Vite 才会用到 Babel?

虽然 Vite 默认不用 Babel,但在一种情况下它会把 Babel 请回来:

你需要兼容“老古董”浏览器时(比如 IE11 或旧版 Chrome)。 如果你安装了 @vitejs/plugin-legacy 插件:

// vite.config.js
import legacy from '@vitejs/plugin-legacy'

export default {
  plugins: [
    vue(),
    legacy({
      targets: ['ie >= 11'], // 只要你需要支持这些老家伙
      additionalLegacyPolyfills: ['regenerator-runtime/runtime']
    })
  ]
}

这时候,Vite 就会在打包时自动下载并使用 Babel,把你的现代代码狠狠地转译成 ES5,以确保在老浏览器上不报错。

总结对照表

任务 Webpack (Vue CLI) Vite (现代模式)
解析 .vue 模板 Vue Loader (调用 Vue Compiler) @vitejs/plugin-vue (调用 Vue Compiler)
JS 转译 (ES6->ES5) Babel (必装,很慢) Esbuild (内置,极快,不转 ES5)
TS 转译 Babel 或 ts-loader Esbuild (毫秒级完成)
AST 类型 Vue AST + Babel AST Vue AST + Esbuild AST

Vite 项目里:

  1. Vue 编译器 负责把  里的代码变成 render 函数(利用 Vue AST)。

  2. Esbuild 负责把你的 JS/TS 变成浏览器能运行的 JS(不做过度的向下兼容)。

  3. Babel 默认是不存在的,除非你为了兼容性专门请它出山。

    所以,Vite 快的原因之一,就是把“慢吞吞”的 Babel 给优化掉了!


结论

转换成 AST 之后的代码,就是 render 函数(JavaScript 代码)。

怎么让浏览器认识?
因为那已经是纯粹的 JavaScript 了!浏览器执行这段 JS,生成虚拟节点对象,最后 Vue 内部通过 document.createElement 等原生 API 把这些对象变成了屏幕上的像素。

昨天以前首页

企业级RBAC 实战(八)手撸后端动态路由,拒绝前端硬编码

作者 小胖霞
2025年12月10日 09:29

在企业级后台中,硬编码路由(写死在 router/index.js)是维护的噩梦。本文将深入讲解如何根据用户权限动态生成侧边栏菜单。我们将构建后端的 getRouters 递归接口,并在前端利用 Vite 的 import.meta.glob 实现组件的动态挂载,最后彻底解决路由守卫中经典的“死循环”和“刷新白屏”问题。

学习之前先浏览 前置专栏文章

一、 引言:为什么要做动态路由?

在简单的后台应用中,我们通常会在前端 router/routes.ts 中写死所有路由。但在 RBAC(基于角色的权限控制)模型下,这种做法有两个致命缺陷:

  1. 安全性低:普通用户虽然看不到菜单,但如果在浏览器地址栏手动输入 URL,依然能进入管理员页面。
  2. 维护成本高:每次新增页面都要修改前端代码并重新打包部署。

目标:前端只保留“登录”和“404”等基础页面,其他所有业务路由由后端根据当前用户的角色权限动态返回。

二、 后端实现:构建路由树 (getRouters)

后端的核心任务是:查询当前用户的菜单 -> 过滤掉隐藏的/无权限的 -> 组装成 Vue Router 需要的 JSON 树。

1. 数据结构转换

我们在上一篇设计了 sys_menus 表。Vue Router 需要的结构包含 path, component, meta 等字段。我们需要一个递归函数将扁平的数据库记录转为树形结构。

文件:routes/menu.js

// 辅助函数:将数据库扁平数据转为树形结构
function buildTree(items, parentId = 0) {
  const result = []
  for (const item of items) {
    // 兼容字符串和数字的 ID 对比
    if (item.parent_id == parentId) {
      // 组装 Vue Router 标准结构
      const route = {
        name: toCamelCase(item.path), // 自动生成驼峰 Name
        path: item.path,
        hidden: item.hidden === 1,    // 数据库 1/0 转布尔
        component: item.component,    // 此时还是字符串,如 "system/user/index"
         // 只有当 redirect 有值时才添加该字段
        ...(item.redirect && { redirect: item.redirect }),
        // 只有当 always_show 为 1 时才添加,并转为布尔
        ...(item.alwaysShow === 1 && { alwaysShow: true }),
        meta: {
          title: item.menu_name,
          icon: item.icon,
          noCache: item.no_cache === 1
        }
      }
      
      const children = buildTree(items, item.id)
      if (children.length > 0) {
        route.children = children
      }
      result.push(route)
    }
  }
  return result
}

2. 接口实现

这里有一个关键逻辑:上帝模式普通模式的区别。

  • Admin:直接查 sys_menus 全表(排除被物理删除的)。
  • 普通用户:通过 sys_users -> sys_roles -> sys_role_menus -> sys_menus 进行四表联查,只获取拥有的权限。

文件 route/menu.js

router.get('/getRouters', authMiddleware, async (req, res, next) => {
  try {
    const userId = req.user.userId
    const { isAdmin } = req.user

    let sql = ''
    let params = []

    const baseFields = `m.id, m.parent_id, m.menu_name, m.path, m.component, m.icon, m.hidden`

    if (isAdmin) {
      // 管理员:看所有非隐藏菜单
      sql = `SELECT ${baseFields} FROM sys_menus m WHERE m.hidden = 0 ORDER BY m.sort ASC`
    } else {
      // 普通用户:通过中间表关联查询
      sql = `
        SELECT ${baseFields} 
        FROM sys_menus m
        LEFT JOIN sys_role_menus rm ON m.id = rm.menu_id
        LEFT JOIN sys_users u ON u.role_id = rm.role_id
        WHERE u.id = ? AND m.hidden = 0
        ORDER BY m.sort ASC
      `
      params.push(userId)
    }

    const [rows] = await pool.query(sql, params)
    const menuTree = buildTree(rows)

    res.json({ code: 200, data: menuTree })
  } catch (err) {
    next(err)
  }
})

三、 前端实现:组件动态加载

前端拿到后端的 JSON 后,最大的难点在于:后端返回的 component 是字符串 "system/user/index",而 Vue Router 需要的是一个 Promise 组件对象 () => import(...)

在 Webpack 时代我们要用 require.context,而在 Vite 中,我们要用 import.meta.glob。

1. Store 逻辑 (store/modules/permission.ts)

import { defineStore } from 'pinia'
import { constantRoutes } from '@/router'
import { getRouters } from '@/api/menu'
import Layout from '@/layout/index.vue'

// 1. Vite 核心:一次性匹配 views 目录下所有 .vue 文件
// 结果类似: { '../../views/system/user.vue': () => import(...) }
const modules = import.meta.glob('../../views/**/*.vue')

export const usePermissionStore = defineStore('permission', {
  state: () => ({
    routes: [],        // 完整路由(侧边栏用)
    addRoutes: [],     // 动态路由(router.addRoute用)
    sidebarRouters: [] // 侧边栏菜单
  }),
  
  actions: {
    async generateRoutes() {
      // 请求后端
      const res: any = await getRouters()
      const sdata = JSON.parse(JSON.stringify(res.data))
      
      // 转换逻辑
      const rewriteRoutes = filterAsyncRouter(sdata)
      
      this.addRoutes = rewriteRoutes
      this.sidebarRouters = constantRoutes.concat(rewriteRoutes)
      
      return rewriteRoutes
    }
  }
})

// 遍历后台传来的路由字符串,转换为组件对象
function filterAsyncRouter(asyncRouterMap: any[]) {
  return asyncRouterMap.filter(route => {
    if (route.component) {
      if (route.component === 'Layout') {
        route.component = Layout
      } else {
        // 核心:根据字符串去 modules 里找对应的 import 函数
        route.component = loadView(route.component)
      }
    }
    // ... 递归处理 children
    if (route.children && route.children.length) {
      route.children = filterAsyncRouter(route.children)
    }
    return true
  })
}

export const loadView = (view: string) => {
  let res
  for (const path in modules) {
    // 路径匹配逻辑:从 ../../views/system/user.vue 中提取 system/user
    const dir = path.split('views/')[1].split('.vue')[0]
    if (dir === view) {
      res = () => modules[path]()
    }
  }
  return res
}

四、 核心难点:路由守卫与“死循环”

在 src/permission.ts 中,我们需要拦截页面跳转,如果是第一次进入,则请求菜单并添加到路由中。

1. 经典死循环问题

很多新手会这样写判断:

// ❌ 错误写法
if (userStore.roles.length === 0) {
  // 去拉取用户信息 -> 生成路由 -> next()
}

Bug 场景:如果新建了一个没有任何角色的用户 user01,后端返回的 roles 是空数组。

  1. roles.length 为 0,进入 if。
  2. 拉取信息,发现还是空数组。
  3. next(to) 重定向,重新进入守卫。
  4. roles.length 依然为 0... 死循环,浏览器崩溃

2. 解决方案:引入 isInfoLoaded 状态位

我们在 userStore 中增加一个 isInfoLoaded 布尔值,专门标记“是否已经尝试过拉取用户信息”

文件:src/permission.ts

import router from '@/router'
import { useUserStore } from '@/store/modules/user'
import { usePermissionStore } from '@/store/modules/permission' // 引入新的 store
import NProgress from 'nprogress'
import 'nprogress/nprogress.css'

NProgress.configure({ showSpinner: false })

const whiteList = ['/login', '/404']

router.beforeEach(async (to, from, next) => {
  NProgress.start()
  const userStore = useUserStore()
  const permissionStore = usePermissionStore()

  if (userStore.token) {
    if (to.path === '/login') {
      next({ path: '/' })
      NProgress.done()
    } else {
      // 判断当前用户是否已拉取完 user_info 信息
      // 这里我们可以简单判断:如果 userStore.userInfo.roles.length==0 动态添加的菜单长度为0,说明还没请求菜单,但似乎 这么写  如果 用户没角色  会陷入死循环
      if (!userStore.isInfoLoaded) {
        try {
          // 2. 生成动态路由 (后端请求)
          const userInfo = await userStore.getInfo()
          console.log('userInfo--', userInfo)
          if (userInfo && userInfo.data.id) {
            const accessRoutes = await permissionStore.generateRoutes()
            // // 3. 后端返回的路由
            console.log('accessRoutes', accessRoutes)
            // 4. 动态添加路由
            accessRoutes.forEach((route) => {
              router.addRoute(route)
            })
          }
          // 4. 确保路由添加完成 (Hack方法)
          next({ path: to.path, query: to.query, replace: true })
        } catch (err) {
          console.log('userinfo -err', err)
          userStore.logout()
          next(`/login?redirect=${to.path}`)
          NProgress.done()
        }
      } else {
        next()
      }
    }
  } else {
    if (whiteList.includes(to.path)) {
      console.log('to.path', to.path)
      next()
    } else {
      next(`/login?redirect=${to.path}`)
      NProgress.done()
    }
  }
})

router.afterEach(() => {
  NProgress.done()
})

五、 侧边栏递归渲染

最后一步是将 sidebarRouters 渲染到左侧菜单. 这里需要注意一个细节:el-tree 或 el-menu 的父节点折叠问题
如果一个目录只有一个子菜单(例如“首页”),我们通常希望直接显示子菜单,不显示父目录。

文件 layout/components/slideBar/index.vue

<template>
  <div :class="classObj">
    <Logo v-if="showLogo" :collapse="isCollapse" />
    <el-scrollbar wrap-class="scrollbar-wrapper" :class="sideTheme">
      <el-menu
        router
        :default-active="activeMenu"
        :collapse="isCollapse"
        :background-color="
          settingsStore.sideTheme === 'dark'
            ? variables.menuBackground
            : variables.menuLightBackground
        "
        :text-color="
          settingsStore.sideTheme === 'dark'
            ? variables.menuColor
            : variables.menuLightColor
        "
        :active-text-color="theme"
        :unique-opened="true"
        :collapse-transition="false"
      >
        <template v-for="item in slidebarRouters" :key="item.path">
          <!-- 如果只有一个子菜单,直接显示子菜单 -->
          <el-menu-item
            v-if="item.children && item.children.length === 1"
            :index="item.path + '/' + item.children[0].path"
          >
            <el-icon v-if="item.children[0].meta?.icon">
              <component :is="item.children[0].meta.icon" />
            </el-icon>
            <span>{{ item.children[0].meta.title }}</span>
          </el-menu-item>

          <!-- 如果有多个子菜单,显示下拉菜单 -->
          <el-sub-menu
            v-else-if="item.children && item.children.length > 1"
            :index="item.path"
          >
            <template #title>
              <el-icon v-if="item.meta?.icon">
                <component :is="item.meta.icon" />
              </el-icon>
              <span>{{ item.meta.title }}</span>
            </template>
            <el-menu-item
              v-for="subItem in item.children"
              :key="subItem.path"
              :index="item.path + '/' + subItem.path"
            >
              <el-icon v-if="subItem.meta?.icon">
                <component :is="subItem.meta.icon" />
              </el-icon>
              <span>{{ subItem.meta.title }}</span>
            </el-menu-item>
          </el-sub-menu>

          <!-- 如果没有子菜单,直接显示当前菜单 -->
          <el-menu-item v-else :index="item.path">
            <el-icon v-if="item.meta?.icon">
              <component :is="item.meta.icon" />
            </el-icon>
            <span>{{ item.meta.title }}</span>
          </el-menu-item>
        </template>
      </el-menu>
    </el-scrollbar>
  </div>
</template>

<script setup lang="ts">
import { computed } from 'vue'
import Logo from './Logo.vue'
import { useRoute } from 'vue-router'
import variables from '@/assets/styles/var.module.scss'
const route = useRoute()

import { useSettingsStore } from '@/store/modules/settings'
import { useAppStore } from '@/store/modules/app'
import { usePermissionStore } from '@/store/modules/permission'
const appStore = useAppStore()
const settingsStore = useSettingsStore()
const permissionStore = usePermissionStore()
const sideTheme = computed(() => settingsStore.sideTheme)
const theme = computed(() => settingsStore.theme)
const showLogo = computed(() => settingsStore.showLogo)
const isCollapse = computed(() => !appStore.sidebar.opened)
const classObj = computed(() => ({
  dark: sideTheme.value === 'dark',
  light: sideTheme.value === 'light',
  'has-logo': settingsStore.showLogo,
}))

const slidebarRouters = computed(() =>
  permissionStore.sidebarRouters.filter((item) => {
    return !item.hidden
  })
)

console.log('slidebarRouters', slidebarRouters.value)

// 激活菜单
const activeMenu = computed(() => {
  const { path } = route
  return path
})
</script>

<style scoped lang="scss">
#app {
  .main-container {
    height: 100%;
    transition: margin-left 0.28s;
    margin-left: $base-sidebar-width;
    position: relative;
  }

  .sidebarHide {
    margin-left: 0 !important;
  }

  .sidebar-container {
    -webkit-transition: width 0.28s;
    transition: width 0.28s;
    width: $base-sidebar-width !important;
    background-color: $base-menu-background;
    height: 100%;
    position: fixed;
    font-size: 0px;
    top: 0;
    bottom: 0;
    left: 0;
    z-index: 1001;
    overflow: hidden;
    -webkit-box-shadow: 2px 0 6px rgba(0, 21, 41, 0.35);
    box-shadow: 2px 0 6px rgba(0, 21, 41, 0.35);

    // reset element-ui css
    .horizontal-collapse-transition {
      transition:
        0s width ease-in-out,
        0s padding-left ease-in-out,
        0s padding-right ease-in-out;
    }

    .scrollbar-wrapper {
      overflow-x: hidden !important;
    }

    .el-scrollbar__bar.is-vertical {
      right: 0px;
    }

    .el-scrollbar {
      height: 100%;
    }

    &.has-logo {
      .el-scrollbar {
        height: calc(100% - 50px);
      }
    }

    .is-horizontal {
      display: none;
    }

    a {
      display: inline-block;
      width: 100%;
      overflow: hidden;
    }

    .svg-icon {
      margin-right: 16px;
    }

    .el-menu {
      border: none;
      height: 100%;
      width: 100% !important;
    }

    .el-menu-item,
    .menu-title {
      overflow: hidden !important;
      text-overflow: ellipsis !important;
      white-space: nowrap !important;
    }

    .el-menu-item .el-menu-tooltip__trigger {
      display: inline-block !important;
    }

    // menu hover
    .sub-menu-title-noDropdown,
    .el-sub-menu__title {
      &:hover {
        background-color: rgba(0, 0, 0, 0.06) !important;
      }
    }

    & .theme-dark .is-active > .el-sub-menu__title {
      color: $base-menu-color-active !important;
    }

    & .nest-menu .el-sub-menu > .el-sub-menu__title,
    & .el-sub-menu .el-menu-item {
      min-width: $base-sidebar-width !important;

      &:hover {
        background-color: rgba(0, 0, 0, 0.06) !important;
      }
    }

    & .theme-dark .nest-menu .el-sub-menu > .el-sub-menu__title,
    & .theme-dark .el-sub-menu .el-menu-item {
      background-color: $base-sub-menu-background !important;

      &:hover {
        background-color: $base-sub-menu-hover !important;
      }
    }
  }

  .hideSidebar {
    .sidebar-container {
      width: 54px !important;
    }

    .main-container {
      margin-left: 54px;
    }

    .sub-menu-title-noDropdown {
      padding: 0 !important;
      position: relative;

      .el-tooltip {
        padding: 0 !important;

        .svg-icon {
          margin-left: 20px;
        }
      }
    }

    .el-sub-menu {
      overflow: hidden;

      & > .el-sub-menu__title {
        padding: 0 !important;

        .svg-icon {
          margin-left: 20px;
        }
      }
    }

    .el-menu--collapse {
      .el-sub-menu {
        & > .el-sub-menu__title {
          & > span {
            height: 0;
            width: 0;
            overflow: hidden;
            visibility: hidden;
            display: inline-block;
          }
          & > i {
            height: 0;
            width: 0;
            overflow: hidden;
            visibility: hidden;
            display: inline-block;
          }
        }
      }
    }
  }

  .el-menu--collapse .el-menu .el-sub-menu {
    min-width: $base-sidebar-width !important;
  }

  // mobile responsive
  .mobile {
    .main-container {
      margin-left: 0px;
    }

    .sidebar-container {
      transition: transform 0.28s;
      width: $base-sidebar-width !important;
    }

    &.hideSidebar {
      .sidebar-container {
        pointer-events: none;
        transition-duration: 0.3s;
        transform: translate3d(-$base-sidebar-width, 0, 0);
      }
    }
  }

  .withoutAnimation {
    .main-container,
    .sidebar-container {
      transition: none;
    }
  }
}

// when menu collapsed
.el-menu--vertical {
  & > .el-menu {
    .svg-icon {
      margin-right: 16px;
    }
  }

  .nest-menu .el-sub-menu > .el-sub-menu__title,
  .el-menu-item {
    &:hover {
      // you can use $sub-menuHover
      background-color: rgba(0, 0, 0, 0.06) !important;
    }
  }

  // the scroll bar appears when the sub-menu is too long
  > .el-menu--popup {
    max-height: 100vh;
    overflow-y: auto;

    &::-webkit-scrollbar-track-piece {
      background: #d3dce6;
    }

    &::-webkit-scrollbar {
      width: 6px;
    }

    &::-webkit-scrollbar-thumb {
      background: #99a9bf;
      border-radius: 20px;
    }
  }
}

.dark {
  background-color: $base-menu-background !important;
}
.light {
  background-color: $base-menu-light-background !important;
}
.scrollbar-wrapper {
  overflow-x: hidden !important;
}
.has-logo {
  .el-scrollbar {
    height: calc(100% - 50px);
  }
}
</style>

六、 总结与下篇预告

通过本篇实战,我们实现了:

  1. 后端:根据角色权限过滤菜单数据。
  2. 前端:利用 Vite 的 glob 导入特性,将后端字符串动态转为前端组件。
  3. 守卫:通过 isInfoLoaded 状态位完美解决了空权限用户的死循环问题。

现在的系统已经具备了动态菜单的能力。但是,如何更方便地管理这些用户和角色呢?  如果用户很多,列表怎么分页?怎么模糊搜索?

下一篇:《企业级全栈 RBAC 实战 (九):用户管理与 SQL 复杂查询优化》,我们将深入 Element Plus 表格组件与 MySQL 分页查询的结合。

❌
❌