前言
在 收藏向 | 2026 前端开发知识体系:React、Vue、Next.js、Nuxt 全解析 中,我们把前端技术栈拆成了 12 个模块,解决了「有哪些、各管什么」的问题。
但真正动手做项目时,难点往往不是「知不知道 Vite、Pinia」,而是「先装什么、目录怎么分、规范怎么落地、请求怎么封装」。
本篇就沿着第 1 篇的脉络,用 Vue 3 技术栈(Vite + TypeScript + Pinia + Vue Router + Element Plus)串一条完整的落地链路。
读完你应该能独立搭出一个能开发、能规范、能构建的项目骨架,而不是停留在会用某个 API。
本篇技术栈选型如下:
| 关注点 | 选型 | 理由 |
|---|---|---|
| 构建工具 | Vite | 冷启动快、配置轻,Vue 官方推荐 |
| 语言 | TypeScript | 大型项目可维护性与类型提示 |
| 状态管理 | Pinia | Vue 官方推荐,组合式 API 友好 |
| 路由 | Vue Router | 官方路由,支持嵌套与守卫 |
| UI 组件库 | Element Plus | 中后台常用,按需引入 |
| HTTP 客户端 | Axios | 拦截器机制成熟,统一处理 token 与错误 |
| 代码规范 | ESLint + Prettier + Husky | 统一风格、提交前检查 |
工程化搭建全景思维导图
先用一张思维导图展示本篇的完整搭建链路,你可以先扫一眼这张图,建立对工程化脚手架的整体认知:
一、项目初始化
先用 pnpm 创建 Vite + Vue + TS 项目。pnpm 的硬链接机制能节省磁盘空间,也方便后续 monorepo。
pnpmcreate vite my-app--templatevue-tscdmy-apppnpminstallpnpmdevvue-ts模板已经内置了 Vite、Vue 3、TypeScript 与@vitejs/plugin-vue,开箱即可运行。
接着安装本篇所需的全部依赖:
# 运行时依赖pnpmaddvue-router pinia axios element-plus# 开发依赖pnpmadd-D@types/node unplugin-vue-components unplugin-auto-importpnpmadd-Deslint prettier husky lint-stagedpnpmadd-D@eslint/js typescript-eslint eslint-plugin-vue eslint-plugin-prettier eslint-config-prettier说明:ESLint 10 已彻底移除
.eslintrc.*legacy 配置,必须使用 flat config(eslint.config.mjs),因此需要额外安装@eslint/js和typescript-eslint。
二、目录结构
一个清晰的分层目录是长期维护的基础。建议按职责划分,而不是按类型堆砌。
src/ ── api/ # 接口请求封装 ── assets/ # 静态资源 ├── components/ # 通用组件 ├── composables/ # 组合式函数(useXxx) ├── router/ # 路由配置 ├── stores/ # Pinia 状态 ├── utils/ # 工具函数(request 等) ├── views/ # 页面级组件 ├── App.vue ├── env.d.ts # 环境变量类型声明 ├── main.ts └── style.css # 全局样式顺手配置路径别名,避免满屏的../../:
// vite.config.tsimport{fileURLToPath,URL}from'node:url'import{defineConfig}from'vite'importvuefrom'@vitejs/plugin-vue'exportdefaultdefineConfig({plugins:[vue()],resolve:{alias:{'@':fileURLToPath(newURL('./src',import.meta.url)),},},})配置别名后记得在tsconfig.app.json中同步。注意 TypeScript 6 已废弃baseUrl,paths必须使用相对路径:
{"extends":"@vue/tsconfig/tsconfig.dom.json","compilerOptions":{"paths":{"@/*":["./src/*"]}},"include":["src/env.d.ts","src/**/*.ts","src/**/*.vue"]}踩坑提示:旧教程中常见的
"baseUrl": "."写法在 TypeScript 6 中会报TS5101废弃警告,TS 7 将彻底移除。去掉
baseUrl后,paths的值改为"./src/*"即可,TypeScript 会相对于 tsconfig 文件位置解析。
三、TypeScript 类型声明
Vite 通过import.meta.env注入环境变量,但 TypeScript 不知道这些变量的存在,需要手动声明类型。
新建src/env.d.ts:
/// <reference types="vite/client" />interfaceImportMetaEnv{readonlyVITE_APP_TITLE:stringreadonlyVITE_API_BASE_URL:string}interfaceImportMeta{readonlyenv:ImportMetaEnv}这样在代码中使用import.meta.env.VITE_API_BASE_URL时就有完整的类型提示,写错变量名也会在编译期报错。
四、代码规范落地
规范靠自觉是守不住的,要靠工具强制。ESLint 负责检查、Prettier 负责格式化、Husky + lint-staged 在提交前自动跑一遍。
4.1 Prettier 配置
// .prettierrc.json{"semi":false,"singleQuote":true,"tabWidth":2,"trailingComma":"none"}4.2 ESLint 10 Flat Config
ESLint 10 不再支持.eslintrc.*,必须使用eslint.config.mjs:
// eslint.config.mjsimportjsfrom'@eslint/js'importtsfrom'typescript-eslint'importvuefrom'eslint-plugin-vue'importprettierfrom'eslint-plugin-prettier/recommended'exportdefault[{ignores:['dist/**','node_modules/**','src/auto-imports.d.ts','src/components.d.ts']},js.configs.recommended,...ts.configs.recommended,...vue.configs['flat/recommended'],prettier,{files:['**/*.vue'],languageOptions:{parserOptions:{parser:ts.parser}}},{rules:{'@typescript-eslint/no-explicit-any':'off','@typescript-eslint/no-unused-vars':['error',{argsIgnorePattern:'^_'}],'vue/multi-word-component-names':'off'}}]踩坑提示:旧版
--ext .vue,.ts参数在 flat config 中已不支持,package.json的 lint 脚本应改为eslint . --fix。
4.3 Husky + lint-staged
pnpmexechusky initecho"npx lint-staged">.husky/pre-commit在package.json中声明提交前自动格式化的范围:
{"lint-staged":{"*.{js,ts,vue}":["prettier --write","eslint --fix"]}}这样任何人git commit时,改动的文件都会被自动检查与格式化,脏代码进不了仓库。
五、路由搭建
安装并配置 Vue Router,用createWebHistory启用干净的 URL。
pnpmaddvue-router路由配置支持嵌套与懒加载,路由组件按需引入可减小首屏体积:
// router/index.tsimport{createRouter,createWebHistory}from'vue-router'constrouter=createRouter({history:createWebHistory(import.meta.env.BASE_URL),routes:[{path:'/login',name:'Login',component:()=>import('@/views/Login.vue')},{path:'/',component:()=>import('@/views/Layout.vue'),children:[{path:'',name:'Home',component:()=>import('@/views/Home.vue')}]}]})exportdefaultrouter配合全局前置守卫做登录鉴权,这是路由里最实用的能力:
// router/index.ts(追加)router.beforeEach((to)=>{consttoken=localStorage.getItem('token')if(to.meta.requiresAuth&&!token){return{path:'/login'}}})说明:
import.meta.env.BASE_URL是 Vite 内置变量,代表应用的部署基础路径,默认值为/。部署到子路径时(如
https://example.com/app/),只需在 Vite 配置中设置base: '/app/',路由会自动适配。
六、状态管理(Pinia)
安装 Pinia 并在入口注册:
pnpmaddpinia// main.tsimport{createApp}from'vue'import{createPinia}from'pinia'importElementPlusfrom'element-plus'import'element-plus/dist/index.css'importrouterfrom'./router'importAppfrom'./App.vue'import'./style.css'constapp=createApp(App)app.use(createPinia())app.use(router)app.use(ElementPlus)app.mount('#app')注意:Element Plus 的 CSS 必须在
main.ts中显式引入,否则组件样式不会生效。
用组合式写法定义 Store,类型推导更友好:
// stores/user.tsimport{defineStore}from'pinia'import{ref}from'vue'import{userApi}from'@/api/user'exportconstuseUserStore=defineStore('user',()=>{consttoken=ref(localStorage.getItem('token')||'')constuserInfo=ref<{name:string}|null>(null)asyncfunctionlogin(username:string,password:string){constres=awaituserApi.login({username,password})token.value=res.token localStorage.setItem('token',res.token)}asyncfunctionfetchUserInfo(){userInfo.value=awaituserApi.getUserInfo()}functionlogout(){token.value=''userInfo.value=nulllocalStorage.removeItem('token')}return{token,userInfo,login,fetchUserInfo,logout}})组件中直接消费:
<script setup lang="ts"> import { useRouter } from 'vue-router' import { useUserStore } from '@/stores/user' const router = useRouter() const userStore = useUserStore() function handleLogout() { userStore.logout() router.replace('/login') } </script> <template> <div> <h1>首页</h1> <el-button @click="handleLogout">退出登录</el-button> </div> </template>七、数据请求封装
请求层的价值在于统一处理 baseURL、token、错误与登录失效,而不是每处都手写 fetch。
pnpmaddaxios// utils/request.tsimportaxios,{typeAxiosInstance,typeAxiosRequestConfig,typeAxiosResponse}from'axios'import{ElMessage}from'element-plus'importrouterfrom'@/router'constservice:AxiosInstance=axios.create({baseURL:import.meta.env.VITE_API_BASE_URL,timeout:10000})// 请求拦截:统一带上 tokenservice.interceptors.request.use((config)=>{consttoken=localStorage.getItem('token')if(token&&config.headers){config.headers.Authorization=`Bearer${token}`}returnconfig})// 响应拦截:统一处理错误与登录失效service.interceptors.response.use((response:AxiosResponse)=>{returnresponse.data},(error)=>{if(error.response?.status===401){localStorage.removeItem('token')router.replace('/login')}ElMessage.error(error.message||'请求失败')returnPromise.reject(error)})exportfunctionget<T=any>(url:string,config?:AxiosRequestConfig):Promise<T>{returnservice.get(url,config)asPromise<T>}exportfunctionpost<T=any>(url:string,data?:any,config?:AxiosRequestConfig):Promise<T>{returnservice.post(url,data,config)asPromise<T>}踩坑提示 1:响应拦截器返回了
response.data,但 TypeScript 认为service.get()返回的是Promise<AxiosResponse<T>>,拦截器的类型变换不会被类型系统自动感知。需要在get/post函数中加as Promise<T>断言。踩坑提示 2:不要在请求拦截器里调用
useUserStore()。Axios 拦截器在模块加载时就会注册,而 Pinia Store 需要等app.use(createPinia())之后才能使用,在拦截器里调用会导致「Pinia 未激活」的运行时错误。直接从localStorage读取 token 是最稳妥的做法。
业务接口只关心「调哪个接口、拿什么数据」:
// api/user.tsimport{get,post}from'@/utils/request'exportinterfaceLoginParams{username:stringpassword:string}exportinterfaceUserInfo{id:numbername:stringrole:string}exportconstuserApi={login:(data:LoginParams)=>post<{token:string}>('/user/login',data),getUserInfo:()=>get<UserInfo>('/user/info')}八、UI 方案与按需引入
Element Plus 配合unplugin-vue-components实现按需自动引入,省去手动 import 组件:
pnpmaddelement-pluspnpmadd-Dunplugin-vue-components unplugin-auto-import// vite.config.tsimport{fileURLToPath,URL}from'node:url'import{defineConfig}from'vite'importvuefrom'@vitejs/plugin-vue'importAutoImportfrom'unplugin-auto-import/vite'importComponentsfrom'unplugin-vue-components/vite'import{ElementPlusResolver}from'unplugin-vue-components/resolvers'exportdefaultdefineConfig({plugins:[vue(),AutoImport({resolvers:[ElementPlusResolver()],imports:['vue','vue-router','pinia'],dts:'src/auto-imports.d.ts'}),Components({resolvers:[ElementPlusResolver()],dts:'src/components.d.ts'})],resolve:{alias:{'@':fileURLToPath(newURL('./src',import.meta.url))}},server:{proxy:{'/api':{target:'http://localhost:3000',changeOrigin:true}}}})之后模板里可直接使用<el-table>、<el-button>,无需手动注册:
<template> <el-table :data="users"> <el-table-column prop="name" label="姓名" /> <el-table-column prop="email" label="邮箱" /> </el-table> </template> <script setup lang="ts"> import { ref } from 'vue' import { userApi } from '@/api/user' const users = ref<UserInfo[]>([]) userApi.getUserList({ page: 1, size: 10 }).then((res) => { users.value = res.list }) </script>说明:
unplugin-auto-import的imports: ['vue', 'vue-router', 'pinia']配置会自动把ref、computed、useRouter、defineStore等 API 注入为全局可用,无需在每个文件中手动 import。生成的类型声明文件
src/auto-imports.d.ts和src/components.d.ts应加入.gitignore或提交到仓库(推荐提交,保证 CI 环境也能获得类型提示)。
九、环境变量与开发代理
不同环境的接口地址、功能开关用环境变量区分,避免把测试地址带到生产。
# .env.developmentVITE_APP_TITLE=前端项目实战手册VITE_API_BASE_URL=/api# .env.productionVITE_APP_TITLE=前端项目实战手册VITE_API_BASE_URL=https://api.example.com注意:开发环境中
VITE_API_BASE_URL设为/api是相对路径,需要配合 Vite 的server.proxy把请求转发到后端服务(见第八节 vite.config.ts 中的server.proxy配置)。生产环境则直接填写完整的后端地址。
代码里通过import.meta.env.VITE_API_BASE_URL读取(见第七节)。构建时 Vite 会按模式注入对应变量:
pnpmbuildpnpmpreviewpnpm preview用生产构建产物本地预览,是上线前验证构建结果是否正常的最后一道关口。
本篇小结
到这里,一个 Vue 3 项目的工程骨架已经成型:
- 用 pnpm + Vite 完成初始化与别名配置
- 用
@vue/tsconfig+ 相对路径paths配置 TypeScript(兼容 TS 6) - 用
env.d.ts声明环境变量类型 - 用 ESLint 10 flat config + Husky 强制代码规范
- 用 Vue Router 搭建路由与鉴权守卫
- 用 Pinia 组合式 API 管理全局状态
- 用 Axios 拦截器统一封装请求(避免在拦截器中调用 Store)
- 用 Element Plus + unplugin 按需引入提升开发效率
- 用环境变量 + Vite proxy 隔离多环境配置
这套骨架解决的是「能不能持续开发」的问题。但代码写完只是开始,怎么把它安全、稳定地发到线上,才是很多前端团队的短板,这里挖个坑,下一次再说这个问题。