首页 > 网页制作 >Vue3全栈实战第三周:组件化开发Props/Emit/Provide/Inject/插槽/自定义指令

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

来源:互联网 2026-07-21 08:10:03

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 缓存组件

侠游戏发布此文仅为了传递信息,不代表侠游戏网站认同其观点或证实其描述

热游推荐

更多
湘ICP备14008430号-1 湘公网安备 43070302000280号
All Rights Reserved
本站为非盈利网站,不接受任何广告。本站所有软件,都由网友
上传,如有侵犯你的版权,请发邮件给xiayx666@163.com
抵制不良色情、反动、暴力游戏。注意自我保护,谨防受骗上当。
适度游戏益脑,沉迷游戏伤身。合理安排时间,享受健康生活。