ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Vue项目目录结构:api、services、router、store分层指南

Vue项目目录结构:api、services、router、store分层指南 第一次翻别人写的 Vue 项目打开 src 目录那一刻多少有点懵api、assets、components、router、services、store、styles、views 八个文件夹齐齐排开旁边还杵着 App.vue 和 main.js 两个单文件。我当时的想法很朴素——一个前端项目犯得着分这么细吗随手建个 pages 加 utils 不就完了。这个念头在我接手一个中型后台系统之后彻底消失了那个系统需求改了七轮页面从最初的 12 个长到 60 多个接口从 30 个涨到接近 200 个如果当初没有一套清晰的目录约定光是在编辑器侧边栏里滚动找文件就够喝一壶。Vue 项目结构这件事说大不大说小也不小。它不涉及什么高深算法但它是所有协作的底座新同事入职第一天能不能自己找到该改哪儿代码评审时 reviewer 能不能一眼看出这次改动的影响面三个月后你回来修 bug 还能不能想起当初为什么这么写全都压在这套约定上。下面就把这套结构一个文件夹一个文件夹地拆开说清楚每个位置该放什么、不该放什么、为什么这么分以及我在实际项目里踩过的那些具体坑。中间会穿插可以直接抄的配置和代码也会给出几个容易搞混的地方的判断标准比如 api 和 services 到底怎么分工、组件什么时候该从 views 抽到 components。1. 目录骨架真正解决的是什么问题1.1 一次在一百多个文件里找接口的教训有个项目我接手的时候src 底下只有三个文件夹pages、utils、images。所有东西往里塞。刚开始页面少倒也没觉得有什么不便改一个页面就在 pages 里翻一翻五分钟总能找到。等到项目后期pages 目录下躺了 70 多个 .vue 文件utils 里混着请求封装、日期格式化、权限判断、常量字典、埋点工具images 里则散落着图标、背景图、甚至还有几张产品经理临时丢进来当占位图的截图。那次要改一个下单接口的错误提示文案我干了这么几件事先在 pages 里搜关键词命中了二十多个文件再挨个打开确认哪个是真正发起请求的地方发现请求被封装在 utils/request.js 的拦截器里统一处理然后发现不同页面还在各自的地方覆盖了错误处理逻辑。整个过程花了将近四十分钟改的其实只有一行文案。问题不在于代码写得差而在于没有一个约定俗成的放的规则。文件夹名字本身是一种索引索引失效了检索成本就会随着文件数量非线性上升。八个文件夹的价值就在这里它把该放哪儿这个每天要重复几十次的决策变成了一次性的、可以口头传承的规则。1.2 八个文件夹的职责边界对照先把这套结构的边界关系用一张表捋清楚后面几节会逐个展开。目录 / 文件核心职责典型内容常见误用api接口定义层按业务模块划分的请求函数往里塞数据处理逻辑services业务编排层多个接口的组合调用、数据加工和 api 混为一谈assets参与构建的静态资源图片、字体、会被 import 的样式片段放不需要处理的第三方库文件styles全局样式体系变量、重置、公共类、主题把组件私有样式也放进来components可复用组件按钮、表格封装、弹窗、业务复用块把页面级组件也塞进来views页面级组件与路由一一对应的页面在里面写大量可复用逻辑router路由体系路由表、守卫、权限路由所有路由写在一个文件里store全局状态用户信息、权限、全局配置什么都往里放App.vue根组件布局容器、全局挂载点承担具体业务逻辑main.js应用入口创建实例、注册插件、挂载写业务流程代码这张表看起来平平无奇但真正落到项目里最常打架的是两对关系api 和 services 的边界以及 components 和 views 的边界。前者关系到数据流是否清晰后者关系到复用是否失控后面会各用一节专门讲。1.3 约定带来的三个隐性收益按这套结构走能拿到的好处不止看着整齐。第一个收益是改动的爆炸半径可预测。当你看到一次提交只动了 services/order.js你会下意识知道这大概率是业务流程调整如果动了 api/order.js那多半是接口契约变了得同步看后端。目录本身就是一层语义标注比 commit message 更早地告诉 reviewer 这次改动的性质。第二个收益是新人的上手路径变短。我给自己团队定的规矩是新人第一天不看文档直接让他完成一个在列表页加一列数据的任务。如果目录结构清晰他需要动的就是 views 里的那个页面文件、api 里对应的请求函数、可能加一个 store 里的字段。路径明确不需要在群里反复问请求写在哪儿。第三个收益是重构有抓手。当某个模块要整体下线时你可以按目录成片删除而不是在几百个文件里大海捞针。我做过一次把旧的订单模块替换成新流程的活最后的改动清单是 services/order/、api/order/、views/order/、store/modules/order.js四个位置删完就干净了没有留下任何孤儿代码。2. api 与 services 的分工请求翻译和业务编排不是一回事2.1 api 层只做一件事把后端接口翻译成函数api 目录的职责非常窄窄到只有一个把后端提供的 HTTP 接口一对一地翻译成前端可以调用的 JavaScript 函数。它不该做数据加工不该做业务判断甚至不该关心调用方是谁。一个典型文件长这样// src/api/order.js import request from /api/request export function fetchOrderList(params) { return request({ url: /api/order/list, method: get, params }) } export function submitOrder(data) { return request({ url: /api/order/submit, method: post, data }) } export function cancelOrder(orderId, reason) { return request({ url: /api/order/${orderId}/cancel, method: post, data: { reason } }) }这里有几个坚持要守住的细节。第一个是函数命名跟接口语义对齐而不是跟页面按钮对齐。接口叫 list 就叫 fetchOrderList不要因为某个页面上的按钮叫查询我的订单就改名成 searchMyOrder一旦有第二个页面也需要这个接口名字就尴尬了。第二个是参数原样透传。api 层不要做params.page params.page || 1这种兜底默认值属于业务决策应该在调用方或者 services 层处理。api 层一旦开始做默认值同一个接口在不同调用点就会产生不同的隐式行为排查问题时非常难受。第三个是统一入口 request。所有请求都走同一个 axios 实例这样 token 注入、错误码统一处理、loading 控制这些横切关注点才有唯一的落点。见过太多项目里有的地方用 axios 裸调有的地方用封装结果就是拦截器覆盖不全某些接口的鉴权莫名其妙失效。2.2 services 层承接的是多个接口串起来的活services 存在的理由只有一个当一个业务动作需要调用两个以上接口或者需要对接口返回的数据做整合加工时这段逻辑必须有地方去。放在页面组件里会让组件臃肿且无法复用放在 api 层又会污染纯粹的接口定义。举个实际例子。提交订单这个动作后端拆成了三步先校验库存、再创建订单、最后拉取支付参数。但用户视角里这就是点一下按钮。这段编排逻辑就该在 services 里// src/services/order.js import { checkStock, submitOrder, getPayInfo } from /api/order export async function placeOrder(cartItems) { const stockResult await checkStock({ items: cartItems }) if (!stockResult.allPassed) { const failed stockResult.details.filter(item !item.passed) throw new Error(以下商品库存不足${failed.map(i i.name).join(、)}) } const order await submitOrder({ items: cartItems, source: web }) const payInfo await getPayInfo(order.orderId) return { orderId: order.orderId, amount: order.totalAmount, payChannel: payInfo.channel } }这段代码里能看到 services 层的三个特征。一是编排它决定了三个接口的调用顺序和依赖关系。二是业务判断库存不足该怎么提示、要不要中断流程属于产品规则而不是接口契约。三是数据整形把三个接口的返回拼成一个页面真正需要的结构让 views 层拿到的就是能直接渲染的数据。拆 api 和 services 带来的直接好处是接口变更的影响可控。后端如果把库存校验合并进了创建订单接口你只需要改 services 里的一段编排逻辑所有调用的页面一行都不用动。反过来如果是页面直接调接口你就得逐个页面去改。2.3 要不要合并成一个目录坦白说小项目里 api 和 services 合并成一层是完全没问题的。我的判断标准是当出现第一个需要调用多个接口的业务动作时就该拆了。项目早期接口都是一对一页面调一个接口渲染一个列表此时合并成一个 api 目录简单直接。但只要你发现某个函数里出现了两个 await而且第二个 await 的参数依赖第一个的返回这就是 services 的信号。继续混在 api 目录里久而久之 api 目录就会变成一个什么都有的大杂烩失去了接口契约层的意义。还有一种中间做法是在 api 目录下按模块分子目录把编排逻辑放在同模块的index.js里。这个做法在模块数量不多的时候挺好用但当编排逻辑开始跨模块比如下单要调订单接口也调用户接口就会发现文件该放哪个模块都不合适最终还是得独立出来。3. router 目录的三个坑路由表拆分、守卫位置、动态注入时机3.1 单文件路由表撑不过三个月新项目起步时router/index.js 里写一个几十行的数组很舒服一眼能看全所有路由。但这个文件的膨胀速度远超你的预期因为每加一个页面就要加一条记录而且每条记录还会带上 meta、children、props 这些配置。我自己的经验是路由表在超过 40 条之后就该拆。拆的方式有两种看团队习惯选一种是按业务模块拆文件router/modules/order.js、router/modules/user.js每个文件导出一个路由数组然后在 index.js 里汇总。// src/router/index.js import { createRouter, createWebHistory } from vue-router import orderRoutes from ./modules/order import userRoutes from ./modules/user import baseRoutes from ./modules/base const routes [ ...baseRoutes, ...userRoutes, ...orderRoutes ] const router createRouter({ history: createWebHistory(import.meta.env.BASE_URL), routes }) export default router另一种是把静态路由和动态路由分开router/constant.js 存不需要权限的登录页、404router/async.js 存需要根据权限动态注入的。这个分法在权限系统里几乎是必须的下一小节会细说。不管用哪种有一点要守住路由表的拆分维度必须和业务模块的拆分维度一致。见过有人按页面类型拆列表页一个文件、表单页一个文件结果每次改一个模块要动三个文件反而更乱。3.2 守卫写在 router 目录还是 main.js路由守卫该放哪儿这个问题我被问过很多次。答案是放 router 目录但不要全塞在 index.js 里。我的习惯是在 router 下建一个 guard.js把守卫逻辑单独抽出去index.js 只负责创建实例和注册守卫。这样 index.js 保持纯粹的配置角色守卫的调整不会污染路由表。// src/router/guard.js import { useUserStore } from /store/modules/user export function setupGuard(router) { router.beforeEach(async (to, from, next) { const userStore useUserStore() if (to.meta.public) { next() return } if (!userStore.token) { next({ path: /login, query: { redirect: to.fullPath } }) return } if (!userStore.userInfoLoaded) { await userStore.fetchUserInfo() } next() }) }注意这里useUserStore()在守卫函数内部调用而不是在模块顶层调用。这是个高频踩坑点下面的小节会展开。守卫写法上还有两个小细节值得提。一是to.meta.public标记白名单比在守卫里写一个 URL 数组去 includes 判断要清爽得多而且新加公开页面时只需要在路由配置里加一个字段不需要改守卫代码。二是 redirect 参数登录后要跳回用户本来想去的页面这个参数非常容易被漏掉导致用户每次登录都回到首页体验上很突兀。3.3 动态路由注入的时机陷阱权限路由的动态注入是这套结构里最容易翻车的地方报错现场的典型表现是页面白屏、路由跳转后内容不渲染、或者控制台甩出getActivePinia was called with no active Pinia。先说这个报错的根因。如果像下面这样写// 错误示范 import { useUserStore } from /store/modules/user const userStore useUserStore() // 模块顶层就调用了 export function setupGuard(router) { router.beforeEach((to, from, next) { if (userStore.token) next() }) }问题在于模块顶层代码的执行时机是在 main.js 里app.use(pinia)之前。此时 Pinia 还没被安装到应用实例上useUserStore()自然拿不到活跃的实例。修正方式就是把调用挪进函数体内部等到守卫真正执行时再取那时候应用已经挂载完毕了。再说动态注入的时机。正确流程是这样的登录成功拿到 token守卫里判断用户信息未加载拉取用户信息根据返回的权限码筛选出可访问的动态路由用router.addRoute()逐条加进去然后next({ ...to, replace: true })重新触发一次导航。最后那一步非常关键。如果你加完路由直接调next()Vue Router 会认为当前导航已经结束但因为路由表刚刚才变化这一次导航匹配的仍是旧表结果就是明明路由加上了页面却还是 404 或者空白。用replace: true重新导航一次让路由器基于新表重新匹配问题就解决了。// src/router/permission.js 片段 export async function loadAsyncRoutes(router, userStore) { const routes await userStore.generateRoutes() routes.forEach(route { router.addRoute(route) }) router.addRoute({ path: /:pathMatch(.*)*, redirect: /404 }) }顺带说一句动态路由方案下必须自己补一条通配兜底路由。静态表里的 404 通配规则是在动态路由之前注册的注入新路由后它的匹配顺序会被影响补一条放在最后是最省心的做法。4. store 目录一份状态该不该进全局用三条判断线决定4.1 判断线一是否跨路由或跨层级共享store 用多了是灾难用少了也难受。我给自己定的第一条判断线是这份数据会不会被两个以上没有直接父子关系的组件同时需要典型的该进 store 的例子用户信息顶部导航要显示头像个人中心要显示详情权限判断也要读它、全局字典状态枚举、地区列表这类几乎每个页面都用、购物车列表页、详情页、结算页共用。典型的不该进 store 的例子某个列表页的筛选条件、某个弹窗的开关状态、一个表单的临时草稿。这些东西的生命周期和某个组件绑定放在组件内部或者 provide/inject 里就够了。硬塞进 store 的后果是下一次打开这个页面时要记得手动重置一旦忘了用户就会看到上一次残留的筛选条件这类 bug 特别隐蔽。4.2 模块化拆分与命名空间store 目录下必须按模块拆文件一个文件对应一块独立的状态域。Pinia 的写法大致是这样// src/store/modules/user.js import { defineStore } from pinia import { fetchUserInfo as fetchUserInfoApi } from /api/user import { generateRoutes as generateRoutesService } from /services/permission export const useUserStore defineStore(user, { state: () ({ token: localStorage.getItem(token) || , userInfo: null, routes: [] }), getters: { isAdmin: (state) state.userInfo?.roleCode admin }, actions: { async fetchUserInfo() { this.userInfo await fetchUserInfoApi() return this.userInfo }, async generateRoutes() { this.routes await generateRoutesService(this.userInfo.roleCode) return this.routes }, logout() { this.token this.userInfo null this.routes [] localStorage.removeItem(token) } } })拆模块的时候有个命名细节值得注意store 的模块名要和 services、api 的模块名保持一致。user 就统一叫 user不要 api 里叫 account、store 里叫 userInfo、services 里叫 member三方对不上号的时候跨文件跳转就失去了直觉。另外在 store 目录下再放一个 index.js 统一导出所有模块好处是引用路径规范// src/store/index.js export { useUserStore } from ./modules/user export { useAppStore } from ./modules/app export { useDictStore } from ./modules/dict这样业务代码里统一写import { useUserStore } from /store将来模块文件改名或者挪位置业务侧不用动。4.3 持久化与刷新丢失的处理store 是内存态的刷新页面就没了。哪些数据需要持久化标准是刷新后立刻需要、且重新获取代价高的数据。token 属于必须持久化的否则用户一刷新就掉登录体验无法接受通常放 localStorage。用户信息和权限路由理论上可以重新拉但如果每次刷新都拉一遍接口首屏会有明显等待这时候也可以持久化但要注意版本问题——用户权限被管理员改了之后本地缓存还是旧的会出现看得到菜单但打不开的尴尬。我的处理方式是在 token 里带一个时间戳或者版本号服务端在权限变更时让旧 token 失效强制重新拉取。至于持久化手段手动在 action 里读写 localStorage 是最可控的方式虽然啰嗦但逻辑清晰。用插件自动持久化省事但容易出现状态和缓存不一致的情况尤其是对象嵌套的时候插件序列化出来的结构可能和预期不同。我现在的做法是token 这类简单字符串手动存复杂对象不持久化、刷新时重新拉。5. components 与 views 的拆分粒度什么该抽、按什么分组5.1 页面级组件和通用组件的分界线views 和 components 的区别很多人以为只是页面和组件这两个词的差别实际上真正的分界是是否与路由绑定。views 下的组件每一个都应该能在路由表里找到对应项或者作为某个路由页面的子组件存在于同级目录。components 下的组件则完全不知道路由的存在它只认 props。这条分界带来的直接好处是components 里的东西可以随便放进 Storybook 或者单独写单测因为它没有路由依赖而 views 里的组件因为耦合了路由参数、页面级状态测试成本更高也就更需要保持精简。我在做代码评审时有个简单的判断动作如果某个 .vue 文件的 template 超过了 300 行我会问它能不能拆。拆出来的部分如果不需要知道当前路由信息就进 components如果需要读路由参数那说明它本质上还是页面的一部分放在 views 下的同名子目录里更合适比如 views/order/detail/ 下放 DetailHeader.vue、DetailTable.vue。5.2 组件目录按什么维度分组components 目录的分组维度我用过三种最后稳定在按功能类型 业务域的混合方式上components/ ├── base/ # 与业务无关的基础组件 │ ├── BaseButton.vue │ ├── BaseModal.vue │ ── BaseTable.vue ├── business/ # 跨页面复用的业务组件 │ ├── UserAvatar.vue │ ├── OrderStatusTag.vue │ └── DictSelect.vue └── layout/ # 布局相关 ├── PageHeader.vue └── SideMenu.vuebase 里的组件不 import 任何 api 或 store纯靠 props 和 emit 工作这样它们可以被复制到任何项目里直接用。business 里的组件可以读全局状态但必须和具体业务无关的通用能力解耦。layout 里面放的是和页面骨架相关的东西。这个分组方式最大的好处是新人能快速判断自己的组件该放哪儿。纯 UI 的放 base带业务语义的放 business和框架布局有关的放 layout三种情况基本能覆盖 95% 的组件。实在判断不了就说明这个组件可能暂时还不该抽出来。5.3 公共组件入口文件的取舍当 components/base 下有二十多个组件时每个页面 import 五个组件就得写五行 import很啰嗦。这时候可以在 base 下加一个 index.js 统一导出// src/components/base/index.js export { default as BaseButton } from ./BaseButton.vue export { default as BaseModal } from ./BaseModal.vue export { default as BaseTable } from ./BaseTable.vue更进一步可以在 main.js 里全局注册import * as BaseComponents from /components/base Object.entries(BaseComponents).forEach(([name, component]) { app.component(name, component) })但我个人的建议是慎用全局注册。它确实省了 import代价是组件来源变得不透明——你在 template 里看到一个BaseTable得先想一下它是全局注册的还是局部引入的跳转定义的时候编辑器也可能定位不到。我的做法是只把三五个高频基础组件全局注册其余一律局部引入保持可追溯性。6. main.js 与 App.vue注册顺序、职责边界和样式注入的先后6.1 main.js 里注册顺序为什么会影响运行main.js 看着简单但顺序写错会出各种奇怪的问题。一个稳妥的模板是这样// src/main.js import { createApp } from vue import App from ./App.vue import router from ./router import pinia from ./store import { setupGuard } from ./router/guard import ./styles/index.scss const app createApp(App) app.use(pinia) app.use(router) app.use(globalComponents) setupGuard(router) app.mount(#app)关键在于pinia 必须在 router 之前 use。原因在上一节已经说过守卫里会用到 store如果 store 插件还没装上守卫执行时就会报错。虽然守卫是异步执行的理论上等到导航触发时插件已经装好了但把顺序写对能避免很多看起来好了但偶尔报错的情况比如某些在路由模块顶层就调用 store 的写法。另外setupGuard(router)放在app.mount()之前、app.use(router)之后是为了保证守卫在首次导航前就注册好。如果注册晚了第一次进入应用时的导航就不会被拦截用户可能短暂看到未鉴权的页面内容。样式的 import 位置也有讲究。放在所有 import 的最后是为了让全局样式的优先级在打包后的样式表中处于合理位置。不过更稳的做法是给全局样式加上明确的层级控制下一节展开。6.2 App.vue 该做什么、不该做什么App.vue 是根组件它的合理职责只有三件事提供根级的布局容器、放置全局性的组件比如全局 loading、消息提示容器、承载router-view。template div idapp-root router-view v-slot{ Component, route } keep-alive :includecachedViews component :isComponent :keyroute.fullPath / /keep-alive /router-view GlobalLoading / /div /template不该做的事也很明确不要在里面写具体的业务逻辑。我见过 App.vue 里塞了几百行代码处理登录态监听、轮询任务、权限校验结果整个应用的生命周期都被这一个文件绑死想抽出来测试都无从下手。这些逻辑要么进守卫要么进 store要么进一个专门的 composable。关于keep-alive有个细节值得提醒:include匹配的是组件的name属性而不是路由的 name。用script setup写的组件默认没有 name需要通过额外的插件或者在组件里显式声明否则 include 会失效缓存不起作用。这个坑我自己踩过一次排查了半小时才发现是 name 对不上。6.3 assets 与 styles 的归属划分这两个目录特别容易混。我的划分标准是会被构建工具处理的、且在代码中被 import 的资源放 assets用来组织全局样式的放 styles。具体一点assets 里放 logo、图标字体、需要 import 的 scss 变量文件、会被img :src引用的图片。styles 里放 reset.css、variables.scss、mixins.scss、common.scss、theme/。public 目录如果有放不需要构建处理的文件比如 favicon、robots.txt。styles 下的组织方式建议按被谁消费来分层styles/ ├── reset.scss # 重置浏览器默认样式 ├── variables.scss # 颜色、间距、字号等变量 ├── mixins.scss # 断点、文本截断等混入 ├── common.scss # 全局通用类如 .flex-center ├── theme/ # 主题变量支持换肤 └── index.scss # 统一入口按顺序 import 上面几个index.scss 里的 import 顺序决定了最终样式的覆盖关系。reset 必须最先变量和混入在编译期不产生实际 CSS 输出放中间无所谓common 放最后这样页面里覆盖通用类时稍微加点权重就能生效。6.4 别名与全局样式的注入别名配置是让这套目录结构真正好用的前提。每次写../../../components/base/BaseButton.vue是折磨。配好 别名之后路径统一成/components/base/BaseButton.vue文件挪位置时也不用改引用。用 Vite 的话配置在 vite.config.jsimport { defineConfig } from vite import vue from vitejs/plugin-vue import { fileURLToPath, URL } from node:url export default defineConfig({ plugins: [vue()], resolve: { alias: { : fileURLToPath(new URL(./src, import.meta.url)) } }, css: { preprocessorOptions: { scss: { additionalData: use /styles/variables.scss as *; } } } })additionalData这一项值得解释一下它的作用是把变量和混入自动注入到每个 scss 文件的头部这样你在任意组件里写$primary-color都不用再手动 import 变量文件。但要注意只能注入变量、混入、函数这类不产生实际 CSS 输出的内容如果把 common.scss 也注进去每个组件都会重复生成一份全局样式打包体积直接翻倍。这个坑很多人都踩过现象是打包后 CSS 文件莫名其妙大了好几倍。如果用 Vue CLI对应的配置在 vue.config.js 的css.loaderOptions.sass.additionalData里逻辑是一样的。另外别忘了配一个 jsconfig.json 或 tsconfig.json让编辑器能识别 别名否则代码写起来能跑但跳转定义会失效{ compilerOptions: { baseUrl: ., paths: { /*: [src/*] } }, exclude: [node_modules, dist] }7. 从空目录搭到能跑起来落地顺序与高频报错排查7.1 建目录的推荐顺序搭一套结构不需要一次性把所有文件夹都建出来空目录在编辑器里除了占地方没有任何作用。我的习惯是按这个顺序推进第一步先建 main.js、App.vue、router、views 四个。目标是让一个页面能跑起来此时 routes 里只配一条首页路由views 下只放一个 Home.vue。这个阶段跑通npm run dev看到页面渲染出内容说明基础链路是通的。第二步加 api、services。在首页里发一个真实的请求验证 request 封装、代理配置、错误处理都能正常工作。这一步最好用一个真实接口来验证不要用 mock因为代理配置、跨域、token 注入这些问题只有打到真实服务才会暴露。第三步加 store、styles。引入状态管理和样式体系把之前写在组件里的公共状态和样式挪过去。第四步加 components、assets。这一步是纯粹的抽离把已经写出来并且确认要复用的部分拆到这两个目录里。这个顺序背后的逻辑是每一步都有可验证的产出。反过来如果先花一天把所有文件夹和配置文件都建好中间某个环节出问题的时候你很难定位是结构本身的问题还是配置写错了。7.2 几个高频报错的排查思路我把在项目里反复遇到的几个和目录结构相关的报错整理了一下方便对照排查。报错现象大概率原因排查方向路由跳转成功但组件不渲染动态路由注入后没重新导航检查守卫里 addRoute 之后是否 next({...to, replace:true})刷新后样式错乱、部分样式丢失全局样式注入顺序不对检查 styles/index.scss 的 import 顺序reset 是否在最前组件缓存不生效keep-alive 的 include 对不上组件 name检查组件是否显式声明了 name别名路径在编辑器里报红但能运行缺少 jsconfig.json 的 paths 配置补上 baseUrl 和 paths打包后 CSS 体积异常大把输出 CSS 的文件注入了 additionalData只注入变量和混入文件store 数据刷新即丢未做持久化处理判断该数据是否必须持久化token 类走 localStorage排查这类问题时有一个通用思路值得分享先把问题归到某一层再在那一层内部缩小范围。比如页面白屏先判断是路由没匹配上、还是组件抛错了、还是样式把内容遮住了。判断方法很简单打开控制台看有没有 Vue 的警告、看 DOM 树里目标元素到底有没有渲染出来。DOM 有但视觉上看不见是样式问题DOM 都没有是渲染问题路由地址变了但 DOM 没变是路由匹配问题。这个三步定位法帮我省了非常多次盲目调试的时间。还有一个实际经验是关于目录结构变更的。当项目发展到一定规模想调整目录时千万不要一次性大改。我做过一次把 utils 拆成 api、services、helpers 的迁移第一版一口气改了 80 多个文件结果测试环境和线上环境出现了一堆路径引用错误。第二次我改成了渐进式先建新目录新写的代码走新路径老代码保持不动然后在接下来的两周里按模块逐个迁移每次迁移完跑一遍回归。虽然慢但出问题的概率大幅下降而且随时可以停下来。8. 结构之外一些我踩出来的零碎经验关于 store 用 Pinia 还是 Vuex现在的项目我基本都用 Pinia模块划分思路和前面说的一样只是写法上 action 直接是函数没有 mutations 这一层。从目录结构角度讲两者在 store 目录下的组织方式几乎一致迁移成本主要在使用语法上结构本身不用动。关于 api 目录下的 request 封装有个细节是我后来加的给每个请求打上业务标签。因为服务端的错误码有时会是通用的一串数字前端需要根据场景决定是弹全局提示还是交给调用方处理。我在 request 里加了一个silent选项标记为 silent 的请求不弹全局提示由调用方自己 catch 处理。这个选项的默认值是 false也就是说绝大多数请求走统一提示少数需要自定义的显式声明。这样一来弹窗重复、提示被静默吞掉这类问题就基本绝迹了。关于 views 下的目录深度我见过的项目里有做到四层的路径写起来非常长读起来也累。我的建议是控制在两层以内views/order/list.vue 这种就够了再深就该考虑是不是模块本身该拆出去了。目录层级每多一层文件跳转的成本就高一点收益却越来越小。最后说一个挺有意思的现象这套目录结构用熟之后你会发现它其实是一套思考框架的映射。api 层对应系统之间怎么通信services 层对应业务流程怎么编排store 层对应哪些数据需要被记住components 和 views 对应界面怎么组装router 对应用户怎么到达。当你在纠结一个文件该放哪儿的时候其实是在问自己这段代码到底在解决哪一类问题。想清楚这个问题位置自然就出来了。我刚开始写 Vue 的时候总觉得目录约定是给别人看的写了几年才明白它首先是给三个月后的自己看的。
返回列表