
学习这件事我一直觉得有个规律前几天的热情容易泄后面几天要么形成惯性要么直接弃坑。Vue学到第五天恰好是最容易迷茫也最容易量变累积到质变的节点——前面几天的输入开始沉淀下来需要一次“组装”来验证自己是不是真的懂了。这篇文章就记录一下我Day5的学习过程把组件从“能写”推向“能协作”顺便把我踩过的坑和当时的思考过程一起写出来给同样在边学边练的朋友做个参考。1. 第五天该学什么从“会写页面”到“写像样组件”先说清楚我前四天的进度方便上下文对齐。第一天环境搭建和模板语法第二天指令和计算属性第三天组件基础第四天把单文件组件和脚手架项目结构过了一遍。到第四天结束我能做出一个像模像样的静态页面也能把一个页面拆成几个组件让数据通过props从上往下传、通过emits从下往上抛。但这几天里我始终有一个说不清楚的不舒服组件之间的关系只要超过两层代码就开始别扭。那个别扭来自三个方面。第一我只会自上而下传数据想给深层的孙组件传值就要在中间层写一堆透传props看着就烦。第二页面组件里塞满了各种各样的逻辑可复用性并不高换个项目基本得重写。第三我传数据和通知事件的方式太单一遇到“兄弟组件之间需要同步状态”这种场景就卡住。所以Day5我的目标很明确把组件通信从“够用”提升到“按场景选方案”同时把组件的生命周期和插槽这两块短板补上。这里也顺便分享一个学习上的体会第五天最容易犯的错是“急着把全家桶学完”比如立刻去啃状态管理库和路由。我的选择是先巩固组件能力本身因为不管以后上不上管理系统、写不写小程序组件协作的基本功永远绕不开。如果说前四天学的是“组件自转”第五天开始就是在学“组件公转”——怎么让多个组件配合怎么把公共能力抽出来怎么设计接口让别人敢用你的组件。这个认知一旦建立后面学路由、学状态管理都会顺畅很多。第五天我给自己定的任务清单是三条一是把生命周期的每个阶段亲手验证一遍二是学会provide/inject、事件总线、组件上使用v-model这三种进阶通信方式三是搞懂插槽的完整用法然后做一个综合小练习把这三块串起来。接下来就按这个顺序讲。2. 生命周期不是背表格是“跑一遍看日志”生命周期这个概念很多教程上来就甩表格告诉你created适合做什么、mounted适合做什么、beforeUnmount适合做什么。但说实话刚学的时候背这些表格非常痛苦因为你不清楚这些阶段到底按什么顺序跑、每个阶段对数据和DOM的访问能力到底差在哪。我Day5的第一件事就是写一段最朴素的代码在每个生命周期钩子里打印日志然后看着控制台把这些阶段“踩实”。2.1 Vue 3生命周期钩子的实际触发顺序如果用的是Vue 3的组合式API常用的生命周期钩子是onBeforeMount、onMounted、onBeforeUpdate、onUpdated、onBeforeUnmount、onUnmounted另外还有两个特殊的onRenderTracked和onRenderTriggered前者能跟踪渲染依赖后者能调试是什么触发了重新渲染。第一次看这个名字很容易晕但其实跟Vue 2时代的beforeMount、mounted对照着记就很快只是把名字改成了on开头并且都要求在组件配置阶段同步调用。我写了一个小组件模板里显示一个count点击按钮改变count再做一个按钮控制组件的卸载。控制台输出的顺序是setup执行、onBeforeMount输出、渲染完成后的onMounted输出点击更新按钮后先出现onBeforeUpdate再出现onUpdated卸载时先onBeforeUnmount再onUnmounted。这个顺序看起来简单但亲手跑一遍的意义在于你会在心里种下一个非常扎实的“时机感”比如你就知道了在onMounted里才能拿到真实DOM因为此时组件已经挂载完毕。2.2 每个阶段该干什么我的判断逻辑有了“时机感”再去看该在哪个钩子里做事的讨论就不容易被绕晕。我自己的判断逻辑很简单要看这个操作需不需要真实DOM以及什么时候开始。数据请求我习惯放在onMounted里虽然理论上放到setup里面也能发请求但onMounted的语义更明确——组件已经渲染完成了这时去改数据引起更新不会出现“渲染中间态”的视觉问题。定时器、滚动监听这类需要在页面存在期间一直生效的东西我就在onMounted里开启在onBeforeUnmount里清理。这里特别提醒一下很多人记得要开定时器却忘了卸载前必须清理。不清理的情况下组件销毁后定时器还在工作它继续去修改已经卸载组件的状态轻则控制台报错重则内存泄漏。我在实测中就遇到过列表页反复进出定时器越积越多页面越来越卡。还有两个现象值得新手注意。第一onBeforeUnmount触发时组件还没有完全卸载此时仍然可以访问DOM和实例数据所以如果你想做“组件离开前的补偿操作”这是最后的机会。第二Vue 3把原先beforeDestroy和destroyed合并成了beforeUnmount和unmounted语义上都在强调“组件要从视图里消失”而不是“彻底销毁”。这个差别对理解缓存组件比如keep-alive非常关键——被缓存的组件卸载时不走unmounted而是走onDeactivated数据不会丢。3. 组件通信进阶三种方案和它们的适用场景前四天只会props和emits时我写代码的模式非常固定父组件往子组件传值子组件通过emit给父组件发消息。第五天一接触到更多场景就发现这个模式不够用。典型例子是一个列表页页面组件管理列表数据列表里的每一项是一个组件每一项里面又有一个小按钮这个按钮要触发“删除这一项”的动作。如果用props和emits中间要经过至少一层转发代码绕且难读。3.1 provide/inject跨层级传值的官方快捷方式provide/inject解决的是“祖组件给子孙组件直接传值”的问题。在祖组件里写一个provide()函数返回你要提供的数据在任意子孙组件里通过inject()接收中间隔多少层都无所谓。这比逐层传props干净得多。但用的时候有一个必须注意的点如果provide的值是普通变量它不是响应式的子组件拿到的只是那一瞬间的静态值。如果希望子组件跟着祖组件一起变化你需要在provide时提供一个reactive对象或者ref。业界常见做法是直接在provide里提供一个响应式对象同时提供一个修改它的方法类似“数据操作”打包下发。我用一个实际例子来演示// 祖组件中 import { provide, reactive } from vue const state reactive({ userName: 某开发者, level: 5 }) function updateUser(name) { state.userName name } provide(userContext, { state, updateUser })子孙组件这样接import { inject } from vue const { state, updateUser } inject(userContext)这样子孙组件拿到的state就是响应式的调updateUser时祖组件的视图也会更新。3.2 事件总线模块化之后怎么挂载与卸载事件总线EventBus是一种经典的非父子组件通信方案本质是一个发布订阅器。Vue 2时代很多人直接new一个Vue()实例作为事件中心Vue 3里Vue构造函数不再暴露$on、$off这些实例方法所以不能照搬老写法。我现在更推荐用一个独立的小工具模块来模拟发布订阅这也是前端常用的做法。一个几十行的工具类就能满足大多数场景class EventBus { constructor() { this.events new Map() } on(event, callback) { if (!this.events.has(event)) { this.events.set(event, []) } this.events.get(event).push(callback) } off(event, callback) { if (!this.events.has(event)) return const callbacks this.events.get(event).filter(cb cb ! callback) this.events.set(event, callbacks) } emit(event, payload) { if (!this.events.has(event)) return this.events.get(event).forEach(callback { try { callback(payload) } catch (error) { console.error([EventBus] ${event} 回调执行出错, error) } }) } } export default new EventBus()用的时候接收方在onMounted里订阅在onBeforeUnmount里退订避免组件卸载后回调仍然被触发。发送方只需要写一行eventBus.emit。这种方案对于兄弟组件传值特别友好比如一个组件修改筛选条件另一个组件立即响应更新表格。这里要给个提醒事件总线方便但也要克制。项目大了以后全局事件满天飞你很难追踪某个事件到底被谁监听、谁发出了。如果你发现自己已经在维护一长串事件名列表那说明该考虑更正式的状态管理方案了。不过在学习阶段亲手写一个EventBus的过程很值得因为它让你理解“跨组件通信背后的机制其实就是事件机制”。3.3 v-model在组件上的进阶用法很多人学v-model只记了表单元素上的用法一放到自定义组件上就不知道怎么处理了。其实组件上的v-model可以拆解成两部分一个值为modelValue的prop一个update:modelValue的事件。把这两部分搞清楚v-model就完全透明了。父组件写UserInput v-model:contentcontent /子组件内部script setup const props defineProps({ content: { type: String, default: } }) const emit defineEmits([update:content]) function handleInput(e) { emit(update:content, e.target.value) } /script template input :valuecontent inputhandleInput / /template如果你需要多个双向绑定值Vue 3也支持多个v-model比如v-model:title和v-model:content同时用。这个特性在写复杂表单组件时特别有用。我以前总觉得“组件上能用v-model”很神秘拆解完之后发现只不过是一个约定好的命名规则通了之后很多组件接口设计都豁然开朗。4. 插槽组件里的“填空设计”props传的是数据插槽传的是内容结构。打个比方props像一个表格表单里预先留好的字段你只能填指定类型的数据插槽则像一套毛坯房的预留门洞门洞位置固定但门用什么材质、什么样式完全由使用者决定。这个类比让我一下子理解了为什么很多通用组件库大量使用插槽——因为组件作者不可能预先猜到所有使用方的渲染需求。4.1 默认插槽和具名插槽默认插槽最直接子组件里用 占位父组件传入的任何内容都会渲染到这个位置。具名插槽就是给插槽起名字一个组件里可以有多个洞口。父组件用template v-slot:header这种方式指定内容放进哪个插槽。v-slot有个简写是#写起来非常清爽。我第五天边学边写的一个卡片组件就是典型的具名插槽案例卡片头部区域留给外部自定义内容区域也留给外部组件的责任只是负责外框、布局、样式统一。这样一来同一个卡片组件能容纳图片、文本、视频、表单等完全不同的内容组件的复用边界清晰了很多。4.2 作用域插槽让父组件接管渲染细节作用域插槽是个天然劝退很多新手的点因为它牵扯到“数据在子组件、渲染在父组件”的逆向流动。我理解它的钥匙在于子组件在渲染插槽内容时可以把自身的数据“递”给父组件父组件拿到这些数据才能决定怎么渲染。就好比毛坯房的门洞不仅是空的开发商还在门洞旁边留了一根电源线——你不光能决定安什么门还能用这根电源线给门装上通电设备。实际应用场景里最典型的就是表格列自定义。子组件只负责遍历数据、生成行但每一行怎么渲染、有几个按钮、按钮怎么布局全部下放给父组件。父组件通过作用域插槽拿到当前行的数据自由拼装自己的节点。我在练习里写过一个列表组件数据由列表组件管理但每一行的末尾操作区通过作用域插槽暴露出来具体里面放编辑按钮还是删除按钮完全由页面决定。这种“数据归我管UI归你定”的设计模式非常强大。5. 把第五天的知识串起来一个卡片列表小场景学了新知识如果没有一个综合练习把它们拧在一起第二天基本就忘光。第五天下午我花了几个小时做了一个很小的卡片列表场景一个页面管理一组卡片数据卡片组件负责渲染外框卡片里面的用户信息和操作按钮由外部决定同时卡片组件内部有一个点赞状态点赞结果需要通知页面层。5.1 需求拆解和组件设计拆完需求之后我用了三样第五天学的东西provide/inject负责把页面级的状态共享给所有卡片插槽负责卡片头部的自定义渲染生命周期钩子负责初始化数据以及卸载前清理临时事件。这个组合看起来简单但正好覆盖了“跨层级共享状态”“组件复用解耦”“定时更新清理”三个知识点。组件关系是App页面维持一个reactive对象保存卡片数据和公共状态provide给所有子组件CardList组件负责v-for渲染卡片每一张卡片内部用CardItemCardItem是实际渲染单元。为了让插槽有意义我还单独做了一个CardHeader组件通过作用域插槽接收当前卡片条目让外部决定头部展示什么。5.2 关键代码与串起来的思路核心的App部分script setup import { provide, reactive, onMounted, onBeforeUnmount } from vue const pageState reactive({ cardList: [], currentUser: 某开发者, likedMap: {} }) function toggleLike(id) { pageState.likedMap[id] !pageState.likedMap[id] } provide(pageState, pageState) provide(toggleLike, toggleLike) onMounted(() { // 模拟接口返回数据 pageState.cardList [ { id: 1, title: 第一条卡片, author: A同学 }, { id: 2, title: 第二条卡片, author: B同学 } ] }) onBeforeUnmount(() { // 清理任何临时注册的浏览器事件 }) /script template CardList template #cardHeader{ item } div classcard-header-custom strong{{ item.title }}/strong span{{ item.author }} 的作品/span /div /template /CardList /templateCardItem内部通过inject拿到pageState和toggleLike然后在模板里渲染自己的布局。这里最值得细品的点是页面层通过provide下发状态和操作方法子组件完全不需要逐层接收props代码结构非常扁平。5.3 当时踩到的两个坑第一个坑是没有把provide的对象用reactive包起来。我一开始provided一个普通对象子组件确实能读到但页面数据一更新子组件视图纹丝不动。排查了很久才意识到响应式没有建立。加上reactive之后就一切正常。这个坑几乎每个用provide/inject的人都会踩一次知道了就能省不少排查时间。第二个坑和作用域插槽的解构有关。我在父组件里写template #cardHeader{ item }子组件插槽里往外传的数据字段名却起的不是item导致一直拿到undefined。插槽传值和props一样约定什么字段就必须接什么字段名字对不上就是undefined。这种错很难靠报错信息一眼定位因为模板编译不会拦你只会悄悄渲染空白。6. Day5结束后的心里话与下一步方向第五天结束我对Vue的“手感”和前四天完全不一样了。如果说前四天是用Vue写代码第五天之后更像是用Vue做设计——每写一个组件之前会先想一想它要和哪些兄弟组件通信、要暴露哪些插槽、数据变化应该从哪里流入。这个转变是我觉得第五天最大的收获。给同样学到第五天的朋友几点建议。一是别急着把事件总线或者provide/inject当成所有问题的万能钥匙先用好props和emits这仍然是Vue组件通信的基础遇到跨层级或者兄弟通信再考虑今天学的这些。二是动手之前先把组件关系图草草画出来哪怕只是画三个方框和几条线也比什么都不画直接开写强。三是每学一个新能力就做一个“把新知识塞进旧项目”的小练习比单纯跟着教程抄一遍例题有用得多。下一步我打算开始看路由不过我知道路由里的很多难点恰好来自组件协作的理解不够深入——比如路由懒加载和组件生命周期配合、路由参数变化不同页面组件如何响应。走到这总算是真正有点“心里有底”的感觉了。