新项目启动,前端负责人跑过来问我:“技术栈用啥?React还是Vue?要不要上TypeScript?工程化你帮我把把关。”这个场景我经历过太多次了。很多人觉得前端框架搭建就是从脚手架里挑一个模板,跑起来就完事。但真正到了项目中期,组件乱放、请求层没人管、权限逻辑散落在各个页面、构建慢到让人想摸鱼——这些问题的根源,往往都能追溯到框架搭建那几天的决策。
这篇东西,就是我基于多次从零搭建项目前端框架的经验,完整复盘一遍从技术选型、工程初始化、代码规范、请求层设计到权限落地的全过程。适合刚接手新项目的前端负责人、准备脱离纯业务的开发同学,以及想系统化理解前端工程化的人。内容不浮夸,每一步都有取舍理由,也附上了我踩过的坑。
1. 技术选型:别追新,先看团队和业务
1.1 框架选择的底层逻辑
选前端框架这件事,我见过太多人拍脑袋。有人因为GitHub star多就选,有人因为某篇爆款文章就换,最后项目做了一半发现生态跟不上、招人费劲、团队成员学不动,苦不堪言。
我选框架的核心逻辑只有四个维度:团队熟悉度、业务场景匹配度、生态成熟度、长期维护成本。这四个维度按权重排序,团队熟悉度往往要排第一。再好的框架,团队没人写过,效率必然打折。业务场景决定了你需要的框架特性——如果你的业务是重度交互的中后台系统,Vue的响应式模型和React的Hooks都能胜任,但如果你要做一个数据可视化大屏或实时协作工具,React生态里可选的库会更丰富。生态成熟度解决的是“遇到问题能不能找到答案”的问题,这对开发效率影响极大。
技术选型不是技术问题,是管理问题。我后来做选型时都会开一次团队会议,让核心成员各自调研、投票、陈述理由。这个过程既统一了认知,也防止了“一个人拍板,全团队躺平”的情况。
1.2 Vue还是React:不要迷信“最终答案”
React和Vue之争,社区里吵了很多年,到今天已经没必要站队了。我的经验是:代码写得烂的人,用什么框架都能写出烂代码;框架本身的差异,远没有团队规范和工程化水平带来的差异大。
Vue的优势是上手曲线平滑,模板语法直白,适合大多数中后台场景,而且Vue 3的组合式API在逻辑复用方面补齐了Options API的短板。React的优势是函数式思维彻底、生态大、灵活度高,但它的灵活性也是一把双刃剑——同一个项目里,一百个人可能写出一百种风格,如果没有强力的架构约束,后期维护成本会很高。
如果你的项目是大型中后台管理系统,且团队以Vue为主,我建议直接选Vue 3 + TypeScript + Vite。如果你的项目面向C端重交互场景,或者团队React基础更扎实,那选Next.js或React + Vite的组合也没问题。重点不是哪个好,而是你选定之后能否建立一套可执行、可持续的规范体系。框架搭建的真正分水岭,从来不是框架本身,而是你在这套框架上长出来的工程化能力。
1.3 UI组件库与“前端好看的框架”这个后端都爱提的需求
很多非技术同事会跟我提“前端好看的框架”,翻译过来就是“页面必须好看、拿得出手”。这一点很现实——中后台项目的体验,直接决定了客户对产品的第一印象。UI组件库的选型,是这里面的关键牌。
我的惯例是:
| 场景 | 推荐方案 | 理由 |
|---|---|---|
| 中后台管理系统(Vue技术栈) | Element Plus + 定制主题 | 成熟稳定、组件全、文档好 |
| 中后台管理系统(React技术栈) | Ant Design 5.x | 设计语言统一、业务组件生态好 |
| 极简风格 | Naive UI / Arco Design | 设计更现代、颜值在线 |
| C端落地页 | Tailwind CSS + 无头组件库 | 可以做出强设计感且不千篇一律 |
“好看的框架”背后另一个常被忽略的点是设计规范。你可以把组件库的主题变量统一管理,传入设计团队的色彩规范、圆角规范、间距规范,让整个项目用一套令牌驱动样式,这样出来的页面才不是“组件库默认脸”。如果团队里有设计资源,这一步非常值得花时间;没有的话,直接把知名开源后台模板的主题变量拿来改,也比裸用组件库强得多。
提示:选组件库前,先确认它是否支持你的构建工具和框架版本。Element Plus在Vue 3配合Vite下体验很好,但老版本Element UI是配Vue 2的,这个细节经常有人搞混。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 工程化初始化:把Vite配置和目录设计一次想清楚
2.1 为什么我坚定切换到Vite
早期用Webpack搭项目,启动慢、配置繁琐,最难受的是改个配置还要各种查文档。后来全面切到Vite,开发体验是质的飞跃。Vite基于原生ES Module,本地开发按需编译,项目大了也不会卡成PPT;底层用Rollup做生产构建,产物质量有保证。
Vite对Vue 3和React都有官方模板,执行一条命令就能跑起来。但我强烈建议不要直接拿默认模板就开干,必须做几件事:补TypeScript严格配置、加路径别名、配代理、设置打包分析。
初始化的命令很简单:
bash复制# Vue 3 + TypeScript
npm create vite@latest my-project -- --template vue-ts
# React + TypeScript
npm create vite@latest my-project -- --template react-ts
cd my-project
npm install
安装完之后,项目能跑,但这只是地基的第一步。你还得手动加依赖、改配置,一点也偷不了懒。
2.2 路径别名的意义不止是少打几个字
很多初学者不理解为什么要配路径别名,觉得../../components/xxx也能用。但一个深层嵌套的业务组件里,../../../..这种相对路径会让你怀疑人生,更致命的是——当你重构目录结构时,所有相对路径全是隐性炸弹。
在vite.config.ts里配置:
typescript复制import { 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))
}
},
server: {
port: 3000,
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true
}
}
}
})
同样,在tsconfig.json里配置对应的paths,否则TypeScript不知道@代表什么:
json复制{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
}
}
配完之后,组件里引用就清爽多了:import Button from '@/components/Button.vue'。
2.3 目录结构设计的标准答案
说到目录设计,我见过太多项目把所有的“页面组件”堆在views里,把所有的“公共组件”堆在components里,时间一长,views下面几百个文件,看一眼就头大。
我目前验证下来最稳的目录结构长这样:
code复制src/
├── api/ # 接口请求层,按业务模块拆分
├── assets/ # 静态资源
├── components/ # 通用组件,按组件粒度建目录
├── composables/ # 组合式函数/自定义Hooks
├── directives/ # 全局自定义指令
├── layouts/ # 布局组件
├── router/ # 路由配置
├── stores/ # 状态管理
├── styles/ # 全局样式/主题变量
├── types/ # 全局类型声明
├── utils/ # 工具函数
└── views/ # 页面级组件
api目录按照后端模块拆分,例如api/user.ts、api/order.ts。components目录下的通用组件做到“一个组件一个目录”,里面放index.vue、types.ts、hooks.ts,组件复杂时甚至可以内置子组件目录。composables目录放可复用的组合式函数,比如useTable.ts、useForm.ts,这是Vue 3和React Hooks哲学里最精华的部分。
注意:目录划分最怕的是过度设计。如果团队规模不大,暂时不要搞微前端、不要搞多包管理,把上面这层结构维护好就足够用了。架构是演进出来的,不是一步到位设计出来的。
3. 代码规范与质量控制:靠工具而不是靠自觉
3.1 ESLint与Prettier的正确打开方式
我接手过的项目里,最让人头疼的不是性能问题,而是代码风格分裂。有的人用分号,有的人不用;有的人单引号,有的人双引号;有的人组件选项按字母排序,有的人先写生命周期钩子。代码Review一半时间花在争论风格上,纯属浪费生命。
解决办法只有一条:让机器管风格,让人管逻辑。具体来说就是ESLint检查代码质量和潜在错误,Prettier负责格式化,两者配合但不重复。配置方式如下:
bash复制npm install -D eslint eslint-plugin-vue typescript-eslint prettier
npx eslint --init
初始化之后,.eslintrc.cjs大致是这样一个结构:
javascript复制module.exports = {
root: true,
env: {
browser: true,
es2022: true
},
extends: [
'eslint:recommended',
'plugin:vue/vue3-recommended',
'plugin:@typescript-eslint/recommended',
'prettier'
],
parserOptions: {
ecmaVersion: 'latest',
parser: '@typescript-eslint/parser',
sourceType: 'module'
},
rules: {
// 团队自定义规则
'vue/multi-word-component-names': 'off',
'@typescript-eslint/no-explicit-any': 'warn'
}
}
注意extends最后加了一个prettier,这很重要。它的作用是关掉所有与Prettier有冲突的ESLint规则,让两者各司其职,否则格式化完ESLint报错,ESLint修完Prettier又格式化回去,直接精神分裂。
3.2 Husky和lint-staged:把守住最后一关
规范只写在配置文件里等于没有规范——因为大多数人压根不会在提交前手动跑一次lint。真正让规范落地的,是Git钩子。
我推荐用Husky + lint-staged的组合,在代码提交前自动完成格式化、lint、类型检查。这套流程并不复杂:
bash复制npm install -D husky lint-staged
npx husky init
初始化之后,在package.json里增加lint-staged配置,在.husky/pre-commit文件里写入检查逻辑:
json复制{
"lint-staged": {
"*.{vue,ts,tsx}": ["eslint --fix", "prettier --write"]
}
}
这样每次git commit时,只有暂存区里改动的文件会被检查并自动修复,全量检查既慢又容易误伤旧代码。团队里有人想绕过钩子怎么办?有--no-verify参数可以扛,但这属于团队纪律问题,工具能做到的已经做到了。
3.3 提交信息规范:用Commitlint约束
代码风格之外,提交信息的混乱程度也是重灾区。看提交历史全是“fix bug”、“update”、“update again”,三个月后想定位一个功能是哪个提交引入的,只能靠猜。
Commitlint限制提交信息格式,让团队遵循Conventional Commits约定。格式大致是:<type>[optional scope]: <description>。比如feat(user): 新增用户列表筛选功能、fix(order): 修复订单金额精度问题。
bash复制npm install -D @commitlint/cli @commitlint/config-conventional
echo "export default { extends: ['@commitlint/config-conventional'] }" > commitlint.config.js
在Husky里加一个commit-msg钩子:
bash复制npx husky add .husky/commit-msg 'npx --no -- commitlint --edit "$1"'
虽然多了一步约束,但项目大了之后翻提交历史、自动化生成CHANGELOG、配合版本发布的时候,这个习惯能帮你节省大量时间。这套规范对前端、后端、App端都适用,是整个项目协作的基础设施。
4. 请求层和状态管理:写一个用到项目结束都不会想重构的封装
4.1 Axios封装:拦截器的正确理解
项目里最常见的API请求方案是Axios。但同一个Axios,有人直接在每个页面axios.get,有人封装了一层。前者的结果是一旦后端接口前缀调整或需要统一处理登录态过期,就要全局搜索replace。
我习惯把请求层拆成三层:基础配置层(创建实例、拦截器)、模块接口层(按业务模块导出的函数)、页面调用层(只关心数据和错误)。核心代码是封装一个统一的请求实例:
typescript复制// src/utils/request.ts
import axios from 'axios'
import { ElMessage } from 'element-plus'
import useUserStore from '@/stores/user'
const service = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL,
timeout: 15000
})
// 请求拦截器:统一携带token
service.interceptors.request.use(
(config) => {
const userStore = useUserStore()
if (userStore.token) {
config.headers.Authorization = `Bearer ${userStore.token}`
}
return config
},
(error) => Promise.reject(error)
)
// 响应拦截器:统一处理错误
service.interceptors.response.use(
(response) => {
const res = response.data
if (res.code !== 0) {
ElMessage.error(res.message || '请求失败')
return Promise.reject(new Error(res.message))
}
return res.data
},
(error) => {
if (error.response?.status === 401) {
const userStore = useUserStore()
userStore.logout()
// 跳转到登录页
window.location.href = '/login'
} else {
ElMessage.error(error.message || '网络异常')
}
return Promise.reject(error)
}
)
export default service
这里有个后端约定要说清楚:所有正常响应用code === 0表示,业务数据放在data字段里。如果你们的后端习惯用HTTP状态码200表达成功,那可以把res.code的判断逻辑删掉,直接返回response.data。封装没有唯一答案,关键是团队内要达成统一约定。
4.2 响应数据类型的正确写法
配合TypeScript使用的时候,很多人会写any一了百了。但这会绕过程序员的保护——类型系统的意义就在于把“运行到时候才爆”的问题提前到编译期。
我通常先和后端约定好通用的响应包裹结构,用泛型表达:
typescript复制// src/types/api.ts
export interface ApiResponse<T = unknown> {
code: number
message: string
data: T
}
然后再在每个接口函数里,声明具体的业务类型。比如用户接口:
typescript复制// src/api/user.ts
import request from '@/utils/request'
import type { ApiResponse } from '@/types/api'
export interface UserInfo {
id: number
name: string
avatar: string
email: string
}
export interface UserQuery {
page: number
size: number
keyword?: string
}
export function fetchUserList(params: UserQuery) {
return request.get<ApiResponse<UserInfo[]>>('/user/list', { params })
}
页面拿到返回结果后,res会明确推导出UserInfo[]类型,字段名打错了编译期就会报错。这个习惯需要后端接口文档足够清晰支撑,但在接口定义阶段就花时间把类型对齐,联调期能少吵一半的架。
4.3 Pinia vs Vuex:状态管理选型要轻
Vue 3项目里还在用Vuex的,大概率是历史包袱。Pinia作为Vue官方推荐的新状态库,API更简洁、天然支持TypeScript、没有了mutations那层冗余概念。它让你把状态管理写得像自定义Hook一样自然。
我推荐的做法是:不把所有的数据都塞进store里。服务端数据走请求层,组件内部临时状态用ref/reactive组合式函数维护,只有跨页面共享的、需要被多处引用的状态(用户信息、权限数据、全局缓存)才进Pinia。
typescript复制// src/stores/user.ts
import { defineStore } from 'pinia'
export const useUserStore = defineStore('user', {
state: () => ({
token: localStorage.getItem('token') || '',
userInfo: null as UserInfo | null,
roles: [] as string[]
}),
actions: {
setToken(token: string) {
this.token = token
localStorage.setItem('token', token)
},
async fetchUserInfo() {
const data = await fetchUserInfoApi()
this.userInfo = data
this.roles = data.roles
},
logout() {
this.token = ''
this.userInfo = null
this.roles = []
localStorage.removeItem('token')
}
}
})
很多团队到了项目中期,遇到“任意两个页面需要共享一个值”,第一反应就是往store里加。加到最后整个store变成了一个超大的全局变量抽屉,谁都能往里塞,谁都不知道被谁改了。我比较推崇的现代做法是优先用composables封装可复用逻辑,当数据需要跨页面共享且响应式更新时,才是Pinia真正出场的时候。
5. 路由与权限:首屏体验和页面守卫生效的关键战场
5.1 路由懒加载与按需引入的区别
Vite天然支持动态导入,路由懒加载是零配置的。但真正容易出问题的,是“组件库全量引入”这个坑。全量引入Element Plus或Ant Design,打包体积轻松上1MB,首屏加载等你怀疑人生。按需自动导入也简单:
bash复制npm install -D unplugin-vue-components unplugin-auto-import
在vite.config.ts里配好:
typescript复制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: [
vue(),
AutoImport({
resolvers: [ElementPlusResolver()]
}),
Components({
resolvers: [ElementPlusResolver()]
})
]
})
配置完之后,模板里直接用<el-button>,组件和API都会被自动导入,用不到的不打包。第一次配置之后的构建体积差距,会让你觉得之前项目白写了。
5.2 动态路由与按钮权限的设计要点
中后台项目权限体系,是所有“框架搭建”最难做对的一块。权限一般分三层:路由级权限(能不能进这个页面)、菜单级权限(侧边栏显示哪些入口)、按钮级权限(页面里能不能执行删除、导出等操作)。
路由级权限的常见方案是:前端定义全部路由(constantRoutes),登录后根据用户角色动态计算路由表(asyncRoutes),通过router.addRoute()动态添加。服务端返回用户的权限标识,前端做两件事:一是过滤出该用户能访问的路由表,二是把按钮级别的权限存下来供指令使用。
按钮级权限我用的是自定义指令:
typescript复制// src/directives/permission.ts
import type { Directive } from 'vue'
import { useUserStore } from '@/stores/user'
export const permission: Directive = {
mounted(el, binding) {
const { value } = binding
const userStore = useUserStore()
if (value && Array.isArray(value)) {
const hasPermission = value.some((perm: string) =>
userStore.roles.includes(perm)
)
if (!hasPermission) {
el.parentNode?.removeChild(el)
}
}
}
}
模板里这样用:
html复制<el-button v-permission="['admin', 'editor']">编辑</el-button>
这套模式能解决90%中后台的权限需求。另一条建议是,权限控制的最优策略是“后端兜底”,前端控制只是为了用户体验——真正要防数据越权,必须在后端接口层校验权限。
5.3 路由守卫处理登录态与动态标题
路由守卫是权限执行者的主战场。我习惯把逻辑写干净一点,分成两步:先判断登录态,再判断权限。
typescript复制// src/router/guard.ts
import router from '@/router'
import { useUserStore } from '@/stores/user'
import { getAsyncRoutes } from '@/router/dynamic'
const whiteList = ['/login', '/404']
router.beforeEach(async (to, _from, next) => {
const userStore = useUserStore()
document.title = to.meta.title ? `${to.meta.title} - 管理后台` : '管理后台'
if (userStore.token) {
if (to.path === '/login') {
next({ path: '/' })
} else {
if (!userStore.userInfo) {
try {
await userStore.fetchUserInfo()
const routes = getAsyncRoutes(userStore.roles)
routes.forEach((route) => router.addRoute(route))
// addRoute完成前,需要重新导航才能匹配
next({ ...to, replace: true })
} catch (error) {
await userStore.logout()
next(`/login?redirect=${to.path}`)
}
} else {
next()
}
}
} else {
if (whiteList.includes(to.path)) {
next()
} else {
next(`/login?redirect=${to.path}`)
}
}
})
路由守卫里做动态标题其实成本极低,一次配置全局生效。但这个细节对用户感知影响非常大——尤其当你的系统开了多个标签页的时候,能一眼分辨出每个标签页是干什么的。
6. 我踩过的坑与团队落地的心得体会
6.1 生命周期内最容易出问题的几个点
框架搭建过程里,有几个坑是我的高频翻车区。第一个是版本一致性问题,某个依赖锁在“^1.0.0”,另一处写成“~1.0.0”,两天后队友拉代码发现启动报错。这种情况现在通常可以通过统一使用package-lock.json或pnpm-lock.yaml解决,但前提是团队约定所有依赖变更必须通过包管理器命令执行,不能手动改package.json。
第二个坑是环境变量命名不统一。Vite里通过import.meta.env.VITE_XXX访问环境变量,必须在变量名前加VITE_前缀才会暴露给客户端代码。有人把后端地址放在.env里,但变量名没加前缀,结果线上一直请求localhost。正确做法是建好.env.development、.env.production、.env.test,并在文档里注明变量命名规则。
第三个坑是团队协作层面的:一个人把目录结构、代码规范、命名约定全部决定好,丢给团队执行。别人不知道为什么要求api目录按模块拆、为什么不能用any、为什么必须走封装好的请求实例。一套好的架构必须有“入门文档”,把关键决策背景写清楚。框架的意义不是限制自由,而是让团队在正确的轨道上跑得更快。
第三个坑在团队协作层面。如果架构风格是一个人拍板但其他人不懂背后的理由,项目里就会慢慢长出各种“绕过架构的花式操作”。我给自己的要求是,每一条架构约定都要能回答“为什么”——如果给不出说得通的理由,这条规则就不要存在。
6.2 建设周期内值得做的时间切分
很多新人问框架搭建需要多长时间。我的经验是,单人搭建一个小型中后台项目的可用前端框架,包含Vite初始化、组件库接入、Axios封装、Pinia仓库、路由权限、代码规范,高强度投入大概2到3个工作日。
但这里有个经验教训:不要把框架搭建拖成“永无止境的前期准备”。有的项目在工程化阶段磨蹭了两周,又是设计统一的错误码,又是抽象各种“通用业务组件”,结果业务页面还没开始写,排期已经过了一半。足够用的标准是:能支持两三个典型业务页面并行开发、请求方式统一、权限模式已验证。更多的东西,等到第一个业务模块做出来之后再抽象,那时候的抽象才是有据可依的。
6.3 后续扩展的可能性
框架一旦稳定,后续可以很自然地扩展:接入Mock服务做前后端并行开发,引入自动化测试体系保障工程质量,配合CI/CD流水线做自动部署和代码扫描,甚至在业务规模扩大后接入微前端。这些扩展的前提,都是早期的地基打得足够清晰。
说一句心里话,做前端框架搭建,技术方案再炫都不如“团队能顺利在上面迭代”更有价值。我用过的方案里,没有哪一个是因为某个API多优雅而成功的,成功的原因永远是:决策有理由、代码有规范、流程有保障。希望这篇复盘能帮你少走一些弯路,如果你团队正在搭建或重构前端框架,这些经验和配置思路可以直接用起来。
