游乐游手机版
首页/前端开发/文章详情

Vue3全栈实战第三周:组件化开发Props/Emit/注入/插槽/自定义指令

时间:2026-07-21 19:22
Vue3全栈实战第三周聚焦组件化开发,将收件箱页面拆分为独立可复用组件。涵盖Props单向数据流、Emit自定义事件、provide inject跨层级通信、插槽及自定义指令,通过类型安全写法实现组件间高效通信与职责分离。

Vue3 全栈实战第三周:组件化开发完整记录 —— Props / Emit / provide-inject / 插槽 / 自定义指令

聊到组件化开发,本周的核心任务其实非常明确:将收件箱页面中那些原本混杂在一起的逻辑,逐一拆解为独立、可复用的功能单元。从单向数据流到跨层级通信,从插槽到自定义指令,我们把Vue3组件通信的完整技术图谱系统性地梳理了一遍。

目录

  • 本周项目结构
  • Day1:组件拆分 + Props
  • Day2:Emit 自定义事件
  • Day3:provide / inject 跨层级通信
  • Day4:插槽 slot
  • Day5:自定义指令
  • 五种通信方式对比
  • 本周总结
  • 下周计划

本周项目结构

先来看看经过本周重构之后,项目结构发生了怎样的变化: ``` src/ components/ MailItem.vue ← 新增:单封邮件组件 MailList.vue ← 新增:邮件列表组件 MailListScoped.vue ← 新增:支持作用域插槽的列表组件 MailToolbar.vue ← 新增:工具栏组件 BaseCard.vue ← 新增:通用卡片容器组件 directives/ index.ts ← 新增:自定义指令(v-focus、v-click-outside、v-loading、v-highlight) types/ injectionKeys.ts ← 新增:provide/inject 的类型安全 key views/ InboxView.vue ← 修改:组件拆分、搜索过滤、关键词高亮、loading 遮罩 MailDetailView.vue ← 修改:使用 BaseCard 组件 main.ts ← 修改:全局注册自定义指令 App.vue ← 修改:加入主题切换,provide 主题模式 ``` 每个文件现在都各司其职,不再是杂乱无章的一锅乱炖。 ---

Day1:组件拆分 + Props

**为什么要拆组件** 原先的 `InboxView.vue` 把所有代码都堆在一起,随着功能持续增加,维护难度越来越大,代码量直奔500行。 拆分之前,`InboxView.vue` 里列表、工具栏、搜索框全部挤在一个文件中。拆分之后,`InboxView.vue` 只负责布局协调和数据分发,具体的功能交给了各个子组件: - `MailToolbar.vue`(工具栏) - `MailList.vue`(邮件列表) - `MailItem.vue`(单封邮件)

Vue3 全栈实战第三周:组件化开发完整记录 —— Props / Emit / provide-inject / 插槽 / 自定义指令

判断是否要拆分组件,其实有几个很实用的标准: - 可以被复用的 UI 片段 → 拆 - 逻辑相对独立的区块 → 拆 - 超过 150 行的组件 → 考虑拆 **Props 基础写法** Props 的核心价值在于类型安全。推荐使用泛型配合 interface 的方式来定义: ```typescript interface Props { subject: string from: string isRead: boolean createdAt: Date | string isSelected?: boolean // ? 表示可选 } const props = defineProps() ``` **withDefaults 设置默认值** 可选属性如果没有传递,就会是 `undefined`,这时候 `withDefaults` 就非常有用: ```typescript const props = withDefaults(defineProps(), { isSelected: false, // 基础类型直接写值 tags: () => [], // 引用类型用函数返回,避免共享 }) ``` **Props 单向数据流** 需要特别注意,不能直接修改 Props,否则会报错。正确的做法是用本地变量复制一份: ```typescript // 错误:props.isRead = true // 正确:用本地变量 const localIsRead = ref(props.isRead) localIsRead.value = true ``` **新增文件:`src/components/MailItem.vue`** ```vue ``` **新增文件:`src/components/MailList.vue`** ```vue ``` **新增文件:`src/components/MailToolbar.vue`** ```vue ``` ---

Day2:Emit 自定义事件

**为什么需要 Emit** Props 是单向的数据流(父 → 子),当子组件需要通知父组件发生了某些操作时,就必须使用 Emit。数据流向非常清晰: ``` 父组件 InboxView ↓ Props(数据向下传) 子组件 MailItem ↑ Emit(事件向上传) ``` **基础写法** 在子组件中定义可以发出的事件: ```typescript const emit = defineEmits<{ click: [id: number] // 事件名:参数类型 markRead: [id: number] 'update:isRead': [value: boolean] // 带连字符用引号 }>() // 触发事件 emit('click', mail.id) emit('markRead', mail.id) ``` 父组件监听事件: ```vue ``` **事件流向** 点击一封邮件的完整事件链很有意思: ``` MailItem 点击 → emit('markRead', id) + emit('click', id) → MailList 接收,转发 → emit('mailMarkRead', id) + emit('mailClick', id) → InboxView 接收 → markAsRead(id) + router.push(...) ``` 每一层都只专注做自己该做的事,不越权处理。 **修改文件:`src/components/MailItem.vue`** ```vue ``` ---

Day3:provide / inject 跨层级通信

**为什么需要 provide / inject** Props 逐层传递在层级较深时会变得非常繁琐,这就是所谓的 Props Drilling: ``` InboxView ↓ :theme="theme" MailList(只是中转,自己不用) ↓ :theme="theme" MailItem(真正用到的地方) ``` provide / inject 直接解决了这个问题:数据从祖先组件,跨越中间层,直接注入到需要的地方。 **类型安全的写法** 为了保证类型安全,推荐使用 `InjectionKey` 配合 `Symbol` 的方式: ```typescript // src/types/injectionKeys.ts import type { InjectionKey, Ref } from 'vue' export type MailDensity = 'compact' | 'normal' | 'comfortable' export type ThemeMode = 'light' | 'dark' // InjectionKey:把 Symbol 和数据类型绑定,类型自动推断 export const mailDensityKey: InjectionKey> = Symbol('mailDensity') export const themeModeKey: InjectionKey> = Symbol('themeMode') ``` **为什么用 Symbol** 字符串 key 很容易拼写错误,而且多个库如果使用同名的 key 会产生冲突。Symbol 是全局唯一的,不可能拼错。 ```typescript // 字符串 key:容易拼错 provide('theme', themeMode) inject('Theme') // 拼错了,取不到 // Symbol key:全局唯一,不会冲突 provide(themeModeKey, themeMode) inject(themeModeKey) // 用同一个引用,不可能拼错 ``` **provide 和 inject 的用法** ```typescript // 父组件:App.vue provide 主题 const themeMode = ref('light') provide(themeModeKey, themeMode) // 深层子组件:MailItem.vue inject 使用 // inject 第二个参数是默认值,取不到时使用 const themeMode = inject(themeModeKey, ref('light')) const mailDensity = inject(mailDensityKey, ref('normal')) ``` **provide / inject vs Props vs Pinia 怎么选** | 场景 | 推荐方案 | |------|----------| | 父子组件直接通信 | Props + Emit | | 跨多层级,局部数据 | provide / inject | | 全局共享数据 | Pinia | ---

Day4:插槽 slot

**三种插槽** **默认插槽**:传入简单内容,最常用的方式。 ```vue

这里的内容放到 slot 位置

``` **具名插槽**:多个插入点,子组件定义多个插槽位置。 ```vue 正文内容放这里 ``` **作用域插槽**:子组件把数据暴露给父组件,让父组件自定义渲染方式。 ```vue
``` **`$slots` 判断插槽是否传入** 有时候需要根据插槽有没有传入来决定是否渲染某个区域: ```vue

``` **新增文件:`src/components/MailListScoped.vue`** ```vue ``` **修改文件:`src/views/MailDetailView.vue`(使用 BaseCard)** ```vue ``` **新增文件:`src/components/BaseCard.vue`** ```vue ``` ---

Day5:自定义指令

**什么时候用自定义指令** 需要直接操作 DOM 的时候,使用自定义指令。如果只是纯数据逻辑,用 Composable;如果是 UI 片段复用,用组件。 **指令的生命周期** ```typescript const myDirective = { beforeMount(el, binding) {}, mounted(el, binding) { // el:绑定的 DOM 元素 // binding.value:指令的值(v-highlight="'yellow'" 里的 'yellow') // binding.arg:指令参数(v-highlight:bg 里的 'bg') // binding.modifiers:修饰符(v-highlight.bold 里的 { bold: true }) }, updated(el, binding) {}, beforeUnmount(el, binding) {}, unmounted(el, binding) {}, } ``` **新增文件:`src/directives/index.ts`** ```typescript import type { Directive, DirectiveBinding } from 'vue' /** * 功能:自动聚焦指令 * 场景:搜索框、登录表单输入框打开时自动聚焦 * 用法: */ export const vFocus: Directive = { mounted(el) { el.focus() } } /** * 功能:点击外部关闭指令 * 场景:下拉菜单、弹窗点击外部区域自动关闭 * 用法:

*/ export const vClickOutside: Directive void> = { mounted(el, binding: DirectiveBinding<() => void>) { const handler = (event: MouseEvent) => { if (!el.contains(event.target as Node)) { binding.value() } } ;(el as any)._clickOutsideHandler = handler document.addEventListener('click', handler) }, unmounted(el) { // 组件销毁时移除监听,避免内存泄漏 document.removeEventListener('click', (el as any)._clickOutsideHandler) } } /** * 功能:loading 遮罩指令 * 场景:邮件列表加载时显示半透明遮罩 * 用法:

*/ export const vLoading: Directive = { mounted(el, binding: DirectiveBinding) { el.style.position = 'relative' if (binding.value) { addMask(el) } }, updated(el, binding: DirectiveBinding) { const mask = el.querySelector('[data-loading-mask]') if (binding.value && !mask) { addMask(el) } else if (!binding.value && mask) { mask.remove() } }, unmounted(el) { el.querySelector('[data-loading-mask]')?.remove() } } function addMask(el: HTMLElement) { const mask = document.createElement('div') mask.style.cssText = ` position:absolute;top:0;left:0; width:100%;height:100%; background:rgba(255,255,255,0.7); display:flex;align-items:center; justify-content:center; font-size:14px;color:#999;z-index:10; ` mask.textContent = '加载中...' mask.setAttribute('data-loading-mask', 'true') el.appendChild(mask) } /** * 功能:文字高亮指令 * 场景:搜索结果里高亮匹配的关键词 * 用法:

*/ export const vHighlight: Directive = { mounted(el, binding: DirectiveBinding) { highlight(el, binding.value) }, updated(el, binding: DirectiveBinding) { highlight(el, binding.value) } } function highlight(el: HTMLElement, keyword: string) { const original = el.getAttribute('data-original') || el.textContent || '' el.setAttribute('data-original', original) if (!keyword) { el.textContent = original return } el.innerHTML = original.replace( new RegExp(keyword, 'g'), `${keyword}` ) } ``` **修改文件:`src/main.ts` 全局注册** ```typescript import { vFocus, vClickOutside, vLoading, vHighlight } from '@/directives' app.directive('focus', vFocus) app.directive('click-outside', vClickOutside) app.directive('loading', vLoading) app.directive('highlight', vHighlight) ``` **在 `InboxView.vue` 里使用** ```vue

{{ mail.subject }} ``` **搜索过滤:`filteredMails` 计算属性** `keyword` 不只做高亮显示,还要真正过滤列表。用 `computed` 基于关键词实时过滤,同时匹配主题、发件人、正文三个字段: ```typescript const filteredMails = computed(() => { if (!keyword.value) return mails.value return mails.value.filter(mail => mail.subject.includes(keyword.value) || mail.from.includes(keyword.value) || mail.body.includes(keyword.value) ) }) ``` 模板里把所有 `:mails="mails"` 改成 `:mails="filteredMails"`,搜索就真正生效了——过滤和高亮同时工作。 **Day5 踩坑记录** **坑一:点击搜索按钮,搜索框一闪而过** 现象:点击「搜索」按钮,搜索框出现后立刻消失。 原因:点击按钮时事件会冒泡到 `document`,`v-click-outside` 检测到点击发生在搜索框外部(按钮不在搜索框里),立刻触发 `closeSearch`,搜索框刚出现就关掉了。 解决:用 `setTimeout` 延迟一帧再挂载监听,让当次冒泡先结束: ```typescript export const vClickOutside: Directive void> = { mounted(el, binding: DirectiveBinding<() => void>) { const handler = (event: MouseEvent) => { if (!el.contains(event.target as Node)) { binding.value() } } ;(el as any)._clickOutsideHandler = handler // 延迟一帧,避免触发按钮点击事件的冒泡 setTimeout(() => { document.addEventListener('click', handler) }, 0) }, unmounted(el) { document.removeEventListener('click', (el as any)._clickOutsideHandler) } } ``` **坑二:v-loading 遮罩加载太快看不到** 模拟数据只延迟 500ms,加载太快看不到遮罩效果。验证时临时改成 2000ms,接入真实后端 API 后,网络请求本身就有延迟,不需要再模拟。 **坑三:搜索只高亮没有过滤** 最初 `keyword` 只绑定到 `v-highlight` 做高亮显示,没有真正过滤列表。加上 `filteredMails` 计算属性之后,搜索才真正生效——过滤和高亮同时工作。 ---

五种通信方式对比

这周学完之后,Vue3 组件通信的完整图谱: | 方式 | 方向 | 适用场景 | |------|------|----------| | Props | 父 → 子 | 父组件传数据给子组件 | | Emit | 子 → 父 | 子组件通知父组件发生了什么 | | provide / inject | 祖先 → 后代 | 跨多层级传数据,避免 Props 逐层透传 | | slot | 父 → 子(结构) | 父组件自定义子组件内部的 HTML 结构 | | Pinia | 任意组件 | 全局共享状态,需要持久化的数据 | ---

本周总结

**最大的收获:理解了「职责分离」** 拆完组件之后,每个文件的职责变得非常清晰: - `MailItem.vue` → 只负责展示一封邮件的样式 - `MailList.vue` → 只负责渲染列表和透传事件 - `MailToolbar.vue` → 只负责工具栏布局 - `InboxView.vue` → 只负责协调数据和处理业务逻辑 改一个功能只需要改一个文件,不会牵一发动全身。 **掌握的知识点自检:** - [x] defineProps + interface:类型安全的 Props 定义 - [x] withDefaults:给可选 Props 设置默认值 - [x] Props 单向数据流:不能直接修改 Props - [x] defineEmits:定义子组件可以发出的事件 - [x] InjectionKey + Symbol:类型安全的 provide/inject - [x] provide/inject vs Props vs Pinia 怎么选 - [x] 默认插槽、具名插槽、作用域插槽的使用场景 - [x] `$slots.xxx` 判断插槽是否传入 - [x] 自定义指令的生命周期钩子 - [x] unmounted 里清理事件监听,避免内存泄漏 ---

下周计划

第四周:**Vue3 进阶 + 性能优化** - `watch` / `watchEffect` 进阶用法 - `nextTick` 原理和使用场景 - `defineExpose` 暴露组件方法 - 组件性能优化:`v-memo`、`shallowRef`、`markRaw` - keep-alive 缓存组件
来源:https://juejin.cn/post/7664407875024224296
上一篇Vue3 DOM异步更新原理与性能优化学习笔记 下一篇Markdown渲染管线全部细节:Worker、源行标注与按需加载
本站内容用于信息整理与展示,如有侵权或内容问题请及时联系处理。

相关推荐

补充同频道和同主题内容,方便继续浏览更多相关内容。

同类最新

继续查看同栏目最近更新的文章。

更多
JavaScript数组字面量与构造函数创建稀疏数组的差异
前端开发 · 2026-07-25

JavaScript数组字面量与构造函数创建稀疏数组的差异

数组字面量创建稠密数组,空位默认为undefined;Array()构造函数传入单个数字参数会生成稀疏数组,索引不存在且遍历方法跳过,多参数或非数字参数则行为与字面量一致。初始化稠密数组应使用Array from或fill。

如何优化Bootstrap按钮的焦点状态环CSS样式方法详解
前端开发 · 2026-07-25

如何优化Bootstrap按钮的焦点状态环CSS样式方法详解

Bootstrap按钮焦点样式优化需将内阴影改为外发光,覆盖所有焦点选择器避免原生蓝边闪烁。使用:focus-visible区分键盘与鼠标交互,同时处理按钮组圆角、父容器溢出及浏览器兼容性,确保焦点反馈清晰且符合无障碍标准。

Less中强制转换CSS单位适配不同移动端方案详解
前端开发 · 2026-07-25

Less中强制转换CSS单位适配不同移动端方案详解

Less单位转换需手动完成:用unit()剥离单位,通过变量控制基准值,再拼接目标单位。px2rem函数须区分输入类型(纯数字、带px单位等),基准值@base-font-size需全局定义且不可在媒体查询中重定义。所有运算发生在编译期,适配需提前编译多套CSS文件。

Vue 插件开发与使用完整指南
前端开发 · 2026-07-25

Vue 插件开发与使用完整指南

Vue插件通过install方法为应用注入全局属性、组件、指令、混入和provide等扩展能力,注册时机须在createApp之后、mount之前。插件支持对象或函数形式,使用app use()注册。开发时需注意命名冲突、配置默认值及错误处理,确保工程健壮性。

CSS响应式视频全屏黑边排版问题解决方案
前端开发 · 2026-07-25

CSS响应式视频全屏黑边排版问题解决方案

CSS响应式视频全屏黑边源于盒子模型、定位与加载策略缺失。需重置body边距及溢出,父容器用position:fixed与100dvh,video设为block+object-fit:cover。autoplay需加muted、playsinline。移动端用100dvh防地址栏抖动,低端机分辨率不超1倍。