Vue 3 出来之后,我在好几段项目里反反复复纠结过同一个问题:为什么一个页面写着写着就变成了一坨几百行的脚本?为什么明明是组件化框架,代码却能堆到让同事不敢动?后来把模块化这条思路彻底捋顺,才意识到问题不在框架本身,而在于工程组织方式。这也是这次想聊的内容——Vue 模块化开发,从搭建项目开始,到常用工具怎么选、怎么用,完整过一遍。
这篇会从一个新手最容易犯的“全堆一个文件”的坑说起,讲清楚模块化的几个层面,然后给出实际可以复现的项目构建步骤,包括脚手架选型、目录结构设计、ESLint/Prettier 配置、路由和状态管理拆分、Vite 打包优化,最后附上我在真实开发里踩过的环境变量、缓存、依赖版本相关的问题。适合两类人:一是刚学完 Vue 基础、正准备接触工程化的同学,二是已经在写业务但总觉得项目结构乱的开发者。
1. 模块化到底解决什么问题
1.1 从“一个文件写到死”说起
很多新手项目长这样:src 下就两个文件,App.vue 和 main.js,所有页面组件全塞到一起,一个 methods 写了 30 个函数,data 里堆了 20 个字段。表面上看 Vue 的响应式仍然正常工作,页面也能跑,但改起来非常痛苦:想找一个 click 事件对应的函数,要在 400 行 template 和 300 行 script 之间来回滚;想复用某段逻辑,只能复制粘贴,改也得改两处。
这就是没有模块化的问题。模块化本质上是在回答“代码该怎么放”这个问题。它不改变 Vue 的核心机制,但改变了组织代码的方式。组件化是 Vue 自带的能力,模块化是你在工程层面自己定义的规则,两者并不冲突:组件负责把界面拆成块,模块负责把逻辑和资源拆成块。
1.2 Vue 模块化的三个层面
我习惯把 Vue 模块化拆成三个层面:组件模块化、逻辑模块化、功能模块化。
组件模块化最简单,就是把大的页面拆成一个个小的 Vue 文件,比如把用户列表拆成 UserTable、UserFilter、UserPagination。难点往往不在“拆”,而在定接口:props 和 emit 怎么设计,组件间是父子通信还是全局状态,这决定了后续好不好维护。初始设计欠考虑的话,拆完反而会让通信链路变得很长,改一个字段要穿过三层组件。
逻辑模块化指的是把不依赖视图的那部分代码抽出来,比如接口请求、格式转换、权限判断,放到单独的.js或.ts文件中。很多 Vue 项目最大的隐患就是业务逻辑全写在 methods 里,导致组件行数失控。我的经验是:组件里只留“响应式状态 + 事件绑定 + 视图逻辑”,所有能独立测试的功能都应该抽到外部模块,这样既方便单测,也方便跨组件复用。
功能模块化则是站在路由、状态管理、请求封装这些维度,把系统按业务领域切分。比如用户模块可能包含 user.routes.js、user.store.js、user.api.js、views/user 目录、components/user 目录,新同事接手时,按目录就能快速定位到相关代码,改一个模块不需要把整个项目翻一遍。
1.3 构建工具为什么会参与进来
可能会有人问:我直接用浏览器 script 标签引入 Vue,不也能做模块化吗?答案是能做,但非常不方便。浏览器原生虽然支持 ES Module,但你没法方便地处理单文件组件、Sass、TypeScript 这些资源,也没法在生产环境做打包压缩、代码分割。
构建工具解决的就是这些事:它把 Vue 单文件组件编译成 JS,把各种资源当作模块来处理,让整个项目可以像搭积木一样组织。现在的主流是 Vite,底层基于 ESBuild 做依赖预构建,开发环境下利用原生 ES Module 做按需加载,启动速度和热更新都比老一代工具快得多。Vue CLI 基于 Webpack,虽然生态成熟,但我现在只在老项目里用它,新项目建议直接上 Vite。
2. 从零构建一个 Vue 模块化项目
2.1 脚手架选型:Vite 还是 Vue CLI
先给结论:新项目直接选 Vite,老项目如果已经被 Vue CLI 的约定锁死了,可以继续用一段时间,但升级计划要尽早排。Vite 5 配合 Vue 3 的开发体验已经很稳定,尤其是依赖预构建和 HMR 的速度,和 Webpack 时代完全不是一个量级。
创建项目的命令如下:
npm create vue@latest这个命令对应的是官方脚手架 create-vue,交互式提问会一路询问是否需要 TypeScript、Vue Router、Pinia、ESLint 等,按需勾选即可。我常用的勾选组合是:TypeScript 看团队情况,如果打算引入就选上;Vue Router 和 Pinia 基本必选;ESLint 选上,Prettier 选上;其余可以先不选。
如果想快速验证一个想法,可以用更轻量的命令:
npm create vite@latest区别在于 create-vue 额外帮你生成了 router、store、eslint 等一套完整的工程化配置,create-vite 只生成最基础的 Vite + Vue 骨架。我建议正式项目用 create-vue,它省下的配置时间远比多出来的模板文件值钱。自己从头手写路由、状态管理、ESLint 这些,对新手来说容易在细节上踩坑。
2.2 标准目录结构设计
项目创建完成后,我会立刻把 src 目录调整成下面这样:
src/ ├── api/ # 接口请求模块 │ └── user.js ├── assets/ # 静态资源 ├── components/ # 公共组件 │ └── common/ ├── router/ # 路由配置 │ ├── index.js │ └── routes/ ├── stores/ # 状态管理 │ └── user.js ├── styles/ # 全局样式 ├── utils/ # 工具函数 ├── views/ # 页面组件 │ └── user/ └── main.js这个结构不是绝对标准,但它是按“职责”而不是按“文件类型”分层的。api 目录统一管理所有接口调用,stores 管理状态,views 只放页面级组件。很多人喜欢把接口请求直接写在页面组件里,前期确实省事,后期一旦多个页面都要调用同一个接口,就只能复制粘贴,接口变更时改到怀疑人生。
关键原则是:components 里只放可复用组件,views 里的页面组件只是做组合。我见过不少项目把 components 当成收纳盒,什么东西都往里塞,最后组件数量上百,真正能复用的没几个。与其这样,不如让模块边界从一开始就清晰,组件复用自然会出现。
2.3 配置路径别名与基础规范
紧接着第一件事,就是把路径别名配置好。Vite 的配置文件是 vite.config.js,我在里面加一段 resolve.alias:
import { defineConfig } from 'vite' import vue from '@vitejs/plugin-vue' import path from 'node:path' export default defineConfig({ plugins: [vue()], resolve: { alias: { '@': path.resolve(__dirname, 'src') } } })这里 @ 指向 src 目录,之后所有 import 都写成import UserApi from '@/api/user',而不是一长串../../..。路径别名的好处不仅是代码美观,更重要的是移动文件时不用改一长串相对路径引用,重构成本会低很多。
不过这里有个细节容易踩坑。如果项目 package.json 里没有 type: module,配置文件走 CommonJS,__dirname 可以用;但一旦项目切换成 ESM,也就是 package.json 里声明了 type: module,__dirname 就不存在了,直接使用会报错。这时候要用 import.meta.url 配合 new URL 来定位目录:
import { fileURLToPath, URL } from 'node:url' export default defineConfig({ resolve: { alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) } } })这个写法在 Vitest 和 Vite 配置文件需要被复用时也更稳,避免跑测试时出现路径解析偏差。顺便提醒一句,JS 配置文件里用了 node:path 或 node:url,记得确认项目里装了 @types/node,否则在 TS 场景下会报类型错误。
3. 核心开发工具链的配置与使用
3.1 ESLint + Prettier:让模块化代码风格统一
模块化的好处只有在多人协作时才能被放大,而多人协作最容易产生的冲突就是代码风格差异。有人用 4 个空格缩进,有人用 2 个;有人加分号,有人不加。ESLint 负责检查规则,Prettier 负责格式化,两者配合基本能解决风格问题。
用 create-vue 初始化项目时,ESLint 和 Prettier 会自动装好。我观察过不少项目,装了 ESLint 但没有真正启用规则,形同虚设。要让工具链发挥作用,至少要在编辑器里开启“保存时自动修复”,VSCode 的 settings.json 里加这一段:
{ "editor.codeActionsOnSave": { "source.fixAll.eslint": true } }Prettier 的配置也很简单,在 .prettierrc.json 里写:
{ "semi": false, "singleQuote": true, "printWidth": 100, "trailingComma": "none" }我这里故意不用分号,是因为我更习惯无分号风格,而且统一之后 git diff 里能看到更干净的变更。这不是针对分号本身的争论,团队内一旦定了规则就要保持一致,否则整个代码库会充满格式冲突,ESLint 规则也会反复报警告。格式问题虽然不影响运行,但会影响 code review 的效率。
3.2 路由拆分的模块化实践
路由不要全写在一个文件里。小项目的路由表在一个文件里还能接受,但页面一旦超过 10 个,你会觉得路由文件越来越难维护。我把路由按业务模块拆,比如 user 模块单独放一个文件:
// router/routes/user.js export default { path: '/user', component: () => import('@/views/user/UserLayout.vue'), children: [ { path: '', name: 'UserList', component: () => import('@/views/user/UserList.vue') }, { path: 'detail/:id', name: 'UserDetail', component: () => import('@/views/user/UserDetail.vue') } ] }然后在主路由文件里合并:
import userRoutes from './routes/user' // 其他模块路由 const routes = [ { path: '/', redirect: '/user' }, ...userRoutes ]这样做的直接好处是:不需要在几百行的 route 数组里翻找 user 相关的路由。删除模块时,直接删掉 routes/user.js,再清理 index.js 里的一行引用,副作用很小。动态 import 实现路由懒加载不是可选项,而是建议默认开启,它会把每个路由页面单独打包成 chunk,用户访问到哪个页面才加载对应 JS,这是首屏性能提升成本最低的手段之一。
3.3 状态管理的模块化切分
Vuex 和 Pinia 都可以做模块化,我更推荐 Pinia,尤其 Vue 3 项目里基本是标配。Pinia 的模块化思路很直观:每个 store 就是一个独立模块,互相引用时直接调用,类型提示完整,也没有 mutations 那套样板代码。
我通常把 store 按业务领域划分,比如用户模块:
// stores/user.js import { defineStore } from 'pinia' export const useUserStore = defineStore('user', { state: () => ({ profile: null, token: '' }), actions: { async fetchProfile() { const res = await userApi.getProfile() this.profile = res.data return res.data } } })组件里调用时,用 storeToRefs 保持解构后的响应性:
import { storeToRefs } from 'pinia' import { useUserStore } from '@/stores/user' const userStore = useUserStore() const { profile } = storeToRefs(userStore)这里最关键的一点是:不要在 store 里堆积太多个性化的组件状态。一个 store 越写越大,到最后无非是把组件里的混乱搬到了 store 里而已。遇到这种情况就继续拆,比如 user 模块可以拆成 userProfile、userPermission、userSettings 几个 store。模块化的粒度永远跟着业务边界走,而不是按“技术职责”走。
3.4 请求层封装与 API 模块化
请求层是我认为最能体现模块化价值的地方。axios 是默认选择,我会在api/request.js里做统一封装,核心是拦截器的设计:
import axios from 'axios' import { useUserStore } from '@/stores/user' const service = axios.create({ baseURL: import.meta.env.VITE_API_BASE_URL, timeout: 10000 }) service.interceptors.request.use((config) => { const token = useUserStore().token if (token) { config.headers.Authorization = `Bearer ${token}` } return config }) service.interceptors.response.use( (response) => { return response.data }, (error) => { if (error.response && error.response.status === 401) { // 统一处理登录过期,比如跳转登录页 } return Promise.reject(error) } )然后api/user.js里按接口模块写函数:
export const getUserList = (params) => service.get('/user/list', { params }) export const getUserDetail = (id) => service.get(`/user/detail/${id}`)为什么这样封装?因为一旦后端接口的返回结构需要统一处理,比如 data 字段包了一层、或者需要在响应里做数据转换,你只需要改拦截器,而不是逐个改页面里的请求代码。模块化最大的价值就在这里:变更被隔离到最小范围,不会一个接口格式变动就炸掉十几个页面。
4. 构建与打包的模块化进阶
4.1 Vite 配置拆分的技巧
随着项目越来越大,vite.config.js 会逐渐膨胀。我一般会把它拆成多个文件,按环境区分,而不是写一大坨配置。目录结构大约是这样:
config/ ├── vite.base.js # 公共配置 ├── vite.dev.js # 开发环境配置 ├── vite.prod.js # 生产环境配置 └── vite.analysis.js # 产物分析配置然后在 vite.config.js 里按 mode 加载对应文件:
import { defineConfig, mergeConfig } from 'vite' import baseConfig from './config/vite.base' export default defineConfig(({ mode }) => { if (mode === 'development') { return mergeConfig(baseConfig, viteDevConfig) } return mergeConfig(baseConfig, viteProdConfig) })mergeConfig 是 Vite 自带的方法,专门用来合并配置对象,比手写对象展开更稳,尤其是 plugins 数组和 resolve.alias 这类复杂结构,手写展开很容易踩到覆盖顺序的坑。配置拆分的价值在于,生产环境特有的 minify、sourcemap、CDN 域名替换、gzip 压缩等设置不会混在开发配置里,也就不会出现“开发环境构建出压缩代码导致报错不好定位”的诡异问题。
4.2 模块懒加载与分包策略
路由懒加载解决了首屏的问题,但还不够,因为公共依赖如果全打在一起,首次访问仍然要下载一个很大的 JS 文件。这里需要理解浏览器缓存逻辑:vendor 包如果长期稳定不变,浏览器会一直缓存它,后续页面访问体验会好很多。因此要把不常变的依赖单独拆出去。
一个比较稳的手动分包配置:
export default defineConfig({ build: { rollupOptions: { output: { manualChunks: { vendor: ['vue', 'vue-router', 'pinia'], icons: ['@element-plus/icons-vue'], utils: ['axios', 'lodash-es'] } } } } })注意别把包拆得太碎。手动分包的目标是平衡“缓存复用”和“浏览器并发请求数”这两个指标。如果拆成几十个几十 KB 的小包,首屏会同时发起大量请求,弱网环境下反而比一个大包更慢。我一般只拆 4 到 8 个 chunk,把 vue 全家桶、UI 组件库、工具库各放一层,剩下的交给 Rollup 的默认逻辑处理。
4.3 多环境变量与构建流程
日常开发通常有 dev、test、prod 三个环境。Vite 用环境变量文件管理:
.env # 所有环境通用 .env.development # 开发环境 .env.test # 测试环境 .env.production # 生产环境文件内容:
VITE_API_BASE_URL=https://api.example.com VITE_APP_TITLE=某管理系统注意:只有 VITE_ 前缀的变量会被暴露给客户端,其他前缀不会被打包进代码里。很多人第一次接触时,在 .env 里放了一个不带 VITE_ 的变量,然后在代码里怎么都取不到,排查半天才发现是前缀问题。这一点要特别留意。
构建命令也需要区分:
{ "scripts": { "dev": "vite", "build": "vite build", "build:test": "vite build --mode test" } }--mode test 必须对应 .env.test 文件的名字,这样测试环境的 VITE_API_BASE_URL 和生产环境就区分开了,不会联调时请求打到线上地址去。另外还容易忽略一点:环境变量文件如果被加进了 .gitignore,同事拉新代码后构建出来的包接口地址就会不对。不要因为文件名叫 .env 就想当然认为它是敏感文件,带 VITE_ 前缀的变量最终都会被浏览器拿到,谈不上保密,放心提交就好。
5. 日常开发中的高频问题与排查
5.1 环境变量读取不到
开发环境下import.meta.env.VITE_XXX返回 undefined,我遇到最多的原因有两个。第一,变量名没有 VITE_ 前缀,Vite 默认不会暴露非白名单变量给客户端。第二,修改 .env 文件后没有重启 dev server。Vite 的环境变量文件修改,一般需要重启进程才能生效,热更新不会自动帮你刷新到新变量,这不是 Bug,是设计如此。
还有一种情况:构建机上设置了系统级的环境变量,但名字和 .env 里定义的不一致,导致某个环境专属变量覆盖了通用变量。建议在代码里加一个启动时的简单校验:
if (!import.meta.env.VITE_API_BASE_URL) { throw new Error('VITE_API_BASE_URL 未配置,请检查环境变量文件') }这种明显报错,比运行时请求 404 再慢慢排查要高效得多。
5.2 缓存导致的开发期异常
Vite 开发模式下存在缓存,表现为:修改代码后页面没反应,或者控制台出现莫名其妙的报错。常见来源是 node_modules/.vite/deps 目录。如果依赖版本刚升级过,缓存没有刷新,就会出现“模块找不到导出”之类的怪异错误。解决办法是把 node_modules/.vite 删掉,然后重启 dev server。
还有一个隐蔽的缓存问题是浏览器强缓存。如果页面引用了带 hashed 文件名的 JS,修改后文件名会变,浏览器应该会拉新文件,但开发服务器返回的 HTML 有时被代理层缓存了,导致浏览器还在请求旧的 JS 文件。遇到“改了代码不生效”的情况,先开无痕窗口验证,再逐层排查代理配置。
5.3 ESM 与 CJS 冲突
随着依赖库版本迭代,ESM 和 CJS 的互操作问题越来越常见。比如某个 npm 包只导出 CJS,而你的项目开启了 type: module,某些构建场景下 Vite 会给出 warning,甚至直接报错。我就在一个模拟项目里遇到过 eslint-plugin 版本冲突,导致 ESLint 直接无法启动。
遇到这类问题,先看报错信息里指出的模块名。如果是主包,优先考虑换版本,或者找官方支持 ESM 的替代品;如果是构建期才有问题,可以在 optimizeDeps.include 里显式声明依赖预构建:
export default defineConfig({ optimizeDeps: { include: ['package-name'] } })这招不一定能包治百病,但确实帮我解决过两次类似报错。如果还不行,就去 Vite 官方 issue 里搜一下报错关键词,很多坑已经有人填过。
5.4 组件注册时的模块循环引用
模块之间互相引入,有时会产生循环引用。最典型的是 store 之间互相依赖,或者工具函数互相调用。业务代码里出现循环引用,最常见的表现是某个组件或 store 报 “Cannot access before initialization”。我之前在一个项目里,A 组件引用了 B,B 又引用 A,控制台报错堆栈让人一头雾水。
排查方法很简单:先看报错堆栈里的入口,再检查目录结构里是否存在闭环。前端模块化没有自动解决循环引用的机制,设计和开发阶段就要尽量避免。如果确实需要互相引用,可以在函数内部延迟引入,而不是在文件顶部 import,这是 ES Module 动态 import 的一种实际应用,但应该谨慎使用,最好还是从设计层面打破环形依赖。
5.5 自动导入工具的坑
再补充一个我自己踩过的坑。项目里如果用了 unplugin-auto-import 和 unplugin-vue-components,它确实能让 ref、computed、reactive 以及组件库的按需导入变得非常方便,不再需要手写一长串 import 语句。但它会在编译期生成 auto-imports.d.ts 和 components.d.ts 这两个类型声明文件。
这两个文件必须提交到代码仓库里去,否则在 CI 环境跑类型检查时报错会非常让人费解。我第一次遇到时,本地一切正常,push 之后 CI 直接失败,查了很久才发现是 .d.ts 文件被 .gitignore 拦住了。这种文件是构建副产品,但它是 TS 类型检查需要的一部分,不属于应该忽略的产物文件。
6. 一点个人的工程化心得
写到这里,我想把几个项目里总结出的最实在的三条经验分享出来。
第一,模块化不是越细越好。我见过有人把一个组件拆到每个文件只剩三五行代码,结果为了跨文件传参写了大量 props 和 emit,可读性反而下降。合理的模块边界是:你能在 5 秒内说清这个文件是干什么的,并且它不会因为页面需求微调而被迫频繁修改。
第二,项目初期一定要花足够时间设计目录结构和命名规范。前期多花半小时,之后能省下几十次“这个文件应该放在哪”的纠结。团队可以先用一个迭代严格审查,让约定变成肌肉记忆,后面 code review 就能把精力放在逻辑上,而不是放在“文件放错了位置”这种问题上。
第三,自动导入工具确实能提升开发幸福感。配合 unplugin-auto-import 和 unplugin-vue-components,ref、computed、reactive 以及组件库的全局按需注册都不需要手动写 import 了。配置起来不复杂,但一定要注意我前面提过的 .d.ts 文件提交问题。
我现在做 Vue 项目基本都会落到这个范式:Vite + Vue 3 + Pinia + Vue Router,目录按业务模块拆,接口统一封装,规则交给 ESLint 和 Prettier,构建配置按环境拆分。这套组合不是最新潮的,但足够稳定,也足够让新人在半天内理解项目结构。与其追求一次到位的“完美模块化”,我更相信持续小步迭代,让代码库在一次次重构里慢慢长成大家都愿意维护的样子。