Vue3+Ts+Vite购物车实战
这是一篇实战经验分享文章,其中的一些具体的功能点为了简化就前端实现了,没有后端支持,主要目的是学习Vue3+Typescript的环境下结合Vuex@4.x以及Vue-Router@4.x的开发。大致需求是模拟实现购物车功能,功能比较简单,但还是当作一些简化版的项目来进行开发的,如果有什么问题欢迎大家留言探讨和指正,谢谢。
先来看看效果:
maybe~看到最后你能了解到这些:
- 如何搭建一个如标题一样的项目环境
- 通过设置模版快速创建SFC的小技巧
- 加强理解
setup
以及使用场景(希望没有给大家造成误解) - 关于
vite
的一些基础配置知识 - 如何在ts中使用
vuex@4.x
,vue-router@4.x
- 在vue3中设置全局方法
- more???
如果对Vue3还没了解过的同学可以先看下这篇文章:
开始吧~
创建项目
首先用Vite
工具创建Vue3+Typescript的项目环境
注意:Vite需要你的Nodejs版本>=12.0.0
// yarn 方式
yarn create @vitejs/app v3-ts --template vue-ts
cd v3-ts
yarn install
// yarn create vite-app v3-ts --template vue-ts 这个1.x的命令
// npm
npm init @vitejs/app v3-ts --template vue-ts
cd v3-ts
npm install
Vite
还提供了一些其他的模版:
vanilla
vue
vue-ts
react
react-ts
preact
preact-ts
先把项目里面的东西清理一下,把官方demo删除(HelloWorld
组件相关的全部删除)。
添加 less
yarn add less less-loader --dev
// npm
npm install less less-loader --save-dev
注意:这边如果不加
--dev
,包会安装到dependencies
中,这样会导致编译不通过。需要将less
和less-loader
迁移到devDependencies
中再重新执行yarn
安装。
完善目录结构
在项目中创建几个文件夹以及对应的vue文件模版,目录结构大致如下(大家可以先创建pages文件夹,其他的后续会陆续创建):
小技巧:这里顺便给大家介绍一个vscode快速创页面或者组建的vue模版的小方法(已经知道的伙伴请忽略)
step1 : vscode > 首选项 > 用户片段
step2 : 输入vue,找到vue.json打开
step3 : 设置代码模版(已经给大家准备好了,请复制,粘贴!)
// vue.json
{
"Vue Template":{
"prefix":"vueTemplate",
"body":[
"<template>\n\t<div>\n\n\t</div>\n</template>",
"<script lang=\"ts\">\nimport{ defineComponent }from 'vue';\nexport default defineComponent({\n\tname: \"\",\n\tsetup: () => {\n\n\t}\n})\n</script>",
"<style lang=\"less\" scoped>\n\n</style>"
],
"description":"生成vue文件"
}
}
step4 : ctrl(command)+s(保存)
step5 : 在vue文件中输入vueTemplate
,vscode会给出提示,自信回车!
step6 : good job ~
// 模版长这样
<template>
<div>
</div>
</template>
<script lang="ts">
import{ defineComponent }from 'vue';
export default defineComponent({
name: "",
setup: () => {
}
})
</script>
<style lang="less" scoped>
</style>
可根据自己的情况设置模版哦。
添加 vue-router
我们开始设置路由。
yarn add vue-router@4.0.1
// 查看历史版本
// npm(yarn) info vue-router versions
注意避雷:建议大家不要使用4.0.0版本(工兵连的同志请随便~)。
vue-router
的4.x版本中引入的不再是一个类了,而是一组功能。我们通过createRouter
创建路由。
1. 在router/index.ts
中通过创建路由并导出。
注意:在v3关于异步组件的定义有些调整,并且vite
替代了webpack
,所以通过require
和require.ensure
实现路由懒加载的方式失效了。
- ES中的
import
// index.ts
import { createRouter, createWebHistory, RouteRecordRaw } from 'vue-router';
import Home from "/@pages/Products/index.vue";
const routes: Array<RouteRecordRaw> = [
{
path: '/',
name: 'product',
component: Home,
},
{
path: '/shoppingCart',
name: 'shoppingCart',
component: () => import('/@pages/ShoppingCart/index.vue'),
},
];
const router = createRouter({
history: createWebHistory(),
routes,
});
export default router;
- 通过
defineAsyncComponent
创建
import { defineAsyncComponent } from 'vue'
...
{
path: '/shoppingCart',
name: 'shoppingCart',
component:defineAsyncComponent(() => new Promise((resolve,reject)=>{
...doSomething
resolve({
// 异步组件选项
...
})
})) ,
},
{
path: '/shoppingCart',
name: 'shoppingCart',
component:defineAsyncComponent(() => import('.....')) ,
},
我们采用别名的方式对组件进行引入,让路径更简洁,清晰。这需要我们做一些配置,首先在项目根目录创建vite.config.js
(高版本的Vite
会自动创建这个文件,或者是vite.config.ts
),并作如下配置。
注意:在vscode中可能会存在vetur插件提示某些通过别名引入路径的错误,但问题不大,路径正确的情况下不会影响项目运行,原因我也不是特别清楚,如果有知道的朋友麻烦留言告知一下,不胜感激!
// vite.config.js
const path = require('path');
module.exports = {
alias:{
// 注意这边一定要加双斜杠
'/@pages/':path.resolve(__dirname,'./src/pages'),
'/@components/':path.resolve(__dirname,'./src/components')
}
}
// vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
alias:{
'@src':'/src/',
}
})
这边顺便给大家列一下vite.config.ts
的常见配置,vite.config.js
的配置也大致一样,详情可以跳官网参考配置:
// vite.config.ts
export default defineConfig({
plugins: [vue()],
// 项目根目录,可以是绝对路径也可以是相对配置文件所在的路径
root?: '',
// 运行编译模式 'development' || 'production'
mode?: 'development' ,
// 路径别名
alias?: {} ,
// 全局定义变量替换
define?:{
'':''
},
// build选项
build?:{
base:'/', // 基础路径
target:'modules', // 浏览器兼容模块
outDir:'dist', // 输出路径
assetsDir:'assets' // 静态资源路径
...
},
// 依赖优化项
optimizeDeps?:{
...
},
// 开发服务器
server?:{
host:'', // 主机
prot: 3000, // 端口
https: true, // 是否开启 https
open: false, // 是否在浏览器自动打开
proxy: {
'/api': {
target: 'http://jsonplaceholder.typicode.com',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, '')
},
}
...
}
})
注意:在开发过程中我们会发现在ts文件中引入vue文件会提示无法找到对应模块的报错。这是应为ts文件无法识别到vue文件,需要我们教它做事,在src
目录下创建shims-vue.d.ts文件,并编辑。
// shims-vue.d.ts
declare module '*.vue' {
import { Component } from 'vue'
const component: Component
export default component
}
2. 在main.ts
中引入router
:
// main.ts
import { createApp } from 'vue'
import App from './App.vue'
import router from './router';
const app = createApp(App);
app.use(router);
app.mount('#app')
3. 最后在app.vue
中设置视图窗口
// App.vue
<template>
<nav-bar :count="0" :active="'product'"></nav-bar>
<div class="body">
<router-view />
</div>
</template>
这边引入了我们自定义的组件NavBar
:
<template>
<div class="nav-bar">
<router-link
to="/"
:class="{ active: active === 'product' }"
>商品列表</router-link>
<router-link
to="/shoppingCart"
:class="{ active: active === 'shoppingCart' }"
>购物车{{count?`(${count})`:''}}</router-link>
</div>
</template>
<script lang="ts">
import{ defineComponent }from 'vue';
export default defineComponent({
name: "NavBar",
props:{
count: Number,
active: String
}
})
</script>
<style lang="less" scoped>
...
</style>
完事,开始编译yarn dev
,完美运行!
开始把页面充实起来~
添加Vant组件库
为了让这个demo看起来更“体面”,我们再添加一个组件库vant。
npm i vant@next -S
然后按照Vant官网安装步骤进行就好。
值得一提是,我们的项目中使用的是Vite,所以你无需使用按需加载的方式。
Vant官方说明:在 Vite 中无须考虑按需引入的问题。Vite 在构建代码时,会自动通过 Tree Shaking 移除未使用的 ESM 模块。而 Vant 3.0 内部所有模块都是基于 ESM 编写的,天然具备按需引入的能力。现阶段遗留的问题是,未使用的组件样式无法被 Tree Shaking 识别并移除,后续我们会考虑通过 Vite 插件的方式进行支持
既然我们没有使用babel-plugin-import
按需加载,那一定要记得引入css样式!!!!!
// main.ts
import 'vant/lib/index.css';
突然想起来同事之前在实践v3的时候说怎么添加全局方法?我们用toast来举个例子,哦豁~成功给后面埋下伏(地)笔(雷),给大家提供几种方法。
- 在
app.config.globalProperties
添加属性。
// main.ts
const app = createApp(...);
// 添加全局方法
app.config.globalProperties.$toast = (msg)=>{
return Toast(msg) // 根据需求自定义
};
// this.$toast('轻提示太舒服了');
- 添加minixs
// utility/minix.ts
const mixin = {
methods: {
fn(){
console.log('----doSomething-----');
}
}
}
export default mixin;
// 添加
import mixin from '/@src/utility/minix.ts';
export default defineComponent({
name: "Products",
mixins:[mixin],
...
})
- 提取函数并导出
// utility/index.ts
import { Toast } from "vant"
export const toast = (msg:string) => {
return Toast(msg);
}
// 调用
import { toast } from '/@src/utility/index.ts';
...
toast('轻提示');
商品列表
画页面的东西都没啥好说的,八仙过海可显神通,撸起袖子干就完事了~
<template>
<div class="products">
<!-- 在v3里面,v-for,v-if已经可以这么干了,v-if总是优先于v-for -->
<div class="product-list"
v-for="(item,index) in products"
:key="index"
v-if="!loading"
>
<!-- 作者比较懒没有封装成组件,大家请无视 -->
<span class="name">{{item.title}}</span>
<span class="price">{{item.price}}元</span>
<van-button
type="primary"
size="small"
@click="addToCart(item)"
>加入购物车</van-button>
</div>
<van-loading v-else />
</div>
</template>
<script lang="ts">
import { defineComponent,ref }from 'vue';
import { Product } from '/@src/interface';
import { apiGetProducts } from '/@src/api/index';
export default defineComponent({
name: "Products",
setup(){
const products= ref<Product[]>([]);
const loading = ref(false);
// 获取产品列表
const getProducts = async () => {
loading.value = true;
products.value = await apiGetProducts();
loading.value = false;
}
getProducts();
return {
loading, // 加载状态
products // 商品列表
}
},
methods:{
addToCart(product:Product){
console.log('加入购物车');
}
}
})
</script>
<style lang="less" scoped>
...
</style>
我新建了两个文件,路径如下:
/interface/index.ts
定义的模型/api/index.ts
接口列表
// interface/index.ts
export interface Product {
id:number, // id
title:string, // 名称
price:number, // 价格
count:number // 购买数量
}
// api/index.ts
/**
* 获取产品列表
*/
export const apiGetProducts = ()=>{
return new Promise<Product[]>((resolve,reject)=>{
// 模拟接口请求
setTimeout(()=>{
resolve(data);
},1000)
})
}
数据是我在api/data.ts中构造的,
添加Vuex@next
npm install vuex@next --save
// yarn
yarn add vuex@next --save
1. 新建store/index.ts
import { InjectionKey } from 'vue';
import { createStore, useStore as baseUseStore, Store} from 'vuex';
import { Product } from 'src/interface';
export interface State{
shoppingCart: Product[]
}
export const key: InjectionKey<Store<State>> = Symbol();
export const store = createStore<State>({
state:{
shoppingCart:[] // 购物车列表
},
})
export function useStore(){
// 通过key给store提供类型
return baseUseStore(key)
}
2. 在App.vue中引入vuex
import { store , key} from './store/index';
...
app.use(store,key);
...
到这里我们vuex
也成功添加进项目,大部分vuex
的运用和在js环境下一样,在ts中唯一多了类型判断,以及结合v3中使用的一些细微的区别,后面在实际场景中会用到。
我们在vuex
中先加入一些我们会使用到的mutations
,getters
。
// store/index.ts
...
export const store = createStore<State>({
state:{
shoppingCart:[]
},
getters:{
// 是否在购物车中已存在
isInCart(state){
return (data:any)=>{
return state.shoppingCart.findIndex(item=>item.id === data.id) > -1 ? true : false;
}
}
},
mutations:{
// 添加购物车
ADD_TO_CARD(state,data){
state.shoppingCart.push(data);
},
// 更新购物车数量
CHANGE_COUNT(state,{type,data}){
return state.shoppingCart.map(item=>{
if(data.id=== item.id){
item.count += type === 'add' ? 1 : -1;
}
return item;
})
},
// 删除购物车
REMOVE_BY_ID(state,id){
state.shoppingCart = state.shoppingCart.filter(item=>item.id!==id);
}
}
})
export function useStore(){
// 通过key给store提供类型
return baseUseStore(key)
}
接着我们继续完善商品列表的页面,实现添加购物车的功能。
<template>
...
</template>
<script lang="ts">
import { defineComponent ,ref }from 'vue';
import { mapMutations, mapGetters } from 'vuex'
import { Product } from '/@src/interface';
import { apiGetProducts } from '/@src/api/index';
export default defineComponent({
name: "Products",
setup(){
const products= ref<Product[]>([]);
const loading = ref(false);
// 获取产品列表
const getProducts = async () => {
loading.value = true;
products.value = await apiGetProducts();
loading.value = false;
}
getProducts();
return {
loading, // 加载状态
products // 商品列表
}
},
computed:{
...mapGetters(['isInCart'])
},
methods:{
...mapMutations(['ADD_TO_CARD']),
addToCart(product:Product){
// 如果已经存在
if(this.isInCart(product)) return this.$toast('已存在');
// 加入购物车
this.ADD_TO_CARD({
title:product.title,
count:1,
id:product.id
})
this.$toast('添加成功')
}
}
})
</script>
<style lang="less" scoped>
...
</style>
最开始这个页面的代码我是这样写的,可是我越看越觉得乱,越别扭,为什么这么说呢?
v3给我们提供了一个新的钩子setup
,在setup
中也能使用computed
、methods
,但我还是将加入购物车这个功能点拎出来了,我尝试将业务逻辑集中到setup
中,但是setup
中没有this,而我的逻辑里面就会用到this(将toast轻提示设为了全局方法),我没有办法才出此下策。我相信这样的场景再常见不过了,那到底该如何去解决这个问题,让代码看起来更优雅更合理呢?一时之间我也束手无策,我对setup
的理解是用于聚合逻辑,但我隐隐感觉自己对于setup
的理解过于"肤浅"。于是我又去官网学习了。
聚合逻辑,什么是聚合?在信息科学中是指对相关的数据进行分析,归类。关键在于"相关",并不是说有setup
就没有必要在写其他的comoputed
,methods
了,而是我们要真正的明白setup
是用来做什么的,是将我们的逻辑关注点聚集起来。所以这里的问题是this.$toast()
真的是我们的核心相关的业务吗?
如果理解到这一点了,我想我们的代码可以这样优化一下:
<template>
<div class="products">
...
<van-button
type="primary"
size="small"
@click="addHandle(item)"
>加入购物车</van-button>
...
</div>
</template>
<script lang="ts">
...
export default defineComponent({
name: "Products",
setup(){
const products= ref<Product[]>([]);
const loading = ref(false);
const { commit, getters } = useStore();
// 获取产品列表
const getProducts = async () => {
loading.value = true;
products.value = await apiGetProducts();
loading.value = false;
}
// 加入购物车
const addToCart = (product:Product) => {
commit('ADD_TO_CARD',{
title:product.title,
count:1,
id:product.id
})
}
// 判断是否在购物车中已存在
const isInCart = (product:Product)=>{
return getters.isInCart(product);
}
getProducts();
return {
loading, // 加载状态
products, // 商品列表
addToCart, // 加入购物车
isInCart // 是否在购物车中已存在
}
},
methods:{
addHandle(product:Product){
// 如果已经存在
if(this.isInCart(product)) return this.$toast('已存在');
this.addToCart(product);
this.$toast('添加成功')
}
}
})
</script>
我们把加入购物车的相关业务逻辑都聚合起来了,OK,看起来好像舒服多了~
这个例子的逻辑相对简单,并不是解释setup的典型场景,但以上描述的问题此刻确实存在,关于setup
的运用以及代码的组织更多的可能要结果实际的需求场景灵活应对,希望大家多多留言探讨。
我们还可以用上面讲到的用提取函数的方法来替换this.$toast()
:
<template>
<div class="products">
...
<van-button
type="primary"
size="small"
@click="addHandle(item)"
>加入购物车</van-button>
...
</div>
</template>
<script lang="ts">
...
import { toast } from '/@src/utility/index.ts';
export default defineComponent({
name: "Products",
setup(){
...
// 处理函数
const addHandle = (product:Product) => {
// 如果已经存在
if(isInCart(product)) return toast('已存在');
addToCart(product);
toast('添加成功')
}
getProducts();
return {
loading, // 加载状态
products, // 商品列表
addHandle // 添加购物车
}
}
})
</script>
这样的代码风格是不是看起来完整又漂亮。
更新App.vue
为了让我们验证一下购物车是否加入成功,我们来更新一下自定义的NavBar
的入参,在导航中显示购物车列表的数量。
// App.vue
<template>
<nav-bar :count="count" :active="activeRouteName"></nav-bar>
<div class="body">
<router-view/>
</div>
</template>
<script lang="ts">
import { computed, defineComponent } from 'vue'
import { useRoute } from 'vue-router'
import { useStore } from '/@src/store/index'
import NavBar from "/@components/NavBar/index.vue"
export default defineComponent({
name: 'App',
components:{
NavBar
},
setup(props,context) {
const store = useStore();
const route = useRoute(); // this.$route
// 购物车中的商品种类
const count = computed(():number=>{
return store.state.shoppingCart.length;
})
// 当前路由的name
const activeRouteName = computed(():string =>{
return route.name?.toString() || '';
})
return {
count,
activeRouteName
}
}
})
</script>
购物车页面
能动手尽量少说话!
<template>
<div class="shopping-cart">
<h2>我的购物车</h2>
<div
class="product-info"
v-for="item in shoppingCart"
:key="item.id"
>
<span>{{item.title}}</span>
<div class="btn-box">
<button @click="changeCount('reduce',item)">-</button>
<span>{{item.count}}</span>
<button @click="changeCount('add',item)">+</button>
</div>
<van-button
type="danger"
size="small"
@click="removeHandle(item)"
>删除</van-button>
</div>
</div>
</template>
<script lang="ts">
import { defineComponent, computed } from 'vue';
import { Product } from 'src/interface';
import { useStore } from '/@src/store/index';
import { toast } from '/@src/utility/index.ts';
export default defineComponent({
name: "ShoppingCart",
setup: () => {
const { state,commit } = useStore();
const shoppingCart = computed(()=>{
return state.shoppingCart
})
// 更新购物车数量
const changeCount = (type:string,data:Product) => {
// 保证购物车中最小数量为1
if(type === 'reduce' && data.count <= 1) return;
commit('CHANGE_COUNT',{type,data})
}
// 删除购物车
const removeCart = (data:Product) => {
commit('REMOVE_BY_ID',data.id);
}
// 处理函数
const removeHandle = (data:Product) => {
removeCart(data);
toast('删除成功')
}
return {
shoppingCart, // 购物车列表
changeCount, // 更新购物车数量
removeHandle // 删除购物车
}
},
})
</script>
<style lang="less" scoped>
@import './index.less';
</style>
OK~至此全部结束。
末尾
感谢各位耐心看完,辛苦了,希望你有所收获。
大道至简,知易行难,知行合一,得到功成。
转载自:https://juejin.cn/post/6918672538646102029