
1. 先搞清楚组件化到底解决了什么问题上一篇里我们聊了 Vue 的基础语法、响应式原理和生命周期这算是迈进了 Vue 的大门。但如果你只是会用v-for渲染列表、用v-model绑定表单那离真正能用 Vue 做项目还有一段距离这段距离的核心就是组件化编程。拿我自己的经历来说刚接触前端那会儿写页面特别喜欢“一把梭”所有东西都堆在同一个模板里顶部导航、侧边栏、内容区、弹窗、分页器全混在一起。刚开始还挺爽代码翻起来一目了然哪块内容都找得到。可项目一旦超过三四个页面尤其是同一个导航栏需要在每个页面复制粘贴一遍的时候噩梦就开始了。改一个链接要全局搜索替换改完这个忘了那个线上出问题排查半天最后发现是某一份拷贝漏改了。组件化就是干这个用的——把页面拆成一个个独立、可复用、职责单一的部件。导航栏是一个组件分页器是一个组件图片上传是一个组件就连一个带图标的按钮都可以是一个组件。每个组件只管自己的那摊事情对外只暴露必要的接口内部怎么实现外部不用关心。1.1 没有组件化之前的“面条代码”所谓“面条代码”就是逻辑、样式、结构全绞在一起。我在带新人时经常拿一个真实场景举例一个后台管理系统左侧菜单栏和顶部导航几乎每个页面都有。不组件化的写法是每个页面都复制一整段 HTML 结构再各自绑定各自的data。这还只是结构层面的重复。更麻烦的是交互逻辑比如菜单收起展开、用户下拉菜单的显隐也得跟着复制。这种做法的代价有三个。第一是改一处忘一处改版的遗漏率极高第二是样式相互污染每个页面里都写了一遍菜单样式后来为了适配某个页面加了点覆盖样式结果其他页面里同一类名的菜单也变了第三是新人接手成本奇高代码量膨胀真正有业务逻辑的部分淹没在重复代码里。组件化的做法就很直接一个SideBar.vue文件里面把菜单的数据请求、展开收起逻辑、选中高亮样式全部封装好。哪个页面需要就import进来然后写一个SideBar :menusmenus /标签传一个数据进去就完事。1.2 组件化的核心思路分治、复用、解耦一个合格的组件化方案背后其实是三个关键词分治、复用、解耦。分治很好理解把一个复杂页面拆成若干小部件每个小部件只解决一个问题。这样开发的复杂度没有消失但被分解了。你可以把组件一个个单独开发、单独测试最后拼装起来。复用则是组件化最大的红利。拿我自己做过的电商后台项目来说商品列表、订单列表、用户列表表格结构大同小异区别只是列头、数据源、操作按钮。把表格封装成一个通用组件传入不同的列配置和数据三个页面加起来省了将近一半的代码量。解耦这块值得多说两句。组件化要求组件之间尽量少地直接依赖彼此。一个组件内部自己管理自己的状态外部数据通过props传入内部事件通过emit通知外部。这样组件内部怎么改外部感知不到只要接口不变重构组件内部逻辑不会影响其他代码。1.3 组件通信的基本盘props 与 emit 的完整闭环组件化绕不开组件通信。无论你是用 Vue 2 还是 Vue 3props父传子和emit子传父永远是核心地盘。父传子用props这个大家都知道。但实际开发中很多新手容易忽略props的约束问题。在 Vue 3 中定义props时推荐写出完整的类型和默认值而不是简单写一个数组// 推荐的写法 const props defineProps({ visible: { type: Boolean, default: false }, title: { type: String, default: }, dataSource: { type: Array, default: () [] } })有个细节很容易被忽略对象和数组类型的default必须用工厂函数返回也就是() []这种形式。如果直接写成default: []多个组件实例会共用一个数组引用这个坑在页面列表数据错乱时特别难排查。子传父用emit在 Vue 3 的组合式 API 里// 子组件中定义可触发的事件 const emit defineEmits([close, submit]) // 某个事件触发 emit(close, payload)父组件监听时如果事件逻辑稍微复杂一点建议不要在模板里写太长表达式而是抽成一个方法Dialog closehandleClose submithandleSubmit /原生 DOM 事件和组件自定义事件在简写上看起来一样但组件自定义事件不会冒泡这一点得记牢。如果发现子组件里emit了事件父组件却接收不到先检查事件名拼写再看是否在同一层级的父子关系上监听。除了props和emit还有一类场景叫兄弟组件通信。两个组件不在同一父子层级直接传 props 很别扭。常用的解决方案有两种一种是把公共状态提升到父组件由父组件分别向两个子组件传 props再处理两个子组件各自 emit 上来的事件——这种“状态提升”的做法在小项目里特别实用另一种是引入事件总线或全局状态库比如 Pinia当组件层级很深、状态参与方很多时再用。一上来就上状态管理很容易把简单问题复杂化我建议先用状态提升扛不住了再换 Pinia。2. 脚手架不是花架子用 create-vue 快速搭一套工程体系组件化编程讲得再热闹最终都要落到工程里跑起来。很多新手学习组件的时候还在用单个 HTML 文件里引 CDN 的方式组件只能用字符串模板拼一会儿语法报错一会儿编码不对一会儿文件太大加载慢。这种“非工程化”的开发方式只能用于学习阶段演示真正做项目必须把脚手架用起来。脚手架解决的不是“能不能跑”的问题而是“怎么带着团队稳定地跑”的问题。文件怎么组织、依赖怎么管理、代码怎么构建、开发服务器怎么热更新、生产产物怎么优化这些琐碎又必须的事情脚手架全部帮你处理好了。2.1 为什么现在推荐 create-vue而不是 vue-cliVue 官方曾经主推的脚手架是vue-cli底层基于 Webpack。但现在官方推荐的创建方式已经变成了create-vue底层用的是 Vite目前新项目基本上都走这条路线。Vite 和 Webpack 最大的区别在于开发服务器的启动方式和热更新机制。Webpack 启动时要先把整个项目打包一遍项目一大启动时间动辄几十秒甚至几分钟。Vite 不一样它先把模块关系理清楚按需编译浏览器请求哪个模块才编译哪个模块所以大型项目在 Vite 下的冷启动速度远快于 Webpack。热更新这块Vite 也做得更细。改一个组件它只替换这一个组件对应的模块不会像 Webpack 那样经常要把整个依赖图重新算一遍。这个体验在项目变大后会非常明显开发效率完全是两个量级。还有一个不能忽视的因素官方维护的重心已经全面转向 Vitevue-cli进入了维护模式不再添加新功能。新项目再用 Webpack 那套方案等于开张就落后。2.2 环境检查与创建项目几个容易被忽略的小坑动手创建项目之前先检查环境。create-vue对 Node.js 的版本有要求Node 18.3 及以上或者 20 版本比较稳妥。很多人在第一步就卡住多半是 Node 版本太老。安装 Node.js 我用的是nvm-windowsWindows 上或nvmmacOS/Linux这样可以在多个 Node 版本之间灵活切换。不推荐直接在官网下载安装包因为后续项目切换版本时会很痛苦。装完 Node 之后顺手把 npm 的镜像源切到国内可用源这样创建依赖时不会被网络拖死# 检查当前 Node 版本 node -v # 创建项目 npm create vuelatest my-vue-app执行创建命令后会有一个交互式问卷问你要不要装 TypeScript、JSX、路由、Pinia、ESLint、Prettier 等。我的建议是如果是学习阶段可以先不装 TypeScript但路由和 Pinia 建议选上因为绝大多数实际项目都绕不开这两个ESLint 和 Prettier 也建议一开始就装后面代码规范问题会省很多心。创建完成之后进入目录安装依赖cd my-vue-app npm install安装依赖这块有个小坑如果直接npm install比较慢可以先确认镜像源是否配置正确或者直接用pnpm。个人体感 pnpm 在磁盘占用和安装速度上优于 npm新项目用pnpm install基本没什么障碍。2.3 项目结构里的分工逻辑create-vue生成的项目结构看起来很清爽但新手往往搞不清楚每个文件夹是干嘛的。我按实际开发中的使用频率逐个说一遍。src目录是核心代码区里面有views、components、router、stores、assets这几个关键目录。views放页面级组件通常一个路由对应一个页面components放通用型组件比如弹窗、表格封装、分页器这类可以在多个页面复用的东西router放路由配置stores放 Pinia 状态定义assets放静态资源。public目录放那些不需要构建处理的静态文件比如favicon.ico以及一些通过绝对路径引用的静态资源。注意区分public和assetsassets里的文件会被构建工具处理可以经过压缩、指纹化public里的文件原样拷贝到发布目录路径直接引用即可。有个新手容易犯的错误把所有图片都塞到assets里然后发现引用路径怎么写都不对。其实如果图片不做加工处理不经过任何构建优化放在public下用/images/xxx.png引用反而更省事。关于组件命名的规范我也多说一句组件文件名推荐使用 PascalCase比如SideBar.vue、UserTable.vue。这样在模板里使用时能清晰看出这是一个组件标签而不是原生 HTML 元素。2.4 从页面到组件一次改造体验工程化流程搭建好脚手架之后最快理解组件化和工程化关系的方式是拿一个页面做一次“组件化改造”。我以最简单的后台页面为例假设现在有一个views/UserList.vue它里面包含了顶部搜索表单、用户表格、分页器、底部状态栏。先不要急着全部拆开先理清楚页面里哪些部分是“用户列表”特有的哪些是“几乎所有列表页都用得到的”。通常搜索表单是业务特有的先不拆。表格和分页器往往可以通用化。于是我可以把表格封装成components/CommonTable.vue分页器封装成components/AppPagination.vue然后 UserList 页面里分别引入使用。这个改造过程本身就会逼你想清楚一件事每个组件的props和emit边界在哪里表格需要知道列配置columns、数据源dataSource、是否需要多选selectable表格内部的排序、点击行等行为又要通过emit抛给父组件。当你有意识地做这种封装时组件通信的很多细节也就自然而然地掌握了。3. 拿出干货两个高频通用组件的封装全过程组件化编程最练手艺的部分就是封装通用组件。这里挑两个我实际项目中反复用到的场景把完整的封装思路、代码结构和要注意的坑从头到尾捋一遍。3.1 折叠面板从 v-model 到插槽的完整封装折叠面板Collapse是后台管理系统里特别常见的组件比如把搜索条件收纳起来、把详情人肉展示折叠。很多人写折叠面板会从原生details标签入手或者直接在页面里复制粘贴显隐代码。但一个正经的面板组件应该支持v-model来控制展开状态支持自定义标题内容最关键的是——内容区可以传入任意结构。使用插槽slot是核心。插槽的作用是在组件内部留一个“坑”调用方往坑里填什么组件就跑出什么。如果你玩过积木插槽就像积木上的凹槽和凸起不同积木通过标准的接口拼装。我封装折叠面板的逻辑是这样的template div classcollapse-item :class{ active: visible } div classcollapse-header clicktoggle !-- 允许外部自定义整个标题区域 -- slot nameheader :expandedvisible span{{ title }}/span span classarrow {{ visible ? 收起 : 展开 }}/span /slot /div !-- 内容区完全由调用方决定 -- div v-showvisible classcollapse-content slot/slot /div /div /template script setup const props defineProps({ modelValue: { type: Boolean, default: false }, title: { type: String, default: } }) const emit defineEmits([update:modelValue]) const toggle () { emit(update:modelValue, !props.modelValue) } /script这段代码里最关键的是visible和toggle。visible只是props.modelValue的一层包装为什么我不直接用props.modelValue因为父组件传进来的值只能通过emit去改不能直接赋新值否则会违反单向数据流。toggle里emit(update:modelValue, !props.modelValue)就是配合v-model的写法——子组件把新值抛出去父组件收到后再把modelValue更新然后子组件的visible跟着刷新。这样封装出来的组件调用方用起来极其顺手Collapse v-modelisOpen title高级搜索 el-form !-- 这里放任何搜索条件 -- /Collapse调用方完全不用关心它内部怎么切换显隐也不用自己写click。需要注意的一个坑是v-show和v-if的选择。折叠面板内容区是有可能包含输入框的如果用v-if每次折叠都会销毁再创建 DOM输入框里的内容会丢失表单组件的状态也保不住。所以内容区必须采用v-show只做显示隐藏不销毁节点。3.2 图片上传文件选择、预览、列表管理的细节第二个例子是图片上传组件。这个组件比折叠面板要复杂得多因为它涉及文件选择、异步上传、进度反馈、列表展示、删除、重新上传等一整套交互。封装的核心思路是把“上传过程”从业务页面中剥离。业务页面只用关心“我现在有多少张图片”至于怎么选择文件、怎么调接口、怎么显示进度都是上传组件内部的事情。我用 Vue 3 的组合式 API 写了一个简化版template div classuploader !-- 列表区域已上传的图片和待上传的预览 -- div v-for(file, index) in fileList :keyfile.uid classfile-item img v-iffile.url :srcfile.url / span v-else上传中{{ file.percent }}%/span button clickremoveFile(index)删除/button /div !-- 触发选择文件 -- input typefile refinputRef acceptimage/* multiple styledisplay: none changehandleChange / button clickinputRef.click()选择图片/button /div /template script setup import { ref } from vue const props defineProps({ // 用于 v-model 绑定的已上传文件路径数组 modelValue: { type: Array, default: () [] } }) const emit defineEmits([update:modelValue]) const fileList ref(props.modelValue.map((url, index) ({ uid: Date.now() - index, url, percent: 0 }))) // 上传方法实际项目中会替换为 axios 上传 const uploadFile (fileObj) { return new Promise((resolve) { try { // 模拟上传这里可以替换为实际接口 setTimeout(() { fileObj.url URL.createObjectURL(fileObj.raw) fileObj.percent 100 resolve() }, 800) } catch (error) { fileObj.status error } }) } const handleChange async (e) { const files Array.from(e.target.files) for (const file of files) { const fileObj { uid: Date.now() Math.random(), raw: file, url: , percent: 0 } fileList.value.push(fileObj) try { await uploadFile(fileObj) } catch (err) { fileObj.status error } // 将文件路径数组抛给父组件 const newVal fileList.value.filter(i i.status ! error).map(i i.url) emit(update:modelValue, newVal) } } const removeFile (index) { fileList.value.splice(index, 1) const newVal fileList.value.map(i i.url) emit(update:modelValue, newVal) } /script这个版本把很多真实的坑都暴露出来了。第一文件路径的同步问题。我在handleChange里每次上传完成都emit一次新数组而不是等全部完成后一次性 emit。因为真实的接口上传需要时间用户可能在这个过程里又添加了新文件。如果最后统一 emit中间状态丢掉的概率很大。第二删除文件后要立即同步父组件的值。如果你不emit父组件的v-model还是旧数组你这边删得爽父组件却不知道一刷新又回来了。第三预览路径用了URL.createObjectURL(fileObj.raw)。这个 API 生成的链接指向浏览器内存中的文件对象访问速度极快。但有一个配套动作别忘了——在组件卸载时调用URL.revokeObjectURL(url)释放内存否则大量预览后会占着内存不释放。这个细节文档里不一定会跟你说但做后台管理系统用户反复选择图片内存蹭蹭往上涨的时候你就知道这句话的含金量了。3.3 封装时最容易被问到的三个“为什么”第一个“为什么”是为什么属性要用modelValue而不是自己起个名字叫value。因为v-model在组件上本质上是:model-value和update:model-value的语法糖这是 Vue 3 的官方约定。你不是不能叫别的名字但除非用defineModel或者自定义v-model:uploadedListxxx的方式否则老老实实按约定来才能保证v-model的写法成立。第二个“为什么”是为什么父组件传进来的props不能直接改。直接改会导致数据流变成“子组件悄悄改父组件的值”一旦页面出问题数据流向根本查不清楚。Vue 的响应式系统会监控到这种非法操作并且给出警告。正确姿势永远是想改就emit一个事件把新值交给父组件决定。第三个“为什么”是为什么插槽要分具名和匿名。匿名插槽只能有一个满足“把东西塞进组件”的需求具名插槽可以有很多个满足“不同区域放不同内容”的需求。比如折叠面板里我想让标题左边是图标、中间是标题文字、右边是操作按钮那用一个header插槽比放一个标题字符串灵活多了。很多新手会忽略一个插槽的细节作用域插槽。像我在折叠面板里写的slot nameheader :expandedvisible这个expanded参数会把子组件内部的状态传给插槽内容。调用方在写插槽内容时可以像这样拿到内部数据Collapse v-modelisOpen title高级搜索 template #header{ expanded } span{{ expanded ? ⬇️ 收起 : ⬆️ 展开 }}/span /template !-- 内容区 -- /Collapse这算是插槽的高级用法但它解决的问题非常现实插槽内容有时候需要知道组件内部的运行状态才能决定渲染成什么样子。4. 实操环节动态路由、多文件上传与状态管理的联动热词里有一堆真实开发同学在搜的问题vue 路由动态路由、vue 多文件上传、vue 按钮权限控制、vue 打包放进 springboot 中。这些问题本质上都不是孤立的知识点而是工程化开发中“绕不开的组合拳”。这一节我把它们串到一条线上去讲——一个后台管理系统的典型开发路径。4.1 动态路由权限控制的地基后台管理系统的路由跟普通官网不一样普通官网路由是写死的谁都能访问后台管理系统的路由往往是根据登录用户的角色动态生成的。比如管理员能访问用户管理、订单管理、设置中心普通运营只能访问订单管理跟数据看板那路由表就不能静态写在router/index.js里了必须在用户登录后根据返回的权限数据把可访问的路由动态加进来。// 登录成功后动态添加路由 const routes await fetchUserRoutes(user.role) // 假设接口返回 [{ path: /orders, component: order }] routes.forEach(item { router.addRoute(layout, { path: item.path, name: item.name, component: () import(/views/${item.component}.vue) }) })这个方案有几个细节要处理好。一是import路径不能完全动态拼接如果你写成/views/${item.component}.vueVite 在做打包构建时无法静态分析出这个文件在哪里所以它会提示找不到模块。解决办法是把组件映射表写成一个显式的对象const componentMap { order: () import(/views/OrderList.vue), user: () import(/views/UserList.vue), setting: () import(/views/Setting.vue) } // 通过映射表找组件 component: componentMap[item.component]这种写法确保 Vite 在构建时能“看到”所有可能会被加载的组件。映射表方式在项目里很常见因为后端存的通常是组件标识字符串不可能直接给一段动态路径去让前端 import。二是刷新页面后路由会丢失。动态路由是在登录时添加的如果你只存在内存里刷新一下就全没了。解决方式是把用户的角色信息或权限列表存到sessionStorage或 Pinia 中在应用初始化或路由守卫里判断如果当前路由表为空但用户已经登录就重新请求权限数据再addRoute然后next()放行到原来的地址。router.beforeEach(async (to, from, next) { const userStore useUserStore() if (!userStore.isLoggedIn) { next({ path: /login }) return } if (!userStore.routesLoaded) { const routes await fetchUserRoutes(userStore.role) routes.forEach(item router.addRoute(layout, ...)) userStore.routesLoaded true next({ ...to, replace: true }) return } next() })这个路由守卫是权限控制里很重要的一部分。如果你不next({ ...to, replace: true })用户刷新后大概率会白屏因为路由表还没来得及添加页面已经去找对应路由了找不到就报错。加上replace重新走进来一次问题就解决了。4.2 多文件上传进度、取消与并发控制日常业务里“多文件上传”几乎总伴随着进度条和并发限制。上一节我封装的图片上传组件只是单文件逐个上传真实场景里还要考虑同时传多个文件、控制最大并发数、用户点了取消怎么处理。先用 axios 的上传过程做演示。axios 上传文件可以用FormData进度监听主要靠onUploadProgress。const uploadFile (file, onProgress) { const formData new FormData() formData.append(file, file) return axios.post(/api/upload, formData, { onUploadProgress: (e) { if (e.total) { const percent Math.round((e.loaded / e.total) * 100) onProgress(percent) } } }) }至于并发控制最简单可靠的方式是写一个“并发调度器”。把待上传文件数组传入每次最多同时跑 N 个请求我常用 3一个跑完从队列里补一个进来。const concurrency 3 let activeCount 0 const queue files.map(file ({ file, percent: 0 })) const run () { while (activeCount concurrency queue.length 0) { const task queue.shift() activeCount uploadFile(task.file, (p) { task.percent p }).finally(() { activeCount-- run() }) } } run()这个调度器的思想很朴素但它能解决一个实际问题你把 100 个文件一次性全发出去服务器和带宽都扛不住用户也会看到一堆请求挂在那里。限流之后体验会顺滑很多。取消上传需要用AbortControlleraxios 支持传入signal来取消请求。每个文件上传时单独存一个 controller用户点击“取消全部”时循环调用controller.abort()并对已经上传完成的文件不做处理。4.3 状态管理Pinia 比 localStorage 更值得选的原因我经常见到有人用localStorage来存“用户信息”“token”“当前选中的列表项”。这在小项目里不是不能用但它有几个明显短板第一localStorage存储的是字符串拿对象还得JSON.parse第二它不是响应式的改完不会主动通知界面刷新第三localStorage是同步读写的数据量一大性能损失就来了。Pinia 能帮你把这些麻烦都挡掉。它默认把状态放进内存里是响应式的对象且支持跨组件共享。官方目前也推荐用 Pinia 作为 Vue 3 的状态管理方案。// stores/user.js import { defineStore } from pinia export const useUserStore defineStore(user, { state: () ({ token: , name: , roles: [] }), actions: { setUserInfo(user) { this.token user.token this.name user.name this.roles user.roles } } })组件里使用只需要import { useUserStore } from /stores/user const userStore useUserStore() userStore.setUserInfo({ token: xxx, name: admin, roles: [admin] })Pinia 和组件通信的协作方式也特别顺畅——你在任意组件里改了 store 的状态所有用到了这个状态的地方都会自动更新不用再靠一层层emit去传了。这里我想说一个经验组件通信和 Pinia 不是互斥的。props和emit用于构建父子组件之间的接口Pinia 用于全局共享的状态。父子组件通信尽量用props和emit只有状态跨多层级、多页面共享时才上 Pinia。有些同学一开始就把所有数据塞进 Pinia反而让组件的复用性大幅下降。5. 我在开发中遇到的7个高频问题与排查实录最后这部分是把我在带团队和技术社区里最常被问到的问题集中曝光一下。每一个都是真实发生过的坑写出来帮你避雷。5.1 vscode 里点组件标签不跳转这个场景特别典型。装了官方推荐的 Volar 插件压了 Cmd 键点击组件名还是没反应跳不过去。排查思路分两步先确认项目中是否使用 TypeScript如果项目没有tsconfig.json那 Volar 的语言服务可能没正常启动如果项目确实用了 TS再检查jsconfig.json/tsconfig.json里的 paths 配置是否正确。通常是项目根目录没有配置/这个别名Volar 无法把源码里的 import 路径解析到真实文件跳转自然失效。在jsconfig.json里加上{ compilerOptions: { baseUrl: ., paths: { /*: [src/*] } } }保存之后再试基本就通了。5.2 事件总线多次触发页面一度“失控”这个坑我踩得最狠的一次是在一个复杂的表单页里用了一个mitt事件总线组件 A 触发了一个“刷新列表”事件组件 B 和 C 各监听了一份。结果因为某个逻辑里 B 被创建了三次事件被监听了三次一触发就连续发三次请求列表被刷得闪来闪去。排查方式很简单在事件回调里打个console.log数一下打印次数。如果每次触发打印多次基本可以确定是监听没有清理。解决方法是组件卸载时必须调用off释放监听或者在回调中做防抖处理。牢记一点手动注册的全局事件一定要手动清理。这跟props和emit不一样没有框架帮你自动回收。5.3 样式失效与样式污染组件样式失效十有八九是scoped的问题或者没注意样式权重。scoped的原理是给当前组件的元素加一个>style scoped .mask :deep(.el-dialog) { background-color: #fff; } /stylev-deep也常写但 Vue 3 推荐:deep()。污染问题则相反——不加scoped的全局样式很容易通过相同类名影响其他组件。所以我的经验是能给组件加的scoped一定加上需要全局覆盖的样式单独放到src/assets/global.css里并明确注释用途。5.4 Vite 端口占用与代理配置写前端开发时最常见的报错就是Port 5173 is in use。Vite 的处理方式是换一个端口启动。你可以用下面的配置让它自动递增寻找空闲端口。// vite.config.js export default defineConfig({ server: { port: 5173, strictPort: false } })strictPort: false是默认行为当 5173 被占用时会自动试 5174、5175直到找到可用的端口。如果你希望固定端口就改成true被占用直接报错退出。代理配置这块前端开发时调用后端接口会遇到跨域问题。Vite 的 dev server 可以配置代理把前端的/api请求转发到后端接口地址server: { proxy: { /api: { target: http://localhost:8080, changeOrigin: true, rewrite: (path) path.replace(/^\/api/, ) } } }配置好后前端请求/api/user/listVite 会把请求转发到http://localhost:8080/user/list。5.5 组件里写 axios请求地址写死这个不算是 bug但绝对是个坏味道。如果你在某个组件里直接写axios.get(/api/user/list)一旦这个接口被其他页面也用到了你就得再复制一遍。更麻烦的是如果接口地址变了你得到处改。比较好的做法是把接口请求统一抽到一个src/api目录下按业务模块拆分文件比如user.js、order.js在文件里定义接口函数。组件里只import { getUserList } from /api/user调用即可。好处是接口定义集中管理组件里看不到axios的痕迹只看业务逻辑。发送请求时的统一错误处理和建议状态管理可以在src/api/request.js里封装一个 axios 实例统一设置 baseURL、超时时间以及响应拦截器。5.6 插槽内容一直显示不出来新手第一次接触具名插槽最容易踩的坑是template v-slot:header写在组件标签外面了或者是在组件标签自闭合的情况下写了template #header却放在了渲染区外浏览器直接忽略。正确写法是要写在组件标签内部Collapse v-modelisOpen template #header span自定义标题/span /template div内容区/div /Collapse还有另一个隐藏较深的坑插槽内容里用了v-if、v-for控制显隐父组件初始渲染时条件不成立导致插槽内容完全没渲染后面条件变成 true 时也没有正常显示。这通常是因为自闭合标签或条件渲染的位置写错了。排查时建议先删掉所有条件渲染只留一句纯文本测试如果还不行就是插槽本身没对上。5.7 构建产物接入 Spring Boot 时的 publicPath 问题热词里有“vue打包放进springboot中”这是很多用 Java 后端又不想单独部署前端的团队会干的事。把 Vue 项目npm run build之后生成的dist目录拷进 Spring Boot 的src/main/resources/static下面启动后端服务就能直接访问页面。但是经常会遇到一个问题首页能打开但刷新后就 404或者 JS、CSS 资源路径不对。前者大概率是后端路由没有做“前端路由回退”配置后者通常是publicPath的设置问题。Vite 默认资源路径是绝对路径/assets/xxx.js如果你的应用部署在域名的根路径下没问题但部署在子路径下就需要改配置。比较稳妥的方式是用相对路径这样拷贝到任意子路径都能用// vite.config.js export default defineConfig({ base: ./ })改成./之后构建产物里的资源路径就变成了相对路径从static目录加载时不会找不到。至于 Spring Boot 里刷新 404 的问题通常是路由模式选的是createWebHistory它依赖浏览器的 History API刷新时后端没有对应的路由处理就返回了 404。如果你的项目以打包到后端为主可以先用createWebHashHistory模式虽然 URL 上有#但刷新不丢页面部署最省事import { createRouter, createWebHashHistory } from vue-router const router createRouter({ history: createWebHashHistory(), routes })如果一定要用createWebHistory那后端需要配置一条“404 时返回 index.html”的通配路由这不是纯前端能解决的得前后端一起配合。写在最后这篇内容按道理是“第二篇”站在读者角度你大概已经了解了指令、响应式和生命周期这些基础开始往组件化这条路上走了。组件化编程和脚手架这两件事一个是思想层面一个是工具层面两者结合才能真正把 Vue 用起来。我个人在带项目的过程中最深的一点感受是组件化不是把文件拆得越碎越好而是要找到“适合你的复用边界”。一个组件拆到只有一行div那它就不该叫组件一个组件大得跟页面一样那它基本就丧失了复用性。多写多拆多封装拆错的成本比不敢拆要低得多。拆错了改回来就好不敢拆就永远写着一堆复制粘贴的面条代码后面欠下的债只会越来越多。下一篇如果你们想看我可以把重点放在 Vue 的工程化进阶方向上比如虚拟列表、递归组件、组合式函数复用以及大型项目的代码组织策略。这些内容比起“会用”更需要“会想”——想清楚边界想清楚数据的流向组件化这条路才算真正走通。