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`(单封邮件)

判断拆不拆,其实有几个简单标准:
- 可以复用的 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
{{ props.subject }}
{{ props.from }} · {{ formatDate(props.createdAt) }}
```
新增文件:`src/components/MailList.vue`
```vue
```
新增文件:`src/components/MailToolbar.vue`
```vue
收件箱
({{ props.unreadCount }} 封未读)
```
---
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
{{ props.subject }}
{{ props.from }} · {{ formatDate(props.createdAt) }}
```
---
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
{{ index + 1 }}. {{ mail.subject }}
```
`$slots` 判断插槽是否传入
有时候需要根据插槽有没有传入来决定是否渲染某个区域:
```vue
```
新增文件:`src/components/MailListScoped.vue`
```vue
```
修改文件:`src/views/MailDetailView.vue`(使用 BaseCard)
```vue
{{ mail.subject }}
{{ formatDate(mail.createdAt) }}
发件人:{{ mail.from }}
{{ mail.body }}
```
新增文件:`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 缓存组件