Vue3商城架构搭建

ObjectKaz Lv4

创建项目

使用 vite 创建Vue3项目

如果你使用过Webpack,你会发现,在启动开发服务器的时候,会将整个项目编译一遍。当项目的规模越来越大时,这样的编译过程往往十分耗时,光启动开发服务器就要几分钟。如果你修改了文件,可能修改在几秒后才会显示出来。

这极大的影响了开发体验。而且目前,主流的浏览器都已经支持 ES6 模块了。因此,在开发环境下,我们可以直接把模块编译成浏览器支持的ES6模块,然后通过浏览器的ES6模块加载机制来按需加载模块。这样,就无需提前编译整个项目,也无需添加额外的胶水代码,从而提高了开发环境下编译的速度。

Vite 就是使用了这样的方法,使得开发环境的编译速度非常快,开发服务器瞬间启动和更新。但考虑到性能和兼容性,在生产环境下, Vite 则采用 rollup 的方式进行打包。

使用 Vite3 创建Vue3+ts项目的命令:

1
2
3
4
5
6
7
8
9
10
# npm 7+, 需要额外的双横线:
npm init vite@latest vue3-mall-admin -- --template vue-ts

# yarn
yarn create vite vue3-mall-admin --template vue-ts

# 后面的操作
cd vue3-store-admin
yarn # 安装依赖
yarn dev # 启动项目

安装依赖

这里主要安装的依赖有路由、状态管理、网络请求、UI、字体图标、md5处理。

1
yarn add vue-router@4 vuex@next element-plus axios vue-request @fortawesome/fontawesome-svg-core @fortawesome/free-solid-svg-icons @fortawesome/free-regular-svg-icons @fortawesome/vue-fontawesome@prerelease js-md5

添加路径别名

默认情况下只支持模块和相对路径,添加 @~ 两个路径别名:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// vite.config.js
import { defineConfig } from 'vite'
import path from 'path'
import vue from '@vitejs/plugin-vue'

export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'~': path.resolve(__dirname, './'),
'@': path.resolve(__dirname, 'src')
}
}
})


// tsconfig.json

{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": [
"src/*"
]
},
},
}

页面路由配置

定义路由

  1. 定义一个普通的路由:path,name,component
  2. 路由元信息 meta 属性,可以在上面定义网页标题、权限等信息
  3. 嵌套路由(和单纯路径上的嵌套的区别:每个有子节点的路由需要有组件,这个组件里面有 router-view才能保证子路由正常渲染)
  4. 带参数的路由(匹配数字、匹配字符串、有限的字符串匹配)
  5. 重定向路由:path,name,redirect
  6. 路由传递 props
  7. 匹配404页面:/:matched(.*)*
  8. 嵌套路由规划方法:按照网页的布局来
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
const routes: RouteRecordRaw[] = [
{
path: '/login',
name: 'login',
component: () => import('@/pages/Login.vue'),
meta: {
title: "登录"
}
},
{
path: '/404',
name: '404',
component: () => import('@/pages/404.vue'),
meta: {
title: "404"
}
},
{
path: '/',
component: Layout,
redirect: "/index",
children: [
{
path: "goods/list",
component: () => import('@/pages/goods/List.vue')
},
{
path: "goods/create",
component: () => import('@/pages/goods/Edit.vue'),
props: { id: null }
},
{
path: "goods/:id(\\d+)",
component: () => import('@/pages/goods/Edit.vue'),
props: true
},
// 首页配置
{
path: "index-config/:type(hot|new|recommend)",
component: () => import('@/pages/index-config/IndexConfig.vue'),
props: true
},
{
path: "index-config/swiper",
component: () => import('@/pages/index-config/Swiper.vue'),
props: true
},
// 订单
{
path: "order/list",
component: () => import('@/pages/order/List.vue'),
props: true
},
{
path: "order/:id(\\d+)",
component: () => import('@/pages/order/Detail.vue'),
props: true
},
// 用户
{
path: "user",
component: () => import('@/pages/User.vue'),
props: true
},
// 后台首页
{
path: "index",
component: () => import('@/pages/Index.vue'),
props: true
},
]
},
// 匹配404路由
{
path: '/:matched(.*)*',
redirect: '/404'
}
]

定义路由元信息的类型,以获得更好的IDE提示:

1
2
3
4
5
6
7
8
// router.d.ts
import 'vue-router'

declare module 'vue-router' {
interface RouteMeta {
title?: string
}
}

定义一个导航守卫修改网页标题

导航守卫,顾名思义就是一个“守卫”,守卫就是看城门的。在城门门口,守卫可以根据行人的来源和去向,或者其他信息,来决定是否放行。路由守卫,则是根据来源信息和去向信息,来决定是否允许进行路由跳转,或者把路由跳转到其他地方。

1
2
3
4
5
6
7
router.afterEach((to) => {
const titleParts = ["电商后台管理系统"]
if (typeof to.meta.title === 'string') {
titleParts.unshift(to.meta.title)
}
document.title = titleParts.join(' - ')
})

网络请求配置

配置环境变量

  1. 环境变量:保存程序运行时需要的一些通用的参数,如请求路径、应用名称,可以通过传入 .env 文件,也可以使用系统的环境变量。
  2. 环境变量文件 (dotenv
  • .env
  • .env.local(本地环境变量,不会放入Git地址)
  • .env.[环境](特点环境下被识别)
  • .env.[环境].local
  1. 环境变量命名规则: VITE_ 开头才会被 vite 识别
  2. 引入环境变量: import.meta.env.xxxx
  3. IDE提示:新建.d.ts文件,定义 ImportMetaEnv 接口
1
2
3
# .env
VITE_APP_REQUEST_BASE='/api'
VITE_APP_TITLE='电商后台管理系统'
1
2
3
4
5
6
// env.d.ts
interface ImportMetaEnv {
VITE_APP_REQUEST_BASE: string,
VITE_APP_TITLE: string
// 更多环境变量...
}

修改之前的路由守卫:

1
2
3
4
5
6
7
router.afterEach((to) => {
const titleParts = [import.meta.env.VITE_APP_TITLE]
if (typeof to.meta.title === 'string') {
titleParts.unshift(to.meta.title)
}
document.title = titleParts.join(' - ')
})
  1. 优点:方便使用容器技术构建(传入环境变量);对于像数据库密码等本地敏感数据,无需修改代码,只需放在 .env.local 文件,就可以避免通过 git 提交泄露。
  2. 缺点:修改环境变量后需要重启服务器,生产环境下需要重新编译

axios 配置

  1. 创建一个实例
  2. 响应拦截器的设置,针对错误的响应,使用消息框进行提示
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import axios from "axios";
import { ElMessage } from 'element-plus';

// 定义一个实例
const request = axios.create({
baseURL: import.meta.env.VITE_APP_REQUEST_BASE
})

// 定义响应拦截器
request.interceptors.response.use((res) => {
if (res.data.resultCode !== 200)
return Promise.reject(res)
return res
})

// 定义错误处理
request.interceptors.response.use(undefined, (err) => {
const message = err.message || (err.data && err.data.message)
if (message) {
ElMessage.error(message)
}
Promise.reject(err)
})

export default request

vue-request 配置

1
2
3
4
5
6
// request.ts
import { setGlobalOptions } from 'vue-request';
setGlobalOptions({
throttleInterval: 300, // 节流设置
manual: true // 默认手动请求
})

vite反向代理配置

Vite 的反向代理使用 node-http-proxy,而 vue-cli 使用的反向代理是 http-proxy-middleware

浏览器默认会阻止跨域(不同域名、端口、协议)请求,如果需要发送,要么在服务器添加响应头,要么在客户端设置代理,这里使用反向代理来将 /api 代理到服务器。

  1. target:代理目标
  2. changeOrigin:把host改成目标网站,而不是代理服务器
  3. rewrite:路径重写 /api/users/1 -> /users/1 -> http://192.168.234.129:9876/users/1
  4. ws: 是否支持 websocket
1
2
3
4
5
6
7
8
9
10
11
{
server: {
proxy: {
'/api': {
target: 'http://192.168.234.129:9876/',
changeOrigin: true,
rewrite: path => path.replace(/^\/api/, '/manage-api/v1')
}
}
}
}

增强类型识别

通过定义 defineAPI 函数,针对不同的请求进行重载:

  • 对于 get 请求,通常需要 query
  • 对于 post 请求,通常需要 bodyquery(可能)

对于 queryrequestBodyresponseBody ,可以定义三个泛型参数,在调用的时候传入泛型参数,这样返回的函数就有相应的类型提示了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
// 定义通用请求格式
export interface Response<T> {
data: T,
message: string,
resultCode: number
}

// 定义请求返回函数格式
export type GetRequest<R extends Object, Q extends Object> = (query?: Q, config?: AxiosRequestConfig) => AxiosPromise<Response<R>>
export type PostRequest<R extends Object, B extends Object, Q extends Object> = (body?: B, query?: Q, config?: AxiosRequestConfig) => AxiosPromise<Response<R>>

export type RequestOptions = { [key: string]: any, config?: AxiosRequestConfig }

// 定义请求API函数
function defineAPI<R, B extends Object = {}, Q extends Object = {}>(method: 'get' | 'GET', path: string, options?: RequestOptions): GetRequest<R, Q>;
function defineAPI<R, B extends Object = {}, Q extends Object = {}>(method: Exclude<Method, 'get' | 'GET'>, path: string, options?: RequestOptions): PostRequest<R, B, Q>;

// 定义请求接口
function defineAPI<R, B extends Object = {}, Q extends Object = {}>(method: Method, path: string, options: RequestOptions = {}): GetRequest<R, Q> | PostRequest<R, B, Q> {
if (method === 'get' || method === 'GET') {
return (query?: Q, config?: AxiosRequestConfig) => request({
url: path,
method: 'get',
params: query,
...config,
...options.config
})
}
else {
return (body, query, config) => request({
url: path,
method: method,
params: query,
data: body,
...config,
...options.config
})
}
}

export { defineAPI }

UI 配置

引入 element-plus

这里先全局引入 element-plus,后面我们再将其改成按需引入。

1
2
3
4
import ElementPlus from 'element-plus';
import 'element-plus/lib/theme-chalk/index.css';

app.use(ElementPlus)

引入 font-awesome

编写 font-awesome 插件:

  • Vue 中的插件需要暴露一个 install 方法,第一个参数是 app实例,第二个参数是 app.use 传入的插件选项。
  • 在插件中可以拓展app 实例的原型,定义一些组件或者引入一些混入mixin等。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
//plugins/font-awesome/index.ts
import { library } from "@fortawesome/fontawesome-svg-core";
import { fas } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import { App } from '@vue/runtime-core';

export default {
install(app: App) {
library.add(fas)
app.component('fa', FontAwesomeIcon)
}
}

// main.ts
import fontAwesome from "./plugins/font-awesome";
app.use(fontAwesome)

页面布局

布局配置

  1. 布局方案:(使用 element-plus 布局容器+100vh高度)

image-20210718124613507

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
<el-container class="container">
<el-aside width="200px" class="sidebar">
<div class="logo">电商后台</div>
</el-aside>
<el-container>
<el-header class="header">Header</el-header>
<el-main class="main"><router-view></router-view></el-main>
</el-container>
</el-container>
<style scoped>
.container {
height: 100vh;
}

.header {
background: #fff;
box-shadow: 0 0 20px rgba(0, 0, 0, 0.1);
}

.sidebar {
background: #545c64;
}

.sidebar .logo {
height: 60px;
color: #fff;
text-align: center;
line-height: 60px;
font-size: 24px;
}

.sidebar .menu {
border-right: 0;
}
</style>

导航菜单配置

  1. el-menu组件:
  • default-active(默认高亮)
  • background-color(背景颜色)
  • text-color(文字颜色)
  • active-text-color(激活文字颜色)
  • router(路由),将 index 作为 path 进行跳转
  • collapse(菜单折叠)
  1. el-submenu组件:
  • #title 存放标题和图标,标题用span标签
  • 菜单项使用默认插槽
  1. el-menu-item组件:(注意折叠问题)
  • 图标:直接写组件默认插槽
  • 文字:#title插槽、span标签
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
<el-menu
:default-active="activeMenu"
uniqueOpened
class="menu"
background-color="#545c64"
text-color="#fff"
active-text-color="#409EFF"
:collapse="isCollapsed"
router
>
<el-menu-item index="/index">
<i class="el-icon-data-line"></i>
<template #title>首页</template>
</el-menu-item>

<el-submenu index="/index-config">
<template #title>
<i class="el-icon-s-home"></i>
<span>主页配置</span>
</template>
<el-menu-item index="/index-config/swiper">
<i class="el-icon-picture"></i>
<template #title>轮播图</template>
</el-menu-item>
<el-menu-item index="/index-config/new">
<i class="el-icon-sell"></i>
<template #title>最新商品</template>
</el-menu-item>
<el-menu-item index="/index-config/hot">
<i class="el-icon-star-on"></i>
<template #title>最热商品</template>
</el-menu-item>
<el-menu-item index="/index-config/recommend">
<i class="el-icon-thumb"></i>
<template #title>推荐商品</template>
</el-menu-item>
</el-submenu>
<el-menu-item index="/category/list">
<i class="el-icon-menu"></i>
<template #title>分类管理</template>
</el-menu-item>
<el-menu-item index="/goods/list">
<i class="el-icon-s-goods"></i>
<template #title>商品管理</template>
</el-menu-item>
<el-menu-item index="/order/list">
<i class="el-icon-s-order"></i>
<template #title>订单管理</template>
</el-menu-item>
<el-menu-item index="/user">
<i class="el-icon-user-solid"></i>
<template #title>用户管理</template>
</el-menu-item>
</el-menu>

导航高亮

  1. el-menu 组件 default-active="$route.path"

  2. 通过路由元信息定义高亮导航

1
2
const {path, meta} = useRoute();
const activeMenu = computed(() => meta.activeMenu || path);

导航菜单折叠

  1. 设置导航区折叠:el-menu 的 collapse 属性
  2. 修改导航区宽度:el-aside 的 width 属性
  3. 折叠动画:对el-aside添加 transition CSS 样式

辅助操作:后退和刷新页面

后退页面:

1
2
3
<a class="header-action" href="javascript:;" @click="$router.go(-1)">
<fa :icon="['fas', 'arrow-left']"></fa>
</a>

刷新页面(只刷新主要部分):

1
2
3
4
5
6
7
8
9
<!--主要部分-->
<transition name="el-fade-in" mode="out-in">
<router-view v-if="isShow"></router-view>
</transition>

<!--刷新按钮-->
<a class="header-action" href="javascript:;" @click="refresh">
<fa :icon="['fas', 'sync']"></fa>
</a>
1
2
3
4
5
6
7
const isShow = ref(true);
const refresh = () => {
isShow.value = false;
nextTick(() => {
isShow.value = true;
});
};

由于Vue更新DOM是异步的,所有响应式的变更会保存在队列里,当本轮DOM更新周期结束的时候将队列里面所有的改变合并成一个,再来刷新DOM。而nextTick,则可以将一些状态变更的操作放在下一轮DOM更新周期中。

如果直接这样写:

1
2
isShow.value = false;
isShow.value = true;

那么这两个状态变更将会被合并成一个,最终相当于只执行了:

1
isShow.value = true;

这和之前的状态是一样的,DOM不会发生变更,就不能实现刷新的效果了。

用户信息

  1. el-dropdown: #dropdown 插槽用来渲染菜单项
1
2
3
4
5
6
7
8
9
10
11
<el-dropdown>
<span class="el-dropdown-link">
用户<i class="el-icon-arrow-down el-icon--right"></i>
</span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item>修改密码</el-dropdown-item>
<el-dropdown-item>退出登录</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>

管理员登录功能

页面布局

采用 flex+100vh 来实现垂直居中的布局效果。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
<div class="login-layout">
<div class="login-panel">
<h1>登录电商后台</h1>
</div>
</div>
<style scoped>
.login-layout {
height: 100vh;
display: flex;
justify-content: center;
align-items: center;
}
.login-panel {
background: #fff;
padding: 20px;
width: 400px;
box-shadow: 0 0 20px rgba(0, 0, 0, 0.1);
}

.login-panel h1 {
text-align: center;
}

.login-panel .login-btn {
width: 100%;
}
</style>

表单定义

  1. 定义表单数据
  2. 定义对表单本身的引用
  3. 定义表单验证规则
1
2
3
4
5
6
7
8
9
const loginForm = reactive<LoginForm>({
name: "",
password: "",
});
const loginFormRef = ref<typeof ElForm | null>(null);
const loginFormRules = {
name: [{ required: true, message: "账号不能为空" }],
password: [{ required: true, message: "密码不能为空" }],
};

表单页面:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<el-form
label-position="top"
ref="loginFormRef"
:rules="loginFormRules"
:model="loginForm"
>
<el-form-item label="账号" prop="name" required>
<el-input v-model="loginForm.name"></el-input>
</el-form-item>
<el-form-item label="密码" prop="password" required>
<el-input v-model="loginForm.password" show-password></el-input>
</el-form-item>
<el-form-item>
<el-button
class="login-btn"
type="primary"
>登录</el-button
>
</el-form-item>
</el-form>

表单提交逻辑

  1. 引入路由跳转钩子
  2. 引入登录请求钩子
  3. 登录表单提交函数
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// 引入路由跳转钩子
const { push } = useRouter();

// 引入登录请求钩子
const { run, loading } = useRequest(login);
const store = useStore("userInfo");

// 登录表单提交
const loginFormSubmit = () => {
// ref 没有值,返回
if (!loginFormRef.value) return;

// 表单验证
loginFormRef.value.validate().then((valid: boolean) => {
if (!valid) return;
run({
userName: loginForm.name,
passwordMd5: md5(loginForm.password),
}).then((res) => {
// 类型推断
if (res == null) return;

// 保存 token
store.token = res.data.data;

// 提示信息
ElMessage.success("登录成功");

// 路由跳转
push("/index");
});
});
};

路由权限验证

  1. 为无需用户验证的路由添加一个allowAnonymousmeta
1
2
3
4
5
6
7
8
9
{
path: '/login',
name: 'login',
component: () => import('@/pages/Login.vue'),
meta: {
title: "登录",
allowAnonymous: true
}
}
  1. 定义路由守卫:检测allowAnonymous属性或者 token
1
2
3
4
5
6
router.beforeEach((to, from, next) => {
const userInfo = useStore('mallUserInfo')
if (to.meta.allowAnonymous || userInfo.token) next(); // 允许匿名
ElMessage.error('需要登录!')
next('/login') // 否则跳转到登录页面
})
  1. 禁止已登录用户访问登录界面
1
2
3
4
5
6
7
8
9
// 已登录绕过登录页面
router.beforeEach((to, from, next) => {
const userInfo = useStore('mallUserInfo')
if (userInfo.token && to.path === '/login') {
next('/index') // 否则跳转到登录页面
}
else
next()
})

用户登出

  1. 清空token
  2. 路由跳转
1
2
3
4
5
6
7
const userInfo = useStore("mallUserInfo");
const { push } = useRouter();
const logout = () => {
userInfo.token = null;
ElMessage.success("已退出!");
push("/login");
};
  • 标题: Vue3商城架构搭建
  • 作者: ObjectKaz
  • 创建于: 2021-07-19 15:11:02
  • 更新于: 2021-11-15 14:56:12
  • 链接: https://www.objectkaz.cn/1e76e5b4a6b5.html
  • 版权声明: 本文章采用 CC BY-NC-SA 4.0 进行许可。