我一开始学 Vue 的时候心里想的是这不就是个模板语法嘛结果真正动手才发现从环境搭建到项目跑通中间全是坑。尤其是路由和接口请求这两个环节花掉的时间比写页面多好几倍。这篇心得不是官方文档的复述是我从零开始搭项目、调路由、发请求的真实过程里面有不少我踩过的坑和最后的解决办法希望对准备入门 Vue 或者刚学到中间卡住的朋友有用。1. 环境搭建第一个痛苦是 Node 版本不是 Vue 本身1.1 我为什么建议先装 nvm而不是直接装 Node很多人入门 Vue 的第一步是打开官网然后直接下载 Node.js 最高版本。我之前也是这么干的结果后面吃了个闷亏vue create创建项目时依赖安装老出错换个项目又提示某个包不支持当前版本。最后查了一圈才发现问题出在 Node 版本太高某些编译原生模块的依赖跟不上。如果你的电脑上还没有 Node我强烈建议先装一个 nvmNode Version Manager用来随时切换 Node 版本。不用纠结装哪个版本看 Vue CLI 或者 Vite 的官方要求一般选 LTS 版本就够稳。我现在的习惯是 nvm 装好之后默认用一个长期支持版本遇到老项目再切换。装好 nvm 之后验证一下nvm list node -v npm -v如果能看到版本号基础环境就通了。这里有一个容易忽略的地方很多人只检查 node 版本不检查 npm 版本但 npm 版本过低会导致后续安装依赖特别慢甚至卡在某个包上不动。建议顺手把 npm 也更新到跟 Node 匹配的最新版。1.2 脚手架安装和国内镜像的那些事环境变量配好了接下来就是安装 Vue 脚手架。我用的是 Vue CLI 方式因为它的项目结构对新手更友好各种配置项都有默认值适合先跑起来再说。命令很简单npm install -g vue/cli但这里有一个国内用户很难绕过去的坎直接 npm install 经常卡在等待响应上。我试过挂代理、换 DNS都不如直接换镜像源有效。最简单的方式是给 npm 设置一个国内镜像npm config set registry https://registry.npmmirror.com设置完检查一下npm config get registry返回的地址是镜像地址就可以放心装了。这一步是我的经验之谈——如果你后面安装任何依赖都很慢先检查 registry 是不是被改回去了。1.3 创建项目时的选项怎么选CLI 装好后创建项目走一遍交互流程vue create my-first-vue-app会出现两个大选项Default默认预设Manually select features手动选择功能第一次我选了 Default确实很快但后面学路由的时候发现项目里没有 router 文件还得自己手动加麻烦。第二次我改成手动选择把 Babel、Router、Vuex 或者 Pinia、Linter 都勾上。先不用管每一项具体干嘛后面用到的时候自然会懂。关键是让项目自带 router可以省掉后面很多手工配置的时间。创建完成后进入项目目录cd my-first-vue-app npm run serve浏览器打开http://localhost:8080看到一个带导航的默认页面说明项目已经跑起来了。到这里环境搭建才算真的结束。我见过很多新手卡在npm install这一步就放弃了其实只要把 Node 版本和镜像源搞定后面非常顺。2. 拿到项目后我花了一晚上才看懂的目录结构2.1 main.js、App.vue 和组件的关系项目跑起来的第一天我盯着目录结构发了很久的呆。src下面一堆文件和文件夹我不知道该看哪个、改哪个。后来我把整个启动流程捋了一遍才弄明白它们之间的关系。所有事情的核心在src/main.js。这个文件是整个应用的入口它做的事情很简单创建应用实例、挂载路由、挂载到页面上。import { createApp } from vue import App from ./App.vue import router from ./router/index.js createApp(App) .use(router) .mount(#app)App.vue是根组件它里面通常只放了一个router-view /这个标签的作用是告诉 Vue路由匹配到的页面组件显示在这个位置。我当时的理解是main.js是总开关App.vue是页面容器router决定哪个页面进容器。后面写再多组件都是往这个框架里填东西。2.2 单文件组件的三分结构Vue 项目里的.vue文件看起来像是一个文件其实包含了三个部分模板template、逻辑script、样式style。我第一次看到这种结构很不习惯因为之前写 HTML、CSS、JS 都是分开的现在一个文件全搞定。后来发现这种结构有个好处每个组件自成一体改一个按钮不会影响别的页面。template div classcard h1{{ title }}/h1 button clickhandleClick点击/button /div /template script setup import { ref } from vue const title ref(Vue 学习笔记) function handleClick() { alert(你点击了卡片) } /script style scoped .card { border: 1px solid #ddd; padding: 20px; } /stylescoped关键字非常重要。初次写样式时我忘了加scoped结果发现一个组件的样式跑到了另一个组件里页面乱成一锅粥。加了scoped之后Vue 会自动给当前组件的元素加一个 data 属性让样式只在这个组件内生效。2.3 热更新给我带来的信心刚开始写代码时我最大的心理障碍是改错了会不会把项目搞崩。好在 Vue CLI 自带热更新保存文件之后浏览器页面自动刷新甚至不需要手动刷新。这个功能对新手太友好了我可以随时改代码、随时看效果不用反复执行启动命令。但热更新也有一个副作用有时候改到一半代码是残缺的保存后页面报错红屏一片。我第一次看到红屏以为自己把项目弄坏了慌得不行。后来发现只要把代码改回正确状态页面就恢复正常了。所以遇到红屏不要怕先看报错信息大部分情况只是语法错误不是项目炸了。3. 路由学习从页面跳转到参数传递3.1 为什么路由文件要单独拆出来路由是 Vue 项目里页面怎么跳的解决方案。一开始我不理解为什么不能直接写a标签跳转后来亲自试了一遍才明白用a标签跳转浏览器会重新加载页面整个应用状态全部丢失体验很差。路由的作用是在不刷新浏览器的情况下通过 JavaScript 控制 URL 变化和组件切换这也是单页应用SPA的核心。Vue CLI 创建的项目会在src/router/index.js里生成路由配置。把路由单独放一个文件而不是写在main.js里是为了方便维护——项目大了之后路由可能几十条放在入口文件里会非常混乱。路由基本配置import { createRouter, createWebHistory } from vue-router import Home from ../views/Home.vue const routes [ { path: /, name: Home, component: Home }, { path: /about, name: About, component: () import(../views/About.vue) } ] const router createRouter({ history: createWebHistory(), routes }) export default router这里有一个需要留心的地方Home 组件用的是直接 importAbout 组件用的是() import()。前者的意思是项目启动时就把 Home 加载进来后者是访问 /about 这个地址时才去加载 About 组件。这种写法叫路由懒加载可以缩小首屏加载的体积。项目刚开始可能感觉不到差别但后面页面多了懒加载能显著加快首页打开速度。3.2 query 和 params 参数的区别以及刷新丢参数的坑路由学到参数传递的时候我踩了一个影响挺深的坑。当时想在列表页点击一条数据跳转到详情页并把这条数据的 id 带过去。浏览器地址栏里最直观的方式就是问号参数也就是 query// 列表页跳转 router.push({ path: /detail, query: { id: 123 } })跳过去之后在详情页拿参数import { useRoute } from vue-router const route useRoute() console.log(route.query.id) // 123这种方式的好处是参数直接在 URL 里刷新页面参数还在适合分享链接。另一种方式是 params 参数配合命名路由使用router.push({ name: Detail, params: { id: 123 } })然后需要在路由配置里配合动态路径{ path: /detail/:id, name: Detail, component: Detail }这种情况下URL 变成/detail/123刷新也不会丢。但如果你用了 params 却没有在 path 里配置:id仅仅是在跳转时传了 params那么刷新页面之后参数就丢了因为 URL 里根本没有记录它。这个坑我踩过排查了半天才发现是路由配置的问题。3.3 嵌套路由和路由守卫扫盲实际项目里几乎不可能只有一层页面。比如一个后台管理系统左侧是菜单栏右侧是内容区——这时候就需要嵌套路由让子组件在父组件的某个区域显示。嵌套路由是在父路由的children里配置{ path: /admin, component: AdminLayout, children: [ { path: user, // 最终路径是 /admin/user component: () import(../views/admin/User.vue) }, { path: setting, component: () import(../views/admin/Setting.vue) } ] }父组件AdminLayout.vue里要放一个router-view /否则子路由不知道渲染到哪里。路由守卫是另一个实用功能它的作用是在页面跳转前执行一段逻辑。最典型的场景是登录判断如果用户没登录跳转任何页面都重定向到登录页。我写过最简单的全局前置守卫router.beforeEach((to, from, next) { const token localStorage.getItem(token) if (to.path ! /login !token) { next(/login) } else { next() } })第一次看到这代码时我疑惑next到底要调几次。经验是所有分支必须且只能调用一次next()否则路由会卡住不跳转。后来项目里我习惯用return的方式代替next逻辑更清晰router.beforeEach((to) { const token localStorage.getItem(token) if (to.path ! /login !token) { return /login } })3.4 路由懒加载和 404 兜底懒加载前面提过了我再补充一个细节在路由配置里加一条兜底规则让所有未匹配的路径都指向一个 404 页面不然用户输错地址就看到一片空白。{ path: /:pathMatch(.*)*, name: NotFound, component: () import(../views/NotFound.vue) }注意 Vue Router 4 的写法是/:pathMatch(.*)*老版本的写法已经废弃了。第一次写错时页面一直报错我还以为是 404 组件的问题后来才发现是通配符语法不对。4. 接口请求阶段跨域、封装和重复初始化问题4.1 前端请求用 axios为什么还要做封装页面能跳转了接下来就是要跟后端要数据。Vue 项目里最常用的是 axios因为它基于 Promise支持请求和响应拦截而且浏览器兼容性比 fetch 做得更好。我最初是直接在组件里写的请求代码axios.get(/api/user).then((res) { userList.value res.data })用起来其实没毛病但写多了之后发现里面全是重复代码同意的 baseURL、超时时间、请求头里的 token、错误处理的弹出提示。后来我学着把 axios 封装成了一个独立的模块单独放到src/utils/request.js里import axios from axios import { Message } from element-ui // 或者别的 UI 库的提示组件 const request axios.create({ baseURL: process.env.VUE_APP_BASE_URL || /api, timeout: 10000 }) // 请求拦截器每次请求自动带 token request.interceptors.request.use((config) { const token localStorage.getItem(token) if (token) { config.headers.Authorization Bearer ${token} } return config }) // 响应拦截器统一处理错误码 request.interceptors.response.use( (response) { return response.data }, (error) { Message.error(error.response?.data?.message || 请求失败) return Promise.reject(error) } ) export default request封装之后组件里的请求代码变成import request from /utils/request const res await request.get(/user) userList.value res.data每个页面不用关心 token 怎么加、错误怎么提示这些都在拦截器里统一处理了。4.2 不能每次请求都 new 一个实例单例模式的思维这是我的一个比较深刻的教训。最早学 axios 实例的时候我对axios.create的用法没有理解透彻在封装文件里每次请求都动态创建了一个新实例export function getUser() { // 错误示例每次调用都新建实例 const instance axios.create({ baseURL: /api }) return instance.get(/user) }一开始感觉没啥问题但项目里打开页面时要并发请求十几个接口浏览器控制台里出现大量连接建立和销毁而且有些请求莫名其妙慢。后来我意识到axios 实例应该只创建一次整个应用复用同一个实例。这就是单例模式的应用——一个类或者一个对象在整个应用生命周期里只初始化一次其余地方全部复用。这也关联到一个我在后端同事那里学到的经验他做模型推理服务时如果把模型加载写在每个请求处理函数里每来一个请求就重新加载一次模型响应时间会非常夸张。正确做法是在进程启动时加载一次放到内存里复用一个单例。前端封装 axios 也是同样的思路只是本质上没有那么重但思维模型是一样的。修正后的写法就是上面request.js里那种模块顶层const request axios.create(...)然后到处 import 引用同一个实例。这样可以复用连接设置统一配置拦截器也只注册一次。4.3 跨域问题不是后端改就是前端代理改前端写完代码联调接口的时候十有八九会遇到跨域报错。浏览器控制台里是熟悉的CORS字样。我遇到的场景是前端跑在http://localhost:8080后端接口在http://localhost:3000两边端口不同浏览器认为这是跨域把请求拦下来了。在开发环境最省事的办法是用脚手架自带的代理功能。在项目根目录建一个vue.config.js文件module.exports { devServer: { proxy: { /api: { target: http://localhost:3000, changeOrigin: true, pathRewrite: { ^/api: } } } } }这样配置之后前端代码里请求/api/user开发服务器会把它转发到http://localhost:3000/user。pathRewrite的作用是去掉/api前缀因为后端接口路径里可能没有/api这一段。这里要注意改了vue.config.js之后必须重启npm run serve不是保存就生效。我第一次改完没重启满脑子问号为什么代理没生效排查了半天才想起来要重启。生产环境的跨域通常由 nginx 反向代理来解决原理跟 devServer proxy 相似这里就不展开了。总之跨域问题的解决思路不是前端绕过去而是把同源的请求地址通过代理转发到后端让浏览器认为它们是同一来源。4.4 接口统一管理URL 千万不要散落在各个页面请求写熟练之后我又发现另一个问题接口地址到处乱写今天在用户页面写了一个/api/user/list明天在首页又写了一遍同样的地址后天后端改了路径我得全局搜索替换效率极低。后来我把所有接口集中到一个文件里管理// src/api/user.js import request from /utils/request export function getUserList(params) { return request.get(/user/list, { params }) } export function createUser(data) { return request.post(/user, data) }组件里只依赖这些函数不直接写 URL。后端改路径时只需改 API 文件一个地方。这个习惯越早养成越好项目大了之后会感谢自己当初做了这个决定。5. 几个让我印象很深的问题复盘5.1 每次请求都初始化模型问题的完整排查链路之前提到单例模式这里补一个我在真实项目中遇到的问题正好对应我前面说的如何保证不会每次请求都初始化模型。当时有一个功能需要用到本地的一个模型推理工具同事把它包装成 HTTP 接口供前端调用。前端请求这个接口第一次调用非常慢要好几秒后续调用就快了。但服务端日志显示每次请求都打印模型初始化的日志。排查过程是这样的先确认是不是前端每次都新建了连接或请求实例——查完之后发现前端没问题用的是同一个 axios 实例。再看服务端代码——原来模型初始化的代码写在request_handler函数内部每次请求进来都执行一次。确认根因——模型加载是 CPU 密集且耗时很长的操作放到请求处理里等于每次请求都重新加载一遍模型日志自然每次都打。修复方式——把模型初始化的代码移动到进程启动阶段在全局只加载一次后续请求直接复用。进程里保存一个全局句柄处理请求时只调用推理不重新加载。用压测工具验证——修复前并发 10 个请求时部分请求超时修复后响应时间直接降到毫秒级。这个案例给我的启发挺深很多性能问题不是并发量的问题而是重复初始化导致的无谓开销。前端后端的道理是一样的资源只加载一次别的模块复用这种模式在项目里值得有意识地使用。5.2 视频流 m3u8 播放的尝试项目里有个需求要在 Vue 页面里播放监控视频流视频源是 m3u8 格式。我一开始直接用video标签发现除了 HLS 原生支持的浏览器外Chrome 和 Firefox 都放不了。后来查了一下给 m3u8 补充了 hls.js 库来处理。核心思路是把 m3u8 视频流交给 hls.js 解析然后喂给 video 标签播放。template video refvideoEl controls/video /template script setup import { ref, onMounted } from vue import Hls from hls.js const videoEl ref(null) onMounted(() { if (Hls.isSupported()) { const hls new Hls() hls.loadSource(https://example.com/stream/index.m3u8) hls.attachMedia(videoEl.value) hls.on(Hls.Events.MANIFEST_PARSED, () { videoEl.value.play() }) } }) /script这不是一个特别深入的需求但它让我明白了一个规律前端很多看起来标签搞定的事情实际场景里往往有兼容性限制搜索一下对应库就能解决不需要自己从零实现。5.3 组件里自定义 v-model 的启发学到组件通信时我给一个表单子组件封装了一个校验输入框希望父组件能直接用它像用原生 v-model 一样方便。Vue 3 里组件的 v-model 本质上是modelValueprop 加上update:modelValue事件。子组件写法template input :valuemodelValue input$emit(update:modelValue, $event.target.value) / /template script setup defineProps([modelValue]) defineEmits([update:modelValue]) /script父组件使用MyInput v-modelusername /这个能力让我对v-model 不过是语法糖有了具体理解。真正理解了底层机制之后写复杂表单的复用就不再是难点。6. 学习这段内容的几个经验回头看看整个从环境搭建到路由再到接口请求的过程真正拖慢进度的往往不是知识点本身而是环境问题和思维模式问题。环境问题可以通过换镜像源、管理 Node 版本来解决这些是可以整理的固定方案。思维模式问题则需要一点点做项目、踩坑才能转变比如单例思想、封装思想。如果你正在学 Vue 并卡在中间某个环节我的建议是不要只看不写跟着教程敲一遍然后删掉重写一遍。第一遍敲的时候脑子是跟着手走的第二遍才是真正理解的时候。遇到报错先复制报错信息去搜索别急着问人这个习惯能锻炼排查问题的能力也是后面做项目的基本功。能动手解决一个问题比看完十篇博客都有用。