很多朋友学 Vue 都会经历一个阶段:教程刷了不少,hello world 也写了好几个,但真到了要自己从零搭一个项目的时候,突然不知道从哪儿下手。路由怎么配、状态放哪里、接口怎么联、打包之后为什么样式全乱了——这些 “没人明说但一定会遇到” 的问题,才是实际开发里的重头戏。
这篇文章我就用一个内部数据看板项目的实战例子,把 Vue 开发里最常用到的路由、状态管理、视频流播放、Excel 导出、WebSocket 实时通信、前后端联调、打包部署全部走一遍。文里的代码都是可以直接拿来改的,每一步我都会说明为什么这么做,以及我自己踩过哪些坑。适合刚学完 Vue 基础、准备做第一个完整项目的人,也适合已经写过项目但想系统补一遍常见场景的开发者。
1. 环境搭建与项目脚手架:这一步最容易劝退新手
很多人在项目还没开始写第一行业务代码之前,就被环境折腾到想放弃。Node.js 版本不对、依赖装不上、脚手架启动报错,每一个问题都够卡半小时。其实环境这块只要搞明白三件事,后面就顺了。
1.1 Node.js 版本选择与安装细节
Vue 3 配上 Vite 之后,对 Node.js 的版本要求比之前高了不少。Vite 4 需要 Node 14.18+ 或 16+,Vite 5 开始要求 Node 18+。这里我的建议非常直接:不要用太老的版本,直接装 Node 18 或 20 的 LTS 版本,省得后面各种依赖因为 Node 版本太低而报错。
安装的时候有个细节容易忽略:Windows 用户装完 Node 之后一定要确认 npm 的全局路径是否正常。可以在命令行里跑一句:
node -v npm -v两个都能输出版本号,说明基础环境没问题。如果你用的是 nvm(Node 版本管理器),注意切换版本之后要重新确认 npm 的 registry 源,国内网络环境建议提前设置 npm 镜像,否则后面 npm install 会等到怀疑人生。
1.2 创建项目的两种方式和我的选择
创建 Vue 项目的官方命令是npm create vue@latest,底层用的是 create-vue 这个脚手架。它会问你需不需要 TypeScript、Vue Router、Pinia、ESLint、Prettier 这些,按需勾选就行。我更推荐这个方式,因为它在生成项目的同时就把规范和基础依赖都配好了,比手动去 Vite 里一个个加要省事得多。
比如我创建数据看板项目用的命令是:
npm create vue@latest vue-dashboard过程中我会选择:
- TypeScript:选上。虽然写起来比 JS 多几个类型标注,但在项目变大之后收益非常明显。
- Vue Router:选上。做单页应用基本逃不掉。
- Pinia:选上。后面状态管理会用到。
- ESLint + Prettier:选上。团队协作或者自己维护代码的时候,统一格式能少吵很多架。
项目生成之后,先npm install把依赖装好(这一步在国内建议用配置好的镜像源),然后npm run dev能正常打开页面,脚手架这一步就算过了。
1.3 项目目录结构怎么看
create-vue 生成的结构很清晰,但你如果第一次看可能会懵。核心就几个地方:
src/ views/ # 页面组件,每个路由对应一个 components/ # 公共组件 router/ # 路由配置 stores/ # Pinia 状态 assets/ # 静态资源 App.vue # 根组件 main.ts # 入口文件理解这个结构有个简单的方法:页面往 views 里放,公共部分往 components 里放,凡是多个页面要共享的数据往 stores 里放,就对了。别把逻辑写成一坨塞在单个组件里,后面维护会非常痛苦。
1.4 离线安装依赖的问题
热词里有个“离线安装 vue”,这个场景一般出现在内网开发环境。做法是找一台能联网的机器,把项目依赖完整npm install一次,然后用npm pack或直接把 node_modules 打包拷贝到内网机器上。更规范的做法是部署一个私有 npm 仓库(比如 Verdaccio),把需要的包缓存在内网。小项目图省事直接拷 node_modules 也能跑,但换机器容易出现二进制模块不匹配的问题,能走私有仓库尽量走私有仓库。
2. 路由从入门到够用:参数传递、懒加载和登录拦截
路由是 Vue 单页应用的门面,也是新手最容易用得稀里糊涂的部分。我见过不少项目,路由配置里清一色全是component: () => import(...),但对参数传递、拦截器、懒加载这些到底怎么设计,完全没有概念。这一节我把数据看板项目里实际用到的路由知识串一遍。
2.1 路由参数传递的三种方式
路由参数说白了就三种场景:通过路径传参、通过 query 传参、通过配置的 props 传参。
路径传参适合那种“某个资源详情页”的场景,比如点击一条告警记录进入详情页:
{ path: '/alarm/detail/:id', name: 'AlarmDetail', component: () => import('@/views/alarm/AlarmDetail.vue'), props: true }路由配置里写上props: true之后,组件里就能直接用defineProps(['id'])拿到这个参数,比用route.params.id要清爽很多,代码也更好测试。
query 传参适合列表页带到详情页的附加信息,比如排序字段、来源页面这类不参与资源定位的内容:
router.push({ name: 'AlarmDetail', query: { from: 'dashboard', page: 2 } })组件里用route.query.from拿。注意 query 传的值都会变成字符串,数字类型要自己转换。
还有一种场景是“直接把整份数据带过去”,但我不推荐你这么做。有人喜欢用query: { data: JSON.stringify(obj) },或者更直接的用 sessionStorage 临时存一份,说这样能少发一次请求。实际项目里这种做法的坏处很明显:页面一刷新数据就没了,而且 URL 会变得特别长,还会把不必要的数据暴露出去。详情页需要什么数据就按 id 调接口拿,这个习惯越早养成越好。
2.2 路由拦截器实现登录校验
数据看板这种内部系统,基本都要求登录后才能访问。路由拦截器的作用就是在跳转之前做一次身份校验,没登录就直接踢到登录页。用 create-vue 的项目,路由文件是src/router/index.ts:
import { createRouter, createWebHistory } from 'vue-router' const router = createRouter({ history: createWebHistory(import.meta.env.BASE_URL), routes: [ { path: '/login', name: 'login', component: () => import('@/views/Login.vue'), meta: { public: true } }, { path: '/dashboard', name: 'dashboard', component: () => import('@/views/Dashboard.vue'), meta: { title: '数据总览', requiresAuth: true } } ] }) router.beforeEach((to, from, next) => { document.title = to.meta.title ? `${to.meta.title} - 数据看板` : '数据看板' const token = localStorage.getItem('token') if (to.meta.requiresAuth && !token) { next({ name: 'login', query: { redirect: to.fullPath } }) } else if (to.name === 'login' && token) { next({ name: 'dashboard' }) } else { next() } })这里有两个容易踩的坑。
第一个是query: { redirect: to.fullPath }。这个设计是为了登录成功之后能跳回用户原本想访问的页面,体验会好很多。登录页拿到 redirect 参数之后,登录成功就router.replace(redirect),而不是死板地跳首页。
第二个是meta.public和meta.requiresAuth这类标记一定要显式设计。我之前见过一个项目,判断逻辑是“凡是路由名不是 login 的都要登录”,结果后面加了一个页面忘记写requiresAuth: false,整个页面变成了登录白名单之外的重灾区。后面用 meta 标记反而更稳:默认全部要登录,个别页面用meta.public: true放行。
2.3 路由懒加载与打包体积优化
老项目里常见的问题是把所有页面都import进来,导致首屏加载一个巨大的 JS 文件。Vue Router 的懒加载写法非常简单:
// 不要这样 import Dashboard from '@/views/Dashboard.vue' // 要这样 const Dashboard = () => import('@/views/Dashboard.vue')因为一个路由对应一个页面,所以“路由懒加载”和“组件懒加载”在大多数场景下是一回事。打包的时候 Vite 会按照动态 import 自动把代码拆成小块,每个页面一个 chunk,用户访问哪个页面就加载哪个页面的 JS,首屏速度会明显提升。
数据看板项目里的大图表库(比如 ECharts)开销比较大,可以把图表组件单独做成一个异步组件,用到再加载,也能让首屏压力小很多。
3. 状态管理选型实录:为什么我放弃 Vuex 转向 Pinia
说到 Vue 的状态管理,很多人的第一反应还是 Vuex。但现在新项目我基本直接用 Pinia。原因不复杂:Pinia 的 API 设计更贴近 Vue 3 的组合式风格,没有 mutations 这个概念,不用写一堆模板代码,TypeScript 支持也更好。这一节我顺便把两者对比和实际使用例子都给你。
3.1 Pinia 与 Vuex 的核心差异
对比最直观的方式就是看代码量。Vuex 里要修改 state 必须走 mutations,即使只是把用户名改一下,也要写 action 调 mutation,多了一层概念。Pinia 把 action 和 state 放在一起,一个函数直接改数据,心智负担小很多。
热词里既然提到“vue pinia vs vuex”,我就多说几句选型建议:
| 对比维度 | Pinia | Vuex |
|---|---|---|
| API 风格 | 组合式,简洁 | 选项式,概念多 |
| mutations | 已移除 | 必须写 |
| TypeScript | 类型推导好 | 需要手动标注较多 |
| 学习成本 | 低 | 中等 |
| 适合场景 | 新项目、中小型项目 | 老项目迁移、已深度依赖 Vuex 插件的场景 |
如果项目已经用了 Vuex 4,并且跑得没问题,没必要强行迁移。但如果你是在为新项目做选型,Pinia 是目前更合适的方向。
3.2 用 Pinia 写一个用户状态存储
数据看板项目里,登录用户信息、token、权限列表这些全局数据会放在一个 user store 里。用组合式写法定义一个 store 是这样的:
import { ref, computed } from 'vue' import { defineStore } from 'pinia' import { loginApi, logoutApi, getUserInfoApi } from '@/api/user' export const useUserStore = defineStore('user', () => { const token = ref(localStorage.getItem('token') || '') const userInfo = ref(null) const isLoggedIn = computed(() => !!token.value) const isAdmin = computed(() => userInfo.value?.role === 'admin') async function login(loginForm) { const res = await loginApi(loginForm) token.value = res.token userInfo.value = res.user localStorage.setItem('token', res.token) } async function fetchUserInfo() { const res = await getUserInfoApi() userInfo.value = res } function logout() { token.value = '' userInfo.value = null localStorage.removeItem('token') } return { token, userInfo, isLoggedIn, isAdmin, login, fetchUserInfo, logout } })注意这里我把 token 同步写进了localStorage。这样刷新页面之后,ref(localStorage.getItem('token') || '')会直接从本地把 token 读回来,登录态不会因为刷新就丢失。
组件里用的时候是这样的:
import { useUserStore } from '@/stores/user' const userStore = useUserStore() async function handleLogin() { await userStore.login({ username, password }) router.push(route.query.redirect || '/dashboard') }store 里的状态会响应式地同步到所有组件,一个地方改,全部页面跟着变,这就是全局状态存在的意义。
3.3 composable 复用逻辑与 React Hooks 的对比
热词里有“composable vue”,这里也顺带讲一下。composable 就是把可复用的逻辑抽成一个函数,函数内部可以用 ref、computed、watch 这些组合式 API,然后返回需要暴露的内容。
举个例子,数据看板里做 WebSocket 断线重连逻辑,可以抽成一个useWebSocket:
export function useWebSocket(url, { onMessage, onOpen, onClose } = {}) { const ws = ref(null) const status = ref('closed') let retryCount = 0 let retryTimer = null function connect() { ws.value = new WebSocket(url) ws.value.onopen = () => { status.value = 'open' retryCount = 0 onOpen?.() } ws.value.onmessage = (event) => { onMessage?.(JSON.parse(event.data)) } ws.value.onclose = () => { status.value = 'closed' handleReconnect() } } function handleReconnect() { if (retryCount >= 5) return retryTimer = setTimeout(() => { retryCount++ connect() }, 1000 * Math.pow(2, retryCount)) } function close() { clearTimeout(retryTimer) ws.value?.close() } onBeforeUnmount(close) return { status, connect, close } }如果你用过 React 的话会发现,Vue 的 composable 和 React Hooks 在“把逻辑抽出来复用”这件事上思路是一致的。区别在于 Vue 的 composable 不需要关心调用顺序,也不会因为条件分支导致 Hook 规则报错,心智负担小很多。React Hooks 强调函数式纯度,Vue composable 则更像是把响应式数据封装在一个普通函数里,两者各有所长,没有谁碾压谁的结论。
4. 数据看板里最常用的四个功能例子:视频流、Excel 导出、WebSocket 与扫码
一个数据看板项目,功能点往往五花八门。我挑了四个出现频率很高、而且每个都值得单独写一篇教程的功能:m3u8 视频播放、多表格导出 Excel、WebSocket 实时推送、二维码扫码。这几个能力我都在项目里实际用过,下面讲的内容全部来自实操。
4.1 用 hls.js 播放 m3u8 视频流
热词里“vue 播放 m3u8”搜的人很多,说明这是个真实痛点。m3u8 是 HLS 协议的视频索引文件,浏览器原生并不支持这种格式(Safari 除外),所以在 Chrome、Edge 里播放 m3u8 需要用 hls.js 这个库。
安装依赖:
npm install hls.js在 Vue 组件里封装一个视频播放的通用逻辑:
<template> <video ref="videoRef" controls autoplay muted></video> </template> <script setup> import { ref, onMounted, onBeforeUnmount } from 'vue' import Hls from 'hls.js' const props = defineProps({ src: { type: String, required: true } }) const videoRef = ref(null) let hls = null function play() { const video = videoRef.value if (!video) return if (Hls.isSupported()) { hls = new Hls({ maxBufferLength: 30, liveSyncDurationCount: 3 }) hls.loadSource(props.src) hls.attachMedia(video) hls.on(Hls.Events.ERROR, (event, data) => { if (!data.fatal) return if (data.type === Hls.ErrorTypes.NETWORK_ERROR) { hls.startLoad() } else if (data.type === Hls.ErrorTypes.MEDIA_ERROR) { hls.recoverMediaError() } }) } else if (video.canPlayType('application/vnd.apple.mpegurl')) { // Safari 原生支持 HLS video.src = props.src } } onMounted(play) onBeforeUnmount(() => { hls?.destroy() }) </script>关于播放有几个注意点:
Autoplay 策略。浏览器对带声音的视频自动播放限制很严格,所以代码里给 video 加了muted属性,静音状态下的自动播放成功率会高很多。如果业务要求必须有声音,就别用 autoplay,改成用户点击播放。
直播流的liveSyncDurationCount参数。这个参数控制追播速度,值越小延迟越低,但太低了会导致频繁缓冲。我一般给直播流设置 3 到 5,点播流可以不用管。
组件卸载的时候一定要调用hls.destroy(),否则会残留定时器和事件监听,切换页面之后视频还在后台跑,内存就慢慢上去了。
4.2 多个表格数据导出成一个 Excel
热词里“vue 多个表格导出一个 excel”也是高频需求。数据看板通常会有多个统计表格,比如“各区域设备状态表”“最近一周告警列表”“人员值班表”,用户希望点一下按钮就把这些表格合并导出到一个 Excel 文件里,每个表格一个 sheet。
我用的是 SheetJS(即 xlsx 库):
npm install xlsx导出多 sheet 的核心函数:
import * as XLSX from 'xlsx' function exportMultipleSheets(sheets, fileName = '统计报表.xlsx') { const wb = XLSX.utils.book_new() sheets.forEach(({ name, data }) => { const ws = XLSX.utils.json_to_sheet(data) XLSX.utils.book_append_sheet(wb, ws, name) }) XLSX.writeFile(wb, fileName) }调用的时候传一个数组进去,每个元素对应一个 sheet:
exportMultipleSheets([ { name: '设备状态', data: deviceStatusList.value }, { name: '告警记录', data: alarmList.value }, { name: '值班人员', data: dutyList.value } ])这里的坑主要在中文 sheet 名上。Excel 对 sheet 名的限制是 31 个字符以内,且不能包含\/?*[]:这些特殊字符,导出前要做一层校验。另外json_to_sheet生成的列顺序默认跟对象 key 顺序一致,如果表格列很多、字段名不直观,可以先做一层字段映射再转。
如果是超大表格(几万行以上),SheetJS 的社区版性能比较吃力,可以考虑用 exceljs 做流式写入。但对绝大多数后台管理场景来说,xlsx 库已经完全够用。
4.3 WebSocket 实时推送与组件生命周期管理
数据看板页面要展示设备实时状态,用轮询接口虽然简单但浪费资源,而且不及时。更合理的方案是 WebSocket 长连接,让后端把变化主动推过来。
组件里的基本用法:
import { onMounted, onBeforeUnmount } from 'vue' let socket = null function connectWebSocket() { const protocol = location.protocol === 'https:' ? 'wss://' : 'ws://' socket = new WebSocket(`${protocol}${window.location.host}/ws/dashboard`) socket.onopen = () => { console.log('WebSocket 已连接') } socket.onmessage = (event) => { const data = JSON.parse(event.data) // 根据消息类型更新对应图表 if (data.type === 'device-status') { deviceStatusList.value = data.payload } } socket.onclose = () => { console.log('WebSocket 已断开') } } onMounted(connectWebSocket) onBeforeUnmount(() => { socket?.close() })这里最容易被忽略的问题是:组件被销毁之后,WebSocket 连接可能还挂着。尤其是在路由切换的场景下,如果不主动 close,连接会一直占用资源。所以onBeforeUnmount里关闭连接是必须的。
如果想做得更完整,可以参考前面那个useWebSocketcomposable,把断线重连、心跳检测、消息分发都封装进去。心跳检测这块很多团队会忽略,实际上很多网关设备会在一定时间后自动断开空闲连接,客户端需要定期发送 ping 保持存活,否则会突然掉线。
4.4 扫码与地图:两个高频集成场景
热词里还有 “vue html5-qrcode” 和 “vue 地图”。扫码场景常见于设备巡检、物料入库这类移动端 H5 页面。html5-qrcode 这个库可以直接调用摄像头识别二维码:
npm install html5-qrcode核心用法是创建一个 Html5Qrcode 实例,传入页面上的 div id,然后 start 摄像头扫描。注意这个功能必须走 https 或者 localhost,因为浏览器只有在安全上下文里才允许调用摄像头权限。
地图的话,国内项目用腾讯地图或高德地图都行,都有对应的 Vue 组件封装。腾讯地图一个比较典型的操作是初始化地图之后添加标记点:
const map = new TMap.Map(document.getElementById('map'), { center: new TMap.LatLng(39.908, 116.397), zoom: 11 })这类引入全局 SDK 的操作有个共性:SDK 是直接在 HTML 里通过 script 标签加载的,Vue 组件里要用的时候会找不到全局变量。我的处理方式是在index.html里加载 SDK,然后在组件里把全局变量取出来放到响应式数据里,或者直接使用window.TMap。如果你用 TypeScript,可以给 window 上声明自定义属性,避免类型报错。
5. 前后端联调与框架选型:Spring Boot 搭档 Vue 的实战姿势
热词里有“基于 springboot vue 的项目”“springboot vue 前后端分离”“若依 vue 开源项目在 idea 中部署”,说明很多人其实就是 Vue + Spring Boot 这个组合。这个组合非常成熟,但联调阶段有很多细节。另外也有不少人会纠结 Vue、React、Angular 到底选什么,我在这一节里把我的看法和实际经验都说一下。
5.1 Vite 代理配置:开发环境跨域解决方案
前后端分离之后,前端跑在 5173 端口,后端跑在 8080 端口,直接请求接口必然跨域。解决方式有两种:后端配 CORS 或者前端配代理。我推荐开发环境用前端代理,因为这样后端不用改任何配置,也更贴近生产环境的 nginx 转发方式。
Vite 项目在vite.config.ts里配:
export default defineConfig({ server: { port: 5173, proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true, // 如果后端接口没有 /api 前缀,就需要重写 rewrite: (path) => path.replace(/^\/api/, '') } } } })配置完成后,前端请求/api/user/login就会被转发到http://localhost:8080/user/login。这样做的好处是,前端代码里统一写/api开头,后端换地址时只需要改代理配置,不用动业务代码。
changeOrigin: true这个参数要记得设置,否则后端如果校验请求来源的 Host 头,会出现奇怪的 403 或者 Nginx 转发失败的情况。
5.2 部署环境 nginx 的 try_files 配置
打包之后的前端是纯静态文件,交给 nginx 托管时,有个非常经典的 404 问题:你直接访问http://your-site/dashboard会 404,但如果访问http://your-site/再点按钮跳过去就正常。原因是 vue-router 用的是 history 模式,路由路径在服务端并不存在对应的物理文件,nginx 默认会去找/dashboard这个文件或目录,找不到就 404。
解决办法是配置 try_files,让 nginx 把所有请求都指向 index.html:
location / { root /usr/share/nginx/html; index index.html; try_files $uri $uri/ /index.html; }try_files $uri $uri/ /index.html的意思很简单:先看有没有对应的静态文件,有就直接返回;没有就返回 index.html,由前端路由接管。
配套的还有静态资源缓存策略。打包后的 JS/CSS 文件名都带了 hash,可以放心设置长时间缓存:
location /assets/ { root /usr/share/nginx/html; expires 30d; }5.3 Vue、React、Angular 怎么选
这个问题没有标准答案,但可以根据团队情况和项目类型做判断。我的真实感受是:中小型后台管理系统、内部工具、快速原型,Vue 的上手速度和开发效率是明显优势;大型复杂应用、需要高度可控性和生态完整性,React 更稳;Angular 适合团队本身有 Java/面向对象背景、需要强约束的大型企业级项目,但学习曲线最陡。
选技术栈不要只追热点。你所在的城市招什么岗位多、团队里的人熟悉什么、项目交付周期有多紧,这些因素比“哪个框架更流行”重要得多。技术没有绝对的优劣,适合团队和业务的才是最好的。
5.4 开源项目参考:若依框架的搭建价值
如果有人想学 Vue + Spring Boot 前后端分离的完整套路,若依(RuoYi)是一个非常值得研究的开源项目。它把权限管理、用户管理、菜单管理、日志管理这些后台管理系统的基础设施都做好了,前端基于 Vue + Element Plus,后端基于 Spring Boot。
在 IDEA 里部署若依,一般分三步:
- 导入后端工程,配置
application-druid.yml里的数据源信息,执行项目里的 SQL 脚本。 - 导入前端工程,
npm install之后npm run dev。 - 后端启动后,前端通过代理访问
/dev-api转发的后端接口。
跑通一遍若依的最大价值不是让你直接拿它改业务,而是让你亲眼看到一套完整的前后端分离项目是怎么组织代码的:权限拦截器怎么写、字典数据怎么管理、日志怎么记录、菜单和按钮权限怎么控制。把这些代码读一遍,比看十篇零散教程收获都大。
6. 打包部署与布局异常排查:那些坑我替你踩过了
热词里有一条“vue 打包后 布局异常”,这个现象出现的频率比很多人想象中高得多。明明开发环境一切正常,npm run build 之后部署到服务器上,页面要么白屏,要么样式全乱,要么图标全部消失。这一节我把最可能的原因和排查思路全部列出来。
6.1 绝对路径问题:资源的 base 配置
最常见的原因:打包时资源路径写死了绝对路径,导致部署到子目录时,HTML 里引用的 JS/CSS 都指向了域名根目录,结果 404,页面自然白屏。
Vite 打包默认base是/,如果你的应用部署在域名根目录,没问题。但如果部署在http://example.com/dashboard/这种子路径下,就需要修改vite.config.ts:
export default defineConfig({ base: '/dashboard/' })打包之后 index.html 里引用的资源路径就会变成/dashboard/assets/xxx.js。如果前后端都在同一个域名下,且放在不同子目录,这个配置算是必填项。
6.2 路由 history 模式下的刷新 404
这个前面提到过,刷新页面就 404 也是部署后常见问题。用 history 模式就必须配 nginx 的 try_files,没有别的捷径。如果不愿意动 nginx 配置,也可以把 vue-router 切成 hash 模式:
const router = createRouter({ history: createWebHashHistory(), routes })但 hash 模式的 URL 会带一个#,比如http://example.com/#/dashboard,大部分人会觉得丑丑的。我的建议是能配 nginx 就配 nginx,别为了省事牺牲 URL 体验。
6.3 样式丢失与布局异常的完整排查链路
样式异常的问题比白屏更难排查,因为没有统一报错。我把排查链路整理成固定顺序,遇到问题按这个顺序查,基本都能定位。
第一个检查项是浏览器控制台的网络请求。看 CSS 和字体文件是否请求失败。如果 CSS 文件状态是 404,那就是 base 路径问题,按 6.1 的方式处理。如果字体图标 404,同样是因为 base 路径不对,图片和字体的引用都是在打包时根据 base 生成的。
第二个检查项是 Element Plus 等组件库的样式是否被正确加载。如果你用了组件的按需导入,需要确保配置了对应的样式插件。手动导入组件时只导入了 JS 没有导入样式文件的情况很容易出现,表现就是布局全部都错位,像没穿衣服一样。
第三个检查项是scoped样式的问题。如果一个组件里想覆盖子组件内部的样式,尤其是想修改第三方组件库的内部样式,scoped会导致选择器加上 data 属性,而子组件内部的元素没有这个属性,样式就不生效。解决办法是 Vue 提供的深度选择器:deep():
:deep(.el-table__header) { background-color: #f5f7fa; }编译后它会把 data 属性加到前面的父级选择器上,子组件内部的选择器就正常生效了。这个功能是写 Vue 组件样式时必须掌握的,否则你会一直在“样式不生效”的坑里打转。
第四个检查项是浏览器缓存。部署新版本后,用户浏览器可能还在用旧的 CSS 文件,表现就是你改的样式完全没生效。解决方式是打包时开启文件名 hash,这个 Vite 默认已经做了;部署时注意 nginx 对静态资源不要设置过短的缓存时间。再不行就让用户强制刷新(Ctrl+Shift+R),但这是兜底手段,不能当作常规方案。
6.4 打包体积优化的两个常用手段
部署之后还要关注体积。首次打开页面如果加载了 1MB 以上的 JS,体验会非常差。除了前面说的路由懒加载,还有两个常用的处理办法。
第一个是手动分包。Vite 支持在构建配置里把体积较大的库单独拆出来:
build: { rollupOptions: { output: { manualChunks: { echarts: ['echarts'], 'element-plus': ['element-plus'] } } } }把 ECharts、Element Plus 这类大而独立的库拆成单独 chunk,浏览器可以并行加载,而且只要版本不变,chunk 的 hash 不变,用户再次访问时可以直接命中缓存,不用重新下载。
第二个是使用unplugin-auto-import和unplugin-vue-components让组件和 API 按需导入。如果项目不大、团队比较小,也可以先手动 import,等发现体积确实大了再引入这两个插件,没有必要一开始就把自动化配置全部铺开,增加项目复杂度。
如果部署后遇到布局异常,我个人的建议是:先在本地跑一遍npm run build && npm run preview,在本地起一个静态服务器模拟线上环境,看能不能复现。如果能复现,问题大概率出在打包配置;如果不能复现,那就要怀疑 nginx 配置和服务器环境,比如 gzip 是否开启、MIME 类型是否正确。静态资源请求一直 404 的话,这种问题看控制台就能定位到。
从技术选型到环境搭建,从路由到状态管理,从功能开发到部署上线,一个 Vue 项目真正走完一遍之后你会发现,阻碍你的往往不是某个特别深奥的原理,而是那些“书上没写、文档找不到、只有踩过坑才知道”的细节。我写这篇文章的目的,就是把我踩过的这些坑直接摆在你面前,让你能绕开它们,把精力花在真正有价值的业务代码上。