Vue3+Ts+Vite购物车实战

·  阅读 6409

这是一篇实战经验分享文章,其中的一些具体的功能点为了简化就前端实现了,没有后端支持,主要目的是学习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中,这样会导致编译不通过。需要将lessless-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-router4.x版本中引入的不再是一个类了,而是一组功能。我们通过createRouter创建路由。

1.router/index.ts中通过创建路由并导出。

注意:在v3关于异步组件的定义有些调整,并且vite替代了webpack,所以通过requirerequire.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中先加入一些我们会使用到的mutationsgetters

// 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中也能使用computedmethods,但我还是将加入购物车这个功能点拎出来了,我尝试将业务逻辑集中到setup中,但是setup中没有this,而我的逻辑里面就会用到this(将toast轻提示设为了全局方法),我没有办法才出此下策。我相信这样的场景再常见不过了,那到底该如何去解决这个问题,让代码看起来更优雅更合理呢?一时之间我也束手无策,我对setup的理解是用于聚合逻辑,但我隐隐感觉自己对于setup的理解过于"肤浅"。于是我又去官网学习了。

聚合逻辑,什么是聚合?在信息科学中是指对相关的数据进行分析,归类。关键在于"相关",并不是说有setup就没有必要在写其他的comoputedmethods,而是我们要真正的明白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~至此全部结束。

源码地址

末尾

感谢各位耐心看完,辛苦了,希望你有所收获。

大道至简,知易行难,知行合一,得到功成

分类:
前端
分类:
前端
收藏成功!
已添加到「」, 点击更改