尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Vue 项目目录结构设计:api、services 分层与 Pinia 状态管理

Vue 项目目录结构设计:api、services 分层与 Pinia 状态管理 刚接手一个别人写的 Vue 项目最先崩的往往不是逻辑而是目录。打开 src 一看十几个文件夹横七竖八api 里塞着工具函数services 里写着组件store 里还躺着一段请求拦截器改一个字段要找半小时。我前后在四五个团队里搭过 Vue 项目骨架也重构过不少历史项目慢慢摸出一套相对稳定的目录约定api、assets、components、router、services、store、styles、views 这八个目录加上 App.vue 和 main.js 两个入口文件基本能覆盖中后台系统、移动端 H5、可视化大屏这几类常见场景。这套结构不复杂但每一层的边界划在哪里、依赖方向怎么定、哪些东西坚决不能往里放才是真正决定项目半年后还能不能维护的关键。下面把我自己的理解和踩过的坑完整说一遍新手可以照着搭有经验的朋友可以对照检查自己项目里的隐患。1. 目录结构整体设计与分层思路1.1 为什么要在写第一行业务代码前先定结构很多人拿到需求就急着在 views 里新建页面等页面堆到二十个才发现请求地址散落在各个组件里改后端域名要全局搜索替换样式变量重复定义五六遍路由配置文件长到一千多行。这时候再想重构成本比一开始定好结构高出十倍不止。目录结构本质上是给团队定一套东西该放哪的共识。共识的价值在于降低查找成本任何人接手项目看到 services 就大概知道业务逻辑在哪看到 api 就知道接口定义在哪不需要靠翻阅几十个文件去猜。更深一层结构还约束了依赖方向。正常的依赖应该是 views 依赖 components 和 servicesservices 依赖 apirouter 只依赖 viewsstore 被各方依赖但不反向依赖任何页面。这条链一旦理顺改一处不容易牵连一片测试也好写因为每一层都能单独 mock 掉上下游。我自己的经验是结构设计上花的半天时间大概能省下后续三个月的沟通和调试成本。这不是夸张尤其是团队超过三个人、项目周期超过两个月的时候。1.2 八个目录的职责边界怎么划先把最核心的分工说清楚这是整套结构的地基。api只做一件事声明后端接口。一个函数对应一个接口负责拼 URL、指定 method、传参、返回 Promise。它不处理数据格式转换不做错误提示不关心业务含义。这么设计的原因是接口定义是变化最频繁也最需要集中的部分后端换个路径改一个文件就够。services承接 api 之上的业务逻辑。比如登录这个动作可能要调登录接口、存 token、拉用户信息、写进 store这一串流程放 services 里页面只管调一个 login 方法。api 和 services 分开的最大好处是页面不再关心要调几个接口、按什么顺序调。components放可复用的 UI 单元。判断标准很简单这个组件在超过一个页面里用过或者虽然是单页面专用但足够独立、值得单独测试就放这里。views放路由级页面。每个 view 对应路由表中的一条记录一个 view 可以由多个 component 拼起来。store放需要跨页面共享的状态。判断标准是两个不相邻的组件都要用到同一份数据才考虑放进 store。单个页面内部的状态直接用 ref 或 reactive 就够了别什么都往 store 里塞塞多了状态流向会变得难以追踪。router只负责路由表定义和导航守卫不放业务逻辑。assets放需要被构建工具处理的静态资源比如会被压缩的图片、字体文件。styles放全局样式、变量、mixin、重置样式。1.3 和默认脚手架结构的差异取舍用 Vite 或 Vue CLI 创建的项目默认 src 下只有 assets、components、App.vue、main.js 这几项router 和 store 要看创建时的选项。也就是说官方脚手架只给了最小集合api、services、styles 这三个目录是我们自己加的。有人会问加这么多层是不是过度设计小项目确实没必要。我一般按这个标准判断如果项目预计页面数少于十个且只有一个人维护那 api 和 services 可以合并store 也可能用不上。但只要预期会扩展到十几个页面、有第二个人加入提前分好层绝对划算因为拆分文件容易从一堆混在一起的代码里剥离逻辑难。还有一个取舍是关于 views 和 pages 的命名。两种叫法都有团队在用我个人倾向 views因为它和 Vue 官方文档、路由配置里的 component 语义更贴。命名一致性比命名本身更重要团队里统一就行别一半 views 一半 pages。2. 八个目录逐个拆解与实操要点2.1 api 与 services请求层为什么建议拆成两层注意api 层里写业务判断是后期最难维护的坏味道之一。先看 api 层的写法。我的习惯是每个业务模块一个文件比如 user.js、order.js文件里每个导出函数对应一个接口// src/api/user.js import request from /services/request export function login(data) { return request({ url: /auth/login, method: post, data }) } export function getUserInfo(params) { return request({ url: /user/info, method: get, params }) }可以看到 api 层薄得像一张纸但它带来的好处很实在全项目搜索/auth/login一定能定位到唯一一处定义。后端把接口从/auth/login改成/v2/auth/login改这一个文件即可。services 层则厚一些它承担编排// src/services/user.js import { login, getUserInfo } from /api/user import { useUserStore } from /store/user export async function doLogin(form) { const { token } await login(form) const userStore useUserStore() userStore.setToken(token) const info await getUserInfo() userStore.setUserInfo(info) return info }页面里只写await doLogin(form)一行搞定。这样做的好处是登录流程一旦要加验证码、加设备指纹、加埋点只改 service不用动页面。那 request 这个封装放哪我的做法是放 services/request.js因为它属于请求能力的一部分而不是某个具体接口。拦截器里处理 token 注入、统一错误码、loading 计数这些都属于基础设施。实操中有个坑要提醒拦截器里做全局错误提示时别把业务错误也一并弹出 toast。有些接口返回 200 但业务 code 是失败这种应该抛给调用方决定怎么处理而不是拦截器直接弹窗。我早期就是这么干的结果登录接口密码错误时弹了两次提示一次是拦截器的一次是页面的。2.2 assets 与 styles静态资源和样式的分工assets 存在的意义是让构建工具参与处理。放这里的图片会被 Vite 或 webpack 走一遍处理流程小图可能被转成 base64 内联进 JS大图会生成带 hash 的文件名便于做长期缓存。判断一个文件该放 assets 还是 public标准是需要经过构建处理、需要在 JS 里 import 引用的放 assets完全不需要处理、需要保持原始路径访问的放 public。字体文件、会被 CSS 引用的背景图一般放 assets。styles 的常见组织方式是三个文件打底src/styles/ variables.scss // 颜色、间距、字号变量 mixins.scss // 复用样式片段 index.scss // 重置样式 全局类然后在 main.js 里引入 index.scss。variables 和 mixins 如果要被每个组件自动注入可以在构建配置里用 additionalData 处理省掉每个文件手写 use 的麻烦。这个配置我强烈建议加上不然写着写着就会有人直接在组件里硬编码颜色值变量体系就废了。// vite.config.js 片段 css: { preprocessorOptions: { scss: { additionalData: use /styles/variables.scss as *; } } }提示additionalData 注入的是每个样式文件都会执行的内容所以里面只能放变量和 mixin 定义不能放实际会产出 CSS 的规则否则每个组件都会重复一份样式最终包体积膨胀。关于样式作用域我一般默认给所有组件样式加 scoped。全局样式只在 styles 目录里定义不在组件里写不带 scoped 的样式。这条规矩能避免大量样式互相污染的问题尤其是多人协作时。2.3 components 与 views组件和页面的边界如何界定components 内部我习惯再分两层base 和 business。base 放纯 UI 组件比如按钮、输入框、弹窗封装它们不依赖任何业务数据props 进来什么就渲染什么这类组件最容易复用也最容易测试。business 放带业务语义的组件比如订单状态标签、用户头像组它们可能内部直接调 store 或 services。src/components/ base/ BaseButton.vue BaseTable.vue business/ OrderStatusTag.vue UserAvatar.vueviews 和 components 的关系是包含关系。一个 view 里可以引若干 component但 component 不应该反过来引 view。这条规则一旦打破组件复用就无从谈起因为你没法把一个依赖具体页面的组件搬到别处。实操中最常见的误区是把只在一个页面用的组件也塞进 components。我的建议是如果它确实只服务一个页面可以先放在 views 对应目录下的 components 子目录里等第二个页面要用时再往上提。这样能避免 components 目录膨胀成杂物间。src/views/ order/ index.vue components/ OrderFilter.vue这个做法的好处是归属清晰删页面时不会在 components 里留下孤儿组件。2.4 store 与 router状态管理和路由怎么组织store 用 Pinia 的话组织方式是每个领域一个文件// src/store/user.js import { defineStore } from pinia import { ref } from vue export const useUserStore defineStore(user, () { const token ref() const userInfo ref(null) function setToken(val) { token.value val localStorage.setItem(token, val) } function setUserInfo(info) { userInfo.value info } function logout() { token.value userInfo.value null localStorage.removeItem(token) } return { token, userInfo, setToken, setUserInfo, logout } })选 Pinia 而不是 Vuex 的理由很直接Pinia 的 setup 写法跟组件里的写法一致不用记 mutation、action 那套区分TypeScript 推导也顺。Vuex 的 mutation 强制同步这个设计在新项目里基本没什么价值。store 有个必须守住的边界不要在里面直接调 api。状态层只负责存和改网络请求交给 services 层services 请求完再调 store 的方法写进去。这样状态变化的来源是可控的调试时容易定位。router 部分路由表按模块拆文件最后合并// src/router/index.js import { createRouter, createWebHistory } from vue-router import routes from ./routes const router createRouter({ history: createWebHistory(import.meta.env.BASE_URL), routes }) router.beforeEach((to, from, next) { const token localStorage.getItem(token) if (to.meta.requiresAuth !token) { next({ name: login, query: { redirect: to.fullPath } }) return } next() }) export default routerroutes 里每个页面用动态 import 做懒加载{ path: /order, name: OrderList, component: () import(/views/order/index.vue), meta: { requiresAuth: true, title: 订单管理 } }懒加载的意义在于首屏只加载当前路由需要的代码其他模块等真正访问时才下载。中后台系统几十个页面时这个优化效果相当明显。注意路由的 name 必须全局唯一。我遇到过两次因为复制粘贴忘了改 name导致跳转时跳到错误页面的情况而且不报错只是静默跳错排查起来很费时间。3. 入口文件 App.vue 与 main.js 的关键细节3.1 main.js 的初始化顺序和插件注册main.js 是整个应用的启动脚本它做的事情有严格顺序顺序错了会出现组件里拿不到 store这类问题。// src/main.js import { createApp } from vue import App from ./App.vue import router from ./router import { createPinia } from pinia import /styles/index.scss const app createApp(App) app.use(createPinia()) app.use(router) app.mount(#app)顺序的逻辑是Pinia 要在 router 之前注册。因为路由守卫里很可能要用到 store 里的登录状态如果 Pinia 还没注册守卫执行时拿不到实例就会报错。同理任何在守卫里会用到的插件都要排在 router 之前。样式引入放在顶部还是底部其实影响不大但我的习惯是放在所有 import 之后、创建实例之前这样一眼能看出全局样式被执行了一次。还有一个细节是全局组件的注册。常见的做法是写一个 plugins 目录把所有需要全局注册的组件集中注册// src/plugins/index.js import BaseButton from /components/base/BaseButton.vue export default { install(app) { app.component(BaseButton, BaseButton) } }然后在 main.js 里app.use(plugins)。全局组件不宜太多超过十个就要考虑是不是该改成按需引入否则项目启动时全部加载首屏白屏时间会变长。3.2 App.vue 里该放什么、不该放什么App.vue 是根组件它唯一必须做的是渲染 router-viewtemplate router-view / /template如果项目有全局布局需求比如所有页面都带同一个头部导航可以在 App.vue 里组织布局组件但要注意区分所有页面都要和大部分页面都要后者应该放到对应 view 或嵌套路由的父组件里不要硬塞进 App.vue。我不建议在 App.vue 里写业务逻辑。它的职责是提供挂载点和全局容器一旦开始写数据请求、条件判断就说明某些东西放错了位置。曾经见过一个项目在 App.vue 里监听路由变化做权限校验还做了页面级埋点代码两百多行结果每次路由切换都会触发两次逻辑查了半天才发现根因。App.vue 里可以处理的几件合理的事全局 loading 遮罩、全局弹窗容器、主题切换的根节点类名。这几件事的共同点是它们确实需要跨所有页面生效。3.3 环境变量和请求基地址怎么接进来Vite 用 .env 系列文件管理环境变量只有以 VITE_ 开头的变量才会暴露给客户端代码。.env.development VITE_API_BASE_URL/api .env.production VITE_API_BASE_URLhttps://api.example.com在 services/request.js 里读import axios from axios const request axios.create({ baseURL: import.meta.env.VITE_API_BASE_URL, timeout: 15000 }) request.interceptors.request.use(config { const token localStorage.getItem(token) if (token) { config.headers.Authorization Bearer ${token} } return config }) export default request开发环境用/api是为了配合构建工具的代理把请求转发到本地后端服务避免跨域问题。生产环境直接写完整域名。提示环境变量文件不要提交包含真实密钥的内容到公共仓库尤其是第三方服务的 key。前端代码里的任何变量最终都会打包进产物浏览器里能看到明文所以只放不敏感的配置。4. 从零搭一套可以复用的目录骨架4.1 创建项目与目录落地用 Vite 创建npm create vitelatest my-app -- --template vue cd my-app npm install npm install vue-router pinia axios npm install -D sass创建完目录后手动补齐缺的文件夹最终结构是这样src/ api/ user.js order.js assets/ images/ fonts/ components/ base/ business/ router/ index.js routes.js services/ request.js user.js store/ user.js app.js styles/ variables.scss mixins.scss index.scss views/ login/ index.vue order/ index.vue App.vue main.js这个结构我自己在三个项目里用过页面数从十几个到六十几个都撑得住。关键不是文件夹数量而是依赖方向没有形成环。4.2 路径别名配置省掉一堆相对路径不配别名的话深层组件里会出现../../../services/user这种路径层数一多变一个目录就得改一堆地方。配置别名之后统一用/开头// vite.config.js import { defineConfig } from vite import vue from vitejs/plugin-vue import path from path export default defineConfig({ plugins: [vue()], resolve: { alias: { : path.resolve(__dirname, src) } }, server: { proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } } })如果你的编辑器提示路径无法跳转多半是 jsconfig.json 或 tsconfig.json 里没同步配 paths。这个文件必须和构建配置保持一致否则编辑器识别不了。{ compilerOptions: { baseUrl: ., paths: { /*: [src/*] } } }4.3 一套能落地的分层调用链示例以订单列表为例完整走一遍调用链感受一下每层的作用第一层 api声明接口// src/api/order.js import request from /services/request export function fetchOrderList(params) { return request({ url: /order/list, method: get, params }) }第二层 services处理数据// src/services/order.js import { fetchOrderList } from /api/order export async function getOrderList(query) { const res await fetchOrderList(query) return { list: res.data.records.map(item ({ ...item, statusText: STATUS_MAP[item.status] || 未知 })), total: res.data.total } }第三层 view只管渲染和交互script setup import { ref, onMounted } from vue import { getOrderList } from /services/order const list ref([]) const total ref(0) async function loadData() { const res await getOrderList({ page: 1, size: 20 }) list.value res.list total.value res.total } onMounted(loadData) /script这套链路跑顺之后你会发现页面代码变得很薄几乎都是渲染逻辑测试和调试都省事。接口变了改 api字段处理变了改 services页面基本不用动。5. 常见问题与排查技巧实录5.1 路由跳转后组件内容不渲染这是新手遇到频率最高的一个问题页面地址变了但内容还是旧的或者直接空白。常见原因有四个按排查顺序列一下。第一个是 router-view 缺失。检查 App.vue 里有没有写 router-view以及层级是否正确。嵌套路由的情况下父级组件里也必须放 router-view否则子路由没有渲染出口。第二个是路由 name 或 path 重复。两条路由的 path 完全一样时后定义的会覆盖前面的跳转时可能落到非预期的组件上。第三个是懒加载路径写错。import(/views/order/index.vue)如果实际文件名是OrderIndex.vue构建时不会报错运行时点击跳转才报模块找不到浏览器控制台能看到具体信息。第四个是被导航守卫拦截。守卫里 next 没调用或者调用了但传了错误的参数页面就会卡住不动。排查方法是临时在守卫里打日志看流程走到哪一步。5.2 打包后布局异常怎么定位本地样式正常打包上线就错乱这类问题通常和三件事有关。一是公共路径配置。部署到子目录时如果没有设置 base 或者路由的 history 基准路径资源加载会 404页面看起来就是布局塌掉。Vite 里配 base路由里用 import.meta.env.BASE_URL。二是 CSS 顺序变化。不同环境构建时样式文件的打包顺序可能不同两个选择器权重相同时后出现的一方生效于是线上和本地表现不一样。解决办法是别依赖顺序把权重写明确或者用更具体的选择器。三是 flex 或 grid 的兼容处理。目标浏览器较旧时某些新语法需要降级。检查构建配置里的 target 设置别设得太激进。排查这类问题的通用手法是在本地跑一次生产构建用npm run build然后npm run preview打开产物这样能复现大部分线上表现比直接上线试错安全得多。5.3 高频问题速查表现象大概率原因排查动作页面空白无报错router-view 缺失或守卫未放行检查根组件和守卫 next 调用跳转后内容不更新组件复用了但没监听路由参数watch route.params 或加 key状态改了不起作用store 里解构后失去响应性用 storeToRefs 解构打包体积异常大全量引入组件库或图标库改按需引入检查产物分析接口 404代理配置或 baseURL 不匹配对比开发和生产环境变量刷新页面 404history 模式未配服务端回退服务端配置 index.html 回退样式被覆盖scoped 遗漏或样式顺序变化补 scoped明确选择器权重关于 store 解构那个坑展开说一下。Pinia 返回的是响应式对象如果你直接const { token } useUserStore()解构出来的 token 会丢失响应性后续 store 里更新了页面不会重新渲染。正确做法是const { token } storeToRefs(useUserStore())方法可以正常解构状态必须用 storeToRefs。这个坑我踩过两次第一次排查了很久因为代码不报错只是数据不更新。还有一个关于路由参数的经验同一个组件对应多个路由参数时比如/detail/1跳到/detail/2Vue 会复用组件实例而不重新创建所以 onMounted 不会再次执行。这个行为是设计如此不是 bug。解决办法是要么监听 route.params 变化要么给 router-view 绑一个 keyrouter-view v-slot{ Component } component :isComponent :key$route.fullPath / /router-view加 key 的方式最省心代价是每次跳转都重建组件性能开销略大。数据量大的页面建议用 watch 的方式。我自己在实际项目里的体会是目录结构这东西的价值不在于一开始多完美而在于它能不能自然地容纳变化。上面这套结构用下来最舒服的地方是加一个新业务模块时不用做任何决策api 加一个文件、services 加一个文件、views 加一个目录、store 视情况加不加路径和分层都是现成的模板照抄。真正需要动脑的只有那些跨模块的状态该放哪一层而这种问题本来就是需要在评审时讨论清楚的。最后再分享一个小习惯每接一个新项目我会先花十分钟把它的目录画成一张依赖方向草图谁引谁标清楚这张图往往比读代码更快暴露结构问题比如某个 utils 里偷偷 import 了 store或者某个组件直接调了 axios 绕过 services 层这些在图上会格外扎眼。
返回列表