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:inline
或display: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目录下。二者的处理方式差异巨大,为此,特意创建了一个工程来演示不同目录下,动态路径图片的处理效果,见下图:
请点击 这里 下载该演示效果的工程源码 ⑴
-
对 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" /> <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函数
-
示例中的这个 URL 函数是HTML的客户端JS运行环境标准库中的函数,不是Node的 URL 模块,Node的URL模块只能用于服务器端,或前端的打包构建工具。
-
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, 还可以离线手动安装。安装过程分两步
-
到 node-release 页面下载需要的版本, 建议选 LTS 版本的程序包
-
将下载的 node 程序包解压到 nvm 的安装目录,再改名即可
-
windows 平台
将node程序包解压到 nvm 的根目录。
解压后,强烈建议改文件夹的名字,比如:v18.20.4。这样 nvm ls 命令变会显示 v18.20.4NVM 的转录安装位置是在安装时所使用的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 值是多少?有两个办法:
- 肉眼观察,直到不再出水平滚动条为止 😁
- 根据gutter的原理来推算,虽然从原理上操作看似治本,但效率还不如肉眼尝试来得快 😂
实际上 el-row 用的是flex布局,它的gutter效果,是通过以下css组合来实现的:
- el-row 组件自身使用相对定位(有无gutter均是如此)
- el-row 组件自身左右的margin值均为: - gutter/2 px
- 组件内的所有元素左右padding值均为: gutter/2 px
如下图所示:
由此可推断, 当父元素单侧的 padding >= gutter/2 时,就不会出现滚动条了
14. 如何解决启用时报 ERR_OSSL_EVP_UNSUPPORTED 错误
若执行 npm run serve
或 yarn 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的依赖包 <推荐>
也有两种配置方式,如下:
- 通过全局环境变量设置
export NODE_OPTIONS=--openssl-legacy-provider
- 通过 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个主要的步骤:
-
从后端获得业务数据
-
在页面端通过 JS 生成Excel文件对象
-
创建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 的表头