1. 从零搭一套能落地的前端工程
前段时间接到一个网络设备运维系统的项目,后端选了 Django,前端准备用 Vue 做前后端分离。这类系统在政企、IDC、园区网场景里太常见了,核心是设备台账、状态监控、告警工单、配置备份这些模块。选前后端分离,不是因为赶时髦,而是运维后台要同时面对网管人员、值班员、管理员几种角色,页面交互多、状态更新频繁,用传统 Django 模板硬憋会很难受。
真正动手写代码之前,前端工程的环境搭建往往是第一个坑。Node 版本不对、依赖装不上、跨域代理没配好、token 失效处理不完善,这些问题我在好几个项目里都踩过,而且几乎每个刚转前后端分离的团队都会来问一遍。这篇文章就把网络设备运维系统前端项目从选型到跑通联调的过程完整拆开,包括每一步为什么这么选、命令怎么执行、报错怎么解决,基本是按“每天要打开这个仓库干活”的标准来写的。
如果你的手头正好要搞 Django + Vue 的前后端分离项目,或者准备用 Vue 3 做运维、监控、管理系统这一类偏后台的工具,这篇可以直接当初始化手册用。已经跑过不少项目的朋友,也可以重点看第 4 到第 6 章的 token 处理、代理转发和联调细节,这些是最容易返工的地方。
2. 技术选型与整体架构思路
2.1 为什么是 Django 做后端、Vue 做前端
网络设备运维系统这个业务有个特点:数据模型稳定但关联复杂。设备、端口、IP、VLAN、告警、工单、用户权限,彼此之间都是强关联,Django 的 ORM 和 Admin 在这种场景下开发效率极高。尤其是设备台账这种需要大量列表筛选、关联查询的功能,Django 的 queryset 一套组合拳下来,比手写 SQL 省太多时间。
前端选 Vue 则是看中它的生态和上手曲线。运维系统不是纯展示型网站,有大量表单、表格、弹窗、实时状态刷新,Vue 的响应式机制配合 Element Plus 这类组件库,能把后端给的数据直接映射到界面上,不需要像 jQuery 时代那样手动操作 DOM。Vue 3 的组合式 API 对复杂业务逻辑的复用也更友好,比如设备状态轮询、告警推送这类逻辑可以封装成 hook,多个页面共用。
整个系统前后端通过 RESTful API 通信,Django 侧用 djangorestframework 提供接口,前端用 axios 发请求。前端工程独立维护、独立部署,开发时走 Vite 代理解决跨域,生产环境由 Nginx 统一托管静态文件和反向代理 API。这个架构在中小型运维系统里非常成熟,团队里前后端人员可以并行开发,互不阻塞。
2.2 技术栈版本锁定:先定版本再动手
做前端环境搭建最忌讳的就是“最新版主义”。Vite、Vue、Node 的版本迭代很快,新版本功能是香,但第三方库的兼容性未必跟得上。这个项目的技术栈我直接锁死,全部按经过验证的组合来:
- Node.js 18 LTS(Vite 5 要求 Node 18+,但 Node 20 在某些旧版依赖上会有兼容问题,18 最稳)
- pnpm 8.x(比 npm 安装速度快,磁盘占用低,锁文件统一)
- Vue 3.4 + Vite 5.x
- Element Plus 2.x(网络设备运维系统里的表格、表单、树形控件它都有)
- Pinia 2.x(状态管理,存用户信息、菜单权限、设备筛选条件)
- Vue Router 4.x
- axios 1.x
- unplugin-auto-import + unplugin-vue-components(Element Plus 按需自动导入,避免全量打包)
Node 版本管理建议直接用 nvm-windows(Windows 环境)或 nvm(macOS/Linux),不要手动装。我见过太多人因为本机 Node 版本和项目要求不一致,折腾一整天node_modules都装不干净,换 nvm 之后一键切换,省心非常多。
注意:Node 18 这个版本不是拍脑袋选的。用过 Node 20 跑 Vite 4 的同学可能遇到过
Error: error:0308010C:digital envelope routines::unsupported,其实就是 OpenSSL 3.0 改动导致的,Node 18 是兼容性最稳妥的选择。
2.3 前端项目目录设计:按业务模块划分
后端按 Django app 划分业务域(device、alert、workorder、user 等),前端目录也要跟后端业务形成映射,否则联调时对齐接口会非常费劲。这个项目的src目录结构如下:
src/ ├── api/ # 接口请求定义,按业务模块拆分文件 │ ├── device.js │ ├── alert.js │ ├── workorder.js │ └── auth.js ├── assets/ # 静态资源(图片、全局样式等) ├── components/ # 通用组件(分页表格、状态标签、表单弹窗等) ├── composables/ # 组合式函数(设备状态轮询、告警声音提醒等) ├── layout/ # 主布局(侧边栏、顶部栏、标签页) ├── router/ # 路由配置 ├── stores/ # Pinia 状态管理 ├── utils/ # 工具函数(axios 实例、格式化、权限校验等) ├── views/ # 页面级组件 │ ├── dashboard/ │ ├── device/ │ ├── alert/ │ └── workorder/ ├── App.vue └── main.js为什么强调按业务模块分api目录而不是一个文件收所有接口?网络设备运维系统的接口数量很容易膨胀,一个设备模块就可能有十几个接口。全部堆在api/index.js里,到了后期找接口、改参数、排查问题都像大海捞针。按模块拆开,device.js只管设备模块的接口,后端 Django 的 url 路由也是device/xxx这种前缀,一一对应,谁维护谁看都清晰。
3. 本地开发环境准备
3.1 Node 环境与包管理器安装
这方面网上教程很多,确实也是一路next就能解决的问题,但有三个细节我吃过亏:
第一,安装 nvm 之前把本机原有的 Node 彻底卸载干净,否则 nvm 切换版本可能不生效。第二,npm 镜像源建议全局设置成阿里源,国内下载依赖速度差别巨大。第三,锁定包管理器,项目根目录放一个package.json的锁文件,不要混用 npm 和 pnpm。
# 安装 nvm 后执行 nvm install 18.20.4 nvm use 18.20.4 # 全局安装 pnpm npm install -g pnpm@8.15.9 # 设置镜像源(二选一) npm config set registry https://registry.npmmirror.com pnpm config set registry https://registry.npmmirror.com # 验证 node -v # v18.20.4 pnpm -v # 8.15.9装完 Node 之后建议顺手把pnpm的 store 目录指定到非系统盘,默认是在 C 盘用户目录下,项目多了会占好几个 G。可以在用户目录的.npmrc里配:
store-dir=D:\.pnpm-store3.2 IDE 与插件配置
前端开发 IDE 我推荐 VS Code,免费、插件生态好、团队协作也方便。有几个插件是这个项目必须装的:
- Volar(Vue 官方插件):Vue 3 单文件组件的语法高亮、类型提示、模板表达式检查全靠它。注意 Vetur 是 Vue 2 时代的产物,Vue 3 项目就别装了,会冲突。
- ESLint:统一代码规范,配合项目里的
.eslintrc配置,保存时自动修复格式问题。 - Prettier:代码格式化,和 ESLint 配合使用,不要让它俩规则打架。
- Path Intellisense:文件路径提示,引入组件和封装工具函数时很省时间。
VS Code 的settings.json里我习惯做两个配置:
{ "editor.formatOnSave": true, "editor.codeActionsOnSave": { "source.fixAll.eslint": true } }这样每次保存文件,ESLint 自动修一遍代码风格,Prettier 自动排一遍版,团队里不管谁写出来的代码风格都是一致的,code review 的时候不会为了缩进和引号浪费口舌。
3.3 Git 仓库初始化与提交规范
前端工程初始化完之后第一件事是git init提交一个干净的基线版本,这之后再做任何配置改动都容易对比和回滚。.gitignore必须把node_modules、dist、.env.local这种环境相关文件排除掉,这里有个典型误区:
.env.development和.env.production是可以提交到仓库的公共配置,但.env.local是本机私有的(比如本地调试时连到测试服务器的地址),一定不能提交。我见过有人把测试数据库的 IP 和账号密码不小心推到代码仓库,后面整个内网被扫的风险大增,这种事一次都不能发生。
Git commit 信息建议统一用feat(module): description这种 Conventional Commits 格式,比如feat(device): add device list page。前期养成习惯,后面生成 changelog 和排查 bug 定位到具体提交都会轻松很多。
4. 创建 Vue 3 项目并接入 UI 框架
4.1 用 Vite 创建项目:命令与参数详解
Vite 现在创建项目的方式很成熟,不需要用vue-cli,一条命令直接搞定。这里我用的是create-vite的 Vue + TypeScript 模板:
pnpm create vite network-ops-frontend --template vue-ts cd network-ops-frontend pnpm install这里有个选型细节,为什么要用 TypeScript 而不是纯 JavaScript?网络设备运维系统里设备数据、告警数据的字段结构非常固定,像设备 IP、设备类型、状态、所属站点,用 TS 的 interface 定义好之后,编辑器会给出精确的字段提示,写错字段名直接在编辑器里就报红了。这相当于在后端 Django 的 serializer 之外,又给前端加了一层静态检查,联调时字段对不齐的概率大幅降低。
项目跑起来之后,第一件事是清理模板自带的演示代码。src/components/HelloWorld.vue删掉,App.vue改成空白布局,src/style.css保留基础样式但清掉模板样式。这一步看起来没有技术含量,但很多人偷懒不管,后面写页面时总会被这些模板残留干扰。
4.2 Element Plus 接入与自动按需导入
Element Plus 是这个系统的主要 UI 组件库,我用了它的自动按需导入方案。它的原理是借助unplugin-auto-import和unplugin-vue-components两个 Vite 插件,在编译时自动把代码里用到的组件和 API 导入,避免全量打包把没用到的组件也塞进产物里。
vite.config.ts配置如下:
import { defineConfig } from 'vite' import vue from '@vitejs/plugin-vue' 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()] }) ] })这样在.vue文件里直接写<el-table>、<el-form>就能用,不需要手写import { ElTable } from 'element-plus'。用ElMessage这种命令式 API 的时候要注意,auto-import 配置里需要额外加imports: ['vue', 'vue-router', 'pinia'],否则编辑器里直接写ref、computed、useRouter会提示找不到。
重点:按需导入虽然好用,但样式文件的引入也要跟着走。如果发现组件能用但样式不对,检查一下是不是漏了
ElementPlusResolver({ importStyle: 'css' })这个配置,或者手动在main.ts引入element-plus/dist/index.css做兜底。
4.3 路由与布局的初步搭建
运维系统的界面布局基本是固定的套路:左侧侧边栏、顶部导航、中间内容区。我直接用vue-router+ 一个主布局组件把这套骨架定义好:
// router/index.ts import { createRouter, createWebHistory } from 'vue-router' const router = createRouter({ history: createWebHistory(), routes: [ { path: '/login', component: () => import('@/views/login/index.vue') }, { path: '/', component: () => import('@/layout/index.vue'), redirect: '/dashboard', children: [ { path: 'dashboard', name: 'Dashboard', component: () => import('@/views/dashboard/index.vue'), meta: { title: '运行总览' } }, { path: 'device/list', name: 'DeviceList', component: () => import('@/views/device/list.vue'), meta: { title: '设备管理' } } // ... 其他模块 ] } ] })路由用懒加载(() => import(...))而不是直接静态引入,这样打包时每个页面会单独拆成一个 chunk,首屏只加载当前需要的文件。运维系统页面多,不懒加载的话首屏 JS 动不动就几 MB,加载特别慢。
布局组件layout/index.vue这里不展开写完整代码,思路是左侧el-menu绑定路由的meta.title和path,右侧内容区放<router-view />。侧边栏菜单最好用路由表自动生成,不要手写两套,不然加一个页面要改两个地方,忘改就会 404。
5. 网络设备运维系统的前端工程化配置
5.1 开发服务器配置:跨域代理
前后端分离开发中,前端跑在localhost:5173,Django 跑在localhost:8000,浏览器会有跨域限制。正经的解决方案不是在后端开 CORS 放行(当然 Django 那边可以配django-cors-headers方便调试),而是让 Vite 开发服务器做代理。
vite.config.ts里加server.proxy:
export default defineConfig({ server: { host: '0.0.0.0', port: 5173, proxy: { '/api': { target: 'http://localhost:8000', changeOrigin: true } } } })关键在于changeOrigin: true,它会把请求头的Host字段改成目标地址,Django 那边看到的请求就是来自localhost:8000,不会因为 Host 不匹配被拒。
这样前端调用时统一走/api/device/list这种相对路径,开发环境由 Vite 转给 Django,生产环境由 Nginx 转给 Django 的 uWSGI/Gunicorn,前端代码里不需要关心后端到底部署在哪台服务器。
5.2 环境变量管理:区分开发、测试、生产
网络设备运维系统一般至少有三套环境:本地开发、测试环境、生产环境。不同环境的后端接口地址、是否开启 mock、日志级别都不一样,这些必须用环境变量区分。
Vite 的环境变量机制是基于.env文件的,以VITE_开头命名的变量会被暴露到前端代码:
# .env.development VITE_API_BASE_URL=/api VITE_USE_MOCK=false # .env.production VITE_API_BASE_URL=/api VITE_USE_MOCK=false在代码里这样使用:
// utils/request.ts const baseURL = import.meta.env.VITE_API_BASE_URL || '/api'注意生产环境通常也是/api,因为 Nginx 会把/api反向代理到 Django 服务,写成相对路径可以做到“前端代码不感知后端地址”。只有特殊情况(比如调试时直接连测试服)才在.env.local里覆盖成http://10.0.0.5:8000/api。
5.3 axios 二次封装与请求拦截
axios 不能直接用,必须封装成一个统一的请求实例,把 baseURL、超时时间、请求头、响应拦截、错误处理全部集中起来。这样任何一个页面发请求,都能自动带上 token,响应异常了也不用每个页面都写一遍错误处理。
src/utils/request.ts的核心逻辑:
import axios from 'axios' import { ElMessage } from 'element-plus' import { useUserStore } from '@/stores/user' import router from '@/router' const service = axios.create({ baseURL: import.meta.env.VITE_API_BASE_URL || '/api', timeout: 15000 }) // 请求拦截器:自动附带 token service.interceptors.request.use((config) => { const userStore = useUserStore() if (userStore.token) { config.headers.Authorization = `Bearer ${userStore.token}` } return config }) // 响应拦截器:统一处理错误 service.interceptors.response.use( (response) => response.data, (error) => { if (error.response?.status === 401) { const userStore = useUserStore() userStore.resetToken() router.push('/login') ElMessage.error('登录状态已过期,请重新登录') } else if (error.response?.status === 403) { ElMessage.error('没有权限执行此操作') } else { ElMessage.error(error.response?.data?.detail || '请求失败,请稍后重试') } return Promise.reject(error) } ) export default serviceDjango 那边用的认证方式是 djangorestframework-simplejwt,后端返回的 token 类型是Bearer <token>,所以请求头格式要跟它对齐。这个细节错了很容易踩坑,后端明明校验通过,但前端一直 401,就是Authorization头格式的问题。
提示:axios 的响应拦截器我是直接返回
response.data,而不是返回response。这样业务代码里const data = await getDeviceList()拿到的直接是后端返回的业务数据,不需要每处都写res.data.data,代码会干净很多。
5.4 Token 存储与刷新机制
网络设备运维系统要求用户登录后长时间保持会话,access token 过期后需要自动刷新,不能用“过期就让用户重新登录”这种粗暴方案。JWT 的 access token 有效期一般设 30 分钟到 2 小时,refresh token 有效期设 1 到 7 天。
前端 token 存储我推荐放在 Pinia + localStorage 里。Pinia 负责运行时读取,localStorage 负责持久化,这样刷新页面后登录状态不丢:
// stores/user.ts import { defineStore } from 'pinia' export const useUserStore = defineStore('user', { state: () => ({ token: localStorage.getItem('access_token') || '', refreshToken: localStorage.getItem('refresh_token') || '', userInfo: null }), actions: { setToken(accessToken: string, refreshToken: string) { this.token = accessToken this.refreshToken = refreshToken localStorage.setItem('access_token', accessToken) localStorage.setItem('refresh_token', refreshToken) }, resetToken() { this.token = '' this.refreshToken = '' localStorage.removeItem('access_token') localStorage.removeItem('refresh_token') } } })自动刷新 token 我封装成一个方法,在响应拦截器里遇到 401 时调用:
// utils/refreshToken.ts import request from './request' import { useUserStore } from '@/stores/user' let isRefreshing = false let pendingQueue: Array<(token: string) => void> = [] export async function refreshToken() { const userStore = useUserStore() if (isRefreshing) { // 如果已经在刷新中,返回一个 Promise,让其他请求排队等待 return new Promise((resolve, reject) => { pendingQueue.push((token) => { userStore.token = token resolve(token) }) }) } isRefreshing = true try { const res: any = await request.post('/auth/refresh/', { refresh: userStore.refreshToken }) const newToken = res.access userStore.setToken(newToken, userStore.refreshToken) pendingQueue.forEach((cb) => cb(newToken)) pendingQueue = [] return newToken } catch (e) { pendingQueue = [] userStore.resetToken() window.location.href = '/login' throw e } finally { isRefreshing = false } }这段代码里最容易忽略的是“刷新请求的并发去重”。假设页面上同时有 10 个请求都因为 token 过期返回 401,如果不加isRefreshing判断,这 10 个请求会同时发起刷新 token 的请求,Django 那边收到十次 refresh 请求,浪费资源不说,还可能因为 refresh token 被重复使用而失效。加上这个队列机制,只有第一个请求真正去刷新,其余 9 个排队等新 token,刷新完成后统一重放。
注意:响应拦截器里重放请求时,要记得用
service(originalConfig)而不是直接request(originalConfig),否则会再次进入拦截器死循环。
5.5 Pinia 状态管理:不只是存 token
Pinia 在这个系统里的职责不止 token,还承担用户信息、侧边栏折叠状态、设备列表筛选条件这些全局状态的维护。重点提一下“设备筛选条件的持久化”:运维人员经常在设备列表页筛选“某站点 + 某型号 + 告警状态”,翻页查看,然后不小心刷新页面,条件全没了,重新筛一遍挺烦的。
用 Pinia 存一份筛选条件并同步到 localStorage:
// stores/deviceFilter.ts export const useDeviceFilterStore = defineStore('deviceFilter', { state: () => ({ filterParams: JSON.parse(localStorage.getItem('device_filter') || '{}') }), actions: { setFilterParams(params: any) { this.filterParams = params localStorage.setItem('device_filter', JSON.stringify(params)) } } })页面在onMounted时从 store 里读回条件,回填到搜索表单,再发起查询。这个功能虽小,但在真实使用中用户感知特别强,属于低成本高收益的体验优化。
6. 与 Django 后端联调的关键细节
6.1 登录认证流程对接
Django 那边用 djangorestframework-simplejwt 提供登录接口,通常是POST /api/auth/login/,请求体是{ "username": "...", "password": "..." },返回{ "access": "...", "refresh": "..." }两组 token。前端登录页拿到后直接存进 user store。
有个细节要注意,登录成功后前端会再调一个GET /api/auth/profile/获取当前用户信息(用户名、角色、权限列表)。这个接口的响应里如果有角色和权限信息,就要存起来,因为后面做按钮级权限控制要用。比如普通运维人员看不到“删除设备”按钮,网络管理员才能看到,这时候前端要根据权限字段做判断,而不是简单地把按钮隐藏了事——后端同样要做校验,前端只是改善交互体验。
6.2 用户路由守卫的实现
vue-router的全局前置守卫有两个职责:如果还没登录,所有页面都拦下来跳去登录页;如果已登录,但又访问登录页,就直接跳回首页。实现思路:
router.beforeEach((to, from, next) => { const userStore = useUserStore() if (to.path === '/login') { if (userStore.token) { next('/') } else { next() } } else { if (!userStore.token) { next(`/login?redirect=${to.fullPath}`) } else { next() } } })这里redirect参数很重要,用户被拦截去登录之后,登录成功应该自动跳回原来想访问的页面,而不是固定跳首页。网管人员在值班处理告警时,刷新页面 token 还在,但如果 token 刚好过期被踢到登录页,登录后还要重新找那个告警页面,就很恼火。有redirect参数,体验会好很多。
6.3 接口字段命名风格的统一
Django 后端默认是 snake_case(device_type、ip_address),前端 JavaScript 习惯用 camelCase(deviceType、ipAddress)。前后端联调时最烦的就是字段名对不上。
这个问题我的做法是:后端 Django 的 serializer 里直接定义好字段名,让前端不用做任何转换。比如:
class DeviceSerializer(serializers.ModelSerializer): device_type = serializers.CharField(source='get_device_type_display') class Meta: model = Device fields = ['id', 'name', 'device_type', 'ip_address', 'status', 'site_name']只要这个接口是做给前端用的,字段命名就以“前端最方便使用”为标准来定义,两边的字段名完全一致,省掉前端做 map 转换的功夫。当然,这里需要在项目初期和后端、前端约定好,否则后面改字段名牵连太大。
设备状态这种枚举字段,后端返回的应该是人类可读的字符串(如online、offline、warning),由前端用一个映射表转成中文标签和颜色,而不是后端直接返回“在线”“离线”这种中文,否则做国际化或者状态颜色标识时会很被动。
6.4 Mock 数据方案的临时替代
如果后端接口还没开发完,前端可以先不等。Vite 支持在本地 mock 接口,虽然不是正式方案,但联调前期很实用。最简单的方式是用vite-plugin-mock插件,或者直接在 vite.config 里配一个本地插件:
// vite.config.ts 中配置 mock 插件(开发阶段使用) import type { Plugin } from 'vite' function mockPlugin(): Plugin { return { name: 'mock-dev-server', configureServer(server) { server.middlewares.use('/api/device/list', (req, res) => { res.setHeader('Content-Type', 'application/json') res.end(JSON.stringify({ code: 0, data: { total: 2, items: [ { id: 1, name: '核心交换机-01', ip_address: '192.168.1.1', status: 'online' }, { id: 2, name: '路由器-01', ip_address: '192.168.1.2', status: 'warning' } ] } })) }) } } }发布到生产环境时这个插件不生效,所以不会影响正式代码。前端在 mock 阶段把页面写出来,后端接口就绪后只需要关掉 mock,切换到真实请求,整个过程接口签名不变,验证会很顺畅。
7. 常见问题与排查技巧实录
7.1 依赖安装失败的排查路径
前端项目环境搭建中,“跑不起来”有七成是依赖安装环节出的问题。典型报错和对应解法如下:
| 报错场景 | 原因 | 解决方案 |
|---|---|---|
ERR_OSSL_EVP_UNSUPPORTED | Node 版本过高,OpenSSL 兼容问题 | 切换到 Node 18 LTS |
ETIMEDOUT/ECONNRESET | 网络原因,无法连接到 registry | 配置阿里镜像源,或重试pnpm install |
peerDependencies冲突 | 依赖之间的版本要求不匹配 | 尝试pnpm install --force或手动升级对应依赖 |
Cannot find module 'node-sass' | 项目用了 sass 但只装了 node-sass 或版本不兼容 | 统一换成sass(Dart Sass),不再用 node-sass |
vite 不是内部或外部命令 | 依赖未正确安装或 pnpm 脚本环境异常 | 先执行pnpm install,然后用pnpm run dev而非直接vite |
其中node-sass这个坑我重点提醒一下。node-sass 是 LibSass 的 Node 封装,早已停止维护,新 Node 版本装它几乎必报错。如果你看到项目里有人还在用 node-sass,建议直接换成sass(Dart Sass),API 基本兼容,安装速度还更快。
7.2 跨域与代理问题的自我检查清单
前后端分离项目里,跨域问题是聊天记录里出现频率最高的问题之一。遇到前端请求报 CORS 错误,按这个顺序排查:
- 确认请求是不是走了 Vite 代理:浏览器 F12 看 Network,如果请求 URL 是
http://localhost:5173/api/device/list,说明走了代理。如果显示http://localhost:8000/api/device/list,说明没走 Vite 代理(可能是 baseURL 写成了绝对地址)。 - 检查 target 地址对不对:Django 到底跑在
localhost:8000还是127.0.0.1:8000?Django 启动时要留意runserver 0.0.0.0:8000和127.0.0.1:8000的监听范围不一样。 - 检查 Django 是否开了 CORS:即使有 Vite 代理,如果前端直接访问后端(比如某些上传接口走的是 CDN 地址),后端要安装
django-cors-headers,在settings.py里配置CORS_ALLOWED_ORIGINS。 - 看后端日志:Django 终端有没有打印收到请求的记录?如果收到了,说明请求到达了后端,问题大概率在响应阶段而不是代理阶段。
经验:遇到跨域问题先别急着看前端代码,先确认“后端有没有收到请求”。这一步能快速把问题分成两类:代理没配好 vs 后端响应有问题,排查效率会高很多。
7.3 接口 401 循环跳转问题的处理
这个bug在前后端分离项目里非常常见:token 失效后,某个接口返回 401,响应拦截器跳转到登录页,登录页又发了一个请求去获取用户信息,这个请求如果也带上了无效 token,又 401,然后跳转到登录页……于是页面疯狂刷新,或者卡在登录页出不来。
解法是先判断当前是不是已经在登录页,只有不在登录页时才跳转并提示。在拦截器里加一层判断即可:
if (error.response?.status === 401) { if (router.currentRoute.value.path !== '/login') { try { // 先尝试用 refreshToken 刷新 await refreshToken() // 刷新成功后重放原请求 return service(error.config) } catch { const userStore = useUserStore() userStore.resetToken() router.push('/login') ElMessage.error('登录状态已过期,请重新登录') } } else { // 登录页本身的接口异常,不跳转,只提示 ElMessage.error('用户名或密码错误') } }这里还涉及一个细节,刷新 token 之后原始请求要重新发一次。之前提到过error.config就是 axios 保存的原始请求配置,重放时用service(error.config)即可。注意要在刷新成功后从 store 里重新读取新 token,因为这时候 store 里的 token 已经是新的了。
7.4 HMR 失效与页面白屏的排查思路
开发模式下改代码页面自动更新,这是 Vite 的 HMR(Hot Module Replacement)功能。如果出现 HMR 失效,通常原因有这几个:
- 当前编辑的文件被多个组件依赖,Vite 无法精确热更新,只能整页刷新。这种情况重新刷新一次页面就好,不在代码层面处理。
- 项目中
resolve.alias配置了@指向src,但 Vite 没识别到这个配置。检查vite.config.ts里是否配了resolve.alias。 - 使用的组件库或业务代码里有不受支持的写法(比如模块顶层直接修改
module.exports之类的 CommonJS 写法)。新写的代码尽量用 ESM 规范。
白屏问题一般是 JS 运行报错。打开浏览器 F12 看 Console 面板,最常见的有这几种:
- Uncaught TypeError: Cannot read properties of undefined (reading 'xxx') - Uncaught ReferenceError: xxx is not defined第一个通常是后端返回的数据结构和前端预期不一致,比如接口返回{ data: [] },但前端写的是res.items。第二个是变量名拼写错误或者忘记 import。遇到白屏不要慌,Console 一定有报错,按错误提示定位通常很快。
8. 项目启动后的调优与扩展方向
前端环境搭建完成、登录流程跑通之后,这个项目已经有继续生长的骨架了。从环境搭建角度,还有几件事是值得顺手做掉的:
第一,接入unplugin-icons和图标集。运维系统里设备状态、告警级别、操作按钮都需要小图标,手动引入 SVG 太繁琐,unplugin-icons可以自动按需加载@iconify/json里的图标,用<i-ep-warning />这样的标签直接渲染,代码量少很多。
第二,配置unplugin-html或手动调整index.html的标题、favicon 和 SEO meta。运维系统虽然是后台系统,但浏览器的页面标题(左上角标签)如果不能动态变化,多标签页开多了会很难区分。可以在路由afterEach里根据meta.title动态设置document.title。
第三,给构建产物加上gzip或brotli压缩。生产环境 Nginx 配置了gzip on时,Vite 构建时再用vite-plugin-compression预生成.gz文件,服务器直接把预压缩文件下发,省掉 Nginx 实时压缩的 CPU 消耗,首屏速度也会有明显提升。
第四,如果项目规模继续扩大,考虑接入Vitest做单元测试,优先覆盖工具函数(比如 token 刷新逻辑、权限判断逻辑)和 API 封装。这些是项目里最容易出回归问题的部分,测试成本比页面组件低,收益却高得多。
我还想提醒一点,环境搭建不是一次性的工作。Node 和依赖版本会持续迭代,新成员加入团队时需要一份简明的 README,把 Node 版本、包管理器、启动命令、环境变量说明、常见报错都写进去。这份文档花 20 分钟写,能省下后面无数次“帮我看一下为什么跑不起来”的时间。
网络设备运维系统这类项目,前端环境搭好之后真正的硬仗在业务页面和数据交互,但地基打不牢,后面每走一步都在还债。希望这篇文章能帮你把这个地基一次打好,后续专注在设备管理、告警监控这些真正的业务价值上。