🙌手把手教你用vite搭建vue3项目(仿宜家家居移动端)

3,534 阅读11分钟

前言

最近在学习Vue框架,对于Vue2Vue3本人主要学习Vue3为主,本项目使用Vite脚手架搭建项目开发,在此希望能够将自己学习到的知识分享给各位看官们,放心食用!

开发准备

总体架构

本项目采用的MVVM开发架构,使用Vue3+Vuex+Vue-router+Vite+MockJS+Stylus的开发模式,基于组件化、响应式、模块化、单页应用等思想,利用MockJS实现前后端分离,并且可以通过其对ajax请求进行拦截,便于模拟后端。Stylus这款富有表现力、健壮、功能丰富的CSS预处理器也可以提高开发效率。

|- .vscode 工作区的相关配置文件 自动生成不需要管
|- node_modules 存放下载安装的包的文件
|- public 存放静态资源的文件 适合放第三方的
|- src 项目开发目录
    |- assets 静态资源文件 自己的图标 图片
    |- common 公共通用文件 公共js函数 公共样式
    |- components 通用组件
    |- mock 存放数据 拦截ajax请求
        |- data 存放各种数据
        |- index.js 请求拦截
    |- router 配置路由
    |- service 请求接口
    |- store 状态管理仓库
        |- modules 子仓库模块
        |- index.js 中央仓库 单一状态树
    |- views 页面级别组件
        |- Cart 购物车页面
        |- Category 分类页面
        |- Detail 详情页面
        |- Home 主页面
        |- Login 登录页面
        |- Other 一些跳转后的页面 这里直接用图片代替了
        |- Search 搜索页面 
        |- User 用户页面
    |- App.vue 页面入口文件 所有页面在此切换
    |- main.js 单点入口文件 全局引入库 公共样式等
|- index.html 首页入口文件 vue实例挂载节点在此
|- package-lock.json 准确的描述了当前项目npm包的依赖树
|- package.json 记录了当前项目中你下载的包信息但不包含依赖包关系
|- vite.config.js 使用vite创建项目自动生成 并且vite会自动解析该文件

项目规划

  • 在开发项目之前,我会先分析每个页面的核心交互功能,了解这款应用程序的交互细节,剖析各类数据项。如此可以分为分析页面,解构页面基本布局,梳理业务逻辑,创造接口数据四步来展开。
  • 在配置好node环境下初始化vite项目,使用命令行npm install vite,接着创建项目名称,选择vue选项,接着可以选择javascript或者typescript开发,接着在这个项目路径下使用命令行npm install安装相关依赖,完成后项目就算正式启动了。
  • 需要使用路径别名的话,因为本项目使用的javascript所以可以在vite.config.js文件下配置。
  • 把需要的第三方包库按需引入安装,就可以在src开发目录下大展身手了。

首先展示单点入口文件的代码

code1.png

MockJS

Mock.mock可以用来模拟后端处理请求,所以处理好的数据直接放在data文件中,然后通过在Mock中处理完数据请求后返回相应的数据和信息。需要注意的是,如果单纯只是请求数据我们需要通过GET来发送请求,但是像登录注册这种提交数据的需要使用POST这种请求方式。GET请求中,像详情页或者需要做分页请求数据时,我们还需要在发送请求时提供params额外的参数,虽然本项目并非使用后端真正接口但是也需要通过这种方式做好相关业务的模拟。

code2.png

项目解构

以下展示图是我主要实现的页面效果

p1.png p2.png p3.png p4.png p5.png p6.png p8.png p9.png p8.png p8.png p9.png

接下来开始逐个对页面进行解构

首页页面

home1.gif

h3.png

主页模板中采用组件化思想,这里在数据请求到达前用v-if、v-else指令显示正在加载中,反馈提高用户体验。

交互功能分析

h1.png

搜索栏缩放动画采用添加动态类名的方法,样式用transition来实现动画效果。因为缩短后的搜索栏覆盖在顶部会影响下层地址选项功能的点击事件,所以设置pointer-events属性来解决事件穿透被阻拦的问题。

  <van-sticky class="search-bar" :class="{'search-bar-show': show}">
    <van-search 
      :class="{ 'search-normal': !props.isSort, 'search-sort': props.isSort }" 
      v-model="value" 
      shape="round" 
      readonly 
      @click-input="onClick"
    >
    </van-search>
  </van-sticky>
.search-bar 
  pointer-events none // 事件穿透 让下层事件能被触发
  .search-normal 
    pointer-events visiblePainted // 只有点击元素内部事件才会触发
    padding 0 .32rem .266667rem
    width 100%
    transition width .4s
  .search-sort 
    pointer-events visiblePainted
    transition width .4s
    padding .266667rem .32rem 
    width: 5.333333rem

横幅滑动栏的图片水平滑动采用betterScroll的第三方库,因为此处图片设置了点击跳转事件,但是betterScroll会阻止原生click事件,所以需要在配置的时候设置click: trueprobeType属性是作用是派发scroll事件,当值为2表示仅仅当手指按在滚动区域上,一直派发 scroll 事件。

import { ref, onMounted } from 'vue';
// 按需加载 只要核心部分
import BScroll from '@better-scroll/core';

const props = defineProps({
  bannerList: {
    type: Array,
    default: []
  }
})
const scroll = ref(null);
let bs = null;
onMounted(() => {
  // betterscroll 会阻止原生click 需要设置click参数
  bs = new BScroll(scroll.value, {
    probeType: 2,
    scrollX: true,
    scrollY: false,
    click: true
  })
})

底部标签栏首页图标滚动切换,点击实现回到顶部功能,注意回到顶部需要缓慢滚动,在这可以使用behavior: "smooth"实现效果。

<template>
  <div class="navbar">
    <ul class="nav-list">
      <!-- 页面跳转 -->
      <router-link class="nav-list-item" to="home">
        <div v-if="!props.isToggle" @click="backTop">
          <van-icon name="wap-home-o" />
          <div>首页</div>
        </div>
        <div @click="backTop" v-else>
          <van-icon name="back-top" id="backTop" />
          <div>回顶部</div>
        </div>
      </router-link>
      <router-link class="nav-list-item" to="category">
        <van-icon class-prefix="my-icon" name="category" />
        <div>分类</div>
      </router-link>
      <router-link class="nav-list-item" to="cart">
        <van-icon name="shopping-cart-o" />
        <div>购物袋</div>
      </router-link>
      <router-link class="nav-list-item" to="user">
        <van-icon name="manager-o" />
        <div>我的</div>
      </router-link>
    </ul>
  </div>
  <div class="container">
  </div>
</template>
const backTop = () => {
  let scrollTop = window.pageYOffset || document.documentElement || document.body.scrollTop;
  if (scrollTop > 0) { // 防止频繁点击
    window.scrollTo({
      top: 0,
      behavior: "smooth"
    })
  }
};

h2.png

商品列表瀑布流,通过在Mock中给数据设置随机高度,设置左右两列数组分别存储数据,总是找到最短的一列添加数据。

async GET_GOODITEMLIST({ commit }) {
		// 两列式瀑布流布局
		let leftTempGoods = [],
				rightTempGoods = [];
		const heights = [0, 0];
		const getMinHeight = () => { // 拿到高度最短的一行
			let minHeight = Math.min(...heights);
			return heights.indexOf(minHeight)
		};
		for (let i = 0; i < state.allGoods.length; i++) {
			let minHeightIndex = getMinHeight()
			if (minHeightIndex === 0) {
				leftTempGoods.push(state.allGoods[i])
			} else {
				rightTempGoods.push(state.allGoods[i])
			}
			heights[minHeightIndex] += state.allGoods[i].height // 总是在最短的一行添加商品
		}
		commit('SET_LEFTLIST', leftTempGoods)
		commit('SET_RIGHTLIST', rightTempGoods)
	}

商品列表数据较大需要分页处理,所以可以通过MockJS拦截请求时做处理,通过传递的params参数里的page来返回相应的数据,模拟后端分页。

// 商品列表数据请求函数 带page参数
const getGoodsList = (page) => {
  return axios.get('/goodsList',{params: { page: page}})
}
Mock.mock(/\/goodsList/, 'get', (options) => {
  let page = options.url.split('=')[1];
  let count = page * 20
  let data = goodsList.filter((item, index) => index < count)
  if (count > data.length) {
    return {
      code: 1,
      result: false
    }
  } else {
    return {
      code: 0,
      result: data
    }
  }
})

商品详情页

de1.gif

d0.png

交互功能分析

d1.png

  1. 点击跳转可以通过router-link标签中to属性或者用router对象的push()方法来实现跳转功能,回到上一级页面则可以通过history.back()或者router.go(-1)方法实现。
  2. 购物袋点击跳转需要判断是否登录,未登录状态则直接跳转至登录页,已登录则跳转至购物袋,这里可以通过store仓库中的状态判断。
  3. 购物袋为空时购物袋徽标不显示,当有数量时则根据数量相应显示;加入购物袋点击实现弹出层,选择数量后提交将商品项推送至购物袋,库存不足时提示用户,选择提交后自动关闭弹出层。这里弹出层的关闭可以通过子组件向父组件传值来实现popup的自动关闭效果。
<template>
  <div class="d-action-bar">
    <van-action-bar-icon icon="chat-o" text="客服" @click="onClickIcon" />
    <!-- 购物车徽标 未有商品数时 不显示 -->
    <van-action-bar-icon 
      icon="cart-o" 
      text="购物袋" 
      :badge="o.totalQuantity > 0 ? o.totalQuantity : ''" 
      @click="toCart" 
    />
    <van-action-bar-icon icon="shop-o" text="店铺" />
    <div class="button">
    <van-button class="l-button" plain round type="primary" text="加入购物袋" @click="showPopup" />
    <van-popup
      v-model:show="show"
      round
      position="bottom"
      :style="{ height: '70%' }"
    >
    <DPopup :product="product" @changeShow="changeShow"/>
    </van-popup>
    <van-button class="r-button" round color="#23579c" type="primary" text="立即购买" @click="submit"/>
    </div>
  </div>
  <div class="container"></div>
</template>

<script setup>
import { useRouter } from 'vue-router';
import { useStore } from 'vuex';
import { ref, computed } from 'vue';
import DPopup from './DPopup.vue'
import { showSuccessToast } from 'vant';
const store = useStore();
const router = useRouter();
const show = ref(false)
// 获得子组件传递的值 关闭弹出层
const changeShow = (res) => {
  show.value = res
}
const o = computed(() => store.getters['cart/totalProducts']); // 拿到购物车数量数据
let product = store.state.detail.description; // 拿到当前商品
const toCart = () => router.push('/cart');
// 加入购物车 弹出层
const showPopup = () => {
  show.value = true
}
const submit = () => {
  showSuccessToast('请您支付坤坤🐔')
}
</script>

从首页商品项点击跳转至详情页时,在跳转时把商品的参数id传递。

// 带参数跳转至详情页 通过id取到对应的商品数据
const gotoDetail = async (id) => {
  await store.dispatch('detail/GET_DETAILDATA', id);
  router.push({
      path: `/detail/${id}`
  });
}

详情页数据请求时可以通过首页的商品id获得到对应的详情数据。

// 详情页数据请求
export const getDetailData = (id) => {
  return axios.get('/detailData', {params: { id: id}}) // 请求数据时传递id参数
}
const actions = {
	  async GET_DETAILDATA({ commit }, id) {
		const { result } = await getDetailData(id)
		commit('SET_SWIPERLIST', result.swipeImgUrls)
		commit('SET_DESCRIPTION', result.description)
		commit('SET_PARTICULAR', result.particular)
	},
}

这里采用MockJS拦截请求时,处理判断并返回相应商品数据。

// 模拟详情页传参跳转
Mock.mock(/\/detailData/, 'get', (options) => {
  let id = options.url.split('=')[1]; // 需要跳转至详情页的商品ID 
  let res = detailData.find(item => item.id === id); // 拿到对应商品数据
  if (res.id === id)
    return {
      code: 0,
      result: res.data
    }
})

d2.png

分类页页面

cg1.gif

cy0.png

交互功能分析

cy1.png

  1. 左侧标签栏高亮可以通过点击获得当前标签项的index对该标签栏数组的当前索引下的元素添加动态类名实现高亮效果。
  2. 右侧内容区滚动导致左侧标签栏跟随高亮,在此模糊通过监听滚动到达某块内容区选择相应标签项匹配,一致则将左侧元素高亮处理。
import { reactive, nextTick, onBeforeUnmount } from 'vue';
import _ from 'lodash';
import CItem from './CItem.vue';

defineProps({
  firstList: {
    type: Array,
    default: []
  }
})
let state = reactive({
  show: [true] // 高亮效果默认选择第一项
})
const onClick = (index, firstList) => {
  state.show = Array(firstList.length) 
  state.show[index] = true
  window.scrollTo(0, 500 * index)
}
// 滚动响应侧边栏高亮
const categoryScroll = (firstList) => {
  let scrollTop = window.pageYOffset || document.documentElement || document.body.scrollTop;
  let x = Math.floor(scrollTop / 400)
  switch (x) {
    case 0:
    state.show = Array(firstList.length) 
    state.show[x] = true
    break;
    case 1:
    state.show = Array(firstList.length) 
    state.show[x] = true
    break;
    case 2:
    state.show = Array(firstList.length) 
    state.show[x] = true
    break;
    case 3:
    state.show = Array(firstList.length) 
    state.show[x] = true
    break;
    case 4:
    state.show = Array(firstList.length) 
    state.show[x] = true
    break;
    case 5:
    state.show = Array(firstList.length) 
    state.show[x] = true
    break;
    case 6:
    state.show = Array(firstList.length) 
    state.show[x] = true
    break;
    case 7:
    state.show = Array(firstList.length) 
    state.show[x] = true
    break;
  }
}
const handleScroll = _.throttle(categoryScroll, 200);
nextTick(() => { // 更早拿到更新数据
  window.addEventListener('scroll', _.throttle(categoryScroll, 200));
})
onBeforeUnmount(() => { // 卸载时移除监听的事件
  window.removeEventListener('scroll', handleScroll);
})

cy2.png

分类页面主要以图片数量多为主,所以在此可以使用图片懒加载,骨架屏来做性能优化,提高用户体验感。

购物袋页面

ct1.gif

ct0.png

交互功能分析

ct1.png

未登录状态不提供购物袋功能,点击直接跳转至登录页面。

ct2.png

购物袋图标点击回到顶部,购物袋为空时,用空状态组件占位提示用户,增强用户体验。

ct3.png

  1. 购物袋数据当从详情页第一次添加至购物袋时的数据存储在productItemsproducts中,product做为字典使用,如果重复添加商品时只需要修改productItems中相应商品项的数量即可。
  2. 删除商品项时,可以通过找到productItems数组中的某一项过滤掉即可。
  3. 计算商品总价和总数量可以通过getters计算购物袋的store中相应数据并返回最终计算结果。
import { showToast } from 'vant'
const state = {
  products: new Map(), // 无重复储存商品 做字典使用
  productItems: [] // 存储商品数据 引用式赋值 实时更新数据
}

const mutations = {
  SET_PRODUCTS: (state, val) => {
    // 重复添加
    if (state.products.has(val.iid)) {
      if (state.products.get(val.iid).inventory > 0) {
        val.inventory = val.inventory - val.count
        val.quantity = val.quantity + val.count
      } else {
        showToast('库存不足, 请选择其他商品')
      }
    } else {
      // 第一次添加
      state.products.set(val.iid, val)
      val.quantity = val.count
      val.inventory = val.inventory - val.quantity
      state.productItems.push(val)
    }
  },
  DETALE_PRODUCT: (state, val) => {
    state.productItems = state.productItems.filter(item => item.iid !== val)
  }
}

const actions = {
}

const getters = {
  // 筛选数据返回
  cartProducts: (state) => {
    return state.productItems.map(item => ({
      id: item.iid,
      title: item.title,
      price: `${item.price}.00`,
      desc: item.desc,
      imgSrc: item.imgSrc,
      quantity: item.quantity
    }));
  },
  totalProducts: (state) => {
    // 计算总价
    let totalPrice = state.productItems.reduce((pre, cru) => pre + cru.price * cru.quantity, 0) * 100
    // 计算总数
    let totalQuantity = state.productItems.reduce((pre, cru) => pre + cru.quantity, 0)
    totalQuantity > 0 ? totalQuantity : undefined
    return {
      totalPrice: totalPrice,
      totalQuantity: totalQuantity
    }
  },
  // 购物商品数组长度
  length: (state) => state.productItems.length
}

export default {
  namespaced: true,
  state,
  mutations,
  getters,
  actions
}

商品的全选和反选,我通过ref给节点打上标记拿到全部checked数组值一次性改变状态。

import { useStore } from 'vuex';
import { computed, ref } from 'vue';
import { showToast } from 'vant';
const store = useStore()
const checked = ref([]);
const checkboxGroup = ref(null);
let x = ref(false)
const checkAll = () => {
  x = !x 
  checkboxGroup.value.toggleAll(x); // 全选 反选
}
const products = computed(() => store.getters['cart/cartProducts']) // 拿到商品数据 
const o = computed(() => store.getters['cart/totalProducts']) // 拿到总量数据
const deleteProduct = (item) => { // 删除商品项
  if (store.state.cart.products.get(item.id)) {
    store.commit('cart/DETALE_PRODUCT', item.id)
  }
}
const onSubmit = (val) => {
  showToast(`请您支付${val / 100}只因🐔`)
}

用户页面

u1.gif

u0.png

交互功能分析

u2.png

未登录状态不提供用户页面功能,可以点击跳转至登录页完成登录。

u1.png

退出登录时,改变Login仓库中的数据状态并将本地存储的模拟token删除。

import { useStore } from 'vuex';
import { ref } from 'vue';
import { deleteLocal, TOKEN } from '../../../common/js/utils';
const store = useStore();
const show = ref(false);
const showPopup = () => {
  show.value = true;
};
// 退出登录 并删除token
const quitLogin = () => {
  store.commit('login/SET_LOGIN', false)
  deleteLocal(TOKEN) 
}

登录页面

l0.png

交互功能分析

l1.png

登录输入验证,未注册用户名则提示用户名未注册;密码输入错误则提示密码有误。未注册状态则可通过点击切换至注册表单。登录成功时,则将Login的store数据状态改变成已登录状态

l2.png

注册输入验证,已注册过的用户名则提示用户已注册。注册成功后自动切换至登录表单。

const state = {
	isLogin: false // 判断是否登录
}

const mutations = {
  SET_LOGIN: (state, val) => state.isLogin = val
}

注册和登录表单提交时,使用安装的md5第三方包对密码进行加密操作,不能明文传输。这里通过MockJS对请求进行拦截,通过解构请求传递的options,可以判断本次请求是登录还是注册状态,并返回相应状态码和信息。这样也是对后端的模拟操作。

// 模拟登录注册
Mock.mock(/\/login/, 'post', (options) => {
  const { body } = options
  const { username, password, regname, regpassword, type } = JSON.parse(body)
  const result = regName.has(username) // 用户名是否注册
  const regPassword = usersMap.get(username) // 查询密码
  if (type === 'login') { // 登录请求
    if (result && password === regPassword) { // 成功登录
      return {
        code: 0,
        status: 200,
        token: 'xxvcvdvcvcvdfdfddddddd',
        msg: '登录成功'
      }
    } else if (result) { // 密码错误
      return {
        code: 1,
        status: 400,
        msg: '密码有误,请重新输入'
      }
    } else { // 账号错误
      return {
        code: 1,
        status: 400,
        msg: '用户名未注册'
      }
    }
  } else if (type === 'register') { // 注册请求
    if (usersMap.has(regname)) { // 用户名已注册
      return true
    } else {
      usersMap.set(regname, regpassword); // 用户名未注册
      regName.add(regname)
      return false
    }
  }
})

搜索页面

s1.gif

s0.png

交互功能分析

s2.png

搜索历史通过localStorage本地存储来做数据持久化,点击清除把存储的相应键值对清除即可。 多次搜索同一物品时,需要将存储数据的数据去重保证数据的正确性。

<template>
  <div class="s-header">
    <van-icon name="arrow-left" class="arrow" @click="back" />
    <van-search shape="round" v-model="value" show-action clearable placeholder="手推车">
      <template #action>
        <div @click="onClickButton" class="button">搜索</div>
      </template>
    </van-search>
  </div>
  <div class="logs" v-if="showLogs">
    <div class="lg-header">
      <text class="title">搜索历史</text>
      <van-icon name="delete-o" class="icon" @click="clear" />
    </div>
    <div class="lg-main">
      <div class="lg-item" v-for="(item, index) in items" :key="index">
        <div class="lg-container">{{ item }}</div>
      </div>
    </div>
  </div>
</template>

<script setup>
import { ref, onMounted } from 'vue';
import { useRouter } from 'vue-router';
import { getLocal, setLocal, deleteLocal, LOGS } from "@/common/js/utils";

const value = ref('');
const showLogs = ref(false);
const logs = ref([]);
const router = useRouter();
const items = new Set(JSON.parse(getLocal(LOGS)));
const setLogs = (val) => {
  logs.value.push(val); // 存入输入值
  setLocal(LOGS, JSON.stringify(logs.value)); // 数组对象序列化存储
}
const onClickButton = () => {
  if (value.value.trim()) { // 防止输入空格也跳转
    if (getLocal(LOGS)) { // 持久化 重新回到页面也能得到之前的记录
      // 把之前每个数据依次存入数组
      JSON.parse(getLocal(LOGS)).forEach((val) => logs.value.push(val))
    };
    setLogs(value.value);
    value.value = '';
    router.push('/level');
  } else { // 将默认值作为搜索值
    if (getLocal(LOGS)) { // 
      JSON.parse(getLocal(LOGS)).forEach((val) => logs.value.push(val))
    };
    setLogs('手推车');
    router.push('/level');
  }
};
const back = () => {
  router.go(-1); // 返回上一页
}
const clear = () => {
  deleteLocal(LOGS); // 把本地logs清空
  showLogs.value = false; // 隐藏历史记录
}
onMounted(() => {
  if (getLocal(LOGS)) showLogs.value = true; // 有搜索结果显示历史记录
})
</script>

难点重点

  1. 分页问题:商品列表数据较大时,单次请求数据最好限制在一定数量。
    所以在封装请求函数时需要带上page参数,发送请求时设置params: { page: page},当用MockJS拦截请求时就可以拿到传递的page参数,通过参数返回对应的数据。
  2. 商品跳转详情页问题:不同商品跳转需要显示不同数据的详情页。
    这个问题也是通过发送请求时带上id,设置params: { id: id},当Mock.mock拦截请求时处理后返回相对应的id数据。需要注意的是商品项的id需要和返回数据id相同才可以匹配。所以在写模拟数据是商品项数据id和详情页数据id要相互对应。
  3. 登录注册问题:登录注册发送请求的表单数据密码需要加密传输,登录后的鉴权。
    表单数据传输时密码可以通过第三方包md5对密码单向加密后传输。这里模拟的注册登录不同状态,通过发送POST请求时传递user对象上type属性值Mock.mock拦截POST请求可以解构对象属性判断出具体状态,来存储用户注册数据。当登录状态时,只要在数据中查找是否存在来判断是否成功登录。
    鉴权的token可以用axios.interceptors.request.use()拦截请求在发送前判断是否拿到token。如果设置了token的话,可以使用路由守卫router.beforeEach()方法来鉴权。
  4. 性能优化问题
    • 如果当有多次数据请求时,可以使用Promise.all()并发执行这些请求。
    • 商品数据较多时,分页处理数据。
    • 配置router对象时,除首页之外的页面组件可以使用路由懒加载来优化。
    • 组件布局中数据驱动的组件可以采用骨架屏,图片可以使用图片懒加载来做优化。
    • 引入第三方包库时,按需加载,用不上的不安装。
    • 交互功能用户点击发送请求时,请求到数据后要避免重复请求。
    • 频繁事件的触发需要防抖或者节流处理。

小建议

在写项目的过程中肯定会遇到或多或少的bug或者某些坑,比如对某些库或者某些组件不熟悉的话,遇到问题时可以去看看官网的文档,阅读文档对代码能力的提升也是很大的。再者如果遇到bug可以多通过打印寻找错误出现的阶段在什么地方,或者去网上搜索某些错误的原因有什么好的解决方案等,解决掉bug就是进一步提升自我。

项目源码

在此就先介绍这些业务逻辑,详情请点击下方👇源码链接。
本项目源码

结语

如果你喜欢我的这篇文章或者看到这里对你有些许帮助,不妨点个赞吧❤️!同时也非常欢迎看到文章的你互动交流。

ia2.gif