Vue 3页面模板设计:从目录骨架到组合式函数的工程实践

发布时间:2026/11/3 13:06:25
Vue 3页面模板设计:从目录骨架到组合式函数的工程实践 1. 为什么一个“示例模板”值得单独拿出来讲很多人看到“vue3.0页面模板-示例”这种标题第一反应是这不就是个脚手架生成出来的东西吗有什么好聊的。我一开始也这么想直到我在三个不同规模的项目里反复因为“模板没设计好”这件事返工才意识到一个页面模板的价值根本不在它能跑起来而在于它能不能让后面接手的人少骂两句。这里说的页面模板指的是一个 Vue 3 项目里最基础的那层页面骨架——它通常包含路由入口、布局容器、状态挂载点、请求层接入位、以及一些通用的页面级逻辑比如加载态、错误态、权限判断的挂载位置。示例模板则是把这套骨架做成一个可复制的最小可运行单元让新页面能“照着抄一份就能开工”。它解决的问题很具体团队里每个人写页面的起手式不一样有人把请求写在 setup 顶层有人塞进 onMounted有人把 loading 状态散落在三个 ref 里最后代码 review 变成风格纠察大会。这篇内容适合两类人看。一类是刚开始用 Vue 3 写业务页面、对 Composition API 还停留在“能跑就行”阶段的朋友另一类是被团队协作折磨过、想统一页面起手式的老手。我会围绕一个示例模板的完整设计思路展开把每个位置为什么这么留、参数为什么这么定、哪些坑我踩过全部摊开讲。关键词方面页面模板、Vue 3、Composition API、路由布局、状态管理、请求封装这些都会自然带出来不刻意堆砌。需要提前说明的是下面涉及的具体目录结构、命名习惯、封装方式都是基于常见工程实践做的合理设计不是某个特定项目的翻版。你完全可以根据自己团队的规范调整但每一处调整背后的取舍逻辑我会尽量讲透让你知道改哪里会影响什么。2. 模板的目录骨架每个文件夹都在回答一个问题2.1 从“一个页面需要什么”倒推目录结构设计模板目录时最容易犯的错是先打开脚手架生成一堆文件夹然后往里填东西。正确的顺序应该反过来拿一个真实页面出来问它从被路由命中到渲染完成中间需要哪些东西。我的做法是列一张清单——路由参数解析、布局壳子、页面级状态、接口请求、通用工具函数、类型定义。这六样东西对应下来模板的目录就有了雏形。我习惯的骨架是这样的src/views放页面级组件每个页面一个文件夹里面至少有一个index.vue作为入口src/layouts放布局壳比如带侧边栏的主布局和纯内容布局src/composables放可复用的组合式函数页面模板里引用的usePageQuery、useTable这类都在这src/api按业务模块拆分请求函数src/types放接口返回值和页面状态的类型定义。这个结构不新鲜但关键在于每个目录的职责边界要卡死——页面组件只负责“组装”不负责“实现细节”。为什么要把 composables 单独拎出来因为页面模板最怕的就是逻辑和视图搅在一起。一个列表页如果分页逻辑、搜索条件、请求触发全写在index.vue里这个文件三百行起步后面加个导出功能就得在夹缝里找位置。把这些抽成组合式函数后页面文件基本只剩模板结构和少量事件绑定新人接手时阅读成本直线下降。这里有个经验判断一个页面该不该抽 composable就看这段逻辑有没有“状态行为”的组合有就抽纯计算函数放 utils 就行。2.2 页面文件夹内部的固定三件套单个页面文件夹里我建议固定放三样东西index.vue、types.ts、composable.ts如果逻辑复杂。index.vue是路由直接指向的入口它只做三件事——引入布局需要的元信息、调用页面级 composable、把数据绑到模板上。types.ts定义这个页面独有的状态和接口类型避免类型定义散落在各处。composable.ts则是这个页面专属的逻辑封装和全局 composables 的区别在于它只服务当前页面不需要考虑复用性写起来可以更直接。这个三件套的好处在于当你需要复制一个页面做类似功能时整个文件夹拷过去改个名字就能跑不用去全局文件里翻哪些东西是耦合的。我见过不少项目页面逻辑一半在组件里、一半在全局 store 里复制页面时漏掉一个 store 的引用跑起来就是白屏排查半天。模板设计的一个隐性目标就是让“复制粘贴”这件事变得安全三件套结构就是为此服务的。2.3 路由配置里藏着的模板约定路由配置不只是路径和组件的映射它其实是模板约定的声明处。我在模板里会给每条路由固定几个 meta 字段title用于面包屑和标签页keepAlive控制是否缓存requiresAuth标记权限layout指定用哪个布局壳。这几个字段看起来琐碎但它们决定了页面模板在运行时能拿到哪些上下文。举个具体的keepAlive这个字段如果模板里不统一处理就会出现有的页面缓存了、有的没缓存用户切回来发现搜索条件丢了或者没丢体验不一致。我的做法是在布局壳里统一读取route.meta.keepAlive配合KeepAlive :includecachedViews做集中管理页面组件本身不用关心缓存逻辑。这就是模板的价值——把跨页面的共性约定收口到一处页面只管自己的业务。路由懒加载也是模板里必须定好的规矩。component: () import(/views/xxx/index.vue)这种写法要统一不能有的页面懒加载有的直接 import。直接 import 会让首屏包体变大而且构建时的 chunk 划分也会乱。模板里统一用动态 import配合路由的webpackChunkName注释Vite 下用魔法注释同理能让每个页面产出独立的 chunk首屏只加载当前路由需要的代码。3. 页面入口文件的最小职责边界3.1 setup 里只留“接线”动作一个设计良好的页面入口script setup里的代码应该像接线板——把 composable 返回的状态接到模板上把事件处理函数接到对应的 composable 方法上除此之外不写业务逻辑。我给自己定的硬规矩是index.vue的 script 部分超过 80 行就要停下来审视是不是又把手写逻辑塞回来了。具体写法上我会这样组织先调用页面 composable 拿到解构出来的状态和方法然后定义少量纯视图层的事件包装比如把表格行点击转成路由跳转最后在onMounted里触发首次数据加载。注意首次加载这个动作我倾向于放在 composable 内部用onMounted处理而不是在页面里调这样页面文件更干净而且 composable 自己知道什么时候该初始化。但这里有个坑要提醒如果 composable 里用了onMounted那它必须在 setup 同步执行阶段被调用不能在异步回调里调否则生命周期钩子注册不上。这是 Composition API 的一个常见误解很多人以为 composable 就是个普通函数随便哪里调都行实际上涉及生命周期的 composable 有调用时机约束。模板里我还会预留一个defineOptions({ name: XxxPage })这个 name 在 KeepAlive 的 include 匹配和调试工具里都用得上。Vue 3.3 之后defineOptions是官方支持的之前要用两个 script 块或者插件现在直接写就行。别小看这个 name缓存失效排查时DevTools 里能看到组件名比看到一堆 Anonymous 强太多。3.2 模板结构的分层壳、区、块模板部分我习惯分三层最外层是页面容器负责 padding、背景、滚动中间是功能区块搜索区、操作区、内容区最内层是具体组件表格、表单、图表。这三层在模板里用语义化的 class 区分比如page-container、page-section、page-block配合一套基础的布局样式新页面直接套用就能保证视觉一致。为什么要强调这个分层因为很多模板示例只给一个空壳新人拿到后各写各的有的页面搜索区贴着顶部有的留了 16px 间距视觉上就不像同一个系统。模板里把这三层的间距、圆角、背景色定死页面开发者只需要往区块里填内容不用每次纠结样式。这套分层样式我一般放在全局的styles/page.scss里用 BEM 命名避免冲突页面里直接引 class 就行。还有一个细节内容区的高度处理。列表页经常遇到“表格撑不满容器”或者“表格溢出导致双滚动条”的问题。模板里的做法是给内容区设flex: 1加min-height: 0让它在 flex 布局里正确收缩表格组件内部再自己处理滚动。min-height: 0这个属性是 flex 子项溢出的经典解法不加的话子元素内容会把父容器撑开滚动条就跑到外层去了。这个点在很多模板示例里被忽略但实际项目里几乎每个列表页都会撞上。3.3 错误边界与加载态的挂载位置页面模板必须给加载态和错误态留好位置而且这个位置要统一。我的做法是在页面容器里放一个条件渲染的三态结构loading时显示骨架屏或加载指示error时显示错误提示加重试按钮正常时渲染内容。这三个状态由页面 composable 统一管理页面模板只负责根据状态切换显示。这里的关键是错误态的处理不能只靠全局的请求拦截器弹个 toast 就完事。toast 一闪而过用户不知道页面为什么是空的。模板里预留错误态区域让页面在请求失败时能显示“加载失败点击重试”重试按钮直接调用 composable 里的重新加载方法。这个设计在弱网环境下体验差别巨大也是模板从“能跑”到“好用”的分水岭。骨架屏的选择上我倾向于用简单的占位块而不是复杂的动画骨架原因是骨架屏的维护成本经常被低估——页面结构一改骨架屏就得跟着改不然显示错位更难看。模板里给一个通用的占位组件接受行数和列数参数够用就行。如果团队有设计资源做精细骨架再替换也不迟。4. 组合式函数模板真正的复用核心4.1 usePageQuery 的设计取舍列表页的查询逻辑是重复度最高的部分搜索条件、分页参数、请求触发、结果缓存几乎每个列表页都要来一遍。模板里我把它封装成usePageQuery接收一个请求函数和初始查询参数返回list、total、loading、query、search、reset、handlePageChange这些。看起来接口不少但每个都有明确用途页面里解构出来直接用。设计时有个取舍值得说查询参数用reactive还是ref。我选reactive因为页面里经常要把 query 的某个字段绑到搜索表单的 v-model 上reactive对象的属性可以直接绑ref的话得写query.value.xxx模板里还要注意解构丢失响应性的问题。但reactive也有坑——整体替换对象会丢响应性所以 reset 的时候不能query initialQuery得用Object.assign(query, initialQuery)逐字段覆盖。这个细节模板里必须处理好不然 reset 按钮点了没反应排查起来很费时间。分页参数我固定在 query 里放page和pageSize两个字段而不是单独维护。这样做的好处是请求函数只需要接收一个 query 对象不用区分“查询条件”和“分页条件”接口层处理起来也简单。代价是 query 对象会混着业务字段和分页字段类型定义时要分开描述。我试过把分页单独抽出来结果每次请求都要合并两个对象反而更啰嗦最后还是回到合并方案。4.2 请求触发的时机控制usePageQuery里什么时候发请求是个容易做错的地方。我的实现是初始化时不自动请求由页面在onMounted里显式调一次search()。为什么不自动因为有些页面的查询条件依赖路由参数或者父组件传入的 props自动请求可能拿到的是空条件发出一次无效请求。显式调用让页面自己控制时机虽然多写一行但避免了时序问题。搜索和重置的行为也要定清楚。search()的语义是“回到第一页并重新请求”因为用户改了筛选条件后还停在第三页是没有意义的。reset()则是把 query 恢复初始值后再调search()。这两个方法内部都要处理“请求进行中重复触发”的情况——用户手快连点搜索按钮会发出多个请求后发的可能先返回导致列表数据错乱。模板里的处理是维护一个请求序号每次请求自增响应回来时比对序号不是最新请求的直接丢弃。这个防抖竞态的机制在模板里做好页面开发者就不用每个页面都操心。提示请求序号方案比简单的 loading 锁更可靠。loading 锁只能阻止并发但阻止不了“先发后到”的乱序问题序号比对才能保证最终展示的是最后一次请求的结果。4.3 页面级 composable 与全局 composable 的分工全局 composables 放的是跨页面通用的能力比如usePageQuery、useTableSelection、usePermission。页面级 composable 放的是这个页面独有的业务逻辑比如订单页的“计算优惠后金额”、详情页的“根据状态决定显示哪些操作按钮”。两者的边界判断标准是如果这段逻辑换个页面还能用就往上提如果它依赖当前页面的特定数据结构就留在页面级。这个分工的意义在于控制模板的复杂度。全局 composable 要考虑通用性和参数化写起来更谨慎页面级 composable 可以写得更“自私”直接引用页面类型不用为了复用做过度抽象。我见过反面案例为了“复用”把一个只在一个页面用的逻辑硬抽成全局 composable加了一堆配置参数结果可读性还不如直接写在页面里。模板设计要抵抗这种过度设计的冲动复用的前提是真的有第二个使用方。5. 请求层与类型定义在模板中的落位5.1 api 层的函数签名约定模板里的 api 层我要求每个请求函数都有明确的入参类型和返回类型而且返回类型统一包一层。比如列表接口返回{ list: T[], total: number }详情接口返回T创建更新接口返回void或操作结果。这层约定看起来是类型体操实际收益在页面 composable 里——usePageQuery的泛型能直接推导出 list 的元素类型页面里v-for渲染时字段提示是准的不用去翻接口文档确认字段名。请求函数的命名我习惯用“动词名词”的形式比如getOrderList、createOrder、updateOrderStatus和 RESTful 的语义对齐。参数超过三个时改成对象入参避免调用处出现一长串位置参数看不懂含义。这些约定写进模板的 README 或者注释里新人照着写就不会跑偏。拦截器层面模板里统一处理三件事请求头注入比如 token、响应结构解包把{ code, data, message }拆成 data 和错误、错误提示。解包这步要注意业务代码里拿到的应该是解包后的数据而不是每次都要判断res.code 0。拦截器里判断 code 非成功时 reject 一个带 message 的 error页面 composable 的 catch 里统一处理错误态。这样业务代码里看不到 code 判断清爽很多。5.2 类型定义放在哪、怎么组织类型定义的位置我遵循“就近原则”页面独有的类型放页面文件夹的types.ts跨页面共享的放src/typesapi 的入参出参类型跟着 api 函数走放在同一个文件里或者相邻的types.ts。不搞一个巨大的全局类型文件因为那样改一个字段要在一千行里找位置而且容易命名冲突。接口返回类型和页面状态类型要分开定义。接口返回的是后端契约页面状态是前端自己的事两者可能不一致——比如接口返回的金额是分页面状态里存的是元接口返回的状态码是数字页面里用的是枚举字符串。模板里把这两层类型分开转换逻辑放在 composable 里页面组件只接触页面状态类型。这个分层在接口字段变动时特别有用改动被隔离在 composable 的转换函数里组件层不用动。枚举类型我倾向于用as const的对象加联合类型而不是 TS 的enum。原因是enum编译后有运行时产物而且和字符串字面量类型的互操作有时别扭。as const写法更轻配合typeof取联合类型在模板的条件渲染里用起来顺手。这个选择没有绝对对错但模板里要统一不能一半 enum 一半 as const。6. 那些模板示例里不会写、但实际会踩的坑6.1 响应式解构丢失的连锁反应Composition API 里最经典的坑从reactive对象解构出来的属性会丢失响应性。模板里如果 composable 返回一个 reactive 对象页面里写const { list, total } usePageQuery(...)那 list 更新时页面不会重新渲染。这个坑的隐蔽之处在于初次渲染是正常的因为解构时拿到了初始值只有数据更新时才暴露很容易被误判成“接口没返回”或者“赋值没生效”。模板里的规避方式是composable 返回的状态用ref而不是reactive的属性或者返回toRefs包装后的对象。我选前者因为ref解构后还是响应式的解构的是 ref 对象本身.value访问不变页面里写list.value虽然多几个字符但不会踩坑。如果状态字段多用toRefs也行但要注意toRefs只对 reactive 对象的第一层生效嵌套对象还是 reactive 的解构嵌套属性照样丢响应性。这个细节模板注释里要写清楚不然新人还是会栽。6.2 KeepAlive 缓存与页面状态的纠缠开了 KeepAlive 的页面组件实例被缓存onMounted只执行一次再次进入走的是onActivated。模板里如果只在onMounted里加载数据缓存页面切回来时数据是旧的用户以为没刷新。我的处理是在 composable 里同时注册onMounted和onActivated但用一个标志位区分首次加载和激活刷新——首次走完整加载激活时根据配置决定是否静默刷新。更麻烦的是缓存页面的状态残留。用户在列表页翻了五页、输了搜索词切走再切回来这些状态还在有时候是期望的保持浏览位置有时候不是从详情页返回希望看到最新列表。模板里给页面一个refreshOnActivated的配置项默认 false需要刷新的页面自己打开。这个配置放在路由 meta 里还是 composable 参数里我倾向前者因为它是路由级别的行为约定放 meta 里布局层统一读取处理更集中。6.3 路由参数变化但组件复用的问题同一个路由参数从/detail/1跳到/detail/2Vue 会复用组件实例不会重新创建所以onMounted不会再执行。如果页面数据加载写在onMounted里从详情 1 跳到详情 2 时数据不会更新。这个坑在带“上一页/下一页”或者列表内跳转的场景里必现。模板里的解法是监听路由参数变化watch(() route.params.id, loadData)同时onMounted里也调一次loadData。注意 watch 要加immediate: false默认就是 false避免和 onMounted 重复请求。更稳妥的写法是把加载逻辑抽成一个函数onMounted 和 watch 都调它函数内部做请求竞态保护。这个模式在模板的详情页示例里要体现出来因为详情页是路由参数复用最典型的场景。6.4 样式作用域与组件库样式的覆盖冲突页面模板用style scoped是标配但覆盖组件库样式时 scoped 会失效——因为组件库的内部元素不带当前组件的 scope 属性。模板里的处理是用:deep()选择器比如:deep(.el-table th) { ... }。但:deep()用多了会让样式边界模糊我的原则是只在必要时用而且尽量限定在页面容器内比如.page-container :deep(.el-table th)避免影响全局。另一个坑是 scoped 样式对动态插入的 DOM 不生效比如弹窗、下拉菜单这些挂到 body 上的元素。这类元素的样式要么用全局样式类要么通过组件库的popper-class之类的属性指定类名再写全局样式。模板里给弹窗类组件预留一个统一的类名前缀约定比如app-dialog-xxx全局样式文件里集中管理避免样式散落。7. 把模板用起来复制、改造与团队落地7.1 新建页面的标准动作清单拿到模板后新建一个页面的动作我固定成五步第一步复制示例页面文件夹重命名为业务页面名第二步在路由配置里注册填好 meta 的 title、keepAlive、layout第三步改types.ts里的类型定义对齐接口契约第四步在composable.ts里替换请求函数和数据处理逻辑第五步改index.vue的模板结构把示例的表格列、表单字段换成业务的。这五步里前两步是机械操作后三步是业务开发模板的价值就是把机械操作标准化让开发者精力集中在业务上。这五步里最容易漏的是第二步的 meta 配置。漏了 title面包屑显示空白漏了 layout页面可能套错壳子。模板里可以在路由注册处加一个开发环境的校验meta 缺字段时控制台警告帮新人兜底。这种小机制在团队协作里很实用比写文档提醒有效。7.2 模板的演进什么时候该改模板模板不是定死的用着用着发现某个模式反复出现就该考虑收进模板。判断标准是同样的代码在三个以上页面重复出现且改动时要点改多处那就抽。但抽之前要想清楚抽象的成本——加一个配置参数、加一层间接会不会让简单页面变复杂。我的经验是模板的抽象层次控制在“页面开发者不需要理解模板内部实现就能用”的程度超过这个度就是过度设计。反过来模板里长期没人用的配置项要清理。我见过模板里留着五六个“预留扩展点”结果一个都没用上新人看到还得猜是干嘛的。模板要像代码一样定期重构删掉死配置合并重复逻辑保持精简。每次团队复盘时花十分钟过一遍模板的痛点比攒到项目中期大改要划算得多。7.3 团队约定比模板本身更重要最后说个实在的模板能统一代码结构但统一不了人的习惯。真正让模板发挥作用的是团队约定——命名规范、提交前检查、code review 时对照模板检查起手式。模板是载体约定是灵魂。我在团队里推模板时会配一份简短的约定清单比如“页面入口不超过 80 行”“请求逻辑必须在 composable 里”“类型定义就近放置”review 时对着清单看几轮下来习惯就养成了。这套东西推下去之后最明显的变化不是代码变好看了而是排查问题变快了。页面出问题先看 composable 里的请求和状态再看模板绑定路径清晰不用在几百行里大海捞针。模板示例的意义也在这里——它不是让你少写代码而是让代码出现在该出现的地方。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询