项目篇: 思路清晰的Vue3项目架构设计及复盘

1,221 阅读10分钟

黑缎缠目

这是到掘金的第一篇文章,同时也开启了我的掘金之路。到掘金的目的是分享学习生活和传递一些思考🤔。毕竟过往轨迹是需要一个载体来进行一个更好的呈现。

自我介绍下,作者目前大二,最近在学习前端开发,目前还在新手村苟活着,正在寻求突破㊙法。

本章的故事主要复盘最近刚刚做的一个Vue3练习项目。项目是一点东西的,但不多[自嘲一下]。而且bug也很多,但这不重要。重要的是如何在系统的学习Vue3后把它运用到实际的项目中,Vue是如何高效的构建交互界面这才是重点。

正片开始,接下来有什么讲的不够合理的地方希望各位读者可以给点意见和建议,万分感谢🙏!

项目介绍

项目类型:移动端旅游App

技术栈:vue3(Pinia + Router) + vite + Axios

重点:架构设计-组件化开发-组件复用-项目架构层级的统一

难点:请求数据的处理-hooks函数的封装-网络请求的封装

功能:页面展示以及部分内容之间的交互实现

项目Github源码

项目架构设计

通过vite快速创建项目脚手架,并进行项目的架构设计。这里对src文件夹下面的文件进行以下重构。

assets - 项目统一资源,包括但不限于css文件, image文件, font文件...

components - 复用组件, 项目很多地方需要的公共组件。

hooks - 钩子函数,实现项目中特定的功能

mock - 暂时模拟服务器中的数据在本地项目进行使用

router - 路由相关配置

serivices - 网络请求

store - 状态管理(pinia/vuex)

utils - 工具函数

views - 展示的页面

实现当架构设计完成后, 我们对项目就非常清晰了。接下来就要进行具体操作了。

页面组件设计

由底部导航栏可对应四大主页面, 直接在views中创建四大主页面。而每个主页面中又有很多其他页面, 于是可以在主页面文件夹下创建一个文件夹专门保存其子页面, 如果在子页面中还存在更多子页面, 那么后续就可以按照这种设计思想进行更为深入的分级设计。 图片.png 图片.png 图片.png

路由配置

当主页面确定下来后,就可以进行路由配置了, 后续相关的路由都在这个文件夹下配置。

1.要想在项目中使用路由, 需要安装路由npm install vue-router

2.创建路由

import { createRouter, createWebHashHistory } from "vue-router";

  const router = createRouter({
  // 基于哈希的路由实现(Hash 模式)的工厂函数, 管理App中的路由历史记录
    history: createWebHashHistory(),
    // 映射关系
    routes: [
      {
        path: "/",
        redirect: "/home"
      },
      {
        path: "/Home",
        // 懒加载
        component: () => import("../views/home/Home.vue")
      }
    exprot default router

3.在main.js中导入路由挂载,import router from './router' ——createApp(App).use(router)

状态管理

1.路由配置完成后就可以开始配置状态管理了, 要想在项目中使用状态管理, 得先进行安装npm install pinia

2.模块化状态管理在store文件夹下新建一个modules文件夹存放各个页面的数据方便管理,状态管理器创建index.js文件在文件中创建一个新的 Pinia 实例, 可以将 Pinia 作为 Vue 状态管理器,用于管理应用程序中的状态。

//状态管理器
import { createPinia } from "pinia";
const pinia = createPinia()
export default pinia

3.在main.js中导入store并挂载,import pinia from './store' ——createApp(App).use(pinia)

4.在modules中创建对应store(注意: useHomeAdviseStore, "home-advise", 与文件名统一)

import { defineStore } from 'pinia'

  const useHomeAdviseStore = defineStore("home-advise", {
    state() {
      return {
      }
    },
    actions: {
    }
  })
export default useHomeAdviseStore

项目架构层级的统一

自此项目架构设计基本完成!现在我们项目每个模块间就已经有了紧密联系, 也就是项目架构层级的统一关系。组件有对应得状态管理和路由, 它们之间紧密连接, 处理bug和查找关系的时候会很方便。

图片.png 图片.png

数据处理

一、房间数据列表类型

使用场景: 下拉页面加载更多的数据页面, 并把新加载好的数据和原先数据都保存到数组 houseList: [],中, 所以在actions中是push追加进来数据并且为了防止houseList变为二维数组(res.data.data可能为数组),需要对数据进行解构出来单个元素在push进去,若使用赋值符号就会替换掉原来的数据this.houseList.push(...res.data.data)

二、轮播图类型分配类型
图片.png

写一段伪代码

//模拟服务器数据(为方便显示这里写两组数据)
  const dataArr = [ { id: 1, name: "卫生间1" }, { id: 1, name: "卫生间2"} ]
  const swipeGroup = {}
  for(item of dataArr) {
    let valueArr = swipeGroup[dataArr[item.id]]
    if(!valueArr) {
      valueArr = []
      swipeGroup[dataArr[item.id]] = valueArr
    }
    swipeGroup[dataArr[item.id].push(item)
  }
  //处理后的效果: 
  swipeGroup = { 1:[ { id: 1, name: "卫生间1" },{ id: 1, name: "卫生间2"} ] 
                 2:[ { id: 2, name: "卧室1" },{ id: 2, name: "卧室2"}] }

hooks函数的封装

scroll_Listener: 封装hooks监听滚动, 若滚动到底部则发送网络请求获取数据。具有复用性,在其他面需要使用时直接导入调用即可,并且参数由调用组件传入,可以监听window或元素滚动。

import { ref, onMounted, onUnmounted } from "vue"
// 导入节流函数库提高滚动性能
import { throttle } from "underscore"

// 判断滚动对象, windows or element
export default function scrollListener(element) {
  // 默认el = window
  let el = window
  
  // 定义ref数据对象, 并返回出去, 在外部用watch监听并执行相应操作
  
  // 创建一个ref记录是否滑到底部, 再把它返回出去, 在外部进行监听, 值true时调用函数
  const isBottom = ref(false)

  // 实现下滑到一定位置后顶部弹出搜索框的功能----监听滑动位置scrollTop
  const clientHeight = ref(0)
  const scrollTop = ref(0)
  const scrollHeight = ref(0)
  const scrollEventListener = throttle(() => {
    if(el == window) {
      clientHeight.value = document.documentElement.clientHeight
      scrollTop.value = document.documentElement.scrollTop
      scrollHeight.value = document.documentElement.scrollHeight
      if(scrollTop.value + clientHeight.value >= scrollHeight.value) {
        isBottom.value = true
      }
    } else {
      clientHeight.value = el.clientHeight
      scrollTop.value = el.scrollTop
      scrollHeight.value = el.scrollHeight
      if(scrollTop.value + clientHeight.value >= scrollHeight.value) {
        isBottom.value = true
      }
    }
  }, 200)
  
  // 组件挂载到DOM时添加监听
    onMounted(() => {
      if(element) el = element.value
      el.addEventListener("scroll", scrollEventListener)
    })
    // 离开页面,移除监听, 防止在另外页面进行监听是该页面也会同步监听
    onUnmounted(() => {
      el.removeEventListener("scroll", scrollEventListener)
    })
  
 // 注意返回值是一个响应式数据对象
  return {
    isBottom,
    scrollTop
  }
}

注意哈: 本人开发时在这里被坑过, 发现当滑动到底部时我们的scrollTop + clientHeight并不等于scrollHeight, scrollTop竟然是一个小数而且非常接近理想值(我们想要的值)数据如下, 也就是发生了误差。例如844 2297.60009765625 3142: 发生这种情况的原因最终被杀伐果断的我找到原因

方法一(失败!):我们开发tab-bar的时候使用了:placeholder="true"这个参数,:组件下方添加一个占位元素,设置其高度与 组件相同,并使其透明, 就是因为添加了这个参数后使得我们的scrollTop变小了亿点点.解决方法: 去掉该参数, 在home组件添加一个大于tab-bar高度的padding-bottom

问题好像没有那么简单......

方法二(失败)-- chatGPT:当 document.documentElement.clientHeight + document.documentElement.scrollTop 不等于document.documentElement.scrollHeight 时,可能是因为文档中存在一些不能滚动的元素(例如固定定位的元素),这些元素占据了一部分页面空间,导致整个文档的高度大于可视区域的高度加上滚动位置所在的高度。但是,如果存在不能滚动的元素或固定定位的元素,会导致页面的总高度超过文档总高度,从而导致二者不相等。

解决这个问题的方法是统计所有固定定位元素的高度,并将它们从文档的总高度中减去,再进行比较。可以使用 JavaScript 获取所有固定定位元素的高度并计算它们的总和,然后将这个值从 document.documentElement.scrollHeight 中减去即可。

let fixedElementsHeight = 0;
const elements = document.querySelectorAll('*');
elements.forEach(el => {
const styles = getComputedStyle(el);
if (styles.position === 'fixed') {
  fixedElementsHeight += el.offsetHeight;
}
});
console.log('所有固定定位元素的高度:', fixedElementsHeight);

我只能说是浏览器的问题了, 还是不行, 我tm裂开......

通过我的测试得出最后的结论(结论不一定够准确) 不同浏览器使用访问scrollTob由于图形显示方式和屏幕分辨率的不同会出现误差

由于scrollHeight在不同环境会出现不同程度的误差, 结经过测试大部分误差都在0~1这个范围, 所以用scrollHeight减去一能解决大部分使用场景

网络请求的封装

目录结构如下: modules中配置各个组件需要发送的网络请求, 并在store的actions中调用modules中的方法。request保存封装的网络请求类和网络请求的配置例如BASE_URL, TIME_OUT等。index.js网络请求分拣站 - 导出所有导入的请求模块(例如 export * from './modules/home-city')

图片.png

网络请求类的封装

import useMainStore from "@/store/modules/main";
import { BASE_URL, TIME_OUT } from "./config";
import axios from "axios";

  const mainStore = useMainStore()
  class HY_Request {
    // 对baseURL进行封装, 到时候就可以传入指定的baseURL创建实例
    constructor(baseURL, timeout=2000) {
      // 创建新的axios实例
      this.instance = axios.create({
        baseURL,
        timeout
      })
      // 配置实例拦截器, 操作isLoading
      // 请求拦截
      this.instance.interceptors.request.use(config => {
        // 发送网络请求前操作isLoading, 使页面出现加载效果
        mainStore.isLoading = true
        return config
      }, err => {
        return err
      })
      // 响应拦截
      this.instance.interceptors.response.use(res => {
        // 收到数据后操作isLoading 让加载效果消失
        mainStore.isLoading = false
        return res
      }, err => {
        mainStore.isLoading = false
        return err
      })
    }
    // 封装request实例方法方法, 实例(对象)直接调用
    // 传入的config需要是一个对象
    // 注意: 为了开发优秀的代码, 要记住对应的模块只负责做对应的事情, 不要把多操
    //作加进来. 这里是专门负责网络请求的模块, 所以isLoading的操作不推荐加进来,
    //我们可以在拦截器中操作isloading
    request(config) {
      // return axios.request(config)
      // 是一个基于 promise 网络请求库, 所以我们可以进行进一步的封装
      return new Promise((resolve, reject) => {
        this.instance.request(config).then(res => {
          // 调用resolve, 传入res.data, then回调传入的参数, 所以可以在
          // resolve中对数据进行一定的优化处理
          resolve(res.data)
        }).catch(err => {
          reject(err)
        })
      })
    }
    // 封装get方法
    // 传入的config需要是一个对象
    get(config) {
      return this.instance.request({...config, method: "get"})
    }  
    // post方法
    // 传入的config需要是一个对象
    post(config) {
      return this.instance.request({...config, method: "post"})
    }
  }
  // 创建HY_Request实例, 并指定baseURL,timeout等, 并返回该实例
  const hyRequestData = new HY_Request(BASE_URL, TIME_OUT)
  export default hyRequestData

匹配算法-滚动到指定模块时, 上方导航栏切换到对应模块

  const valueArr = [ 100, 200, 300, 500, 800, 1200 ]
  const value = 600
  const defIndex = valueArr.length - 1
  for(let i = 0; i < valueArr.length; i++) {
    if(value < valueArr[i]) {
      defIndex = i - 1;
      break;
    }                          
  }

补充

网络请求

直接在组件中使用网络请求有弊端, 特别是在一个大项目中的时候, 因为如果一个vue组件中是包含大量的逻辑结构的, 不能够很好的进行管理。那么项目将会越来越复杂, 不利于后期管理。所以,网络请求放到状态管理库中的actions(实际调用serivices/modlues中的网络请求), 发送网络请求一般统一在父组件中

homeAdviseStore.fetchHomeCategories()
homeAdviseStore.fetchHouseList()
homeAdviseStore.fetchHotSuggestData()

封装全局的color

对于在项目中经常使用的颜色可以进行全局set, 方便在项目组件中用 var() 直接使用对应颜色。

图片.png

v-for的使用注意

使用v-for来渲染数据列表时一般都需要绑定key, 且data为对象数组时要注意访问对象中的属性时先判断对象是否为空{{ itme?.text }}, 否则会报错。

组件监听

提升开发效率:可以在组件上监听组件点击时实现对应操作, 但需要注意的是, 在template中监听子组件时,组件会把对应方法传递到子组件中的根元素上, 当具体组件的根元素存在多个时就需要配置具体要传入到哪个根元素上

计算属性和watch

推荐理由, 因为计算属性的返回值是我们所依赖的变量, 而watch中, 是直接执行相关的js代码不需要利用返回值