Vue

项目是一个商场的前端页面,在这里记录开发的过程以及遇到的bug

技术栈

  • vue3
  • vite
  • vue router
  • vuex
  • TypeScript
  • sass

过程

创建项目

1
yarn create vite

项目结构

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
Hyperion_Frontend
├── README.md
├── index.html
├── package-lock.json
├── package.json
├── postcss.config.js
├── public
│ └── vite.svg
├── src
│ ├── App.vue // 入口vue文件,都挂载在这个类中
│ ├── api // 请求类,对请求进行封装
│ │ ├── login.ts
│ │ └── ...
│ ├── assets // 资源
│ ├── components // 所有函数式组件
│ │ ├── searchBar
│ │ └── ...
│ ├── layouts // 布局文件
│ │ ├── consumerLayout
│ │ │ ├── components
│ │ │ └── index.vue
│ │ └── managerLayout
│ │ ├── components
│ │ └── index.vue
│ ├── main.ts // 入口文件
│ ├── router // 路由
│ │ ├── index.ts
│ │ └── router.ts
│ ├── store // 状态组件
│ │ ├── index.ts
│ │ └── login.ts
│ ├── styles
│ │ ├── common.scss // 全局样式
│ │ ├── index.css // tailwindcss 定义的样式
│ │ └── variables.scss // 全局变量
│ ├── types // 类型定义
│ │ ├── index.ts
│ │ └── user
│ │ └── userInfo.ts
│ ├── utils // 工具
│ │ ├── request.ts
│ │ └── token.ts
│ ├── views // 所有的页面
│ │ ├── consumer
│ │ │ ├── ...
│ │ │ └── home
│ │ │ ├── components // 为了让页面不那么复杂,将部分组件分离出来
│ │ │ └── index.vue
│ │ └── ...
│ └── vite-env.d.ts // ts的全局环境变量,声明一些变量
├── tailwind.config.js // tailwind配置文件
├── tsconfig.app.json
├── tsconfig.json // ts的配置文件
├── tsconfig.node.json // ts的node配置文件
├── vite.config.ts // vite配置文件
└── yarn.lock

其中 tsconfig.node.json 的作用,来自

  • tsconfig.node.json 是专门用于 vite.config.ts 的 TypeScript 配置文件。
  • tsconfig.json 文件通过 references 字段引入 tsconfig.node.json 中的配置。
  • 使用 references 字段引入的配置文件需要设置 composite: true 字段,并用 includefiles 等等属性指明配置覆盖的文件范围。

其中 tsconfig.app.json 的作用

  • 用于Web应用程序的配置,可能会针对浏览器环境调整代码分割、路径映射或是AOT编译的设置,目的是优化生产构建性能

vite-env.d.ts 的作用

在 Vite 项目中,env.d.ts 文件主要用于声明 TypeScript 中的环境变量类型。这些类型声明可以通过 /// <reference /> 标签进行引用。该标签是一种三斜杠指令,用于向 TypeScript 编译器提供额外的类型信息。它通常用于以下情况:

  1. 引用类型声明文件:使 TypeScript 了解一些类型信息,这些类型信息可以在其他文件中使用。
  2. 项目范围的全局声明:为整个项目提供全局的类型声明。

对其中页面和组件的划分问题

看了一些文章1文章2

总结一下,根据功能和复用性来划分,可以将组件分为单页面式组件和可复用组件。

主页,列表页,分类页都属于页面组件,将来都会放在views文件夹中,用来配合路由使用。

而每个页面中都有很多小的用于实现功能的可在多个页面中使用的组件,它们就是复用组件,它们被存放在components文件夹中。

而在一些页面中,为了增强可读性和维护性,也会将不同部分分离,将一个大型的大页面分为不同的功能性组件和结构型页面

依赖搭建

sass
1
yarn add sass sass-loader

为了添加全局变量,添加配置文件vite.config.ts

1
2
3
4
5
6
7
css: {
preprocessorOptions: {
scss: {
additionalData: `@import "./src/styles/variables.scss";`
}
}
}

主体结构搭建

路由

路由和入口文件,路由可以看这个官方文档

路由需要注意的点

  • 如果需要强制登陆等,可以设置一个导航守卫
  • 匹配所有notfound问题可以使用正则表达式
    • /:pathMatch(.\*)\*
登录鉴权

需要实现的鉴权功能有以下三点:

  • 没有登录的用户需要登录
  • 登录的用户不能访问登录页面,需要登出再登录
  • 不同权限的用户需要进入不同的页面
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 路由守卫,让没有登录的登录
router.beforeEach(async (to, _from) => {
const isAuthenticated = true;
const role = "consumer";

if (
// 需要鉴权的页面
to.meta.role &&
// 但是没有登录
!isAuthenticated &&
// 避免无限重定向
to.path != "/login"
) {
return { name: "LoginRegister" };
} else if (
// 登录鉴权
// 需要鉴权
to.meta.role &&
// 没有权限
role != to.meta.role
) {
return { name: "404" };
}
});

如果没有token或者其他的登录信息,就需要重新登录,这里除了route的路由鉴权,还有在发送请求的时候,会对token进行一次判断

1
2
3
4
5
6
7
const token = getToken();
if (token) {
config.headers.Authorization = `Bearer ${token}`;
} else {
ElMessage.error("登录过期,请重新登录");
router.push({ name: "LoginRegister" });
}
路由匹配问题

想象一下,只有一个路由的以下路由:

1
2
3
4
const router = createRouter({
history: createWebHistory(),
routes: [{ path: '/:articleName', component: Article }],
})

进入没有具体路由的页面,/aaaa/bbb 最终都会呈现 Article 组件。这会导致无法正确呈现404页面和登录鉴权发生错误

我的解决办法,加上一个前缀'/:articleName'->'/a/:articleName',这样可以区分,之后内部,如果有在列表之外的就直接push到404

1

Type的构造

ts最主要的分别就是类型区别所以会有很多类型需要提前声明,这里我将所有的类型归类在type文件夹下,这个构造主要和所需要的数据相关,其中一些特殊的进行说明

基础类型

定义了一些常用的数据类型,并将store需要的模块类型进行了合并

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 记录一些基础的类型定义
import { ILogin } from "./login/login";
// 基础信息的类型定义
/**
* @interface IRootState
* @param id 用户id
* @param name 用户名
* @param role 用户角色
*/
export interface IRootState {
id: number;
name: string;
role: string;
}

export interface IRootWithModule {
loginModule: ILogin;
}

export type IStoreType = IRootState & IRootWithModule;

axios的封装

axios拦截器
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
const request = axios.create({
baseURL: baseurl,
timeout: 50000,
headers: {
"Content-Type": "application/json",
},
});

request.interceptors.request.use(
(config) => {
// 如果是注册就不写token
if (config.url === "/user/register" || config.url === "/user/login") {
return config;
}
const token = getToken();
if (token) {
config.headers.Authorization = `Bearer ${token}`;
} else {
ElMessage.error("登录过期,请重新登录");
router.push({ name: "LoginRegister" });
}
nProgress.start();
return config;
},
(error) => {
console.log("request error:", error);
return Promise.reject(error);
}
);

request.interceptors.response.use(
(response) => {
// 对响应数据做点什么
const { code, message } = response.data;
if (code !== 0) {
const msg = message ? message : getError(code.toString());
ElMessage.error(msg);
}
nProgress.done();
return response.data;
},
(error) => {
// 对响应错误做点什么
if (error.response?.status === 401) {
ElMessage.error("登录过期,请重新登录");
removeToken();
router.push("/login");
} else if (error.response?.status === 404) {
ElMessage.error("请求资源不存在");
} else if (error.response?.status === 400) {
ElMessage.error("请求有误");
} else {
ElMessage.error("请求失败");
}
nProgress.done();
return Promise.reject(error);
}
);

主要是为了在发送和接收请求的时候对其进行统一处理

其中:getError是一个错误码对应字典的返回函数

nProgress是一个进度条插件

api的编写

api的作用就是很简单的封装请求的过程,并返回promise,这里使用的是restful的风格,在api的编写过程中,会遇到以下的常见的请求方式

1
2
3
4
5
6
7
8
9
10
11
12
13
export const reqGoodsDetail = (
id: number,
role: string
): Promise<IDataType<IgoodsDetail>> => {
return request({
url: `/goods/detail`,
method: "get",
params: {
id,
role,
},
});
};

其中的request有不同的参数在使用上需要和后端的api文档相配合,对应的字段也需要和api文档对齐

vuex的使用

vuex是对vue的状态进行管理,可以在各个组件中共享和修改状态

本项目将涉及到共享数据的部分都放在store中进行编写

使用modules将各个板块分开

其中用户信息作为根节点,之后分为其他板块

主模块

放置一些根节点所需要的信息,这里根据项目,使用的是用户的部分信息

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
const store = createStore<IRootState>({
state() {
return {
name: "",
...
};
},
mutations: {
changeName(state, name: string) {
state.name = name;
},
...
},
actions: {
async initUserInfoAction({ commit }) {
console.log("initUserInfoAction");
const token = getToken();
const role = getCache("role");
if (!token || !role) {
return;
}
const res = await getUserInfoAPI();
if (res.code !== 0 || !res.data) {
return;
}
commit("changeName", res.data.name);
commit("changeTel", res.data.tel);
commit("changeEmail", res.data.email);
commit("changeRole", role);
},
getters: {
gRole(state) {
return state.role;
},
...
},
modules: {
loginModule,
registerModule,
ShoppingListModule,
goodsStoreModule,
tableStoreModule,
addressStoreModule,
orderStoreModule,
},
});

interface IRootWithModule {
loginModule: ILogin;
regesterModule: IRegister;
goodsStroeMudule: IGoods;
shoppingListMudule: IShoppingListType;
tableStoreMudule: ITableStore;
addressStoreMudule: IAddressState;
orderStoreMudule: IOrder;
}
type IStoreType = IRootState & IRootWithModule;

export function useStore(): Store<IStoreType> {
return useVuexStore();
}

export default store;
  • getterscomputed()结合使用,达到数据的同步

    1
    2
    3
    const data = computed<IgoodsDetail>(() => {
    return store.getters["goodsStoreModule/gGoodsDetail"];
    });

    通过以上的方式,只要goodsStoreModule中的对应state发生改变,就可以同步各个页面之间的数据

  • 计算属性和函数的区别:

    若我们将同样的函数定义为一个方法而不是计算属性,两种方式在结果上确实是完全相同的,然而,不同之处在于计算属性值会基于其响应式依赖被缓存,一个计算属性仅会在其响应式依赖更新时才重新计算

    • 建议在模板中使用计算属性而不是直接调用普通函数
    • 仅仅需要展示的数据使用计算属性
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    const calculatePrice = () => {
    let price = 0;
    console.log("1", price);
    selectedGoods.value.forEach((item) => {
    const foundItem = data.value.find((i) => i.id === item);
    if (foundItem?.price) {
    price += foundItem.price;
    }
    });
    return price.toFixed(2);
    };
    const calculatePrice = computed(() => {
    let price = 0;
    console.log("1",price);
    selectedGoods.value.forEach((item) => {
    const foundItem = data.value.find((i) => i.id === item);
    if (foundItem?.price) {
    price += foundItem.price;
    }
    });
    return price.toFixed(2);
    });

    其中前者被调用了两次,后者只有一次

  • 具体的vuex可以查看官方文档

组件复用

如果在项目中对于一些重复使用的组件抽象出来并进行复用,可以让项目更加简洁,这里记录自定义的table

src\components\dataTable\table.vue

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
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
<script setup lang="tsx">
import { CSSProperties, onMounted, ref } from "vue";
import { computed } from "vue";
import { useStore } from "@/store";
import { JSX } from "vue/jsx-runtime";
const store = useStore();
const props = defineProps<{
title: string;
titleIcon: string;
flexBasis?: string;
tbodyMaxHeight?: string;
iconAdd?: boolean;
orders?: Array<{ id: number; [key: string]: any }>;
}>();
/**
* @emits handleFilter 返回筛选事件
* @emits 返回搜索事件
* @emits handleClickOrder 返回点击订单事件, 参数为订单id
*/
const emit = defineEmits<{
(e: "handleFilterAdd"): void;
(e: "handleSearch", i: any): void;
(e: "handleClickOrder", i: any): void;
(e: "handleLoadMore"): void;
}>();
// 获取store中的columns
// 其中包含着列数据和对应的渲染函数
const columns = ref(store.getters["tableStoreModule/columns"]);
const styleScroll = computed<CSSProperties | undefined>(() => {
return {
display: "block",
maxHeight: props.tbodyMaxHeight,
overflowY: "auto",
};
});
// 渲染列表
const DataTable = (): JSX.Element => {
return (
<tbody style={props.tbodyMaxHeight ? styleScroll.value : null}>
{props.orders?.map((order) => (
<tr
key={order.id}
onClick={() => emit("handleClickOrder", order.id)}
>
{columns.value.map((column: any, index: any) => (
<td
key={index}
style={{ width: column.width }}
>
{column.renderCell(order)}
</td>
))}
</tr>
))}
<tr ref={loadMoreTrigger}></tr>
</tbody>
);
};
// 搜索框
const searchText = ref("");
const visible = ref(false);
const handleClickSearch = () => {
visible.value = !visible.value;
};
// 滚动加载
const loadMoreTrigger = ref<HTMLElement | null>(null);
onMounted(() => {
const observer = new IntersectionObserver(
async (entries) => {
if (entries[0].isIntersecting) {
emit("handleLoadMore");
}
},
{
root: null,
rootMargin: "0px",
threshold: 1.0,
}
);
observer.observe(loadMoreTrigger.value as HTMLElement);
});
</script>
<template>
<div
class="orders"
:class="{ ordersFlex: !!props.flexBasis }"
:style="{ flexBasis: props.flexBasis }"
>
<div class="header">
<i
class="bx"
:class="props.titleIcon"
></i>
<h3>{{ props.title }}</h3>
<i
class="bx"
:class="props.iconAdd ? 'bx-plus' : ''"
@click="emit('handleFilterAdd')"
></i>
<input
class="searchInput"
:class="{ visible: visible }"
v-model="searchText"
placeholder="搜索商品信息"
@keyup.enter="emit('handleSearch', searchText)"
/>
<i
class="bx bx-search"
@click="handleClickSearch"
></i>
</div>
<table>
<thead>
<tr>
<th
v-for="(item, index) in columns"
:key="index"
:style="{ width: item.width }"
>
{{ item.title }}
</th>
</tr>
</thead>
<!-- 默认插槽隐藏 -->
<slot style="display: none"> </slot>
<!-- 通过自己定义的tsx函数来定义tbody -->
<DataTable />
</table>
</div>
</template>
<style lang="scss">
.orders {
&.ordersFlex {
flex-grow: 1;
}
color: $dark;
border-radius: 20px;
background: $light;
padding: 24px;
overflow-x: auto;
.header {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 24px;
.searchInput {
background: $grey;
border-radius: 36px;
width: 0px;
color: $dark;
font-size: 18px;
padding: 0px;
transition: all 0.2s ease-in-out;
&.visible {
padding: 3px 16px;
width: 160px;
}
&::placeholder {
font-size: 15px;
}
}
h3 {
margin-right: auto;
font-size: 24px;
font-weight: 600;
}

.bx {
cursor: pointer;
}
}

table {
width: 100%;
overflow-y: auto;
border-collapse: collapse;

tbody {
&::-webkit-scrollbar {
width: 5px;
}
&::-webkit-scrollbar-thumb {
background-color: rgba(0, 0, 0, 0.3);
border-radius: 10px;
}

&::-webkit-scrollbar-track {
background: transparent;
}
}

th {
min-width: 100px;
padding-bottom: 12px;
font-size: 13px;
text-align: left;
border-bottom: 1px solid $grey;
}

td {
padding: 16px 0;
}

tr {
display: table;
width: 100%;
}

tbody tr {
cursor: pointer;
transition: all 0.3s ease;
}

tbody tr:hover {
background: $grey;
}

tr td .status {
font-size: 10px;
padding: 6px 16px;
color: $light;
border-radius: 20px;
font-weight: 700;
}

tr td .status.completed {
background: $success;
}

tr td .status.process {
background: $primary;
}

tr td .status.pending {
background: $warning;
}
}
}
</style>

src\components\dataTable\tableColumn.vue

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
<script lang="tsx" setup>
import { useStore } from "@/store";
import { JSX } from "vue/jsx-runtime";
const props = defineProps<{
title: string;
width: string;
label: string;
filter?: [];
}>();
const store = useStore();
// 插槽函数,用于对子组件传递的插槽进行定义
const slots = defineSlots<{
default(props: { row: any }): JSX.Element;
}>();
// 负责接收父组件的数据以及收集插槽的数据
// 传入的数据为{row,column}
const collectCell = (row: any) => {
let children = null;
if (slots.default) {
// 如果插槽存在,那么就渲染插槽
// 传入的数据为:data
children = slots.default({ row: row });
} else {
// 仅仅渲染列对应的数据
children = <span>{row[props.label]}</span>;
}
return children;
};
// 利用收集的数据渲染表格的cell
const renderCell = (row: any) => {
const children = collectCell(row);
return <div>{children}</div>;
};
// 将数据传入store
let item: {
title: string;
width: string;
label: string;
filter?: [];
renderCell?: (row: any) => JSX.Element;
} = {
title: props.title,
width: props.width,
label: props.label,
filter: props.filter,
};
item.renderCell = renderCell;
store.commit("tableStoreModule/pushColumns", item);
</script>

<template></template>

其中主要的思路是,思路参考

  • 渲染表格是先挂载tableColumn组件,再挂载table组件。

  • 每次创建tableColumn组件时,需要将表头信息全部保存下来,并创建一个渲染表格每一项数据的方法,若表头函数插槽内容存在,将数据通过作用域插槽传递到外层

  • 最后在table组件,渲染table表格

需要用到jsx达到自定义的需求

css样式文件搭建

使用了scss

定义共同的变量

比如颜色和边框曲度等

1
2
3
4
5
6
7
8
9
10
11
12
13
// base color
$cus_pink: #feb7bb;
$cus_lightBlue: #a9e3e4;
$cus_yellow: #f4de4d;
$cus_lightPink: #e9e5e2;
$cus_lightPurple: #bbafd7;
$cus_blue: #2e74ff;
$cus_white: #fdfdfd;

// base style
$cus_radios: 30%;
$cus_background: #fdfdfd;
$cus_line-grey: #e5e7eb;
全局scss

一般css都有一些全局定义

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
*,
*:after,
*:before {
box-sizing: border-box; // 宽和高包括margin和padding
}
html {
height: 100%;
width: 100%;
margin: 0;
padding: 0;
font-size: 18px;
background: #f5f5f5;
user-select: none;
}
body {
overflow-y: hidden; // 不让垂直方向有滑动条
height: 100%;
width: 100%;
color: #333;
font: "Microsoft Yahei", "PingFang SC", "Avenir", "Segoe UI",
"Hiragino Sans GB", "STHeiti", "Microsoft Sans Serif",
"WenQuanYi Micro Hei", sans-serif;
}

BUG的解决

vscode 相关

@别名问题

vite.config.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
+ import { resolve } from "path";

// https://vitejs.dev/config/
export default defineConfig({
plugins: [vue()],
+ resolve: {
+ alias: [
+ {
+ find: "@", // 别名
+ replacement: resolve(__dirname, "src"), // 别名对应地址
+ },
+ ],
+ },
});

tsconfig.app.json

1
2
3
4
5
6
7
    "compilerOptions": {
...
+ "baseUrl": "./",
+ "paths": {
+ "@/*": ["src/*"]
}
},

vite-env.d.ts

1
2
3
4
5
6
declare module "*.vue" {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
import type { DefineComponent } from "vue";
const component: ComponentOptions | ComponentOptions["setup"];
export default component;
}

以及一个插件 @types/node

配置相关

无法找到模块“vuex”的声明文件

There are types at ‘d:/Resince/project/Hyperion_Frontend/node_modules/vuex/types/index.d.ts’, but this result could not be resolved when respecting package.json “exports”. The ‘vuex’ library may need to update its package.json or typings.ts(7016)

tsconfig.app.json中添加

1
2
3
4
5
"compilerOptions": {
"paths": {
"vuex": ["./node_modules/vuex/types"]
},
}

vite项目使用“tsx”

无法使用官方的插件babel.config.js,需要更换

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// yarn add '@vitejs/plugin-vue-jsx'
// 配置vite.config.ts

// vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import vueJsx from '@vitejs/plugin-vue-jsx' // 添加这一句

// https://vitejs.dev/config/
export default defineConfig({
plugins: [
vue(),
vueJsx() // 添加这一句
]
})

// 如果是tsx的话,需要在tsconfing.json中添加
// "jsxImportSource": "vue"

拓展

快速转 Number
1
2
3
4
5
var a = '1'

console.log(typeof a)
console.log(typeof Number(a)) // 普通写法
console.log(typeof +a) // 高端写法
快速转 Boolean
1
2
3
4
5
var a = 0

console.log(typeof a)
console.log(typeof Boolean(a)) // 普通写法
console.log(typeof !!a) // 高端写法

混写,先转为 Number 再转为 Boolean

1
2
3
4
var a = '0'

console.log(!!a) // 直接转将得到 true,不符合预期
console.log(!!+a) // 先转为 Number 再转为 Boolean,符合预期