2026年Vue3项目架构从零到一:目录、分层、代码,每一行都给你说明白

发布时间:2026/6/30 2:39:23
2026年Vue3项目架构从零到一:目录、分层、代码,每一行都给你说明白 大家好我是你们的老朋友[你的ID]。前面咱们已经把Vue3从入门到进阶的知识点全过了一遍但有个东西一直没细聊——项目架构。你可能会问学Vue不就会写组件、会调接口就行了吗架构关我啥事嗯等你项目超过10个页面、3个人协作、或者过两个月想加新功能时你就会发现没架构的代码就像缠成一团的耳机线解开它比重新买一副还费劲。今天这篇文章我就用2026年最新实战视角手把手带你搭一个Vue3项目骨架把“目录怎么分、组件怎么拆、代码怎么分层”这些事儿说清楚。而且我会做到每一行代码都加上注释你看完直接能拿去用哪怕你才刚学完Vue基础。本文核心原则就三条视图不直接发请求页面组件只负责“长什么样”数据从composable来。能复用的逻辑必须抽离哪怕现在只用一次养成好习惯。数据流清晰从api到composable到store到组件单向流动别到处乱窜。下面咱们一步步来。一、项目目录结构每个文件夹是干什么的一个成熟项目的目录就像超市的货架分区——零食在哪、调料在哪一眼就能找到。我们直接看推荐的目录结构然后我逐个解释。textmy-vue3-app/ ├── public/ # 不经过编译的静态资源如favicon.ico ├── src/ │ ├── api/ # 数据层所有后端接口请求都放这 │ │ ├── request.js # - Axios实例、拦截器配置 │ │ ├── user.js # - 用户模块接口 │ │ └── product.js # - 商品模块接口 │ │ │ ├── assets/ # 静态资源图片、字体、全局样式 │ │ │ ├── components/ # 视图层组件库 │ │ ├── common/ # - 通用组件和业务无关 │ │ │ ├── BaseButton.vue │ │ │ ├── BaseModal.vue │ │ │ └── BaseTable.vue │ │ └── business/ # - 业务组件和具体业务有关 │ │ ├── UserCard.vue │ │ └── OrderItem.vue │ │ │ ├── composables/ # 逻辑层组合式函数业务逻辑 │ │ ├── useAuth.js # - 登录/注册逻辑 │ │ ├── useFetch.js # - 通用请求封装 │ │ └── usePagination.js # - 分页逻辑 │ │ │ ├── router/ # 路由配置 │ │ └── index.js │ │ │ ├── stores/ # 状态管理Pinia │ │ ├── user.js # - 用户状态 │ │ └── cart.js # - 购物车状态 │ │ │ ├── utils/ # 纯工具函数无副作用 │ │ ├── format.js # - 日期格式化、金额格式化 │ │ └── validate.js # - 校验规则 │ │ │ ├── views/ # 页面组件路由对应的页面 │ │ ├── Home.vue │ │ ├── Login.vue │ │ └── UserList.vue │ │ │ ├── App.vue # 根组件 │ └── main.js # 入口文件 │ ├── .env.development # 开发环境变量 ├── .env.production # 生产环境变量 ├── package.json └── vite.config.js各个文件夹的核心作用我用大白话概括api/管跟后端“说话”发HTTP请求。components/管“长什么样”可复用的UI积木。composables/管“干什么”业务逻辑如分页、登录、搜索。stores/管“共享的数据”跨组件使用的全局状态。views/管“整个页面”把组件和逻辑拼起来。utils/管“工具”纯函数不涉及响应式。二、数据层API封装与统一管理数据层的任务把所有后端接口集中管理所有请求都从这走。这样做的好处是万一后端接口路径变了你只改api/文件夹里的代码就行不用满世界搜axios.get。2.1 创建Axios实例api/request.js这个文件用来创建一个配置好的Axios实例并设置请求/响应拦截器。javascript// api/request.js —— Axios实例与拦截器 // 1. 导入axios import axios from axios // 2. 创建一个axios实例就像给axios克隆一个“专属版” const request axios.create({ // baseURL所有请求都会自动拼上这个前缀 // 这里从环境变量读取开发时指向本地上线指向正式地址 baseURL: import.meta.env.VITE_API_BASE_URL, // timeout请求超时时间10秒没响应就自动放弃 timeout: 10000, // headers每次请求默认带上的请求头告诉后端这是JSON数据 headers: { Content-Type: application/json } }) // 3. 请求拦截器在请求发出去之前统一做一些处理 // config是当前请求的配置对象你可以修改它 request.interceptors.request.use( (config) { // 从localStorage取出登录时保存的token const token localStorage.getItem(token) // 如果token存在就把它加到请求头的Authorization字段中 // Bearer是规范写法后面跟一个空格和token字符串 if (token) { config.headers.Authorization Bearer ${token} } // 最后一定要把config返回否则请求发不出去 return config }, // 请求出错时的处理很少会进入这里 (error) { console.error(请求发送失败:, error) return Promise.reject(error) } ) // 4. 响应拦截器后端返回数据后先在这里统一处理 request.interceptors.response.use( // 状态码为2xx时进入这里 (response) { // response.data是后端返回的实际数据 // 我们提前把.data解出来后面直接用 return response.data }, // 状态码不是2xx时进入这里如404、500 (error) { // 如果有响应对象说明请求发出去了但后端报错 if (error.response) { const status error.response.status switch (status) { case 401: // 401表示未登录或token过期 // 清除本地token并跳转到登录页 localStorage.removeItem(token) window.location.href /login break case 403: // 403表示没有权限 alert(您没有权限执行此操作) break case 404: alert(请求的资源不存在) break case 500: alert(服务器内部错误请稍后再试) break default: alert(请求失败状态码${status}) } } else if (error.code ECONNABORTED) { // 请求超时 alert(请求超时请检查网络连接) } else { // 网络错误或其他问题 alert(网络异常请检查网络连接) } // 把错误继续抛出方便调用方catch处理 return Promise.reject(error) } ) // 5. 导出配置好的请求实例其他文件直接import使用 export default request2.2 按模块写接口api/user.js每个后端模块如用户、商品、订单一个文件里面只写跟该模块有关的请求函数。javascript// api/user.js —— 用户相关接口 // 1. 导入上面配置好的axios实例 import request from ./request // 2. 登录接口 // data参数格式{ username: xxx, password: xxx } export function login(data) { // request.post(url, data) 返回一个Promise return request.post(/user/login, data) } // 3. 获取用户列表带查询参数 // params格式{ page: 1, keyword: } export function getUserList(params) { // get请求的查询参数放在params对象里 return request.get(/user/list, { params }) } // 4. 新增用户 export function createUser(data) { return request.post(/user/create, data) } // 5. 更新用户信息 export function updateUser(id, data) { // RESTful风格路径包含用户id return request.put(/user/${id}, data) } // 6. 删除用户 export function deleteUser(id) { return request.delete(/user/${id}) } // 7. 获取当前登录用户的信息 export function getUserProfile() { return request.get(/user/profile) } // 8. 修改密码 export function changePassword(data) { return request.put(/user/password, data) }为什么按模块拆分因为一个项目可能有上百个接口全写在一个文件里光翻页就翻半天。按模块分找接口就像查字典一样快。三、逻辑层Composables 把业务逻辑打包逻辑层是架构的“心脏”。所有跟业务有关的逻辑都封装在这里。组件只管展示逻辑全部交给composable这是Vue3最核心的设计模式。3.1 通用请求封装composables/useFetch.js这是一个更强大的异步请求封装自动管理loading、error、data。javascript// composables/useFetch.js —— 通用异步请求封装 // 1. 导入Vue的API import { ref, onBeforeUnmount } from vue // 2. 导出一个组合式函数 // asyncFn: 一个返回Promise的函数比如 () getUserList({ page: 1 }) // options: 配置项 { immediate?: boolean } immediate为true时自动执行 export function useFetch(asyncFn, options {}) { // 3. 定义响应式状态 // data: 存放请求返回的数据初始为null const data ref(null) // loading: 是否正在加载中 const loading ref(false) // error: 存放错误信息 const error ref(null) // 4. 解决竞态问题用一个变量记录“当前请求的序号” let requestId 0 // 5. 执行请求的函数 // ...args: 接收外部传进来的参数原封不动传给asyncFn async function execute(...args) { // 每次调用execute序号自增 requestId const currentRequestId requestId // 开始加载 loading.value true // 清空之前的错误 error.value null try { // 调用传入的异步函数等待结果 const result await asyncFn(...args) // 请求返回后检查当前序号是否还是最新 // 如果不是说明在此期间又发起了新请求这个旧结果作废 if (currentRequestId ! requestId) { console.log(旧请求已过期丢弃结果) return result } // 把结果存到data data.value result return result } catch (err) { // 错误也需判断序号防止旧请求的错误覆盖新请求 if (currentRequestId requestId) { // 从错误对象中提取错误信息如果没有message就用默认文本 error.value err.message || 请求失败 } // 继续抛出让调用方可以catch throw err } finally { // 只有最新请求才关闭loading if (currentRequestId requestId) { loading.value false } } } // 6. 如果设置了immediate为true自动执行一次 if (options.immediate) { execute() } // 7. 组件卸载时重置requestId让所有进行中的请求失效 // 避免组件卸载后请求返回时去更新已销毁的组件 onBeforeUnmount(() { requestId -1 }) // 8. 返回响应式状态和execute方法 return { data, loading, error, execute } }3.2 用户列表业务逻辑composables/useUserList.js这里封装“用户列表页”的全部逻辑包括分页、搜索、删除。javascript// composables/useUserList.js —— 用户列表页业务逻辑 // 1. 导入需要的API import { ref, watch } from vue import { getUserList, deleteUser as deleteUserApi } from /api/user // 2. 导出一个组合式函数 // keyword: 外部传入的搜索关键词ref类型 export function useUserList(keyword) { // 3. 定义页面需要的数据 // 用户列表 const userList ref([]) // 当前页码 const page ref(1) // 总条数从后端返回 const total ref(0) // 每页数量写死10条 const pageSize ref(10) // 是否正在加载 const loading ref(false) // 4. 获取用户列表的方法 async function fetchUserList() { loading.value true try { // 调用api接口传入当前的关键词、页码、每页条数 const res await getUserList({ keyword: keyword.value, page: page.value, pageSize: pageSize.value }) // 后端返回格式{ list: [...], total: 50 } userList.value res.list total.value res.total } catch (err) { console.error(获取用户列表失败:, err) } finally { loading.value false } } // 5. 删除用户 async function handleDelete(id) { // 可以加个确认提示这里省略 try { await deleteUserApi(id) // 删除成功后重新拉取列表 fetchUserList() } catch (err) { console.error(删除失败:, err) } } // 6. 切换页码的方法 function handlePageChange(newPage) { // 更新页码watch会自动触发重新请求 page.value newPage } // 7. 搜索方法点搜索按钮触发 function handleSearch() { // 搜索时页码回到第一页 page.value 1 // watch会自动触发fetchUserList所以不用手动调 } // 8. 监听keyword和page的变化只要变了就重新请求数据 watch( [keyword, page], () { fetchUserList() }, // immediate: true 表示一开始就执行一次页面加载时 { immediate: true } ) // 9. 返回组件需要用到的所有东西 return { userList, page, total, pageSize, loading, handleDelete, handlePageChange, handleSearch, // 手动刷新列表的方法也暴露出去 refresh: fetchUserList } }四、状态管理层Pinia 全局共享不是所有数据都放Pinia只有跨多个组件/页面共享的数据才放这里。比如用户登录状态、购物车。4.1 用户状态stores/user.jsjavascript// stores/user.js —— 用户状态管理 // 1. 从pinia导入defineStore从vue导入ref和computed import { defineStore } from pinia import { ref, computed } from vue // 导入api层的登录和获取用户信息接口 import { login as loginApi, getUserProfile } from /api/user // 2. 定义一个名为user的store // defineStore第二个参数是一个setup函数组合式风格 export const useUserStore defineStore(user, () { // 3. 状态state // userInfo: 当前登录用户的信息头像、昵称等 const userInfo ref(null) // token: 登录凭证 const token ref(localStorage.getItem(token) || ) // 4. 计算属性getters // 是否已登录判断token是否为空字符串 const isLogin computed(() token.value ! ) // 用户名方便模板里直接用 const userName computed(() { // 如果userInfo存在且name属性存在返回name否则返回未登录 return userInfo.value?.name || 未登录 }) // 5. 操作方法actions // 登录 async function login(credentials) { // credentials格式{ username: xxx, password: xxx } const res await loginApi(credentials) // 假设后端返回 { token: xxx, user: { id:1, name:小明 } } token.value res.token userInfo.value res.user // 持久化token到localStorage防止刷新后丢失 localStorage.setItem(token, res.token) } // 退出登录 function logout() { // 清空状态 token.value userInfo.value null // 移除本地存储的token localStorage.removeItem(token) // 可以在这里跳转到登录页 window.location.href /login } // 获取当前用户信息用于页面刷新后根据token重新获取 async function fetchUserInfo() { // 如果没有token不请求 if (!token.value) return try { const user await getUserProfile() userInfo.value user } catch (err) { // 如果token过期清除token console.error(获取用户信息失败:, err) logout() } } // 6. 返回所有需要暴露的东西 return { userInfo, token, isLogin, userName, login, logout, fetchUserInfo } })五、工具函数层纯函数无副作用utils/下放一些跟业务无关、但到处都能用的小工具。javascript// utils/format.js —— 格式化工具 // 1. 格式化日期将时间戳或日期字符串格式化为 YYYY-MM-DD export function formatDate(date) { // 如果传入的是null或undefined返回空字符串 if (!date) return // new Date()会自动处理各种格式的日期 const d new Date(date) // getFullYear() 获取四位年份 const year d.getFullYear() // getMonth() 返回0-11所以1padStart(2,0)保证两位 const month String(d.getMonth() 1).padStart(2, 0) // getDate() 返回几号 const day String(d.getDate()).padStart(2, 0) // 拼接返回 return ${year}-${month}-${day} } // 2. 格式化金额在数字前加¥保留两位小数 export function formatMoney(num) { if (num null || num undefined) return ¥0.00 return ¥${Number(num).toFixed(2)} } // 3. 将时间戳转换为相对时间如“3分钟前” export function timeAgo(timestamp) { const now Date.now() const diff now - new Date(timestamp).getTime() const seconds Math.floor(diff / 1000) if (seconds 60) return 刚刚 const minutes Math.floor(seconds / 60) if (minutes 60) return ${minutes}分钟前 const hours Math.floor(minutes / 60) if (hours 24) return ${hours}小时前 const days Math.floor(hours / 24) if (days 30) return ${days}天前 return formatDate(timestamp) }javascript// utils/validate.js —— 校验工具 // 1. 验证邮箱格式 export function isEmail(value) { // 正则表达式字符串中必须有和.且前和.后至少有一个字符 return /^\S\S\.\S$/.test(value) } // 2. 验证手机号中国大陆 export function isPhone(value) { // 1开头第二位3-9后面跟9位数字共11位 return /^1[3-9]\d{9}$/.test(value) } // 3. 验证密码强度至少6位包含字母和数字 export function isStrongPassword(value) { // 长度至少6位 if (value.length 6) return false // 必须包含字母 if (!/[a-zA-Z]/.test(value)) return false // 必须包含数字 if (!/\d/.test(value)) return false return true } // 4. 验证身份证号码18位最后一位可能是数字或X export function isIdCard(value) { return /^[1-9]\d{5}(18|19|20)?\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[\dXx]$/.test(value) }六、视图层组件与页面6.1 通用组件components/common/BaseButton.vue一个最基础的按钮组件支持颜色、大小、加载状态。vue!-- components/common/BaseButton.vue -- template !-- click$emit(click) 触发自定义click事件让父组件可以监听 当按钮处于loading状态时禁用点击 -- button classbase-btn :class[ btn-${type}, // 动态类型类名如btn-primary btn-${size}, // 动态尺寸类名如btn-medium { is-loading: loading } // 如果loading为true加is-loading类 ] :disabledloading || disabled click$emit(click, $event) !-- 如果loading为true显示转圈图标否则显示传入的文本 -- span v-ifloading classspinner/span slot v-else{{ text }}/slot /button /template script setup // 定义组件的props从父组件接收的属性 defineProps({ // 按钮文本也可以通过插槽自定义 text: { type: String, default: 按钮 }, // 按钮类型primary(蓝), success(绿), danger(红), warning(橙) type: { type: String, default: primary, // validator: 验证传入的值必须是这几种之一 validator: (val) [primary, success, danger, warning].includes(val) }, // 按钮尺寸 size: { type: String, default: medium, validator: (val) [small, medium, large].includes(val) }, // 是否加载中 loading: { type: Boolean, default: false }, // 是否禁用 disabled: { type: Boolean, default: false } }) // 声明组件可以发出的事件这里只有click defineEmits([click]) /script style scoped /* 基础按钮样式 */ .base-btn { border: none; border-radius: 4px; cursor: pointer; padding: 8px 20px; transition: opacity 0.3s; } /* 不同颜色的样式 */ .btn-primary { background: #409eff; color: #fff; } .btn-success { background: #67c23a; color: #fff; } .btn-danger { background: #f56c6c; color: #fff; } .btn-warning { background: #e6a23c; color: #fff; } /* 不同尺寸 */ .btn-small { font-size: 12px; padding: 5px 12px; } .btn-medium { font-size: 14px; } .btn-large { font-size: 16px; padding: 12px 30px; } /* 加载状态 */ .is-loading { opacity: 0.7; cursor: not-allowed; } .spinner { /* 加载动画省略 */ } /style6.2 页面组件views/UserList.vue页面组件负责装配把composable提供的数据和方法通过props传给子组件。vue!-- views/UserList.vue —— 用户列表页面 -- template div classuser-list-page !-- 搜索区域 -- div classsearch-bar !-- 搜索输入框v-model绑定搜索关键词 -- input v-modelkeyword placeholder输入用户名搜索 keyup.enterhandleSearch / !-- 搜索按钮点击触发handleSearch -- BaseButton text搜索 typeprimary clickhandleSearch / /div !-- 表格区域用BaseTable展示数据 -- BaseTable :columnscolumns :datauserList :loadingloading !-- 操作列作用域插槽自定义每一行的按钮 -- template #action{ row } BaseButton text编辑 typeprimary sizesmall / BaseButton text删除 typedanger sizesmall clickhandleDelete(row.id) / /template /BaseTable !-- 分页组件当前页、总条数、页码改变事件 -- BasePagination :currentpage :totaltotal :page-sizepageSize changehandlePageChange / /div /template script setup // 1. 导入需要的组件 import BaseButton from /components/common/BaseButton.vue import BaseTable from /components/common/BaseTable.vue import BasePagination from /components/common/BasePagination.vue // 2. 导入业务逻辑 import { useUserList } from /composables/useUserList import { ref } from vue // 3. 定义表格的列配置写死也可以从composable返回 const columns [ { label: 用户名, prop: name }, { label: 邮箱, prop: email }, { label: 创建时间, prop: createTime }, { label: 操作, slot: action } ] // 4. 定义搜索关键词响应式 const keyword ref() // 5. 调用composable获取页面需要的全部状态和方法 const { userList, page, total, pageSize, loading, handleDelete, handlePageChange, handleSearch } useUserList(keyword) // 注意useUserList内部通过watch监听了keyword和page的变化 // 所以当这些值改变时会自动重新请求数据。 // 页面组件只需关心“数据和事件绑定”完全不用管请求细节。 /script style scoped .user-list-page { padding: 20px; } .search-bar { display: flex; gap: 10px; margin-bottom: 20px; } .search-bar input { flex: 1; padding: 8px; } /style七、路由配置与main.js入口7.1 路由配置router/index.jsjavascript// router/index.js —— 路由配置 // 1. 导入Vue Router的创建函数 import { createRouter, createWebHistory } from vue-router // 2. 定义路由规则路径和组件的映射关系 const routes [ { // 首页 path: /, name: Home, // 懒加载只有访问时才加载组件加快首屏 component: () import(/views/Home.vue) }, { path: /login, name: Login, component: () import(/views/Login.vue) }, { path: /users, name: UserList, component: () import(/views/UserList.vue), // meta可以放自定义信息这里标记需要登录 meta: { requiresAuth: true } }, { // 404页面匹配所有未定义的路由 path: /:pathMatch(.*)*, name: NotFound, component: () import(/views/NotFound.vue) } ] // 3. 创建路由实例 // createWebHistory() 使用HTML5 History模式URL不带#号 const router createRouter({ history: createWebHistory(import.meta.env.BASE_URL), routes }) // 4. 全局前置守卫在每次路由跳转前执行 router.beforeEach((to, from, next) { // 检查目标路由是否需要登录 if (to.meta.requiresAuth) { // 从localStorage读取token如果有就认为已登录 const token localStorage.getItem(token) if (!token) { // 没登录跳转到登录页并携带原目标路径登录后可跳回 next({ name: Login, query: { redirect: to.fullPath } }) return } } // 其他情况正常放行 next() }) // 5. 导出路由实例 export default router7.2 入口文件main.jsjavascript// main.js —— Vue应用入口 // 1. 导入Vue的createApp函数 import { createApp } from vue // 2. 导入根组件 import App from ./App.vue // 3. 导入路由和状态管理 import router from ./router import { createPinia } from pinia // 4. 创建Vue应用实例 const app createApp(App) // 5. 挂载插件 // Pinia状态管理 app.use(createPinia()) // 路由 app.use(router) // 6. 全局错误处理捕获组件渲染和观察期间的错误 app.config.errorHandler (err, instance, info) { // 可以在这里上报错误到监控平台如Sentry console.error(全局错误捕获:, err, info) } // 7. 把应用挂载到index.html中的#app元素上 app.mount(#app)八、总结架构给我们带来了什么相信你已经感觉到了按这套架构写完代码后找接口直接去api/文件夹按模块找秒定位。改业务逻辑去composables/逻辑集中不怕改漏。换UI样式去components/组件独立改一处全局生效。查全局数据去stores/数据流动清晰。记住架构三原则视图不直接发请求。可复用逻辑抽离为composable。api、composable、store、view 各司其职单向数据流。这篇文章的代码你拿过去就能搭出一个专业的Vue3项目架子。后续加任何功能都在这套骨架里填肉就行。如果你在搭建中遇到问题欢迎评论区留言我每条都会回复。下篇预告Vue3响应式原理源码级拆解记得关注不迷路