• Vue3+Vite+ElementPlus管理系统常见问题


    本文本记录了使用 Vue3+Vite+ElementPlus 从0开始搭建一个前端工程会面临的常见问题,没有技术深度,但全都是解决实际问题的干货,可以当作是问题手册以备后用。本人日常工作偏后端开发,因此,文中的一些前端术语描述可能不严谨,敬请谅解。重点是:这里记录的解决方案都是行之有效果的,拿来即可用 🧑‍💻 🦾

    1. 页面整体布局

    通常管理后台有以下几种经典布局

    • 布局一:纯侧面菜单

      ┌────────────────────────────────────────────────────────────────────────────────┐
      │ LOGO Avatar | Exit │
      ├─────────────────────┬──────────────────────────────────────────────────────────┤
      │ MenuA │ │
      ├─────────────────────┤ │
      │ MenuItem1OfMenuA │ │
      ├─────────────────────┤ │
      │ MenuItem2OfMenuA │ │
      ├─────────────────────┤ Main Content Area │
      │ MenuB │ │
      ├─────────────────────┤ │
      │ │ │
      │ │ │
      │ │ │
      └─────────────────────┴──────────────────────────────────────────────────────────┘

    • 布局二:顶部菜单 + 侧面二级菜单

      ┌────────────────────────────────────────────────────────────────────────────────┐
      │ LOGO ┌───────┐ ┌───────┐ Avatar | Exit │
      │ │ MenuA │ │ MenuB │ │
      ├─────────────────────┬──┘ └──┴───────┴────────────────────────────────────┤
      │ SecondMenu-A-1 │ │
      ├─────────────────────┤ │
      │ ThirdMenuItem1-A-1 │ │
      ├─────────────────────┤ │
      │ ThirdMenuItem2-A-1 │ │
      ├─────────────────────┤ Main Content Area │
      │ SecondMenu-A-2 │ │
      ├─────────────────────┤ │
      │ │ │
      │ │ │
      │ │ │
      └─────────────────────┴──────────────────────────────────────────────────────────┘

    • 布局三:顶部菜单 + 侧面二级菜单 + 内容区一菜单一TAB

      ┌────────────────────────────────────────────────────────────────────────────────────┐
      │ LOGO ┌───────┐ ┌───────┐ Avatar | Exit │
      │ │ MenuA │ │ MenuB │ │
      ├─────────────────────┬───┘ └──┴───────┴───────────────────────────────────────┤
      │ SecondMenu-A-1 │ ┌────────────────────────┐ │
      ├─────────────────────┤ │ ThirdMenuItem2-A-1 x │ │
      │ ThirdMenuItem1-A-1 ├─┘ └───────────────────────────────────┤
      ├─────────────────────┤ │
      │ ThirdMenuItem2-A-1 │ │
      ├─────────────────────┤ │
      │ SecondMenu-A-2 │ Main Content Area │
      ├─────────────────────┤ │
      │ │ │
      │ │ │
      │ │ │
      └─────────────────────┴──────────────────────────────────────────────────────────────┘

    这个与 VUE 无关,是纯 HTML + CSS 基本功的问题,实现方案有多种,下面是一种基于 flex 的精简参考方案:

    Flex样式实现后台管理界面整体布局(点击查看)
    html>
    <html lang="en" style="margin:0; padding:0">
    <head>
    <title>Flex样式实现后台管理界面整体布局title>
    head>
    <body style="margin:0; padding:0">
    <div style="display:flex; flex-direction: column; height:100vh; width: 100vw;">
    <div style="background-color:red; height: 60px">
    顶部标题栏,特别说明:固定高度的区域,本身的display不能为flex, 否则高度会随内容而变,可以再嵌套一个flex布局的div
    div>
    <div style="background:white; display:flex; flex:1; overflow-y:auto;">
    <div style="background:black; width:230px; color:white; overflow-y:auto">
    左侧菜单栏,固定宽度
    div>
    <div style="overflow-y:auto; flex:1; background-color: yellow; padding: 14px 16px;">
    <div style="height:1500px;">
    <h2>主内容区
    <p>这里特意使用了一个 div 来代表具体的业务页面内容,并将其高度设得很大,以使其出现垂直滚动条效果 p>
    div>
    div>
    div>
    <div style="background:aqua; height:60px">
    底部信息栏,(但多数管理系统都会取消这它,以留出更多可视区域给内容展示)
    div>
    div>
    body>
    html>

    对于主内容区的「一菜单一TAB」模式,需要编写JS代码来完成,一般都是通过 el-menu + el-tabs 的组合来实现的。监听 el-menu 组件的 @change 事件,根据所激活的菜单项名称,动态地在主内容区添加TAB

    2. 页面刷新后,菜单激活页面的高亮展示问题

    el-menu 组件有个 router属性,将其设置为 true 后,点击菜单项,vue 路由就会自动变成 el-menu-item 组件中 index 属性指向的内容,并且该菜单项也会高亮显示

    如果点击浏览器的刷新按钮,el-menu 通常会不再高亮显示当前打开的路由页面。

    当然,如果 el-menu 指定了default-active属性,则刷新页面后,无论实际路由是什么,菜单栏都会高亮显示default-active属性对应的菜单项。因为刷新页面后,el-menu 组件也重新初始化了,因此它总是高亮default-active指向的菜单项。如果通过代码,将default-active的值改为刷新后的实际路由,则可解决此问题。

    需要特别注意的是:简单通过router.CurrentRoute.value的方式获取的当前路由,在一般情况下是ok的,但在刷新时,获取到的值要么为null,要么为/, 而不是url中实际的路由,需要通过监听这个值的变化才能获取到最真实的路由,示例代码如下:

    import {watch} from 'vue'
    import {useRouter} from 'vue-router';
    let router = useRouter()
    watch(
    () => router.currentRoute.value,
    (newRoute) => {
    // 这里已拿到最新的路由地址,可将其设置给 el-menu 的 default-active 属性
    console.log(newRoute.path)
    },
    { immediate: true }
    )

    3. el-input 组件换行问题

    这通常是我们在给el-input组件添加一个label时,会看到的现象,就像下面这样

    期望的界面: 实际的界面:
    ┌─────────────────┐ Company Name
    Company Name │ │ ┌─────────────────┐
    └─────────────────┘ │ │
    └─────────────────┘

    不只是el-input组件,只要是表单输入类组件,都会换行,有3种解决办法

    • 方法 1
      组件包裹起来,如下所示:

      "公司名称" style="width: 200px">
      <el-input v-model="companyName" placeholder="请输入公司名称" clearable />
    • 方法 2
      自己写一个div, 设置样式display:flex; fext-wrap:nowrap;, 然后将放置该div内即可

    • 方法 3
      组件添加display:inlinedisplay:inline-block样式,比如我们要实现下面这个效果

      ┌─────────────────┐ ┌─────────────────┐
      Student Age Range │ │ ~ │ │
      └─────────────────┘ └─────────────────┘

      可以下面这样写

      "Student Age Range">
      <el-input v-model="minAge" placeholder="最小值" clearable style="display:inline-block;" />
      <p style="display:inline-block; margin: 0 10px;"> ~ p>
      <el-input v-model="maxAge" placeholder="最大值" clearable style="display:inline-block;"/>

    4. el-form-item 组件设置了padding-bottom属性,但未设置padding-top

    由于其padding的上下不对称, 在页面上表现为视觉上的不对称,需要手动设置样式,建议全局为 .el-from-item 类添加对称的 padding

    5. 登录页面+非登录页面+路由处理+App.vue的组合协调问题

    一套管理管理系统,需具备以下基础特性:

    • a. 首次访问系统根 url 时,应该显示「登录」页面
    • b. 登录成功后,应该进入管理系统的「主页面」
    • c. 在管理系统的主页面,做任何菜单切换,主页面的主体结构不变,只在内容区展示菜单项对应的业务内容
      这里的主体结构是指:标题栏、菜单栏、底部信息栏(如果有的话)
    • d. 管理主页面应该提供「退出」入口,点击入口时,显示「登录」页面
    • e. 在浏览器地址栏直接输入一个「非登录」类 url 后,如果用户已经登录过,且凭证没有过期,则应该直接显示该 url 对应的内容,包括管理「主页面」的主体部分 和 url 指向的实际内容部分
    • f. 在浏览器地址栏直接输入一个「非登录」类 url 后,如果用户未登录,或登录凭证已过期,则应该跳转到「登录」页面
    • g. 在浏览器地址栏直接输入「登录」页面 的URL后,如果如果用户已经登录过,且凭证没有过期,则应该直接进入管理「主页面」并展示「管理首页菜单」的内容

    这些基本特征看似很多,其实核心问题就二个:如何实现登录页面与非登录页面的单独渲染,以及以匿名方式访问非登录页面时,自动跳转到登录页面,下面分别说明。

    5.1 登录页面与非登录页面的独立渲染

    因为非登录页面,通常有固定的布局(如本文第1章节所述),布局中会有一个主内容区,大量的业务组件就在这个区域内渲染。如果设计得不好,就会出现登录组件也被嵌入到这个主内容区的现象,使其成为非登录页面布局中的一个局部区块了,就像下面这样:

    期望的界面:

    ┌───────────────────────────────────────────────────────┐
    │ │
    │ ┌───────────────────┐ │
    │ Username │ │ │
    │ └───────────────────┘ │
    │ │
    │ ┌───────────────────┐ │
    │ Password │ │ │
    │ └───────────────────┘ │
    │ │
    │ ┌───────┐ │
    │ │ Login │ │
    │ └───────┘ │
    └───────────────────────────────────────────────────────┘

    实际的界面:

    ┌────────────────────────────────────────────────────────────┐
    │ LOGO Avatar │
    ├───────────────┬────────────────────────────────────────────┤
    │ │ ┌────────────────┐ │
    │ │ Username │ │ │
    │ │ └────────────────┘ │
    │ │ ┌────────────────┐ │
    │ Side Menu │ Passwrod │ │ │
    │ │ └────────────────┘ │
    │ │ ┌───────┐ │
    │ │ │ Login │ │
    │ │ └───────┘ │
    └───────────────┴────────────────────────────────────────────┘

    出现这个现象的原因是:Vue所有组件的统一入口是App.vue,其它组件都是在这个组件内渲染的。如果我们将非登录页面的布局写在App.vue里,就会出现上面的情况。

    方案一:单一 方式

    这个方法是让App.vue内容只有一个 组件,这样最灵活,然后再配置路由,将登录组件与非登录组件分成两组路由。示例代码如下:

    App.vue
    <template>
    <router-view/>
    template>
    LoginView.vue
    <template>
    <div> <h2>这是登录页面h2> div>
    template>
    MainView.vue
    <template>
    <div class="main-pane-container">
    <div class="header-pane">
    <header-content>header-content>
    div>
    <div class="center-pane">
    <div class="center-aside-pane">
    <center-aside-menu/>
    div>
    <div class="center-content-pane">
    <router-view/>
    div>
    div>
    div>
    template>
    <script setup>
    import { RouterView } from 'vue-router'
    import HeaderContent from './components/HeaderContent.vue'
    import CenterAsideMenu from './components/CenterAsideMenu.vue';
    script>
    router.js
    import { createRouter, createWebHistory } from 'vue-router'
    import HomeView from '../views/home/HomeView.vue'
    import LoginHomeView from '../views/login/LoginView.vue'
    import MainView from '../views/main/MainView.vue'
    const router = createRouter({
    history: createWebHistory(import.meta.env.BASE_URL),
    routes: [
    {
    path: '/login',
    name: 'login',
    component: LoginHomeView,
    meta: {
    // ② 允许匿名访问,即不需要登录
    anonymousAccess: true
    }
    },
    {
    path: '/',
    name: 'main',
    component: MainView,
    redirect: {path: '/login'},
    children: [
    {
    path: '/home',
    name: 'home',
    component: HomeView
    },
    {
    path: '/xxx',
    name: 'xxx-home',
    component: () => import('../views/xxx/XxxHomeView.vue')
    },
    {
    path: '/yyy',
    name: 'yyy-home',
    component: () => import('../views/yyy/YyyHomeView.vue')
    }
    ]
    },
    ]
    })

    根据以上路由,当访问 / 或 /home 或 /xxx-home 或 /yyy-home 时,App.vue 中的 会替换成 MainView 组件,而 MainView 组件实现了一个页面主体布局,主内容区(MainView.vue的代码①处)内部又是一个 , 它的内容由 / 后面的路由组件替换。/home 时由 HomeView 组件替换,/xxx-home 时由 XxxHomeView 组件替换。

    当访问 /login 时,App.vue 中的 会替换成 LoginView 组件,与 MainView 组件毫无关系,此时不会加载 MainView 组件,因此页面UI效果就不会出现 MainView 中的布局了,至此便实现了登录页面与非登录页面独立渲染的目的。

    方案二:多个 方式

    该方式利用路由的namen属性指定渲染组件,同样可以实现登录页面与非登录页面的独立渲染。其原理是在 App.vue 上,将整个系统的布局划分好,每一个区块都有对应一个命名路由。就像下面这样

    <template>
    <div id="app">
    <router-view name="header">router-view>
    <router-view name="sidebar">router-view>
    <router-view name="content">router-view>
     ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄
    <router-view name="footer">router-view>
    div>
    template>

    对非登录页面,将其归属到统一的一个根路由上,这个根路由拥有 header、sidebar、content 、footer 四个组件,这样只要在是匹配非登录页面的路由,这四个组件就一定会为渲染。对于非登录页面的路由,只提供一个content组件,这样 header、sidebar 和 footer 就都不会渲染了。比如下面这个路由

    点击查看代码
    const router = createRouter({
    history: createWebHistory(import.meta.env.BASE_URL),
    routes: [
    {
    name: 'default',
    path: '/',
    components: {
    header: HeaderComponent,
    sidebar: SidebarComponent,
    content: ContentComponent, // 非登录页面主内容组件
     ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄
    footer: FooterComponent
    },
    redirect: { name: 'login' },
    children: [......]
    },
    {
    name: 'login',
    path: '/login',
    components: {
    // 将登录组件命名为 content, 这样其它的 就不会渲染
    // App.vue 将只渲染
    content: resolve => require(['../views/login/LoginView.vue'], resolve)
     ̄ ̄ ̄ ̄ ̄
    },
    // ② 允许匿名访问,即不需要登录
    meta: {anonymousAccess: true}
    }
    ]
    })

    5.2 匿名访问非登录页面时,跳转到登录页面

    利用路由跳转期间的钩子函数(官方的术语为导航守卫),在跳转前做如下判断:

    • 目的页面是否允许匿名访问, 如果是则放行,这需要在路由上添加一个匿名访问标志,见上述代码的 ② 处
    • 如果不允许匿名访问,则进一步判断当前用户是否已登录,已登录则放行,反之则将目的页面改为登录页面

    示例代码如下(位于main.js文件中):

    import router from './router'
    // 全局路由监听
    router.beforeEach(function (to, from, next) {
     ̄ ̄ ̄ ̄ ̄ ̄ ̄
    // 无需登录的页面
    if (to.meta.anonymousAccess){
    next();
    return;
    }
    // 判断是否已登录
    if (isLogin()) {
    // 可以在此处进一步做页面的权限检查
    ....
    next();
    } else {
    next({path: '/login'});
    }
    });
    router.afterEach((to, from, next) => {
    window.scrollTo(0, 0);
    });

    6. 非开发环境中CSS、图片、JS等静态资源访问404问题

    6.1 public 目录下的静态资源 <推荐>

    这个目录应该放置那些几乎不会改动的静态资源,代码中应该使用绝对路径来引用它们。且 路径不能以public开头,示例如下:

    <template>
    <div>
    <img alt="public目录图片示例" src="/images/photo/little-scallion.jpg" />
    div>
    template>
    <style>
    .photo-gallery {
    background-image: url(/images/bg/jane-lotus.svg);
    }
    style>

    6.2 assets 目录下的静态资源

    自己编写的大多数公共css、js都应该放在这个目录下,但对于图片,只要不是用来制作独立组件,建议还是放在/public目录下。

    当然,这里要针对的就是图片在assets目录下的情况,代码中应该使用绝对路径下引用它们。但该目录下的文件,在开发环境和非开发环境下有些差异,比如:

    • src 目录在非开发环境中是没有的,因此代码中不能直接以 /src/assets 开头
    • assets 下的文件名,在编译后会追加随机hash码,且没有二级目录
      比如:/src/assets/images/sports/badminton.png 会变成 /assets/badminton-04c6f8ef.png

    在代码中可以通过 @ 来代表 src 目录在具体运行环境中的位置,至于文件名中追加的 hash 值则不用关心,打包构建时,会一并将代码中的引用也改过来。简而言之,像下面示例中这样书写就OK了。

    <template>
    <div>
    <img alt="assets目录图片示例" src="@/assets/sports/badminton.jpg" />
    div>
    template>
    <style>
    .album-container {
    background-image: url(@/assets/bg/album/jane-lotus.svg);
    }
    style>

    🔔 关于SRC目录路径问题:

    SRC 目录的路径,是可以通过代码解析出来的,但要嵌套调用多个方法才行,代码就变得很长冗长,因此才引入了 @ 这个特殊的路径别名,以方便在vue文件中使用。这个别名是在vite.js中声明的,下面是相关片段:

    import {resolve} from 'path'
    import { fileURLToPath, URL } from 'node:url'
    export default defineConfig({
    plugins: [vue()],
    resolve: {
    alias: {
    '@': fileURLToPath(new URL('./src', import.meta.url))
     ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄
    // 下面这种写法也可以,而且更简洁
    // '@': resolve(__dirname, "src")
    }
    },
    ......
    }

    6.3 图片的动态路径

    这是一个经典问题,也需要区分图片是位于public目录下,还是assets目录下。二者的处理方式差异巨大,为此,特意创建了一个工程来演示不同目录下,动态路径图片的处理效果,见下图:

    请点击 这里 下载该演示效果的工程源码

    Vue3图片动态路径效果演示

    • 对 public 目录下的图片做动态路径指定<推荐>

      由于public目录下的所有文件都会原样保留,因此,动态路路径只需要保证最后生成的路径串以 / 开头就可以了。因此强烈建议,当需要在运行期间动态指定本地的图片地址时,把这些图片都放置在 public 目录下吧。

    • assets 目录下的图片动态路径处理

      首先说下结论,要对此目录下的图片在运行期做动态引用,非常麻烦。核心原因还是上面①处提到的对assets目录的处理。或许有个疑问,Vite 或 Webpack 打包构建时,为什么要这样做。 因为 Web 的基础就是 HTML + CSS + JS,尽管JS代码运行在客户端浏览器上,但业务数据和图片、视频等资源都在远程服务器上,前端工程源码目录结构一定与最终部署的目录结构是不一样的。前端在之前的非工程化时期,是没有编译这一阶段的,源码目录结构,就是最终部署的结构。

      Vite 打包后的目录中,除了 index.html 文件和 public 目录下的文件外,其它所有文件都被编译构建到了 assets 目录,如下所示

      dist
      ├─ favicon.ico # 来自public目录,原样保留
      ├─ img/ # 来自public目录,原样保留
      ├─ css/ # 来自public目录,原样保留
      ├─ assets/ # 来自src/asset目录和src/views目录,内容经过编译,路径剪裁至assets目录,文件名追加hash值
      └─ index.html # 来自源码工程的根目录,原样保留

      此目录下动态图片解决方案的核心问题是:必须让构建过程对涉及的图片文件进行编译。 编译过程的主要特征为:

      • 只对代码中用到了的图片进行编译

      • 保证编译后新的文件名能与代码中原来的引用关联上

      可以看出,由于编译后图片名称变了,而在源代码中引用图片时,名称还是编译前的名字,因此,编译过程必须要对代码中的文件名进行修改。可以想象,如果源码中的文件名不是字面量(如:'avator/anaonymous.jpg'), 而仅仅是一个变量的话,编译器是极难推断出需要对哪些图片资源进行编译的。事实上也是如此,如果文件名就是一个普通变量,则会原样保留代码。打包后,源码引用的图片不会被编译到目标目录中,也就没有这个图片了。

      花费一翻功夫后,最终得到两种解决方案

      • 方案一: 利用 URL 函数手动提前解析所有图片路径 <推荐>

        点击查看代码