资讯动态

Vue3企业级通用模板:从工程底座到权限路由的完整落地实践

发布时间:2026/9/9 4:22:42 来源:尧图企业网站定制
说实话我早就受够了每次开 Vue3 项目都重新搭一遍环境。脚手架拉下来、依赖装一轮、目录结构按自己的习惯理一遍再做权限、请求层、打包优化……一套操作下来大半天没了。等半年后同事接手他可能还要把你搭的结构再推倒重来一次。所以我现在更愿意把精力花在一个真正能长期使用的 Vue3 通用模板上让新项目直接站在同一个地基上启动。这篇文章不讲花哨的卖点只讲一个我迭代了不少项目之后沉淀下来的现代 Vue3 模板它面向 2026 年前后的中后台、管理台、内部系统这类最常见场景。对于想从零搭自己的项目底座、不想被各类全家桶模板绑架的同学下面这些选型和设计思路可以直接抄作业也可以按自己的团队习惯做裁剪。1. 模板的真正价值不是“又出来一套新脚手架”而是“把重复决策提前做完”很多人不理解为什么要单独维护一个模板用官方脚手架不就行了确实官方脚手架能帮你生成一个能跑的 Vue3 项目但它只解决了“能不能跑”的问题解决不了“项目长什么样”的问题。模板真正的价值是把那些每个项目都会重复做、但每次做出来都不一样的事提前定好路由怎么加、状态怎么分、请求怎么封装、组件放哪里、页面权限怎么控制、样式的边界在哪里。1.1 开箱即用不等于堆砌全家桶Vue3 通用模板最容易踩的坑是把所有先进工具都堆上去。Vite、TypeScript、Pinia、Vue Router、ESLint、UnoCSS、Element Plus、Axios、自动导入、Mock 服务……看起来什么都有实际用起来互相打架项目一复杂就不知道是哪个插件出的问题。我现在的做法是分层基础设施层只放 Vue3、Router、Pinia、Vite 这类不可替代的骨架应用设施层放组件库、网络请求、权限守卫、跨端适配模板代码层则保持尽量薄。模板里出现的源码必须能被绝大多数项目直接复用而不是为了展示某个新特性硬塞进来。所以一个合格的 Vue3 通用模板应该有三个衡量标准新成员第一天就能跑起来看目录能猜到功能对应的文件在哪儿新增一个页面时路由、菜单、请求、状态每个环节都有明确的落点删除模板里某个业务模块时不会牵连到其他地方1.2 先给模板划定边界通用模板不可能替业务做所有决定。我在模板里会固定一套 Web 端中后台的基础约定但不强行规定“所有页面都必须用某一种表格组件”或者“所有状态都必须放在 Pinia 里”。边界一旦划清使用模板的人才有自由发挥空间。模板里真正要固定的是跨页面协作的部分路由权限模型、用户鉴权流程、请求响应结构、错误提示规范、菜单和面包屑的数据来源。这些内容在项目内部一旦各处写法不一致后面改起来就是灾难。相反某个页面内部是写在单文件组件里还是拆成三四个局部组件这些细粒度决策不该由模板来限制。1.3 长期迭代比一次性发布更重要模板的价值不只在新项目初始化那一刻更在后续的迭代机制。如果你的模板和实际项目是分离的两个仓库那么实际项目中修复的某个通用问题很难回收到模板里。我建议团队把模板本身当成另一个独立的“种子项目”并且规定实际项目对模板的改动要遵守“先改模板再反向同步”的流程。这个习惯很多团队没有结果就是模板越来越旧新项目越来越不“新”。从第四五个项目开始大家宁愿直接复制上一个项目也不愿用模板。为了避免这种结果我在团队里给模板维护单独留了迭代预算每次项目里发现通用问题就回到模板里补一个 commit形成正循环。2. 工程底座选型2026年还要被“版本兼容性”折磨就太亏了工程底座这部分我说的不是“最新最好”而是“当前生态里受过最多人验证、往后两三年也不会断档”的组合。经过多个项目横向对比最后在 Vue3 模板里固定下来的是 Vite TypeScript Vue Router 4 Pinia ESLint。2.1 创建项目时别再被交互式命令卡住现在用 Vite 建 Vue3 项目非常简单但很多模板还停留在某一次生成之后就不维护的状态。我更推荐在模板仓库里直接放一份锁定主要依赖版本的package.json并且用一个create脚本做初始化而不是依赖某个在线脚手架。核心依赖版本可以这样锁定{ name: team/vue3-template, version: 2.6.0, private: true, type: module, engines: { node: 20.0.0 }, scripts: { dev: vite, build: vue-tsc --noEmit vite build, preview: vite preview, lint: eslint . --fix, typecheck: vue-tsc --noEmit }, dependencies: { axios: ^1.6.0, element-plus: ^2.9.0, pinia: ^2.3.0, vue: ^3.5.0, vue-router: ^4.5.0 }, devDependencies: { vitejs/plugin-vue: ^5.0.0, sass: ^1.63.0, typescript: ^5.6.0, unplugin-auto-import: ^0.18.0, vite: ^5.4.0, vue-tsc: ^2.0.0 } }你可能会问为什么还用 Element Plus不用某些更新的东西。因为通用模板面向的是真实业务组件库的生态丰富度、社区资料、和第三方表格/树组件的配合程度远比“视觉上新”重要。当然如果你把模板定位成纯展示型官网不套组件库也是合理的。2.2 路径别名与 TypeScript 严格模式别小看路径别名模板里不统一新项目迟早会出现../../../满天飞的情况。在 Vite 和 TypeScript 里同时配好是基本动作// vite.config.ts import { resolve } from node:path import { defineConfig } from vite import vue from vitejs/plugin-vue export default defineConfig({ resolve: { alias: { : resolve(__dirname, src) } }, plugins: [vue()] })tsconfig.json也要同步设置{ compilerOptions: { target: ESNext, useDefineForClassFields: true, module: ESNext, moduleResolution: Bundler, strict: true, jsx: preserve, resolveJsonModule: true, isolatedModules: true, esModuleInterop: true, lib: [ESNext, DOM], types: [element-plus/global], baseUrl: ., paths: { /*: [src/*] } }, include: [src/**/*.ts, src/**/*.tsx, src/**/*.vue] }这里建议直接把strict打开并且不要在实际项目里关掉。Vue3 配合 TypeScript 的收益大半都来自类型检查帮你在编译期抓出的问题。把vue-tsc --noEmit放到build脚本里能避免很多低级的 props 类型错误流到线上。2.3 lint 规则不要带个人审美ESLint 目前用 flat config 已经非常主流模板里应该用eslint.config.js不要再放旧的.eslintrc。重点不是规则数量而是让大部分团队基础规范能自动修正。我的经验是缩进、引号、分号、import 排序用自动修复命名规范、组件注册、属性顺序这类容易误判的可以保守一点。一个对 Vue3 比较友好的最小配置长这样// eslint.config.js import js from eslint/js import vue from eslint-plugin-vue import tseslint from typescript-eslint export default tseslint.config( { ignores: [dist, node_modules, src/auto-imports.d.ts, src/components.d.ts] }, js.configs.recommended, ...tseslint.configs.recommended, ...vue.configs[flat/essential], { files: [*.vue, src/**/*.vue, src/**/*.ts], rules: { vue/multi-word-component-names: off } } )别把这条vue/multi-word-component-names关了就直接 release尤其如果你用自动导入组件时是BaseButton这种单文件的话。但如果你模板里大量使用components/Button.vue并且团队能接受那么确实可以关。模板阶段最重要的是把 lint 流程跑通让别人拿到模板后不会因为一堆基础错误而删掉 lint。3. 目录结构和模块边界页面代码和通用逻辑必须分家这一部分我在很多项目里被反复问过模板里的 src 目录到底是按技术类型分还是按业务模块分我的回答是“混合但以业务模块为第一层”。业务模块的边界比文件类型更能抵御需求变化。3.1 推荐目录骨架下面这个结构是结合多个企业级中后台项目总结出来的比较适合做通用模板的起点src/ ├── api/ # 接口定义 单一请求模块 ├── assets/ # 静态资源 ├── components/ # 全局通用组件 ├── composables/ # 组合式函数 ├── constants/ # 全局常量、枚举、字典 ├── directives/ # 自定义指令 ├── layouts/ # 布局组件 ├── locales/ # 多语言资源 ├── modules/ # 业务模块有些团队叫 features │ ├── auth/ # 登录、用户信息、权限 │ ├── dashboard/ # 首页大盘 │ └── system/ # 系统管理示例 ├── plugins/ # 插件注册 ├── router/ # 路由定义 ├── stores/ # Pinia 状态 ├── styles/ # 全局样式和主题变量 ├── types/ # 全局类型定义 ├── utils/ # 工具函数 ├── App.vue └── main.tsmodules目录不是必须的但它能有效阻止“所有页面都堆在 views 下”的无序膨胀。用views的团队时间一长页面文件往往变成几十上百个同层文件根本看不出业务关系。3.2 API 层和类型定义必须写在一起很多模板里 API 文件只导出几个 axios 函数接口参数和返回类型靠业务组件里临时定义结果同一个接口响应类型在七八个文件里重复声明。我在模板里固定一个规则每个 API 模块自带它需要的全部请求和响应类型外部引用时只 import 这个模块。// src/api/user/types.ts export interface UserInfo { id: number name: string avatar?: string roleIds: number[] } export interface FetchUserParams { page: number pageSize: number keyword?: string } export interface FetchUserResult { list: UserInfo[] total: number }请求方法文件保持简单// src/api/user/index.ts import { http } from /utils/http import type { FetchUserParams, FetchUserResult, UserInfo } from ./types export function fetchUsers(params: FetchUserParams) { return http.getFetchUserResult(/user/list, { params }) } export function fetchUserById(id: number) { return http.getUserInfo(/user/${id}) }接口函数不直接在组件里写字符串 URL也不从组件层直接调用 axios是模板里最容易形成一致性的做法。组件里只需要关心业务数据流不需要关心 URL、请求头和错误码。3.3 composables 是 Vue3 模板的隐藏王牌同样的“获取用户列表加载状态”逻辑如果每个页面都自己写一个loading变量再写一遍try/finally就要制造大量重复。我建议模板准备几个最常用的 composables不追求所有逻辑都封装但下面这些几乎每个项目都会用到useTable表格的加载、分页、搜索、选中状态useDict字典数据转 optionsusePermission按钮级权限判断useKeepAlive配合路由 meta 控制页面缓存以useTable举例它把页面里最繁琐的“当前页、每页条数、总条数、刷新、加载、数据列表”整合成一个可复用动作// src/composables/useTable.ts import { ref, shallowRef } from vue import type { TablePaginationConfig } from element-plus export function useTableT extends object(fetcher: (params: any) Promise{ list: T[]; total: number }) { const loading ref(false) const data shallowRefT[]([]) const pagination refTablePaginationConfig({ current: 1, pageSize: 20, total: 0 }) async function reload() { loading.value true try { const result await fetcher({ page: pagination.value.current, pageSize: pagination.value.pageSize }) data.value result.list pagination.value.total result.total } finally { loading.value false } } function handlePageChange(page: number) { pagination.value.current page reload() } function reset() { pagination.value.current 1 reload() } return { loading, data, pagination, reload, reset, handlePageChange } }请注意这里用了shallowRef。如果表格数据是纯展示列表不希望被递归代理监听深层字段shallowRef会比ref省下不少性能。当然如果数据交给子组件并希望在子组件里直接改数组里的对象还是需要ref或显式创建响应式数据。4. 路由、权限与菜单模板最容易翻车的地方其实在这里绝大多数 Vue3 后台模板都会宣称自己支持动态权限路由但真正做得舒服的却不多。常见的翻车点是路由表写了两份、刷新页面菜单就乱掉、动态添加路由后 keep-alive 不生效、标签页和面包屑顺序错乱。与其把权限模型做得特别玄不如在一开始就把数据源理清楚。4.1 菜单和路由到底以谁为准我建议模板采用明确的“单一数据源”思路菜单必须由路由配置生成而不是额外维护一份菜单配置。这样新增路由时只需要在路由表里加一条 meta菜单和面包屑会自动跟上。// src/router/routes.ts import type { RouteRecordRaw } from vue-router export const constantRoutes: RouteRecordRaw[] [ { path: /login, name: Login, component: () import(/modules/auth/views/Login.vue), meta: { hidden: true, title: 登录 } }, { path: /, component: () import(/layouts/DefaultLayout.vue), redirect: /dashboard, children: [ { path: dashboard, name: Dashboard, component: () import(/modules/dashboard/views/Dashboard.vue), meta: { title: 工作台, icon: Odometer, affix: true } }, { path: system/user, name: SystemUser, component: () import(/modules/system/views/user/UserList.vue), meta: { title: 用户管理, icon: User, permissions: [system:user:list] } } ] } ]拿到后端返回的权限标识后再决定是否注册这条路由。对于菜单显示模板可以用路由meta.permissions做过滤对按钮显隐再由指令或 composable 去判断。千万不要把后端返回的完整菜单 JSON 直接映射成路由否则很容易出现“接口返回了不存在的组件名”这种问题。4.2 动态路由与刷新页面不丢状态的重难点动态路由最常见的 bug 是用户登录后往路由表里addRoute用户一刷新动态路由就没了。解决思路是在路由守卫里把用户信息和权限信息从接口中恢复出来再次执行动态注册。这里要避免重复注册所以需要记录已经添加过的路由 name 列表。一个模板里的经典守卫逻辑大概是这样// src/router/guard.ts import router from ./index import { useUserStore } from /stores/user const whiteList [/login] router.beforeEach(async (to) { const userStore useUserStore() if (!userStore.token) { if (whiteList.includes(to.path)) return true return { path: /login, query: { redirect: to.fullPath } } } if (!userStore.userInfoLoaded) { try { const routes await userStore.fetchUserAndBuildRoutes() routes.forEach((route) router.addRoute(route)) return { ...to, replace: true } } catch (error) { await userStore.resetAuth() return { path: /login } } } return true })这里有个容易被新手忽略的细节fetchUserAndBuildRoutes里通过addRoute注册的动态路由第一次注册完当前导航可能仍然找不到对应路由。所以要用一个return { ...to, replace: true }重新触发一次导航才能真正进入目标页。很多热词里搜“vue3 路由跳转不刷新页面”有一部分原因也在这里只看 URL 变了但组件没有重新渲染。4.3 标签页缓存keep-alive 需要 include 的 name 和路由 name 一致中后台项目几乎离不开多页签。模板里实现多页签时最容易出现的坑是 keep-alive 不生效。原因通常是router-view外面包了keep-alive :includetabsStore.keepAliveNames但include里放的值和组件内部的 name 不一致。Vue3 单文件组件的 name 需要单独配或者在script setup外再写一个同名defineOptionsscript setup langts defineOptions({ name: SystemUser }) // 业务逻辑 /scriptkeep-alive的 include 匹配的既不是路由名也不是文件名而是组件内部的 name。如果路由的name叫SystemUser组件内部的name就必须也是SystemUser。否则缓存不会命中每次切页签都会重新拉数据。可以把这个逻辑收敛在自定义指令或全局过渡里但最核心的还是这层设计约束。越早认识到这一点后面处理 tabs 标签页样式和缓存行为就越顺手。5. 状态管理与请求层闭环收敛数据流动的边界Vue3 的响应式系统很灵活但越是灵活越容易写出各种风格的代码。模板如果能在状态管理和请求层给出一个默认闭环项目后半程的维护成本会肉眼可见地下降。5.1 Pinia组合式 store 和选项式 store 怎么选Pinia 官方支持两种写法。我的建议是模板里统一用组合式 store因为它的逻辑组织和 Vue3 的 composable 一脉相承跨项目复用性也更好。// src/stores/user.ts import { defineStore } from pinia import { ref, computed } from vue import { loginApi, logoutApi, fetchUserInfo } from /api/user import type { UserInfo } from /api/user/types export const useUserStore defineStore(user, () { const token ref() const userInfo refUserInfo | null(null) const isLoggedIn computed(() Boolean(token.value)) async function login(payload: { username: string; password: string }) { const data await loginApi(payload) token.value data.token } async function loadUserInfo() { userInfo.value await fetchUserInfo() } function resetAuth() { token.value userInfo.value null } return { token, userInfo, isLoggedIn, login, loadUserInfo, resetAuth } })组合式 store 比选项式 store 更适合“按需取用”。模板里甚至可以在storeToRefs后只解构需要的状态逻辑上非常接近普通的ref对新人理解更友好。5.2 Axios 封装别过度设计请求层是模板里几乎每个人都会封装一遍的东西但很多封装把 Axios 包得过于死导致特殊场景反而不好用。我理想中的模板请求层只做四件事统一配置 baseURL、超时、跨域携带 cookie通过拦截器统一加 token统一处理业务状态码弹出错误提示让 TypeScript 泛型能准确传递返回类型如果再加“自动取消重复请求”“多线程控制并发下载”这些进阶能力可以放在utils/http之外做扩展不要塞进主流程。一个常见的请求函数类型签名// src/utils/http.ts import axios, { type AxiosRequestConfig } from axios const service axios.create({ baseURL: import.meta.env.VITE_API_BASE_URL, timeout: 15000 }) service.interceptors.request.use((config) { const userStore useUserStore() if (userStore.token) { config.headers.Authorization Bearer ${userStore.token} } return config }) service.interceptors.response.use( (response) { const res response.data // 约定后端返回结构 { code: number; data: T; message: string } if (res.code 0) { return res.data } throw new Error(res.message || 请求失败) }, (error) { // 业务可在这里统一处理 401 等场景 return Promise.reject(error) } ) export function requestT(config: AxiosRequestConfig): PromiseT { return service.request(config) } export const http { get: T(url: string, config?: AxiosRequestConfig) requestT({ ...config, url, method: GET }), post: T(url: string, data?: unknown, config?: AxiosRequestConfig) requestT({ ...config, url, method: POST, data }) }在这个封装下API 层写返回值类型时不会再吞掉真正的类型。实际调用时也能拿到类型提示const list await http.getFetchUserResult(/user/list, { params })这里唯一要注意的是拦截器返回的res.data类型已经被拆掉一层外面再用http.getUserInfo时就是真正的用户信息对象不需要再写两层泛型。5.3 Pinia 和路由守卫之间的循环依赖预防在我早期模板里路由守卫和 Pinia store 互相 import出现过一个隐性 bug项目一启动就报 “getActivePinia was called but there was no active Pinia”。原因是在router.beforeEach里调用useUserStore时Pinia 实例还没被创建。解决方案是把 Pinia 实例创建顺序放在 router 安装之前或者在main.ts里先createPinia()再注册 router。更稳妥的做法是在路由守卫不直接依赖全局 Pinia 单例而是用pinia实例作为参数传入export function setupRouterGuard(router: Router, pinia: Pinia) { router.beforeEach(async (to) { const userStore useUserStore(pinia) ... }) }这种写法在模板和大型项目里都能减少魔数一样的“环境态”。如果你搜索引擎里还能看到一堆 “pinia not active” 的问题多半是这类初始化顺序引起的。6. 样式方案与组件生态比“好不好看”更重要的是可控感2026 年了样式方案不再是非 Tailwind 不可的白刃战。原子化 CSS、scoped 单文件样式、CSS 变量主题系统在 Vue3 生态里已经能很好地共存。模板选型要解决的主要是“样式写在哪儿”和“主题怎么变”这两个问题。6.1 原子化 CSS 与 scoped style 如何配合模板里既不建议所有东西都用原子类堆到模板上也不建议全面禁止原子类。更合理的默认值是页面级布局和尺寸微调用原子类组件内部结构样式用 scoped style跨组件视觉变量用 CSS Variables用 UnoCSS 做原子化样式时它和 Vite 的结合很顺滑按需扫描生成类名打包产物里只会有实际用到的工具类。下面是最小的接入方式// vite.config.ts import UnoCSS from unocss/vite import { presetUno } from unocss export default defineConfig({ plugins: [ vue(), UnoCSS({ presets: [presetUno()] }) ] })配合shortcuts还能把常用的“卡片 内边距”组合成一个自定义类// uno.config.ts import { defineConfig, presetUno } from unocss export default defineConfig({ shortcuts: { card: rounded-lg bg-white p-4 shadow-sm }, presets: [presetUno()] })模板在这一点上不要向团队收太多智商税也不要强迫所有写页面的人都去背原子类规则。只要保证项目能很平滑地同时使用两种写法就已经合格了。6.2 深色主题通过 CSS Variables 一层层铺开真正能做深色主题的模板不是简单地切换一个 class而是把设计 token 抽离到 CSS 变量中。Element Plus 本身支持 CSS Variables 覆盖我们可以把主题变量放在src/styles/variables.css:root { --app-bg-color: #f5f7fa; --app-content-bg: #ffffff; --app-text-primary: #1f2329; --app-border-color: #e5e6eb; } html.dark { --app-bg-color: #0a0a0a; --app-content-bg: #141414; --app-text-primary: #e5e6eb; --app-border-color: rgba(255, 255, 255, 0.12); }切换暗黑主题时只需要给html增加一个darkclass然后在模板的布局组件里监听系统偏好或用户设置。为了不让整体样式闪烁可以在入口 HTML 里塞一小段内联脚本读取本地存储。这段脚本不需要框架参与也能保住首屏主题正确。组件库的主题也可以用 CSS 变量覆盖不必依赖官方的暗色组件。比如 Element Plus 的--el-bg-color、--el-text-color-primary都暴露出来了直接在html.dark里覆盖即可html.dark { --el-bg-color: #141414; --el-bg-color-overlay: #1d1e20; --el-text-color-primary: #e5e6eb; --el-border-color: #434343; }把这个和 UnoCSS 的dark:变体一起用页面级深色适配会方便很多。6.3 自动导入组件时别把全局注册变成一个黑洞组件库按需导入是模板要顺手处理的问题。unplugin-vue-components配合 Element Plus 自动导入非常好用使用组件时不需要手动import { ElButton }模板里直接写el-button就够了。但这里有一个容易忽略的点自动生成的components.d.ts需要提交到 git否则新 clone 项目后 IDE 提示可能不完整。这个文件最好加入 eslint ignore避免它被 lint 反复提示未使用。同时自动导入不会把ElMessage、ElNotification这类函数式组件导入进来所以还要配合unplugin-auto-import的ElementPlusResolver// vite.config.ts import AutoImport from unplugin-auto-import/vite import Components from unplugin-vue-components/vite import { ElementPlusResolver } from unplugin-vue-components/resolvers export default defineConfig({ plugins: [ AutoImport({ resolvers: [ElementPlusResolver()], dts: src/auto-imports.d.ts }), Components({ resolvers: [ElementPlusResolver()], dts: src/components.d.ts }) ] })自动导入虽然省心但会让新同事看代码时产生“这个函数从哪里来的”困惑。所以模板里的自动导入只适合组件库和少量约定的工具 API像业务里自己封装的函数还是显式 import 更好。7. 从高频搜索里揪出来的几个 Vue3 现实问题模板建好之后真正让它变“优雅”的通常是各种边界情况的处理。我结合平时在各种社区看到的高频问题挑几个和模板直接相关的坑放进模板文档里新项目避开后能省下大量排查时间。7.1 computed 为什么“失效”了很多 Vue3 新手在热词里搜“computed”其实并不是 computed 本身失效而是对响应式依赖的判断有误。举个例子const user reactive({ name: 张三 }) const name computed(() user.name) // 直接替换整个 user 对象 user { name: 李四 }在reactive下直接替换对象会丢失原有代理computed 也不会再更新。这种情况应该使用ref而不是reactive。模板文档里要专门写一条开发约定对象整体赋值频繁时优先用 ref嵌套结构稳定时再用 reactive。另一个常见问题是 computed 里用了数组的.map或过滤结果得到的新数组没有问题但在模板里修改原数组内容时 computed 可能不重新计算。这其实是因为你修改的是原数组中的嵌套对象但没有触发响应式依赖收集。解决办法是用reactive或ref包裹整个数组作为依赖或者显式读取需要监听的字段。7.2 路由跳转不刷新页面“vue3 路由跳转不刷新页面”这个问题每次都有人问。如果是同一个路由组件但 query 参数变了默认情况下 Vue Router 会复用组件实例组件不会重新挂载自然也不会有生命周期重新执行。很多人的第一反应是给路由加key但更标准的做法是监听路由变化import { useRoute } from vue-router const route useRoute() watch(() route.query, () { loadList() }, { immediate: true })模板里如果有“列表页根据查询参数变化刷新”这类场景不要鼓励用router-view :key$route.fullPath /粗暴解决因为那会让整棵组件树重新渲染成本很高。更优雅的方式是让页面自己感知参数变化再调用对应的 reload 逻辑。很多“跳转不刷新”的隐藏问题还和缓存有关。前面提到的 keep-alive 的 include 若没有把组件 name 加进列表页面从标签页切回来时会被销毁重建。模板里最好内置一个“缓存页面白名单”的配置比如在路由meta.keepAlive: true之后再统一收集不要让人手动去 tabsStore 里每个页面都记一次。7.3 修改 tabs 标签页样式后台模板里经常要改 Element Plus 的 el-tabs 样式常见的是去掉底部边框、调整标签栏高度、增加关闭图标间距。因为 scoped style 默认只能作用到当前组件如果要改到子组件内部的 DOM通常要用:deep()选择器。style scoped langscss .tabs-wrapper { :deep(.el-tabs__header) { margin-bottom: 0; border-bottom: 1px solid var(--app-border-color); } :deep(.el-tabs__nav-wrap::after) { height: 0; } } /style在模板里建立一个基础的AppTabs.vue页面多页签组件把这些深层修改都收敛起来比每个页面都复制一份样式要好得多。如果有人要改标签页关闭按钮的位置或图标大小只需要在这个公共组件里动一次。7.4 模板里预留 TSX/JSX 能力不要以为 Vue3 模板就一定要用 template 写所有界面。有些场景比如逻辑复用的动态表单、高度配置化的表格列、或者写起来很别扭的 render 函数用 TSX 会更顺手。Vite 插件默认只解析.vue如果你希望模板能直接支持.tsx需要保证vitejs/plugin-vue的 jsx 能力已经开启。Vue3 的 JSX 并不需要额外引入 React但它的写法跟 template 有些差距。模板里可以放一个简单的.tsx示例文件告诉使用者怎么才能把defineComponent、props、插槽接起来。很多人一提到 JSX 就以为是从 Vue 逃到 React 方向去了其实在 Vue3 里它只是渲染能力的一个补充模板留好这个口子能应付以后更高搞怪的业务需求。我个人的倾向是模板的默认主力语法仍然是 SFC template但不会把 TSX 能力封死。这样既能满足大多数人的习惯也不会在遇到复杂的动态渲染时被迫绕弯路。8. 项目构建与上线前还要做的事模板光能跑不算完还有几个地方需要提前设置否则项目一上线就容易遇到首屏慢、缓存失控、环境配置错乱的问题。8.1 环境变量统一管理Vue3 的 Vite 模板默认支持.env.development、.env.production和.env.test等文件。模板里可以放一份.env.example把变量名写清楚# .env.example VITE_APP_TITLE企业管理系统 VITE_API_BASE_URL/api VITE_USE_MOCKfalse VITE_UPLOAD_BASE_URLhttps://cdn.example.com所有以VITE_开头的变量都会暴露到前端代码里。不能把真实密钥写进VITE_前缀的变量。如果项目有第三方回调密钥应该走后端代理或网关模板文档必须写清楚这条安全约定。8.2 构建体积分析现代模板里建议内置一个report脚本build:report: vue-tsc --noEmit vite build --mode production如果要在正式构建后看包体积可以临时集成rollup-plugin-visualizer或者在 Vite 里使用build.reportCompressedSize。我没法替你决定项目必须优化到什么程度但模板必须能快速看到“哪个包把首屏体积撑大了”。8.3 历史模式部署时的 fallbackVue Router 默认的 history 模式在部署到 Nginx 时需要把try_files指到index.html。模板里最好把部署文档写明白location / { try_files $uri $uri/ /index.html; }如果不习惯 history 模式也可以在路由里改用 hash 模式。但 2026 年了我还是建议默认 history 模式配合 404 页面重定向做前端路由状态管理更干净。9. 把模板落地到团队时的一些心里话我见过很多人拿到一个开源模板后做的第一件事是删掉.github、清空 README、改掉 logo然后当成自己的 New Project。等业务跑起来几个月发现模板里有一堆用不上的抽象又要花大力气解耦。所以模板落地时有一点非常重要“要为你的团队场景做减法而不是只做加法”。在团队里推广模板时我会提前做好三件事把模板当作一个真实验收标准所有新项目成员先按照模板写一个简单页面从建仓库、拉代码、启动、接接口、加菜单到最后发布走一遍完整链路把模板的文档问题排进迭代计划文档不是一次写完就完的遇到新项目里难理解的部分顺手回到模板注释里写清楚给模板写一个“去留清单”哪些功能版本能用、哪些需要谨慎开启、哪些长期没人用就删掉要定期过一遍技术选型部分我更希望大家不要把“炫技”放在第一位。模板里的一个组合式函数、一个自动导入插件今天看着很爽但如果团队里一半人不理解那你实际上生产了新的认知门槛。Vue3 通用模板的优雅最后一定体现在“上手成本不断下降”和“改起来容易定位”这两点上而不是代码里堆了多少高级技巧。最后再分享一个小技巧如果你准备往模板里加一个公共组件或 composable我建议你在项目里连续三次遇到同一种重复逻辑之后再加别在第一天就设计一堆复杂抽象。大多数所谓通用封装在真实需求面前都太脆弱反而是“先写在页面里提取到模块里升级到 templates 里”这条路最适合做可持续演进的 Vue3 通用模板。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价