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样式实现后台管理界面整体布局(点击查看)
<!DOCTYPE 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>

	<!-- 非顶部区域,需要撑满浏览器窗口的剩余部分,因此其 flex 值为 1 -->
	<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>主内容区</2>
			<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
    <el-input><el-form-item>组件包裹起来,如下所示:

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

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

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

    可以下面这样写

    <el-form-item label="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;"/>
    </el-form-item>
    

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里,就会出现上面的情况。

方案一:单一 <router-view/> 方式

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

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 中的 <router-view/> 会替换成 MainView 组件,而 MainView 组件实现了一个页面主体布局,主内容区(MainView.vue的代码①处)内部又是一个 <router-view/>, 它的内容由 / 后面的路由组件替换。/home 时由 HomeView 组件替换,/xxx-home 时由 XxxHomeView 组件替换。

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

方案二:多个 <router-view name="xxx"/> 方式

该方式利用路由的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, 这样其它的 <router-view> 就不会渲染
                // App.vue 将只渲染 <router-view name="content"></router-view>
                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 函数手动提前解析所有图片路径 <推荐>

      点击查看代码
      <template>
          <div>
               <img :src="dynamicImgRef" style="max-height: 300px"/>
               <br/>
               <input v-model="dynamicImageName" /> &nbsp; &nbsp;
               <button @click = "showInputImage">显示输入的图片</button>
          </div>
      </tempalte>
      
      <script setup>
      import {ref} from 'vue'
      
      // ② 需要在运行期动态指定路径的所有图片
      const assetsDynamicImages = {
           // 1. 一定要用相对路径
           // 2. 假定本代码文件所在目录与assets目录是平级关系,否则需要调整 ../assets 的值
          'train.png': new URL('../assets/images/vechile/tain.png', import.meta.url).href,
          'painting.png': new URL('../assets/images/sence/painting.png', import.meta.url).href,
          'sunset.png': new URL('../assets/images/sence/sunset.png', import.meta.url).href,
          'winter.png': new URL('../assets/images/season/winter.png', import.meta.url).href
      }
      
      // 输入框中的图片名称,双向绑定
      let dynamicImageName = 'sunset.png'
      const dynamicImgRef = ref(assetsDynamicImages[dynamicImageName])
      
      // 点击按钮后,显示输入框中的图片
      const showInputImage = () => {
          dynamicImgRef.value = assetsDynamicImages[dynamicImageName]
      }
      </script>
      

      上述 demo 演示的是「根据输入的图片名称显示对应图片」的场景,它的特点为:

      • 适用场景:需要根据条件来获取相应图片路径的情况。

      • 缺陷:需要在代码中,以字符串明文方式将所有的图片都写进去,即上面②处
        因为只有这样,编译器才能识别出是哪些图片需要处理。如果把这个图片的相对路径都写到另外一个数组,然后以遍历的方式来生成运行时路径都是不行的,构建过程依然不会对图片做编译处理。

      本demo对应的演示效果为 ⑴ 处动图的「assets目录·方式一」部分,但动态图工程的源码与该demo代码并不完全相同。

    • 方式二:通过 import.meta.glob 方法提前加载所有图片路径

      点击查看代码
      <template>
         <div>
              <img :src="dynamicImgRef" style="max-height: 300px"/>
              <br/>
              <button @click = "displayNextImage">显示下一张图片</button>
         </div>
      </tempalte>
      
      <script setup>
      import {ref} from 'vue'
      
      // ③ 提前加载指定目录的所有图片,可在编译期间提前生成好图片路径,这里返回的是一个图片module数组
      let assetsImageFiles = import.meta.glob([
              '../sence/**/*.svg'
              '../assets/vechile/*.png',
              '../assets/sence/*.png',
              '../assets/season/*.png'
          ], 
          {eager: true}
      );
      
      // ④ 从上一步加载的所有图片模块中,提取出图片路径
      const assetsDynamicImageUrls = []
      Object.values(assetsImageFiles).forEach(imgModule => {
         assetsDynamicImageUrls.push(imgModule.default)        // default 属性就是编译后的图片路径
                                      ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄
      }) 
      
      // 默认显示第一张
      let imageIndex = 0
      const dynamicImgRef = ref(assetsDynamicImageUrls[imageIndex])
      
      function displayNextImage() {
          imageIndex ++
          if(imageIndex >= assetsDynamicImageUrls.length) {
             imageIndex = 0
          }
          dynamicImgRef.value = assetsDynamicImageUrls[imageIndex]
      }
      </script>
      

      上述 demo 演示的是「循环显示一组图片」的场景,它的特点为:

      • 可以遍历一组图片,而无需要提前知道图片名称
      • 这组图片路径虽然也是在编译阶段提前加载的,但不用在代码中以一图一码的方式硬编码加载(就像上面的方式一)
      • 很难通过图片名称的方式单独提取其中的一张图片路径
        因为编译后图片名称加了Hash后缀,同时图片的目录层级也没有了。如果工程中不同目录下,存在相同名称的图片,就无法在编译后通过原始名称来精准提取图片路径

      本demo对应的演示效果为 ⑴ 处动图的「assets目录·方式二」部分,但动态图工程的源码与该demo代码并不完全相同。

    📌 关于URL函数

    1. 示例中的这个 URL 函数是HTML的客户端JS运行环境标准库中的函数,不是Node的 URL 模块,Node的URL模块只能用于服务器端,或前端的打包构建工具。

    2. vite文档 中提到,如果URL函数的文件路径是 es6 语言规范的模板字符串,编译器也会支持对该模板字符串所指向的图片路径做编译转化,比如:

      function getImageUrl(name) {
         return new URL(`./dir/${name}.png`, import.meta.url).href
                         ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄
      }
      

      经过实测,大多数情况下,以上代码都会在打包部署后得到404响应。因为,上述代码如果要在部署后正确访问图片,必须保证模板字符串(上述代码的下划线部分)在编译阶段是可解析执行的,即它可以被解析成普通字符串,然后编译器再对解析后的普通字符串所指向的图片路径,进行转化(追加hash + 去除中间路径)。

      上述代码中,如果name这个变量指向了一个明确的字符串,则 `./dir/${name}.png` 这个模板字符串在编译期间是可解析成普通字符串的,反之则不可以。由于多数情况下,动态图片的名称不会是一个固定值,因此name变量或许在一开始可以指向一个明确的串,但在运行期一定会变化,而变化后所指向的图片路径,在编译期是无法感知到的,这些图片也就不会做转化了

    📌 关于 import.meta.glob 方法

    import.metea.glob 方法是 vite 引入的,它支持将多个文件以 module 的方式加载,默认是异步加载,也可以通过参数指定为同步加载

7. 非开发环境中业务路径404问题

除了动态图片的404问题,另一类更常见的是页面路径404问题。由于是单页应用,所有的页面都是在浏览器客户端完成的,在访问都页时,所有的页面信息其实就已经加载完了。只需要在浏览器本地加载不同的vue页面即可,这是通过变更本地路由地址来实现的。Vue提供了两种路由模式,分别是 Hash 和 History:

  • Hash 路由
    这是早期vue的默认模式,该模式没有404问题,它在语义上它更符合单页面应用,比如:http://localhost:5173/#/userManage , 其中 # 表示定位到当前页面的某个位置。这种定位语义是 HTML 标准,因此它天然就适合用作单页面应用。当切换路由时,只变更 # 号后面的值,然后 vue 的路由组件会根据 # 后的内容重新加载本地页面。可以看出,页面变更全过程中,客户端均不会请求服务器,因此不会出现404问题。

  • History 路由
    会出现404问题的就是这种模式,由于Hash模式url中的 # 明显暴露了应用的技术细节,且看上去不像是一个网站。vue 路由便引入了history 模式。该模式最大的特点是url的内容看上去与正常的网站没有区别,变更路由时,也会向服务器发请求,即:无论是在视觉上还是行为上,整个路由切换(页面变更)过程都与普通网站访问相同。

    但 vue 项目终究是单页面应用,页面的变更最终还在客户端完成的。当客户端向服务器端请求 http://loalhost:5173/userManage 页面时,服务器端是没有这个页面的,它只有 index.html 和 assets 目录下的image、css、js, 因此会返回404。如果服务器不返回404,而是再次返回到index.html的话,客户端就可以根据请求的 url,来变更单页应用的界面了。

    结合 nginx 服务器的 try_files 指令和 命名location 指令正好可以实现上述方案, 示例代码如下:

    server {
       listen 31079;
    
       location / {
           root /www/vue-demo;          # vue工程打包后部署到服务器上的目录
           index index.html index.htm;
           
           # 凡是在服务器上找不到文件的 uri,都转交给 @vue-router 这个命名Location来处理
           try_files $uri $uri/ @vue-router;
            ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄
       }
       
       # 将所有请求路径,都重写到index.html,这样就又回到了单页面应用上,但浏览器地址栏的url变了
       location @vue-router {
           rewrite ^.*$ /index.html last;
       }
    }
    

Hash 模式与 History 模式的声明示例代码如下:

import { createRouter, createWebHashHistory, createWebHistory } from 'vue-router'

// Hash 模式路由
const hashRouter = createRouter({
    history: createWebHashHistory (import.meta.env.BASE_URL),
    routes: [ ...... ]
})

// History 模式路由
const historyRouter = createRouter({
    history: createWebHistory (import.meta.env.BASE_URL),
    routes: [ ...... ]
})

8. 请求被浏览器本地缓存的问题

浏览器默认会在客户端电脑上缓存 GET 请求方式获得的 http 响应内容,当再次请求时,会直接从缓存中读取,不再向后端服务器发送请求了。这是属于早期 HTML 协议的约定。解决办法为,每次请求时,在 url 后拼接一段随机数,使得每次 GET 请求的地址都不一样,浏览器的缓存里也就没有当前这个URL的内容了,便会向后端服务器发送请求,同时又不影响正常业务参数的传递。

比如,我们约定这个随机数的参数为 rid, 即 RequestIdentifier 的意思,可以像下面这样拼接 url 串

let url= 'http://localhost:3751/company/getByName?name=同仁堂&rid=' + Math.random() * 100000

9. 将 el-pagination 分页组件的语言由默认的英文改为中文

  • 1. 在main.js文件中,引入element-plus/es/locale/lang/zh-cn 这个本地化组件
  • 2. 在 app 应用 ElementPlus 组件时,指定 locale 属性值为第1步中引入的组件
import { createApp } from 'vue'
import ElementPlus from 'element-plus'
import zhCn from 'element-plus/es/locale/lang/zh-cn'
 ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄
import App from './App.vue'

const app = createApp(App)
app.use(ElementPlus, {locale: zhCn})
					  ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄

📌 关于 zh-cn 这个中文 locale 的路径问题:

部分ElementPlus版本,zh-cn 语言包的路径为:element-plus/lib/locale/lang/zh-cn。本文本当前(2023-11-24)使用的这个路径,是基于2.4.1版本的。没准以后该语言包的位置还会移动,不过可以按照以下步骤来定位zh-cn的位置

  • 进入当前工程的 node_modules 目录中
  • 找到 elment-plus 目录,并进入
  • 在该目录中搜索 zh-cn

10. 不同工程的Node版本不同且不兼容的问题

最好的办法是安装 nvm(Node Version Manager) 来实现同一电脑上同时安装和使用多个 node 的目的,windows 系统上请安装 nvm-windows

需要注意的是,如果在安装 nvm 时,你的系统已经安装了node, 则需要将其卸载,并尽可能清除干净,其它则按照官网文档安装即可。下面列出最常用的几个命令:

命令 功能
nvm -v 查看 nvm 的版本
nvm ls 查看已安装的 node 版本和当前正在使用的 node 版本
nvm ls available 列出所有可安装的 node 版本, 新版本已没有此命令,改为 nvm ls-remote
nvm install <version> 安装指定的 node 版本
nvm use <version> 在当前shell环境,使用指定的 node 版本,该版本必须先安装

如果因网络原因,在线安装不了 node, 还可以离线手动安装。安装过程分两步

  1. node-release 页面下载需要的版本, 建议选 LTS 版本的程序包

  2. 将下载的 node 程序包解压到 nvm 的安装目录,再改名即可

    • windows 平台

      将node程序包解压到 nvm 的根目录。
      解压后,强烈建议改文件夹的名字,比如:v18.20.4。这样 nvm ls 命令变会显示 v18.20.4

      NVM 的转录安装位置是在安装时所使用的windows用户的目录下的 AppData\Roaming\目录下,如果用户是 Administrator, 则默认的 nvm 安装目录为:

      C:\Users\Administrator\AppData\Roaming\nvm
      
    • Linux 平台

      将 node 程序包解压到 $home/.nvm/versions/node 目录下.

      强烈建议改文件夹的名字,比如:v18.20.4。这样 nvm ls 命令变会显示 v18.20.4

📌 nvm 还有一些竞争产品,如 fvm(fast node version manager) 和 volta 等,都是不错的选择

11. npm 安装时进度卡在 reify 阶段的问题

这里讲述的方案仅适用于我的的环境,不能保证其它环境也能用同样的方式解决。

我之前是直接从 官网 安装的nodejs, 然后直接使用了配套的 npm 命令安装其它依赖包,这些操作就是OK的。后来我把 Node 卸载了,重新安装了 nvm-windows, 然后以 nvm 的方式安装了 node,再使用 npm 安装它工具包,便出现了安装过程阻塞在 reify 这个阶段,有时候2分钟后完成安装,有时候就一直阻塞在哪里,直到超时。

我的解决办法是将NPM的非官方镜像源(我的是淘宝),还原为官方镜像。

D:\SourceCode\cnblos > npm get registry
https://registry.npmmirror.com                                            # 之前是淘宝镜像源

D:\SourceCode\cnblos > npm set registry https://registry.npmjs.org/       # 还原为官方镜像源

12. Vite 命令启动项目成功,但localhost访问时返回404

我的情况是这样的,通过命令 npm run serve 启动项目后,可以正常访问。退出后再通过命令npx vite 启动项目成功,输出内容如下:

VITE v5.0.3  ready in 567 ms

  ➜  Local:   http://localhost:5173/
  ➜  Network: use --host to expose
  ➜  press h + enter to show help

然后在浏览器里访问 http://localhost:5173/ 返回404状态码,再次使用 npx vite --debug 方式启动,刷新页面后,可以看到控制台有「路径 / 到 /index.html」的redirect内容输出,但页面状态依然是404。

最终发现,该工程在创建时,使用的命令是 vue create xxxx,改用 npm init vite 创建项目后,再以 npx vite 启动便可正常访问了。

事实上,更正统的vite项目创建命令是 npm create vite@latest 工程名 -- --template vue, 在 vite官网 上有创建 vite 工程的详细说明,是我自己将它与 vue 二者的关系搞混了。经过对比,可以看到两种方式创建的工程,其 vite.config.js 文件内容是有差异的。

📌 关于vite server更常见的404问题

另一种常见的404问题,是非本机访问时(局域网的其它电脑访问),会报无法建立连接的错误。原因是 vite 默认只监听了 localhost 这个主机名。

最高效简单的办法是启动命令加上 --host 选项,如:npx vite --host [本机在局域网的IP地址],方括号的内容为可选。多数情况下,前端项目都只在本机自己调试,偶尔才需要他人来访问,因此,这个办法足够了。

如果不想每次都在命令上加 --host 选项,可直接在 vite.config.js 中配置,如下:

import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'

export default defineConfig({
    plugins: [vue()],
    server: {
        host: '0.0.0.0',       // 监听的IP地址
        port: 5173,            // 监听的端口
        open: true             // 启动后是否打开浏览器访问
    }
})

详细配置可去 vite官网配置文档 查阅

📌 工程名不要带有空格

如果vite创建的工程名带有空格,在本机开发调试阶段,可能会遭遇用 localhost 访问也返回404的情况。2022年时已经有老外在 GitHub上提出这个 bug,至少到当前(2023-11)为止,该bug依然未修复。但经过尝试,发现通过脚手架命令无法创建名称带有空格的工程,估计这个老外是手动创建的工程结构。

13. el-row 组件的 gutter 属性导致出现水平滚动条

解决方案:给 el-row 的父组件设置一个合适的左右 padding 值,比如:padding:0 12px;

OK,问题来了,如何知道这个合适的 padding 值是多少?有两个办法:

  1. 肉眼观察,直到不再出水平滚动条为止 😁
  2. 根据gutter的原理来推算,虽然从原理上操作看似治本,但效率还不如肉眼尝试来得快 😂

实际上 el-row 用的是flex布局,它的gutter效果,是通过以下css组合来实现的:

  • el-row 组件自身使用相对定位(有无gutter均是如此)
  • el-row 组件自身左右的margin值均为: - gutter/2 px
  • 组件内的所有元素左右padding值均为: gutter/2 px

如下图所示:

el-row的gutter原理解析图

由此可推断, 当父元素单侧的 padding >= gutter/2 时,就不会出现滚动条了

14. 如何解决启用时报 ERR_OSSL_EVP_UNSUPPORTED 错误

若执行 npm run serveyarn run serve 时,得到类似以下错误输出:

Error: error:0308010C:digital envelope routines::unsupported
    at new Hash (node:internal/crypto/hash:67:19)
    at Object.createHash (node:crypto:135:10)
    at module.exports (D:\SourceCode\SpeedingCloud\fine-speeding-mgmt-web\node_modules\webpack\lib\util\createHash.js:169:42)
    at NormalModule._initBuildHash (D:\SourceCode\SpeedingCloud\fine-speeding-mgmt-web\node_modules\webpack\lib\NormalModule.js:417:16)        
    at handleParseError (D:\SourceCode\SpeedingCloud\fine-speeding-mgmt-web\node_modules\webpack\lib\NormalModule.js:471:10)
    at D:\SourceCode\SpeedingCloud\fine-speeding-mgmt-web\node_modules\webpack\lib\NormalModule.js:503:5
    at D:\SourceCode\SpeedingCloud\fine-speeding-mgmt-web\node_modules\webpack\lib\NormalModule.js:358:12
    at D:\SourceCode\SpeedingCloud\fine-speeding-mgmt-web\node_modules\loader-runner\lib\LoaderRunner.js:373:3
    at iterateNormalLoaders (D:\SourceCode\SpeedingCloud\fine-speeding-mgmt-web\node_modules\loader-runner\lib\LoaderRunner.js:214:10)
    at Array.<anonymous> (D:\SourceCode\SpeedingCloud\fine-speeding-mgmt-web\node_modules\loader-runner\lib\LoaderRunner.js:205:4)
    at Storage.finished (D:\SourceCode\SpeedingCloud\fine-speeding-mgmt-web\node_modules\enhanced-resolve\lib\CachedInputFileSystem.js:55:16)  
    at D:\SourceCode\SpeedingCloud\fine-speeding-mgmt-web\node_modules\enhanced-resolve\lib\CachedInputFileSystem.js:91:9
    at D:\SourceCode\SpeedingCloud\fine-speeding-mgmt-web\node_modules\graceful-fs\graceful-fs.js:123:16
    at FSReqCallback.readFileAfterClose [as oncomplete] (node:internal/fs/read_file_context:68:3) {
  opensslErrorStack: [ 'error:03000086:digital envelope routines::initialization error' ],
  library: 'digital envelope routines',
  reason: 'unsupported',
  code: 'ERR_OSSL_EVP_UNSUPPORTED'
}

表明当前使用的 Node 版本较新,包含了 OpenSSL3.0 的一些特性,这可能引发与旧的 node 依赖产生冲突。

方案一: 配置参数,接受非SSL的依赖包 <推荐>

也有两种配置方式,如下:

  1. 通过全局环境变量设置
export NODE_OPTIONS=--openssl-legacy-provider
  1. 通过 package.json 设置局部环境变量 <推荐>

环境变量影响是全局的,直接修改 package.json 最好, 比如:

// windows 下这样配置
"scripts": {
   "serve": "set NODE_OPTIONS=--openssl-legacy-provider && vue-cli-service serve",
   "build": "set NODE_OPTIONS=--openssl-legacy-provider && vue-cli-service build"
},

// linux 下这样配置
"scripts": {
   "serve": "export NODE_OPTIONS=--openssl-legacy-provider ; vue-cli-service serve",
   "build": "export NODE_OPTIONS=--openssl-legacy-provider ; vue-cli-service build"
},

方案二: 降低 Node 版本

如果安装了 nvm,那么建议使用此方案,需要至少降到16.x 及以下

15. 多级路由与 URL 上多级路径的关系

这个问题说来有点好笑,因为答案是:没有关系。那为什么还要用一小节来专门说明这个事呢?这个问题从何而来?

相信刚做 Web 前端开发的程序员,甚至是做了很久前端开发的人员(没有亲自从零搭建过前端项目框架),可能都认为路径的层级关系与 URL 上路径的层级关系是对应的。什么意思呢?

假如有如下的页面文件组织结构,以及这些页面访问时的 url path

 Vue View File Hierachy          URL Path                Description   
----------------------------------------------------------------------------
 ┌─ home.vue                     /  or /index            首页
 ├─ sales-rank                                           销售排行,这是一个目录
 │   ├─ region-sales-rank.vue    /sales-rank/region      区域销售排行页面
 │   ├─ group-sales-rank.vue     /sales-rank/group       团组销售排行页面
 │   ├─ team-sales-rank.vue      /sales-rank/team        分队销售排行页面
 │   └─ person-sales-rank.vue    /sales-rank/person      个人销售排行页面
 ├─ login.vue                    /login                  登录页面
 └─ sysmgmt                                              系统管理,这是一个目录
     ├─ goods-mgmt.vue           /mgmt/goods             区域销售排行页面
     ├─ account-mgmt.vue         /mgmt/region            区域销售排行页面
     └─ sale-activities.vue      /mgmt/sale-activities   区域销售排行页面

上面的页面视图文件组织结构很清晰,其 URL 路径规划也非常合理,于是,在不了解 vue 路径层级特点的情况,会很自然地设计出以下路由

点击查看代码
export default new Router({
  routes: [
    {
      name: 'login',
      path: '/login',
      component: (resolve) => require('@/pages/login.vue', resolve)
    }, 
    {
      name: 'home',
      path: '/',
      component: (resolve) => require('@/pages/home.vue', resolve)
    }, 
    {
      name: 'sales-rank',
      path: '/sales-rank',
      children: [
        {
          name: 'region-sales-rank',
          path: '/sales-rank/region',
          component: (resolve) => require('@/pages/sales-rank/region-sales-rank.vue', resolve)
        },
        {
          name: 'group-sales-rank',
          path: '/sales-rank/group',
          component: (resolve) => require('@/pages/sales-rank/group-sales-rank.vue', resolve)
        },
        {
          name: 'team-sales-rank',
          path: '/sales-rank/team',
          component: (resolve) => require('@/pages/sales-rank/team-sales-rank.vue', resolve)
        },
        {
          name: 'person-sales-rank',
          path: '/sales-rank/person',
          component: (resolve) => require('@/pages/sales-rank/person-sales-rank.vue', resolve)
        }
      ]
    }, 
    {
      name: 'sysmgmt',
      path: '/sysmgmt',
      children: [
         ...... 省略
      }
    }
  ]  
})

会在路由配置中,明确实的体现出 sales-rank 和其下的4个具体页面间的父子关系,这也很符合逻辑层级。以分队销售排行页面为例,其 url path 为 /sales-rank/team, 可清晰地看出层级关系,但并不意味着这个路径对应页面的 vue 路由也得是这种层级关系。事实上,vue 路由的 URL Path 就是一个普通字符串,只要符合 URL 规范即可,不会对其字符内容作任何语义解析,就仅仅是一个字符串而已。因此,把 /sales-rank/tem 换成 /mygod 也是可以的,或者是换成 /mygod/my-family/my-room/my-dog 同样是可以的。

vue 路由的层级关系,直接由上面的router.js 明确定义。渲染页面时,会先直接呈现根路由对应的视图组件,至于其下的二级路由所对应的视图组件是否会被渲染,取绝于一级视图组件中是否有 <router-view/> 标签。同理,三级路由对应的视图组件是否会被渲染,取绝于二级视图组件中是否有 <router-view/> 标签,依此类推。

说起来这似乎应该是一个常识,但至少我个人最开始确实默认就认为二者应该有这种联系。不过对于单面应用而言,顶级视图通常就是整个页面的框架结构,业务页面通过 <router-view/> 动态渲染。因此,很少会出现三级路由的情况。即便是第一小节中的页面布局二,也用不到三级路由。理清这一点,我们才能更准确灵活地应用 Vue 路由,来完成多种不同布局的页面共存的目的。

示例中的 sysmgmt 和 sales-rank 这两个路由的配置都是错误的,

因为这两个路由没有 component 组件,实际运行会报错。上面故意配置成那样,是为了刻意体现业务关系逻辑上的层级(非路由层级),放大错误

16. 异步JS的地狱回调问题

不啰嗦,先把终极方案附上,项目中直接这样书写即可

import to from 'await-to-js'

let [err, data] = await to(asyncRequest)

接下来,就可以操作err 和 data 了,而且一定是发生在上一步的异步代码执行完成后

毫无疑问,异步是个好东西。但异步代码的书写和阅读却令人头痛。比如下面这个最常见的场景:

  • step1: 添加评论(addComment)
  • step2: 刷新评论列表(getCommentPage)
  • step3: 刷新用户的徽章等级等信息(getUserProfile)

如果3个方法都是同步执行的,则很简单,直接顺序调用即可。如下所示:


// 1. 添加评论
let success = addComment()

if(success) {
    // 2.1 添加成功,则获取新的评论分页数据
    let comments =getCommentPage()

    // 2.2 获取到数据后,刷新页面评论区
    refreshComments(comments)

    // 3.1 重新获取用户信息(增加评论后,用户的徽章和等级有可能会升级)
    cost userProfile = getUserProfile()

    // 3.2 刷新页面的用户信息
    refreshUserInfo(userProfile)
}

1、2、3 是按顺序执行的,非常直观。但 js 的网络调用是基于 ajax 异步执行的。也就是说,第2步的 getCommnets() 方法,并不会等到 addComment() 执行完才执行。同理,getUserProfile() 也不会等到 getComments() 执行完再执行,它们3个的执行顺序是无法保证的,谁的网络调用先完成(返回结果),也是无法确定的。 可以理解为这3个方法分别在单独的线程中执行,大家都不会礼让对方。那要怎样才能保障这3个方法按顺序执行呢?

JavaScript 在解决异步调用代码的书写方面,经过了3个阶段,分别是:Promise 解决基本的异步调用,Generator解决异步等待,await-to-js 实现优雅的异常处理

如今,异步代码的书写和阅读,已经非常友好了,看上去与同步代码非常接近(如本章节开头所展示的那样),某种程度上来说,比Java 后端编写异步程序还友好。下面简单介绍这个过程。

16.1 Proimse 实现基础的异步代码调用

做服务端开发的Geeker, 会想到使用线程,然后利用任务执行器的返回的 Future 对象,在主线程中决定如何执行后续操作。在JavaScript中, 可以把 Promise 当作是一个线程,准确地说,Promise 更像 Java 中的 Executor(任务执行器),Promise 本身没有异步能力,它主要来简化异步代码的书写和管理任务的,真正具有异步执行能力的是 XmlHttpRequest 对象(axios 库 和 jquery.ajax() 方法的底层就是它)。下面是使用 promise 后的异步代码:

点击查看代码
// step1. 添加评论
axios({
    method: 'post',
    url: "添加评论的后端 url ",
    data: { 评论内容 }
}).then(
    (response) => {
        // step2. 查询新的评论列表,并刷新页面(状态码200代表添加成功)
        if(response.status === 200) {
            aixos({
                method: 'get',
                url: '查询评论的url',
                params: {查询参数}
            }).then(
                (response) => {
                    let comments = response.data
                    refreshComentsView(comments)

                    // step3. 获取用户最新信息,并刷新
                    axios({
                        method: 'get',
                        url: '查询用户信息的URL',
                        params: {查询参数}
                    }).then((response) => {
                        ... 最新用户信息的处理逻辑
                    }).catch((error)=> {
                        ... step3 的远程调用的错误处理
                    })
                }
            ).catch(
                // step2 的远程调用的错误处理
            )
        }
    }
).catch(
    // step1 的远程调用错误处理
)

如上所述,Promise.then 方法,提供了编写处理异步调用结果代码的入口,但如果要 依次 进行多个异步调用的结果处理,就只能在 then 方法里编写下一个异步调用的代码。依此类推,最后就会出现多级 then 方法的嵌套,代码会变得异常难以书写和阅读,俗称地狱回调。这简直是一场灾难,没人喜欢这样的代码。

16.2 Generator 解决异步结果的等待与处理

异步调用的结果处理,能否不要写在 then 方法里,以避免多级嵌套呢?利用 Generator 生成器函数,可以实现。这里就不介绍其原理了,以避免篇幅过长。ES6 中引入了 async 和 await 这两个语法糖,有了它俩,异步代码现在可以这样书写了:

async function pprocessCommentAddition() {
    // step1. 添加评论
    let addCommentRequest = axios({
        method: 'post',
        url: '添加评论的url',
        data: {评论内容}
    })
    let response = await addCommentRequest
   
    // step2. 查询最新评论列表
    if( response.status === 200) {
        let commentListRequest = axios(
            method: 'get',
            url: 'the URL of comments',
            params: { the params of query }
        })
        response = await commentListRequest
        refreshComments(response.data)

        // step3. 查询用户信息, 与step2 语法类似
        ...... 
    }
}

3个需要 依次顺序 地执行的异步调用结果处理代码,可以都直接写到一个 async 函数中,通过 await 关键字来保证前后代码的同步性。这样一来,异步代码在视觉上与同步代码几乎一样了。

16.3 await-to-js 优雅编写异常处理代码

上述代码加上错误处理代码的话,会是这样的:

async function pprocessCommentAddition() {
    // step1. 添加评论
    try{
        let addCommentRequest = axios({
            method: 'post',
            url: '添加评论的url',
            data: {评论内容}
        })
        let response = await addCommentRequest

       // step2. 查询最新评论列表
        try{
            let commentListRequest = axios(...)
            response = await commentListRequest

            try{
                // step3. 查询用户最新信息
                let userProfileRequest = axios(...)
                response = await commentListRequest
            } catch(err) {
                // ste3 的异常处理
            }
        }catch(err) {
            // step2 的异常处理
        }
    } catch(err) {
        // step1 的异常处理
    }
}

Oh My God, 感觉又回到解放前了,异常的处理居然出现了嵌套,原因在于 await 只返回了 Promise 的 value 值,对于异常 reject 部分,只能用 try catch 包裹。有没有办法让 await 同时拿到错误对象和无异常时业务结果呢?

我们可以对业务 Promise 再包装一层,就像下面这样:

errorAndData(promise) {
    return promise
             .then((data) => [null, data])
             .catch(error) => [error, null])
}

结合await 后,异常处理就可以这样写了:

async function pprocessCommentAddition() {
    // step1. 添加评论
    let addCommentRequest = axios({...})
    let [err, response] = await errorAndData(addCommentRequest)
    if(err) {
        ......  // step1 的异常处理
        return
    }
   
    // step2. 查询最新评论列表
    if( response.status === 200) {
        let commentListRequest = axios({ ... })
        [err, response] = await errorAndData(commentListRequest)
        if(err) {
            ...... // step2 的异常处理
            return
        }
        refreshComments(response.data)

        // step3. 查询用户信息, 与step2 语法类似
        ...... 
    }
}

OK, 至此,异常处理的代码也没有嵌套了,十分完美。只是这个自己包装的 errorAndData 方法如此重要,有明显的普适性,是否有相关的库已经提供了该函数呢?你想对了,这个库就是 await-to-js, 具体用法已写在了本章节的开始处。

17. 切换页面如何传递参数

有时候,在 A 页面上点击某个数据条目时,需要跳转到B页面进行详细的查询与展示。就需要在跳转的过程中,将 A 页面点中点击的数据传递过去。主要有三种传递手段:

17.1 通过 router 的 query 方式传递

// A 页面跳转时
this.$router.push({
    path: '/path-of-b',
    // vue 的router 会将 query 字段的内容,以 URL Query 的方式传递
    query: {name='Mark Twain', start='2024-03-24', end='2024-03-28'}
})

// B 页面接收参数
let query = this.$route.query

这种传参方式与 get 服务端请求一样,参数是拼接在url 后面的

📌 特别说明:

传递参数时,用的是 vue-router 的路由器对象,即 $router,

而接收参数时,用的是 vue-router 的路由对象,即 $route

17.2 通过 router 的 params 方式传递

// A 页面跳转时
this.$router.push({
    // B 页面路由的名称
    name: 'RouteNameOfB',

    // vue 的 router 会将 params 字段的内容, 追加到 B 页面路由的 params 字段上
    params: {name='Mark Tuain', start='2024-03-24', end='2024-03-28'}
})

// B 页面接收参数
let params= this.$route.params

vue-router 的 params 方式传参,只能使用路由的名称,不能用 path, 使用 path 的话,只会传递 query 字段

17.3 通过全局的各种存储对象

这就简单了,可以通过 vuex store 、 localeSession、localeStorage、cookie 等方式传递。对于简单的两个页面端的参数传递,建议直接用 vue-router, 如果是多页面间传递参数(比如构建一个对象时,要拆分成很多步,每一步都是单独的页面),则建议用 store。

以下是三种传参方式的对比

传参方式 使用场景 刷新页面 新开页面 注意事项
vue-router query 仅两个页面间传递 不丢失参数 丢失参数 传参时,使用路由器对象 $router, 接收参数时,使用路由对象 $route, 下同
vue-router params 仅两个页面间传递 丢失参数 丢失参数 跳转时,要使用路由的 name 而不是 path
vuex store 多页面间传递 丢失参数 丢失参数
localeSession 多页面间传递 不丢失参数 丢失参数
localeStorage 多页面间传递 不丢失参数 不丢失参数
cookie 多页面间传递 不丢失参数 不丢失参数 cookie 会将参数传递给服务器端,比较多作,不建议使用

18. 新页面的 vuex store 初始化问题

当我们把一些全局数据保存在 vuex 的 store 时,还需要区分一下这些数据的作用域范围。像登录 token 这样的信息,如果期望相同客户端代理(浏览器)下次启动时,token 依然有效,则需要将 token 保存在 localStorage 或 cookie 中(建议为 localStorage)。但如果业务代码每次发 ajax 请求要先获取 token 时,都是从 vuex store 中获取的话,会引发一个BUG,即:当新开一个TAB页面时,token 有可能获取不到。然后直接进入到登录页面,或直接出现 ajax 请求错误提醒。原因是针对新开的TAB页面,相当于首次加载整个 vue 应用,因此,需要在 APP 初始化阶段初始化从 cookie 或 localStorage 中读取 token 数据,再初始化到 vuex store 中来,比如下面这段位于 main.js 的样本代码(这里是将数据保存在cookie中):

let userJsonText = Cookies.get("userinfo");
if (userJsonText) {
  try {
    let user = JSON.parse(userJsonText);
    store.commit("SET_USERINFO", user);
  } catch (err) {
    console.error(`无法将以下文本转成JSON对象:\n${userJsonText}`);
  }
}

19. 如何处理 Excel 文件导出

有两种方式完成 Excel 文件的导出,分别是后端方式和前端方式。前一种方式,后端返 Excel 文件的下载流, 后一种方式后只返回所有的业务数据,由页面JS程序根据这些数据生成 excel 文件,再保存到本地,下而分别说明。

19.1 后端方式下载 Excel

此种方式,服务端将直接返回 Excel 文件的下载流,其 MIME 类型一般为 appliction/x-xls 或 application/otect-stream。浏览器在接收到此类 MIME content-type 时,会弹出一个下载对话框,由用户选择将文件保存在本地磁盘上的哪个位置,点击保存后,就可以开始下载了,下载过程由浏览器自动完成。

但有个前提,弹出对话框仅针对页面上的 Link 连接点击事件,由于 SPA 的应用,都是通过 ajax 来异步调用服务端接口的,因此,即使收到来自后端的 excel 文件流 http 响应,浏览器也不会弹出文件保存对话框。

既然弹出框效果需要 Link 连接,那么解决办法就是通过 js 代码,以编程的方式给整个 Html body 添加一个 Link 元素,并将这个 Link 连接指南服务端的 excel 文件导出 url(并追加上相应的数据过滤参数),然后触发该连接的 click 事件,模拟用户在页面点击,下载完成后,将连接删除,避免多次下载,创建多个连接。一段参考代码如下:

axios({
	method: 'post',
	url: '{后端的excel下载url}',
	data: 数据过滤条件参数对象,
	responseType: 'blob'
}).then(result => {
    // 1. 创建 Link 对象
	const link = document.createElement('a')
	const blob = new Blob([result.data], {type: 'application/vnd.ms-excel'})

    // 让link 不可见
	link.style.display = 'none'
    // 将 link 的连接内容,指向 axios 获取到的 excel 数据(二进制格式)
	link.href = URL.createObjectURL(blob)
	link.setAttribute('download', 'downloaded-excel-file.xlsx')

    // 2. 将新创建 Link 对象加入到 html body 上
	document.body.appendChild(link)

    // 3. 触发 Link 的点击事件,之后会弹出文件保存对话框
	link.click()

    // 4. 文件保存后,删除 Link 对象
	document.body.removeChild(link)
}).catch(error => {
	console.log('下载失败')
}).finally(() => {
	....
})

19.2 前端生成 Excel 文件

这是当前比较主流的方式,分为3个主要的步骤:

  1. 从后端获得业务数据

  2. 在页面端通过 JS 生成Excel文件对象

  3. 创建Link对象,完成浏览器端的弹框提示下载

重点是第2步,第3步与「服务端下载」方式是一样的。这需要使用到 xlsx 这个库,该库功能丰富,对于简单的文件生成绰绰有余。下面列出生成 Excel 文件的核心样板代码:

import XLSX from 'xlsx'

function saveXlsxToLocal = function(xlsxRows, sheetName, fileName) {
    if(!xlsxRows){
        return console.error('[EXPORT_TOOL#saveXlsxToLocal]: 未提供 Excel 数据')
    }

    // 创建 excel 文件内容
    sheetName = sheetName? sheetName : 'sheet1'
    fileName = fileName? fileName : 'execel-' + new Date().getTime + ".xlsx"
    let sheet = XLSX.utils.aoa_to_sheet(xlsxRows)
    let workbook = XLSX.utils.book_new()
    XLSX.utils.book_append_sheet(workbook, sheet, sheetName)

    // 这一步将完成 Link 的生成、click 事件的触发等
    XLSX.writeFile(workbook, fileName)
}

其实上面的的方法已经是通用方法了,一个工程有一份就可以了。下载的业务代码,主要是完成接口数据到Excel sheet 数据的转化。即上面的xlsxRows参数,它应该是下面这样的结构:

[
     ['姓名', '电话', '地址别名', '详细地址'],
     ['张三', '13434345655', '南阳老家', '河南省南阳市一个幽静的小山村'],
     ['李四', '15858582424', '瑞士新家', '瑞士一个神秘的小岛']
]

xlsxRows 是一个二维数组,头层数组中的每个元素,代表 sheet 中的一行,通常第一个元素代表的就是 sheet 的表头

posted @ 2023-12-06 13:46  顾志兵  阅读(4120)  评论(24编辑  收藏  举报