ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

Vue组件化核心指南:从组件通信到插槽与异步加载

2026/10/7 12:15:39 拓冰建站 浏览量
Vue组件化核心指南:从组件通信到插槽与异步加载 你是不是也经历过这种状态一个页面写了一千多行从数据请求到按钮样式全堆在一起改一个按钮颜色要翻半天代码想复用又不知道从哪下手我第一次用 Vue 做项目时最大的困惑不是语法而是“组件”和“组件化”这两个词——它们听起来很厉害但到底什么时候该拆、怎么拆、拆完怎么拼回去完全没人给我讲清楚。吃了几个月的亏之后我才慢慢把 Vue 组件这套东西摸透。这篇文章我想把 Vue 入门阶段最关键的组件及组件化知识梳理一遍包括组件到底是什么、怎么创建一个组件、父子组件如何通信、插槽怎么用、动态组件和异步组件怎么玩以及我在实际项目中沉淀下来的一些封装经验和踩坑教训。不管你是刚安装完 Vue 还在配置环境的新手还是已经写过几个页面但总觉得组件设计得别扭的开发者这篇文章应该能帮你把思路理顺。1. 先想明白组件到底是干什么的1.1 组件化解决的是人的问题不是技术问题很多人一上来就盯着技术看props 怎么传、emit 怎么写、注册用什么 API。但组件化解决的首先是人的问题。一个功能模块几千行代码如果全部摊在一个文件里你维护它的时候大脑得同时装下数据请求、条件渲染、事件处理、样式覆盖认知负担重得吓人。而组件化本质上是“分治”——把大问题拆成多个小问题每个小问题在一个相对独立的文件里解决最后再通过组合把整体拼起来。我在团队里经常打一个比方你不可能让一个厨子同时负责买菜、切菜、炒菜、洗碗、收银、管库存。所以餐厅要分后厨、前厅、采购。前端也是一样页面是餐厅组件是各个岗位组件化就是在划分职责边界。1.2 组件的本质一个自带状态和行为的“页面片段”从 Vue 的视角看一个组件就是一段可复用的、带有自己的样式、数据和行为的页面片段。你可以把它理解成一个“带按钮的刷卡机”它从外面接收指令props自己内部也有记忆data/ref操作它的时候它会通过屏幕告诉你结果模板渲染关键动作还会向外广播emit 事件。组件和普通函数不太一样。函数是“输入进去、输出出来”调完就结束了。组件是“长”在页面上的它有自己的生命周期有可以被外部影响的状态还能在多个父组件之间复用。这是理解组件的最重要一步——它不是一段函数而是一个独立运行的“小应用”。为了直观理解你可以把整个页面想象成一棵树App ├── Header │ ├── Logo │ └── UserMenu ├── Content │ ├── SearchBar │ └── ListItem │ ├── ListItemInfo │ └── ListItemAction └── Footer上面每一层都是一个组件每一个组件都只关心自己这一层的逻辑。Header 不关心 ListItem 怎么渲染Footer 不关心搜索逻辑。谁依赖谁一目了然。这也是“高内聚、低耦合”在组件设计里的具体含义每个组件内部尽量把相关的东西聚在一起组件与组件之间的牵连尽量少。2. 从零写一个组件定义、注册、使用的完整链路2.1 三种定义组件的姿势以及它们的差别Vue 里定义组件有三种常见姿势。第一种是 Options API选项式写法Vue 2 和 Vue 3 都支持数据、方法、生命周期各自放在 data、methods、mounted 等选项里。第二种是 Composition APIVue 3 主推的方式用 setup 函数把逻辑组织在一起。第三种是直接写单文件组件 SFC也就是 .vue 文件把模板、脚本、样式塞在同一个文件里。这里要给刚入门的朋友一个建议不要迷信某种写法而是先理解两者解决的问题。Options API 的好处是结构固定新手容易上手但逻辑分散——同一个功能的代码可能被 data、methods、watch 拆到不同区块。Composition API 的好处是同一个功能的代码可以放在一起尤其是一个组件里涉及多个业务逻辑时阅读和后续抽离都更舒服。下面是最基本的一个组件用 Composition API 写的template div classcounter p当前值{{ count }}/p button clickincrement加一/button /div /template script setup import { ref } from vue const count ref(0) function increment() { count.value } /script style scoped .counter { padding: 12px; border: 1px solid #e5e7eb; border-radius: 8px; } /style上面这段代码就定义了一个计数器组件。它有内部状态count有行为increment有UI模板样式也带上了。组件要的就是这种“自包含”状态、行为、UI 长在同一个地方。2.2 全局注册和局部注册选错会带来什么麻烦组件定义好之后需要注册才能用。注册分为全局注册和局部注册。全局注册就是把组件挂到整个应用上任何组件里都能直接用最常见的是在 main.js/main.ts 里调用app.componentimport { createApp } from vue import App from ./App.vue import BaseButton from ./components/BaseButton.vue const app createApp(App) app.component(BaseButton, BaseButton) app.mount(#app)局部注册则是在某个组件的script setup里引入后就只在这个组件里可用script setup import BaseButton from ./components/BaseButton.vue /script注意script setup模式下引入的组件会自动注册到当前组件不需要手动写 components 选项。这个细节经常让从 Vue 2 转过来的同学困惑——在 Vue 2 里你必须在components: {}里注册一下。我的建议是除了真正意义上的通用基础组件如按钮、输入框、弹窗其他一律用局部注册。全局注册看着方便但有两个隐患。第一打包体积全局注册的组件即使某些页面没用也会被打进最终产物里。第二命名污染和维护耦合全局组件多了以后改一个组件要留意全站影响。实际项目里组件总数几十个甚至上百个全局注册会让依赖关系变得非常不透明。2.3 跑起来之后的第一个组件长什么样新手第一次把组件跑起来最重要的是理解“组件一旦注册就可以像普通 HTML 标签一样使用”。比如在 App.vue 里template main Counter / Counter / /main /template一个组件被使用多次每次都是独立实例它们各自维护自己的count互不干扰。很多刚接触组件的人会误以为组件的ref是全局共享的——并不是。组件实例是独立的这也是复用性的前提。这里顺带提一下环境配置里常见的坑如果你用的构建工具是 Vue CLI 或 Vite新装的 Vue 项目里通常已经配置好了vitejs/plugin-vue或vue-loader。如果跑.vue文件时报错先查两件事第一依赖有没有装齐第二构建配置里有没有对应的 SFC 插件。大多数“组件跑不起来”的问题不是组件写错了而是环境没配对。3. 父传子与子传父数据流动的方向感3.1 props 传参只传数据不传控制权组件通信是组件化里最核心的环节。最常见的场景是父组件要往子组件里塞数据。比如父组件拿到用户信息想传给子组件展示这时候就要用 props。!-- 父组件 -- script setup import UserCard from ./UserCard.vue const user { name: 张三, age: 28, email: zhangsanexample.com } /script template UserCard :nameuser.name :ageuser.age :emailuser.email / /template!-- 子组件 UserCard.vue -- script setup const props defineProps({ name: { type: String, required: true }, age: { type: Number, default: 0 }, email: { type: String, default: } }) /script template div classuser-card h3{{ props.name }}/h3 p年龄{{ props.age }}/p p邮箱{{ props.email }}/p /div /template这里有几个关键点。第一props 是只读的子组件不能直接改props.name。第二props 可以带类型校验和默认值这不仅仅是规范更是调试利器——传错类型时 Vue 会在控制台给出警告。第三如果某个 props 不是必填务必给默认值否则渲染时可能出现undefined的报错。3.2 emit 事件子组件怎么“向上汇报”数据是父传子但子组件里发生的动作父组件怎么知道比如子组件里有个删除按钮点了之后父组件需要发起删除请求这就需要子组件向父组件发送事件。!-- 子组件 UserCard.vue -- script setup const emit defineEmits([delete, edit]) function handleDelete() { emit(delete, { id: user_123 }) } function handleEdit() { emit(edit, { id: user_123 }) } /script template div classuser-card !-- 省略展示内容 -- button clickhandleDelete删除/button button clickhandleEdit编辑/button /div /template!-- 父组件 -- script setup import UserCard from ./UserCard.vue function onDelete(payload) { console.log(父组件收到删除事件携带数据, payload) // 在这里写真正的删除逻辑 } function onEdit(payload) { console.log(父组件收到编辑事件携带数据, payload) } /script template UserCard :nameuser.name :ageuser.age :emailuser.email deleteonDelete editonEdit / /template这里最值得注意的一点是子组件只负责“告知”不负责“执行”。删除请求拉到哪、失败怎么提示这些逻辑放在父组件里因为删除动作影响的通常不只有子组件自身。这是一种角色分工子组件做表现和交互父组件做业务和状态管理。如果子组件把删除请求自己也发了下一次另一个页面要用这个 UserCard很可能因为接口地址不一样就废了。3.3 为什么 Vue 坚持单向数据流Vue 的 props 是单向数据流数据从父组件流向子组件。子组件不能修改 props只能通过向外发事件让父组件去改。这个设计的背后逻辑是——如果子组件能随便改 props那状态变化的位置就会变得不可追踪。举个例子三个子组件用了同一个user对象。如果每个子组件都能直接改user.name谁改了什么时候改的完全没法查。单向数据流加上事件机制保证了数据的“所有权”非常清晰谁拥有数据谁才有权修改。数据流只能从拥有者流向使用方使用方想要变更必须通过正式渠道“申请”。这个设计还有一个实际好处排查问题更容易。当页面上一个值不对你可以顺藤摸瓜——值从父组件传下来的那就先去父组件查子组件改不了它就排除了子组件篡改的可能。调试范围一下子缩小很多。4. 跨层传递和兄弟通信不绕远路也不写死4.1 provide/inject长辈给后辈发“资源包”多层嵌套组件时如果每一层都用 props 一层一层转发会非常痛苦。爷爷传爸爸、爸爸传儿子、儿子传孙子……中间每一层都只是“过路”却都得写一遍 props。这种情况适合用 provide 和 inject。provide是在上层组件里提供数据inject是在下层任意深度的组件里注入数据。!-- 祖组件 App.vue -- script setup import { provide, ref } from vue import Header from ./Header.vue const theme ref(dark) provide(theme, theme) /script!-- 任意深层子组件 -- script setup import { inject } from vue const theme inject(theme, light) /script template div当前主题{{ theme }}/div /template用 provide/inject 最大的好处是跨层直通省掉中间层转发的样板代码。但也要小心它让数据来源变得不再透明。任何一个深层的组件用了 inject你都得往上看才知道数据是哪来的。所以我的建议是provide/inject 只用来传递真正的“全局性上下文”比如主题、当前登录用户信息、地区语言包这类几乎所有组件都可能用到的东西不要用它来代替常规的 props 通信。这里有一个 Vue 3 很容易踩的坑provide传ref时如果你在setup里直接写provide(theme, theme.value)你提供出去的是一个普通字符串子组件拿到后不会响应式更新。正确做法是提供theme这个 ref 本身// 正确 provide(theme, theme) // 错误丢失响应式 provide(theme, theme.value)4.2 兄弟组件通信的几种常见处理方式兄弟组件之间没有直接的父子关系不能用 props 直接传也不能靠 emit 直接接收。常见方案有三种。第一种是“状态提升”把两个兄弟都要用的数据放到共同的父组件里父组件通过 props 传给两个兄弟某个兄弟要修改时通过 emit 上报父组件改了之后数据再通过 props 流回另一个兄弟。这是最符合 Vue 单向数据流体系的做法也是我日常最常用的。第二种是全局事件总线。Vue 3 里官方移除了$on/$off实例方法但你可以引入一个轻量的 mitt 库来做。适合对响应性要求不高、只做一次性通知的场景。第三种是引入 Pinia 这类状态管理库把跨组件共享的状态放到集中式 store 里。当应用规模变大多个组件都要读写同一份业务数据时Pinia 是更清晰的选择。我给个选择参考偶尔一两个兄弟组件同步用提升状态就够了频繁多处组件都要同步直接上 Pinia不要用事件总线硬撑越到后期越难维护。4.3 组件通信最容易踩的坑我见过最多的通信 bug 集中在两个地方。第一个是对象类型的 props 被误改。props 虽然是只读的但如果你传的是对象子组件里直接改props.user.name是能改成功的因为对象是引用传递这个修改会绕过 Vue 的警告直接污染父组件数据。这会让排查变得极其困难。所以约定俗成的规则是props 一律只读包括对象内部的属性。要修改先复制一份再改。第二个坑是 emit 事件名称大小写。Vue 3 中事件名如果在子组件里写的是驼峰myEvent父组件模板里通常用my-event监听两种风格都能匹配但你要是记混了就会监听不到。建议统一用 kebab-casedefineEmits([my-event])父组件写my-event一致性最强也不会踩命名匹配的边界问题。5. slot 与内容分发组件不再是一个黑盒5.1 默认插槽一张嘴吃天下如果你只靠 props 和 emit你会发现组件只能展示自己写死的内容。但实际需求里很多组件是“壳子”——布局是固定的中间内容要由使用方自己填。这时候就要用插槽 slot。默认插槽是最简单的插槽子组件里留一个slot /父组件在子组件标签内写的任何内容都会放进去。!-- 子组件 Card.vue -- template div classcard div classcard__header这是卡片标题/div div classcard__body !-- 这里放父组件传进来的内容 -- slot / /div /div /template!-- 父组件 -- script setup import Card from ./Card.vue /script template Card p这一段内容会被渲染进卡片的 body 区域/p button也可以是任意其他元素/button /Card /template插槽的出现让组件的复用层次提升了一级。不带插槽的组件是“完全按我的样式渲染”带了插槽的组件是“我提供框架你来填内容”后者的适用面明显更广。5.2 具名插槽与作用域插槽当一个组件有多个需要填充的位置时默认插槽就不够用了。比如一个布局组件有 header、default、footer 三个区域这时候要用具名插槽。!-- 子组件 Layout.vue -- template div classlayout header slot nameheader / /header main slot / /main footer slot namefooter / /footer /div /template!-- 父组件 -- template Layout template #header h1标题栏/h1 /template p主体内容没有写 name 的 slot 默认就是 default/p template #footer p底部说明/p /template /Layout /template作用域插槽则更进一步子组件可以在插槽向外传数据让父组件根据这些数据来决定渲染什么内容。最典型的使用场景是表格列子组件渲染了一个列表把每一项的数据通过作用域插槽交给父组件自定义展示方式。!-- 子组件 DataTable.vue -- script setup const items [ { id: 1, name: 苹果, price: 5 }, { id: 2, name: 香蕉, price: 3 } ] /script template div classdata-table div v-foritem in items :keyitem.id classdata-table__row slot :itemitem{{ item.name }}/slot /div /div /template!-- 父组件 -- script setup import DataTable from ./DataTable.vue /script template DataTable template #default{ item } span{{ item.name }} —— {{ item.price }} 元/span /template /DataTable /template5.3 什么时候必须用插槽我自己的判断标准是如果一个组件的视觉结构固定但内部某个区域的内容可能由使用方决定那就必须留插槽。比如弹窗组件你可以预置标题、底部取消确认按钮但弹窗正文的内容只可能是“谁用谁知道”所以正文必须用插槽。反过来如果某个区域的内容在所有使用场景下都是一样的就别硬加插槽不然父组件多了很多重复代码。设计插槽的本质是——把“变”的部分暴露出去把“不变”的部分收拢在自己组件内部。这个边界找得越准组件的可用性越高。写 slot 时还有一个和 Vue 2 的差异要注意Vue 2 里具名插槽用slotheaderVue 3 里已经改成了#header或者v-slot:header。对 Vue 2 转 Vue 3 的人来说这个是语法层面的语法糖记清楚就顺了搜“vue插槽”相关文章时留意一下版本差异就行。6. 动态组件、异步组件与页面联想组件也能“随叫随到”6.1 is 属性和动态组件切换动态组件指的是用component :is...在运行期决定渲染哪个组件。典型场景是 Tab 标签页script setup import { ref, shallowRef } from vue import HomePanel from ./HomePanel.vue import ListPanel from ./ListPanel.vue import ProfilePanel from ./ProfilePanel.vue const tabs [ { name: 首页, comp: HomePanel }, { name: 列表, comp: ListPanel }, { name: 我的, comp: ProfilePanel } ] const currentTab ref(tabs[0]) /script template div classtabs button v-fortab in tabs :keytab.name clickcurrentTab tab {{ tab.name }} /button component :iscurrentTab.comp / /div /template用一个currentTab.comp控制比v-if写一堆分支要干净得多。这里一个性能小技巧是如果动态切换的组件很多用shallowRef而不是ref来存组件定义可以避免深层响应化带来的性能开销。组件对象本来就只需要引用不需要 Vue 去把它变成响应式对象。6.2 异步组件与首屏加载优化异步组件是用defineAsyncComponent定义的组件它不会在首屏打包进主文件而是在需要渲染时才动态加载。这里其实和“vue路由”里经常用的路由懒加载是同一种思路按需加载减少首屏体积。import { defineAsyncComponent } from vue const AsyncUserPanel defineAsyncComponent(() import(./UserPanel.vue))异步组件配上一个 loading 状态提示体验会更好。比如用户点开一个管理后台的弹窗里面的富文本编辑器和图表组件都比较重异步加载能明显拉开首屏时间。你可以给异步组件加一个loadingComponent参数在加载期间渲染一个轻量的占位组件。6.3 KeepAlive 缓存组件状态动态切换组件时默认行为是组件被卸载、状态丢失。如果切走再切回来Tab 页里用户已经填写的表单内容就没了这种体验很糟糕。这时候用 KeepAlive 包一层template KeepAlive component :iscurrentTab.comp / /KeepAlive /templateKeepAlive 的意思是组件切走时不销毁而是缓存起来切回来时直接复用之前的状态。它非常适合多 Tab 页面和列表到详情再返回列表的场景。要注意的是KeepAlive 会改变组件的生命周期组件第一次进入时走onMounted之后重新进入只触发onActivated离开时触发onDeactivated而不是onUnmounted。如果组件里有定时器或 WebSocket记得在onDeactivated里清理。7. 把组件做得好用的心法面向复用的封装设计7.1 封装组件前先写使用文档很多初学者封装组件是抄着一个需求就开始写。写完之后发现换个页面用起来不对味又要补 props、又要加事件最后改得千疮百孔。我现在的习惯是动手之前先写“这个组件用起来会是什么样”把期望的标签结构、props、事件、插槽先当成伪代码列出来。这一步理不顺代码写再好也白搭。比如我要封装一个选择器组件我会先写AsyncSelect :request-apifetchUserList v-modelselectedUserId placeholder请选择用户 /然后再去想request-api传什么v-model绑的是单个值还是数组要不要支持搜索空数据时显示什么这些设计问题在写使用文档的阶段成本最低等到代码写一半再改成本高很多。组件粒度也是一个需要平衡的问题。拆得太细文件爆炸一个按钮都要抽个组件维护成本反而高拆得太粗组件内部几百行复用时带了一堆用不上的东西。我的判断标准是一个组件如果超过两百行先想想能不能拆一个组件如果只在一个地方用一次先不要急着抽直接用等第二个用得上的地方出现了再抽。7.2 v-model 在组件封装里的高级用法谈组件封装绕不开 v-model。v-model 本质上是 props 加事件的语法糖父组件传value/modelValue监听update:modelValue事件子组件发起事件后父组件更新绑定的变量。在自定义组件中使用 v-model最典型的场景就是封装一个数字输入组件或日期选择组件!-- 子组件 NumberInput.vue -- script setup const props defineProps({ modelValue: { type: Number, default: 0 } }) const emit defineEmits([update:modelValue]) /script template input typenumber :valueprops.modelValue inputemit(update:modelValue, Number($event.target.value)) / /template!-- 父组件 -- script setup import { ref } from vue import NumberInput from ./NumberInput.vue const num ref(0) /script template NumberInput v-modelnum / /templateVue 3 里还支持多个 v-model比如v-model:title和v-model:content同时绑定两个字段这种写法在封装表单类组件时特别好用。它让父组件的调用代码变得简洁也让子组件的对外接口更接近原生表单项的体验。7.3 组件库拿来主义与二次封装实际业务中大量组件可以直接用现成的第三方库比如 Vant、Element Plus、Ant Design Vue。就拿热搜词里的“vant 做联级多选按照层级的组件”来说Vant 自带级联选择器你直接按文档配置列、绑定字段就行没必要自己造轮子。但组件库也不是永远能满足业务。常见做法是二次封装拿库里的组件当底座在外面包一层你自己的组件统一项目内的 props 约定、默认样式和事件命名。这样以后组件库升级你只需要改一个封装层业务代码不需要大改。做二次封装时最忌讳的是把底层组件的能力全都透传一遍——几十个 props 全列出来没有任何意义。更好的思路是想清楚你的团队在这个组件上实际用到哪几项能力只暴露真正需要的那几个接口内部再把参数透传给底层组件。接口少反而好维护。团队协作时别人看一眼你的封装组件文档就能上手而不是要去翻底层库的完整 API。8. 实操中反复踩过的坑和我的个人经验8.1 组件的 key 问题为什么列表渲染必须加 keyv-for循环渲染组件时给组件加:key是必须的这几乎是我在团队 code review 里提得最多的一条。key 不仅是给 Vue 一个标识更决定了组件是否被复用或重建。如果不加 keyVue 会通过“就地复用”策略来更新节点。列表里每一项的组件实例可能被错误地复用导致组件内部状态错乱。最常见的情况列表前插一条数据后后面的组件的选中状态全跟着变了。key 的最佳选择是数据里稳定唯一的 id不要用数组下标 index。因为 index 在插入、删除时同样会错位。虽然大部分时候不加 key 也“看起来没事”但那只是因为你列表项没有内部状态。一旦组件里有个复选框、有个 input状态错乱的问题立刻冒出来。8.2 组件命名与文件名大小写组件命名有两个容易踩的坑。第一个是组件名必须用多个单词避免和原生 HTML 标签冲突。Vue 官方风格指南里也要求这一点——如果你写一个button组件它会在很多地方和原生 button 混淆以后排查起来非常痛苦。第二个是文件名和组件名不要用无意义名称。很多教程里爱用Demo.vue、Test1.vue实际项目里千万别学。我建议文件名用 PascalCase比如UserProfile.vue、DataTable.vue组件内部通过defineOptions({ name: UserProfile })显式声明姓名方便调试时在 Vue DevTools 里直接定位到端到端组件名称。8.3 用结构化设计梳理组件树先画图再写码我记得热搜词里有一条“组件图 uml”。这里想说画组件图和写 UML 不是为了赶时髦而是真的能省时间。动手写代码前先在纸上把组件树画出来标清楚哪个组件持有哪份数据、哪些组件之间需要通信、哪些数据需要提升到父级。画图的过程中你会发现很多问题这个表单里三个下拉框的数据源其实是一样的完全可以提升到同一个父组件统一管理那个弹窗的关闭逻辑居然拆到了六个组件里。这些问题在代码阶段发现会很难受但要是在设计阶段改动只是纸面上的几笔。8.4 一个实用的自查清单最后分享一个我在写完组件后反复使用的自查清单也算是我这些年在组件化实践里沉淀出的核心经验组件有没有超过两百行超过的话考虑拆。组件接口props、emit、slot是不是最小必要集有没有暴露多余的能力。所有传给子组件的对象 props子组件内部有没有改动它的属性组件名是否至少两个单词和 HTML 原生标签是否冲突列表渲染有没有加稳定的 key状态是应该放在组件内部还是应该提升到父组件、放进 Pinia异步加载的重组件有没有配 loading 占位KeepAlive 缓存组件里有没有需要清理的定时器或事件监听你可能会发现上面这些条目大多不是怎么“写代码”而是怎么“做设计”。组件化从来不只是语法层面的问题它更是一种组织代码和思路的方式。把组件拆对、把通信理顺、把边界划清楚Vue 开发的一半功力其实就已经到位了。剩下的就是靠实际项目里一遍遍打磨手感踩过的坑多了自然就会在动手之前多想一步。