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 classcard template v-ifmode text p{{ text }}/p /template template v-else-ifmode action div classcard-content{{ text }}/div button clickemit(action){{ actionText }}/button /template template v-else-ifmode image img :srcimageUrl 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 classcard header{{ title }}/header div classcard-body slot/slot /div /div /template script setup defineProps({ title: { type: String, default: } }) /script使用方想放什么就放什么CardTemplate title今日数据 p这里完全由外部决定/p button clickhandleRefresh刷新/button /CardTemplate一个slot /的位置就是外部内容注入的位置。外部传入的模板在渲染时依然处于父组件的作用域——它可以访问父组件的变量和方法而不是子组件的。这一点很关键因为很多新手会想当然地以为插槽内容属于子组件结果访问变量时报 undefined半天摸不着头脑。另外插槽还可以写默认内容div classcard-body slot本区域由外部决定/slot /div外部没传东西时渲染默认内容传了就覆盖。这也是有回退方案的组件设计思路值得养成习惯。2.2 具名插槽一个组件开多扇可替换窗口默认插槽解决了一个内容区但真实的组件往往有多个可替换区域。比如弹窗头部标题区、中间内容区、底部按钮区三块都要能定制。用具名插槽可以给每个区域起名字template div classmodal div classmodal-header slot nameheader默认标题/slot /div div classmodal-body slot/slot /div div classmodal-footer slot namefooter/slot /div /div /template使用方用#header、#footer来精确填充MyModal template #header h3编辑用户/h3 /template p这是默认插槽里的内容/p template #footer button clickcancel取消/button button clicksave保存/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 :keyrow.id td{{ index 1 }}/td td slot nameoperation :rowrow :indexindex/slot /td /tr /tbody /table /template script setup defineProps({ rows: { type: Array, required: true } }) /script组件内部把row和index通过:row、:index抛给插槽。使用方就可以在自己写的模板里解构接收这两个值DataTable :rowsusers template #operation{ row, index } button clickeditUser(row)编辑/button button clickremoveUser(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-modelusername本质等价于input :valueusername inputusername $event.target.value /简而言之把:value和input绑在一起省去三行重复代码。组件上的 v-model 也是同一套逻辑的延续给组件传一个叫modelValue的 prop同时监听一个叫update:modelValue的事件。所以这句MyInput v-modelusername /等价于MyInput :modelValueusername update:modelValuenewVal (username newVal) /理解了这一层自定义组件上的 v-model 就再也不神秘了。3.2 自己写一个带 v-model 的输入组件动手写一个最简单的 MyInput.vuetemplate input :valuemodelValue input$emit(update:modelValue, $event.target.value) / /template script setup defineProps({ modelValue: { type: String, default: } }) defineEmits([update:modelValue]) /script外部使用MyInput v-modelusername /这个组件的工作闭环是父组件把username通过modelValue传进来 → 子组件 input 的value接收显示 → 用户输入时触发input事件 → 通过$emit(update:modelValue, ...)把新值抛出去 → 父组件更新username→ 再传回来。这里有个很容易踩的坑一开始我偷懒想在子组件内部直接写v-modelmodelValue结果浏览器控制台立刻报了一个警告——props 是只读的不允许在子组件内部直接修改。Vue 的单向数据流不是用来限制自由的它保证的是数据变化来源可追踪。真正想改数据只能通过 emit 走父组件让父组件改完了再传回来形成循环。这个循环就是双向绑定的本质。3.3 多个值需要双向同步多个 v-model 是很香的写法第四天我还遇到一个场景自定义弹窗组件既要控制是否可见又要绑定表单数据。如果沿用单个 v-model 的思路就得把两个值塞进一个对象里既别扭又难读。Vue 3 支持给 v-model 加参数名这件事帮我省了大量心智负担MyModal v-model:visiblemodalVisible v-model:formDataform/MyModal对应组件内部script setup defineProps({ visible: { type: Boolean, default: false }, formData: { type: Object, default: () ({}) } }) defineEmits([update:visible, update:formData]) /script template div v-ifvisible classmodal div classmodal-header slot nameheader/slot /div div classmodal-body slot/slot /div button click$emit(update:visible, false)关闭/button /div /templatev-model:visible对应 prop 名visible、事件名update:visiblev-model:formData对应formData和update:formData。语义清晰使用方一眼就能看出来哪些属性是受外部控制的。这比当初用 props 一堆不规则的close/change事件要稳健得多。提示给自定义组件设计 v-model 时起名要克制。一个组件最多两三个 v-model 参数再多就考虑换 props 或拆组件。参数名尽量用名词而不是动词因为它描述的是一个值不是一个动作。3.4 如果用的是 Vue 3.4defineModel 出现了写这篇笔记时Vue 3.4 已经提供了defineModel宏可以大幅度简化自定义 v-model 的写法template input :valuemodel inputmodel $event.target.value / /template script setup const model defineModel({ type: String, default: }) /scriptdefineModel会替你完成 props 声明和 emit 触发让代码更干净。但我仍然建议你先理解 3.2 里的基础写法再使用 defineModel——因为 defineModel 本质就是编译成modelValueupdate:modelValue那一套。基础原理不牢换个版本或者看别人的老代码还是会一脸懵。4. provide/inject当 props 传递开始变得窒息4.1 逐层传递的过手问题插槽解决了一部分组件结构不确定问题v-model 简化了双向数据流但还有一类场景让我很头疼组件嵌套层级深深层组件需要一个数据可中间的每一层都不需要它。举个例子页面容器 A 组件里套了 B 组件B 里套了 C 列表C 列表里的每个 Item 需要拿到当前用户的 userId。如果只靠 propsA → B → C → Item 每一层都要声明并转发这个字段。中间层 B 不消费 userId但它必须写C :userIduserId /纯属过手。这种代码堆多了以后组件签名变得又臭又长改一个字段名要在四五个文件里同步修改。这个问题有一个专门的名字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深层子组件里直接 injectscript setup import { inject } from vue const theme inject(theme, light) const toggleTheme inject(toggleTheme) /script template div :classtheme-${theme} button clicktoggleTheme切换主题/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 膨胀成一个大杂烩状态来源完全失控。也见过另一种反面案例什么都用 injectinject 的 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-alivetemplate KeepAlive component :iscurrentTab / /KeepAlive /templateKeepAlive 会把已经切换过的组件缓存下来组件不会销毁。这样一来我的轮询定时器管理逻辑就乱了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 改造一个表单组件最后把定时器和请求放进生命周期里管理。这一套走完组件化才算真正入门。第四天的日志到这儿写下来也算一次强制复盘收益比我预想的大得多。