Day.4 对我来说是个分水岭。前三天我还在照着文档写模板、调指令、注册组件,TodoList 和计数器都写了不止一版,感觉自己已经"会用 Vue"了。但真当手里拿到一个接近真实的后台需求——同一个区域要在不同页面里有三种完全不同的长相时,我发现自己根本不会设计组件。
第一天做卡片组件,我用 props 拼命堆参数,堆到下午,props 已经有十个、模板里全是 v-if 分支。身边一位前端朋友只说了一句话:"你该看看插槽了。"
那天晚上我从插槽体系开始,一路把组件上的 v-model、provide/inject 依赖注入、生命周期里发请求的时序,全部重新过了一遍,踩掉的坑比前三天加起来都多。这篇就是 Vue-Day.4 的完整复盘,写给同样卡在"会写组件但不会设计组件"阶段的读者。
1. 一个"组件复用"需求,把我从文档抄写员逼成了设计者
1.1 前三天的底子:会写组件,但不会设计组件
第一天的内容很基础:用 Vite 搭好项目,搞懂模板语法、插值表达式、v-bind、v-on 这些指令。第二天开始接触 v-if、v-for、v-model,把计算属性 computed 和侦听器 watch 也过了一遍。第三天正式进入组件,学了 props 传值、emits 向上抛事件,一个计数器父子通信的小 demo 做得很顺。
那时候我写组件的方式特别朴素:props 接收数据,template 固定渲染结构,样式不同就加一个 mode 参数控制 class。在小 demo 里完全够用,因为每个组件的内部结构是确定的。
但我心里一直不踏实——如果组件的内部结构是不确定的,该怎么办?比如一个卡片,有时要放一段文字,有时要放一个按钮组,有时要放一张图片,甚至要放一个任意的图表。props 能传字符串、对象、数组,但它传不了"结构"。这个困惑在第四天被一个真实需求彻底引爆了。
1.2 令我破防的需求:三种卡片,一份骨架
需求本身不复杂:后台页面里有一个"资料展示区",同样的卡片骨架,要支持三种形态——纯文本、带操作按钮、带图片。我当时的第一版 Card.vue 长这样:
<template> <div class="card"> <template v-if="mode === 'text'"> <p>{{ text }}</p> </template> <template v-else-if="mode === 'action'"> <div class="card-content">{{ text }}</div> <button @click="emit('action')">{{ actionText }}</button> </template> <template v-else-if="mode === 'image'"> <img :src="imageUrl" alt="" /> </template> </div> </template> <script setup> defineProps({ mode: { type: String, required: true }, text: String, actionText: String, imageUrl: String }) defineEmits(['action']) </script>看起来还行?问题出在"扩展"上。到了下午,需求变成五种形态:加了一个"列表"形态,要传items数组;加了一个"统计"形态,要传statValue和statUnit。于是 props 变成了十个,模板里 v-if 分支变成了五段,点击事件也变成了两三个 emit。
那一刻我意识到,这根本不叫组件复用。这叫把页面拆进组件里,再用一堆 if 缝起来。组件越来越胖,使用方越来越乱,任何一个小改动都要同时改 Card.vue 和所有引用它的页面。
1.3 本质:组件缺的不是参数,而是一张"可插拔的嘴"
反思之后,我明白了问题出在哪:props 适合传"数据",但从骨子里不适合传"结构"。两个组件之间最灵活的协作方式,不是父组件把一切都定义好再交给子组件,而是父组件只负责搭好骨架,把某个区域的"内容权"留给使用方。
这个机制 Vue 里就叫插槽(slot)。
打个比方:props 像是你告诉相框"我要放一张 5 寸的照片",照片内容还得你自己印好塞进去;而插槽就是相框本身就留了一个开口,你想放照片、放明信片、放手绘图,都随你。关键是这个"开口"的位置由组件决定,里面放什么由使用方决定。
这正是我第四天第一个真正有价值的认知转变:组件设计,本质上是在划定"哪些区域归组件管、哪些区域归外面管"。
2. 插槽体系:真正让组件具备"外部供应内容"的能力
2.1 默认插槽,先解决一半问题
我把 Card.vue 重写成了一个带默认插槽的版本:
<template> <div class="card"> <header>{{ title }}</header> <div class="card-body"> <slot></slot> </div> </div> </template> <script setup> defineProps({ title: { type: String, default: '' } }) </script>使用方想放什么就放什么:
<CardTemplate title="今日数据"> <p>这里完全由外部决定</p> <button @click="handleRefresh">刷新</button> </CardTemplate>一个<slot />的位置,就是外部内容注入的位置。外部传入的模板在渲染时,依然处于父组件的作用域——它可以访问父组件的变量和方法,而不是子组件的。这一点很关键,因为很多新手会想当然地以为插槽内容属于子组件,结果访问变量时报 undefined,半天摸不着头脑。
另外,插槽还可以写默认内容:
<div class="card-body"> <slot>本区域由外部决定</slot> </div>外部没传东西时,渲染默认内容;传了就覆盖。这也是"有回退方案"的组件设计思路,值得养成习惯。
2.2 具名插槽:一个组件开多扇"可替换窗口"
默认插槽解决了一个"内容区",但真实的组件往往有多个可替换区域。比如弹窗:头部标题区、中间内容区、底部按钮区,三块都要能定制。
用具名插槽可以给每个区域起名字:
<template> <div class="modal"> <div class="modal-header"> <slot name="header">默认标题</slot> </div> <div class="modal-body"> <slot></slot> </div> <div class="modal-footer"> <slot name="footer"></slot> </div> </div> </template>使用方用#header、#footer来精确填充:
<MyModal> <template #header> <h3>编辑用户</h3> </template> <p>这是默认插槽里的内容</p> <template #footer> <button @click="cancel">取消</button> <button @click="save">保存</button> </template> </MyModal>#header是v-slot:header的简写,#footer同理。默认插槽写成#default也行。
我在这里踩了一个小坑:一开始我以为父组件里 template 的书写顺序决定了渲染顺序,结果发现完全不是。最终渲染位置由子组件里<slot>声明的顺序决定,父组件写哪个顺序都不影响。想明白这一点,"具名插槽只是开窗"的概念就通了——窗口开在哪,由组件架构决定,而不是由使用方的书写顺序决定。
2.3 作用域插槽:子组件手里的数据"递"给外部模板
普通插槽解决了"外部往组件里塞内容"的问题,数据流是父组件 → 插槽内容。但真实场景里还缺一个反向通道:如果组件内部有一份数据(比如表格的行数据、下拉选项列表),我希望让使用方基于这些数据来定制渲染,怎么办?
这就是作用域插槽的用武之地。我把表格组件写成这样:
<template> <table> <thead> <tr> <th>序号</th> <th>操作</th> </tr> </thead> <tbody> <tr v-for="(row, index) in rows" :key="row.id"> <td>{{ index + 1 }}</td> <td> <slot name="operation" :row="row" :index="index"></slot> </td> </tr> </tbody> </table> </template> <script setup> defineProps({ rows: { type: Array, required: true } }) </script>组件内部把row和index通过:row、:index抛给插槽。使用方就可以在自己写的模板里解构接收这两个值:
<DataTable :rows="users"> <template #operation="{ row, index }"> <button @click="editUser(row)">编辑</button> <button @click="removeUser(row, index)">删除</button> </template> </DataTable>看到这里你应该能总结出区别了:普通插槽,外部模板只能访问父组件的变量;作用域插槽,外部模板还能访问子组件通过插槽属性传给它的变量。表格的"列自定义渲染"是最典型的场景——表格组件本身不知道操作区要放什么按钮、放几个按钮,它只负责把每一行数据递出去,由使用方决定怎么渲染。
提示:如果插槽内容需要访问父级的变量,直接在 template 里用即可,不需要额外处理。只有访问子组件内部数据时才需要作用域插槽。这个区分搞清楚,插槽基本就入门了。
2.4 插槽的几个注意点和我的教训
花了一天时间,我把插槽的坑大致踩了一遍:
第一,子组件里如果不写<slot>,外部传进标签中间的内容会被静默丢弃,不会报错,也不会渲染。新手很容易在"传了内容但看不到"的情况下到处找原因,其实只是忘了开孔。
第二,插槽不是 props,不能做 props 类型校验。它更灵活,但也要靠组件使用契约约束。如果你发现一个组件的插槽数量太多、命名含义模糊,那多半是该拆分组件了。
第三,v-slot指令建议只用在<template>标签上。虽然默认插槽也能直接写在组件标签上,但为了可读性,规范写法永远是<template #xxx>包裹。
第四,也是最核心的——什么时候用默认插槽,什么时候用作用域插槽,我的判断标准是:只看结构性差异用默认插槽;需要借用组件内部数据时用作用域插槽。前者是"往里塞",后者是"在塞的同时往里传数据"。
3. 组件上的 v-model:把双向绑定从 input 推广到任意组件
3.1 原来 v-model 是个语法糖
我在前三天用 v-model 时,一直把它当成"表单元素的魔法"。直到第四天我才意识到,它压根不是魔法,而是语法糖。
原生 input 上那句v-model="username",本质等价于:
<input :value="username" @input="username = $event.target.value" />简而言之:把:value和@input绑在一起,省去三行重复代码。
组件上的 v-model 也是同一套逻辑的延续:给组件传一个叫modelValue的 prop,同时监听一个叫update:modelValue的事件。所以这句:
<MyInput v-model="username" />等价于:
<MyInput :modelValue="username" @update:modelValue="newVal => (username = newVal)" />理解了这一层,自定义组件上的 v-model 就再也不神秘了。
3.2 自己写一个带 v-model 的输入组件
动手写一个最简单的 MyInput.vue:
<template> <input :value="modelValue" @input="$emit('update:modelValue', $event.target.value)" /> </template> <script setup> defineProps({ modelValue: { type: String, default: '' } }) defineEmits(['update:modelValue']) </script>外部使用:
<MyInput v-model="username" />这个组件的工作闭环是:父组件把username通过modelValue传进来 → 子组件 input 的value接收显示 → 用户输入时触发input事件 → 通过$emit('update:modelValue', ...)把新值抛出去 → 父组件更新username→ 再传回来。
这里有个很容易踩的坑:一开始我偷懒,想在子组件内部直接写v-model="modelValue",结果浏览器控制台立刻报了一个警告——props 是只读的,不允许在子组件内部直接修改。Vue 的单向数据流不是用来限制自由的,它保证的是"数据变化来源可追踪"。真正想改数据,只能通过 emit 走父组件,让父组件改完了再传回来,形成循环。这个循环就是双向绑定的本质。
3.3 多个值需要双向同步:多个 v-model 是很香的写法
第四天我还遇到一个场景:自定义弹窗组件,既要控制"是否可见",又要绑定"表单数据"。如果沿用单个 v-model 的思路,就得把两个值塞进一个对象里,既别扭又难读。
Vue 3 支持给 v-model 加参数名,这件事帮我省了大量心智负担:
<MyModal v-model:visible="modalVisible" v-model:formData="form"></MyModal>对应组件内部:
<script setup> defineProps({ visible: { type: Boolean, default: false }, formData: { type: Object, default: () => ({}) } }) defineEmits(['update:visible', 'update:formData']) </script> <template> <div v-if="visible" class="modal"> <div class="modal-header"> <slot name="header"></slot> </div> <div class="modal-body"> <slot></slot> </div> <button @click="$emit('update:visible', false)">关闭</button> </div> </template>v-model:visible对应 prop 名visible、事件名update:visible;v-model:formData对应formData和update:formData。语义清晰,使用方一眼就能看出来哪些属性是"受外部控制的"。这比当初用 props + 一堆不规则的close/change事件要稳健得多。
提示:给自定义组件设计 v-model 时,起名要克制。一个组件最多两三个 v-model 参数,再多就考虑换 props 或拆组件。参数名尽量用名词而不是动词,因为它描述的是"一个值",不是"一个动作"。
3.4 如果用的是 Vue 3.4+:defineModel 出现了
写这篇笔记时,Vue 3.4+ 已经提供了defineModel宏,可以大幅度简化自定义 v-model 的写法:
<template> <input :value="model" @input="model = $event.target.value" /> </template> <script setup> const model = defineModel({ type: String, default: '' }) </script>defineModel会替你完成 props 声明和 emit 触发,让代码更干净。但我仍然建议你先理解 3.2 里的基础写法,再使用 defineModel——因为 defineModel 本质就是编译成"modelValue+update:modelValue"那一套。基础原理不牢,换个版本或者看别人的老代码,还是会一脸懵。
4. provide/inject:当 props 传递开始变得窒息
4.1 逐层传递的"过手问题"
插槽解决了一部分"组件结构不确定"问题,v-model 简化了双向数据流,但还有一类场景让我很头疼:组件嵌套层级深,深层组件需要一个数据,可中间的每一层都不需要它。
举个例子:页面容器 A 组件里套了 B 组件,B 里套了 C 列表,C 列表里的每个 Item 需要拿到当前用户的 userId。如果只靠 props,A → B → C → Item 每一层都要声明并转发这个字段。中间层 B 不消费 userId,但它必须写<C :userId="userId" />,纯属"过手"。
这种代码堆多了以后,组件签名变得又臭又长,改一个字段名要在四五个文件里同步修改。这个问题有一个专门的名字:prop drilling。
4.2 provide/inject 的使用姿势
Vue 提供的解法是 provide/inject,它的思路很像楼里的一部电梯:祖先组件通过provide把数据"放在电梯里",任意深度的子孙组件通过inject直接"坐电梯"取用,中间层完全不用管。
<!-- App.vue --> <script setup> import { provide, ref } from 'vue' const theme = ref('light') const toggleTheme = () => { theme.value = theme.value === 'light' ? 'dark' : 'light' } provide('theme', theme) provide('toggleTheme', toggleTheme) </script>深层子组件里直接 inject:
<script setup> import { inject } from 'vue' const theme = inject('theme', 'light') const toggleTheme = inject('toggleTheme') </script> <template> <div :class="`theme-${theme}`"> <button @click="toggleTheme">切换主题</button> </div> </template>这里有几个细节值得注意:
第一,inject 可以给默认值。inject('theme', 'light')的意思是,祖先没提供就默认 'light',不会报错。
第二,如果提供的是 ref 对象,建议直接使用这个 ref,不要在中途解构。直接const theme = inject('theme'),模板里用theme是响应式的;一旦const { value } = inject('theme'),响应式关系就断了。
第三,传入方法也是一种很好的模式。子组件不在自己内部偷偷改共享数据,而是调用祖先提供的方法,让修改逻辑集中在数据源头。这样代码的可追踪性会好很多。
4.3 用主题切换把整个链路跑一遍
我在第四天做的主题切换小练习,结构上很好地演示了 provide/inject 的威力:
- App.vue 在最顶层维护
theme状态,provide 主题值 + 切换方法; - NavBar 组件在中间,它不消费 theme,也不需要转发;
- SidePanel 组件更深层,同样不需要转发;
- 最底层的 SettingsButton 组件,直接 inject 主题值和切换方法,点击按钮就能改变整个应用主题。
这个例子让我彻底明白了:provide/inject 擅长的是"一棵组件子树内部的全局共享"。它不需要引入状态管理库,也不需要把数据放到局部组件之上,代码量比逐层 props 转发少了一大截。
4.4 和 Pinia 怎么选
学到这里,我脑子里冒出一个问题:provide/inject 和全局状态管理(比如 Pinia)都是"共享数据",到底该用哪个?
这一天的实践给我的判断标准是:
- 数据只在某个子树内部流转(比如一个复杂表单组件的各个输入区共享同一个表单上下文),优先 provide/inject;
- 数据要在不同路由页面、不同模块、不同组件树之间共享,或者需要持久化、跨标签页,才放进全局 store。
我见过一种反面案例:任何数据都往 store 塞,结果 store 膨胀成一个大杂烩,状态来源完全失控。也见过另一种反面案例:什么都用 inject,inject 的 key 到处都是,像全局变量一样难维护。正确的姿势应该是,把 provide/inject 看作"局部的全局状态",只在合适的子树范围内使用。
5. 生命周期时序:Day.4 最有价值的一课
5.1 onMounted 里发请求,然后我踩到了"组件没了,定时器还在跑"
第四天做了一个仪表盘页面:进入页面时请求一次概览数据,然后用setInterval每 3 秒刷新一次。初始代码特别朴素:
<script setup> import { onMounted, ref } from 'vue' const data = ref([]) async function fetchData() { const res = await fetch('/api/overview') data.value = await res.json() } onMounted(() => { fetchData() timer = setInterval(fetchData, 3000) }) </script>在小 demo 里它工作得很好。但一旦在真实路由里来回切换,问题就冒出来了:每次进入页面都会启动一个新的定时器,离开时定时器根本没有被销毁。切了几次之后,页面上同时跑着三四个定时器,网络请求叠了好多层,数据还在不断刷新。
这背后的原理很简单:onMounted是挂载后触发,setInterval却不会因为组件卸载而自动停止。如果不主动清理,这个副作用就成了"僵尸进程",跑到地老天荒。
5.2 用 onUnmounted 做好清理工作
解法是在组件卸载时做配对清理:
<script setup> import { onMounted, onUnmounted, ref } from 'vue' const data = ref([]) let timer = null async function fetchData() { const res = await fetch('/api/overview') data.value = await res.json() } onMounted(() => { fetchData() timer = setInterval(fetchData, 3000) }) onUnmounted(() => { clearInterval(timer) timer = null }) </script>同样的思路也适用于网络请求:如果组件已经卸载,请求慢回时再更新数据就是无意义的操作。现代 fetch API 可以配合 AbortController 实现真正取消请求:
<script setup> import { onMounted, onUnmounted } from 'vue' let controller = null onMounted(async () => { controller = new AbortController() try { const res = await fetch('/api/overview', { signal: controller.signal }) // ... } catch (e) { if (e.name === 'AbortError') return } }) onUnmounted(() => { controller?.abort() }) </script>这一课我记到现在的核心是:onMounted里开什么,onUnmounted里必须记得关什么。定时器、事件监听、外部订阅、网络请求,都属于这一类。
5.3 watch 触发的异步请求要防抖
另一个和请求时序相关的坑来自 watch。我写了一个搜索框,每次用户输入关键词就搜索:
<script setup> import { watch, ref } from 'vue' const keyword = ref('') const results = ref([]) watch(keyword, async (newVal) => { const res = await fetch(`/api/search?q=${newVal}`) results.value = await res.json() }) </script>问题在于,watch 默认在值变化后立刻执行回调。用户敲一个字就发一次请求,敲"vue"三个字符,请求能发三次。真实网络环境下,这样既浪费资源又容易造成界面闪烁。
我的处理是用一个简单的防抖:延迟 300ms 执行请求,如果期间关键词又变了,就取消上一次的延迟。
<script setup> import { watch, ref, onUnmounted } from 'vue' const keyword = ref('') const results = ref([]) let timer = null watch(keyword, (newVal) => { clearTimeout(timer) timer = setTimeout(async () => { const res = await fetch(`/api/search?q=${newVal}`) results.value = await res.json() }, 300) }) onUnmounted(() => { clearTimeout(timer) }) </script>注意,这里也别忘了在onUnmounted里clearTimeout,否则组件销毁后定时器仍然可能触发回调。
5.4 keep-alive 让 onUnmounted 不再触发,onActivated 才是真相
第五天之前我完全不知道 keep-alive 的存在。直到我学着用动态组件做 Tab 切换时,切换后组件数据全部丢掉了,于是翻文档找到了 keep-alive:
<template> <KeepAlive> <component :is="currentTab" /> </KeepAlive> </template>KeepAlive 会把已经切换过的组件缓存下来,组件不会销毁。这样一来我的轮询定时器管理逻辑就乱了:onUnmounted不会再在切 Tab 时触发,因为组件只是被停用,没有被卸载。
正确的做法是用onActivated和onDeactivated来处理:
<script setup> import { onActivated, onDeactivated } from 'vue' onActivated(() => { // 每次切回这个 Tab 时执行 startPolling() }) onDeactivated(() => { // 切到别的 Tab 时暂停 stopPolling() }) </script>到这个节点,生命周期钩子的时序才算真正串起来了:首次挂载走 onMounted → 之后被缓存切换就走到 onActivated/onDeactivated → 真正彻底移除时才触发 onUnmounted。想明白这一层,很多"数据消失""定时器叠加"的诡异问题都有了答案。
写在最后
Day.4 最大的收获不是多记了几个 API,而是开始理解"组件设计时,谁有权决定内容"这个根本问题。插槽把结构的决定权交给使用方,v-model 明确了数据的双向通道,provide/inject 划定了局部共享的边界,生命周期钩子决定了副作用什么时候开始、什么时候结束。它们都是"数据流和内容权"的一体两面。
如果你也卡在"会写组件但不会设计组件"的阶段,我的建议是不要按文档顺序一个个 API 看下去,而是找一个真实的多形态卡片或者弹窗加表格的需求,逼自己用插槽重写一遍,再用 v-model 改造一个表单组件,最后把定时器和请求放进生命周期里管理。这一套走完,组件化才算真正入门。第四天的日志到这儿,写下来也算一次强制复盘,收益比我预想的大得多。