Vue 项目是一个商场的前端页面,在这里记录开发的过程以及遇到的bug
技术栈
vue3
vite
vue router
vuex
TypeScript
sass
过程 创建项目
项目结构 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 │ ├── 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 │ │ └── variables.scss │ ├── types │ │ ├── index.ts │ │ └── user │ │ └── userInfo.ts │ ├── utils │ │ ├── request.ts │ │ └── token.ts │ ├── views │ │ ├── consumer │ │ │ ├── ... │ │ │ └── home │ │ │ ├── components │ │ │ └── index.vue │ │ └── ... │ └── vite-env.d .ts ├── tailwind.config .js ├── tsconfig.app .json ├── tsconfig.json ├── tsconfig.node .json ├── vite.config .ts └── yarn.lock
其中 tsconfig.node.json
的作用,来自
tsconfig.node.json
是专门用于 vite.config.ts
的 TypeScript 配置文件。
tsconfig.json
文件通过 references
字段引入 tsconfig.node.json
中的配置。
使用 references
字段引入的配置文件需要设置 composite: true
字段,并用 include
或 files
等等属性指明配置覆盖的文件范围。
其中 tsconfig.app.json
的作用
用于Web应用程序的配置,可能会针对浏览器环境调整代码分割、路径映射或是AOT编译的设置,目的是优化生产构建性能
vite-env.d.ts
的作用
在 Vite 项目中,env.d.ts
文件主要用于声明 TypeScript 中的环境变量类型。这些类型声明可以通过 /// <reference />
标签进行引用。该标签是一种三斜杠指令,用于向 TypeScript 编译器提供额外的类型信息。它通常用于以下情况:
引用类型声明文件:使 TypeScript 了解一些类型信息,这些类型信息可以在其他文件中使用。
项目范围的全局声明:为整个项目提供全局的类型声明。
对其中页面和组件的划分问题
看了一些文章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问题可以使用正则表达式
登录鉴权 需要实现的鉴权功能有以下三点:
没有登录的用户需要登录
登录的用户不能访问登录页面,需要登出再登录
不同权限的用户需要进入不同的页面
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
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" ;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 ) => { 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;
getters
与computed()
结合使用,达到数据的同步
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>
其中主要的思路是,思路参考
需要用到jsx
达到自定义的需求
css样式文件搭建 使用了scss
定义共同的变量
比如颜色和边框曲度等
1 2 3 4 5 6 7 8 9 10 11 12 13 $cus_pink : #feb7bb ;$cus_lightBlue : #a9e3e4 ;$cus_yellow : #f4de4d ;$cus_lightPink : #e9e5e2 ;$cus_lightPurple : #bbafd7 ;$cus_blue : #2e74ff ;$cus_white : #fdfdfd ;$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; } 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" ; 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" { 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 import { defineConfig } from 'vite' import vue from '@vitejs/plugin-vue' import vueJsx from '@vitejs/plugin-vue-jsx' export default defineConfig ({ plugins : [ vue (), vueJsx () ] })
拓展 快速转 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,符合预期