Vue3全栈实战第三周聚焦组件化开发,将收件箱页面拆分为独立可复用组件。涵盖Props单向数据流、Emit自定义事件、provide/inject跨层级通信、插槽及自定义指令,通过类型安全写法实现组件间高效通信与职责分离。
Vue3全栈实战第三周:组件化开发Props/Emit/Provide/Inject/插槽/自定义指令
作者:技术拾光
时间:2026-07-21
浏览:424
文章目录
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`(单封邮件)
Props 基础写法
Props 的核心是类型安全。推荐用泛型加 interface 的方式: ```typescript interface Props { subject: string from: string isRead: boolean createdAt: Date | string isSelected: boolean // 表示可选 } const props = definePropswithDefaults 设置默认值
可选属性不传时会是 `undefined`,这时候 `withDefaults` 就派上用场了: ```typescript const props = withDefaults(definePropsProps 单向数据流
注意,不能直接修改 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
字符串 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 = refprovide / 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修改文件:`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搜索过滤:`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坑二: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 缓存组件实用工具推荐
电子包浆
使用 ›绿化、压缩、噪点和虚拟水印,自由调节做旧程度。
图片压缩
使用 ›选择画质和输出格式,预览压缩前后的大小。
相关文章