Vue3 全栈实战第三周:组件化开发完整记录 ------ Props / Emit / provide-inject / 插槽 / 自定义指令
「Vue前端转全栈实战」系列第五篇。这周完成了 Vue3 组件化开发的全部核心内容,把邮件客户端从单文件重构成了完整的组件化架构。这篇把知识点、修改的文件、踩坑全部记录下来。
目录
- 本周项目结构
- [Day1:组件拆分 + Props](#Day1:组件拆分 + Props "#day1%E7%BB%84%E4%BB%B6%E6%8B%86%E5%88%86--props")
- [Day2:Emit 自定义事件](#Day2:Emit 自定义事件 "#day2emit-%E8%87%AA%E5%AE%9A%E4%B9%89%E4%BA%8B%E4%BB%B6")
- [Day3:provide / inject 跨层级通信](#Day3:provide / inject 跨层级通信 "#day3provide--inject-%E8%B7%A8%E5%B1%82%E7%BA%A7%E9%80%9A%E4%BF%A1")
- [Day4:插槽 slot](#Day4:插槽 slot "#day4%E6%8F%92%E6%A7%BD-slot")
- Day5:自定义指令
- 五种通信方式对比
- 本周总结
- 下周计划
本周项目结构
css
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 把所有东西写在一起,随着功能增加越来越难维护:
拆分前:
InboxView.vue(列表 + 工具栏 + 搜索框全在一起,500行)
拆分后:
InboxView.vue(只负责布局和数据协调)
├── MailToolbar.vue(工具栏)
├── MailList.vue(邮件列表)
└── MailItem.vue(单封邮件)
判断标准:
可以复用的 UI 片段 → 拆
逻辑相对独立的区块 → 拆
超过 150 行的组件 → 考虑拆
Props 基础写法
typescript
// 推荐写法:泛型 + interface,类型安全
interface Props {
subject: string
from: string
isRead: boolean
createdAt: Date | string
isSelected?: boolean // ? 表示可选
}
const props = defineProps<Props>()
withDefaults 设置默认值
typescript
// 可选属性不传时会是 undefined,用 withDefaults 设置默认值
const props = withDefaults(defineProps<Props>(), {
isSelected: false, // 基础类型直接写值
tags: () => [], // 引用类型用函数返回,避免共享
})
Props 单向数据流
typescript
// ❌ 不能直接修改 Props,会报错
props.isRead = true
// ✅ 用本地变量复制一份
const localIsRead = ref(props.isRead)
localIsRead.value = true
新增文件:src/components/MailItem.vue
vue
<script setup lang="ts">
/**
* 功能:单封邮件组件
* 场景:收件箱列表里的每一行,展示邮件摘要信息
*/
interface Props {
id: number
subject: string
from: string
isRead: boolean
createdAt: Date | string
isSelected?: boolean
}
const props = withDefaults(defineProps<Props>(), {
isSelected: false
})
function formatDate(date: Date | string): string {
return new Date(date).toLocaleDateString('zh-CN')
}
</script>
<template>
<li
:style="{
padding: '12px 16px',
marginBottom: '8px',
fontWeight: props.isRead ? 'normal' : 'bold',
cursor: 'pointer',
border: '1px solid #ddd',
borderRadius: '6px',
background: props.isSelected ? '#f0f0f0' : 'white',
}"
>
<div>{{ props.subject }}</div>
<div style="font-size:12px;color:#999;margin-top:4px">
{{ props.from }} · {{ formatDate(props.createdAt) }}
</div>
</li>
</template>
新增文件:src/components/MailList.vue
vue
<script setup lang="ts">
import MailItem from './MailItem.vue'
import type { MailV2 } from '@/types/mail'
/**
* 功能:邮件列表组件
* 场景:收件箱页面展示所有邮件,支持选中状态
*/
interface Props {
mails: MailV2[]
selectedId?: number | null
loading?: boolean
}
const props = withDefaults(defineProps<Props>(), {
selectedId: null,
loading: false
})
/**
* 透传 MailItem 的事件给父组件
* MailList 自己不处理点击,交给 InboxView 处理
*/
const emit = defineEmits<{
mailClick: [id: number]
mailMarkRead: [id: number]
}>()
</script>
<template>
<div>
<p v-if="props.loading" style="color:#999">加载中...</p>
<p v-else-if="props.mails.length === 0" style="color:#999">
暂无邮件
</p>
<ul v-else style="list-style:none;padding:0">
<MailItem
v-for="mail in props.mails"
:key="mail.id"
:id="mail.id"
:subject="mail.subject"
:from="mail.from"
:is-read="mail.isRead"
:created-at="mail.createdAt"
:is-selected="props.selectedId === mail.id"
@click="emit('mailClick', $event)"
@mark-read="emit('mailMarkRead', $event)"
/>
</ul>
</div>
</template>
新增文件:src/components/MailToolbar.vue
vue
<script setup lang="ts">
/**
* 功能:邮件工具栏组件
* 场景:收件箱顶部的标题和操作按钮区域
*/
interface Props {
unreadCount: number
hasUnread: boolean
}
const props = defineProps<Props>()
</script>
<template>
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
<h2>
收件箱
<span v-if="props.hasUnread" style="color:red;font-size:14px">
({{ props.unreadCount }} 封未读)
</span>
</h2>
<div style="display:flex;gap:8px">
<slot name="actions" />
</div>
</div>
</template>
Day2:Emit 自定义事件
为什么需要 Emit
Props 是单向的(父 → 子),子组件想通知父组件用 Emit:
markdown
父组件 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
@click="handleMailClick"
@mark-read="handleMarkRead"
/>
事件流向
点击邮件的完整事件链:
scss
MailItem 点击
→ emit('markRead', id) + emit('click', id)
→ MailList 接收,转发
→ emit('mailMarkRead', id) + emit('mailClick', id)
→ InboxView 接收
→ markAsRead(id) + router.push(...)
修改文件:src/components/MailItem.vue
vue
<script setup lang="ts">
interface Props {
id: number
subject: string
from: string
isRead: boolean
createdAt: Date | string
isSelected?: boolean
}
const props = withDefaults(defineProps<Props>(), {
isSelected: false
})
/**
* 定义可以发出的事件
* click:点击邮件,通知父组件跳转详情页
* markRead:标记已读,通知父组件更新状态
*/
const emit = defineEmits<{
click: [id: number]
markRead: [id: number]
}>()
function formatDate(date: Date | string): string {
return new Date(date).toLocaleDateString('zh-CN')
}
function handleClick() {
emit('markRead', props.id)
emit('click', props.id)
}
</script>
<template>
<li
:style="{
padding: '12px 16px',
marginBottom: '8px',
fontWeight: props.isRead ? 'normal' : 'bold',
cursor: 'pointer',
border: '1px solid #ddd',
borderRadius: '6px',
background: props.isSelected ? '#f0f0f0' : 'white',
}"
@click="handleClick"
>
<div>{{ props.subject }}</div>
<div style="font-size:12px;color:#999;margin-top:4px">
{{ props.from }} · {{ formatDate(props.createdAt) }}
</div>
</li>
</template>
Day3:provide / inject 跨层级通信
为什么需要 provide / inject
Props 逐层传递在层级深时很麻烦(Props Drilling):
ini
InboxView
↓ :theme="theme"
MailList(只是中转,自己不用)
↓ :theme="theme"
MailItem(真正用到的地方)
provide / inject 解决这个问题:
InboxView(provide 数据)
↓ 跨越中间层,直接注入
MailItem(inject 直接取)
类型安全的写法
typescript
// src/types/injectionKeys.ts
import type { InjectionKey, Ref } from 'vue'
export type MailDensity = 'compact' | 'normal' | 'comfortable'
export type ThemeMode = 'light' | 'dark'
// InjectionKey<T>:把 Symbol 和数据类型绑定,类型自动推断
export const mailDensityKey: InjectionKey<Ref<MailDensity>> = Symbol('mailDensity')
export const themeModeKey: InjectionKey<Ref<ThemeMode>> = Symbol('themeMode')
为什么用 Symbol
typescript
// ❌ 字符串 key:容易拼错,多个库用同名 key 会冲突
provide('theme', themeMode)
inject('Theme') // 拼错了,取不到
// ✅ Symbol key:全局唯一,不会冲突
const themeModeKey = Symbol('themeMode')
provide(themeModeKey, themeMode)
inject(themeModeKey) // 用同一个引用,不可能拼错
provide 和 inject
typescript
// 父组件:App.vue provide 主题
const themeMode = ref<ThemeMode>('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
<!-- 子组件 -->
<template>
<div><slot /></div>
</template>
<!-- 父组件 -->
<Card><p>这里的内容放到 slot 位置</p></Card>
具名插槽:多个插入点
vue
<!-- 子组件 -->
<template>
<div>
<header><slot name="header" /></header>
<main><slot /></main>
<footer>
<!-- 有默认内容:不传时显示默认,传了则覆盖 -->
<slot name="footer">
<p style="color:#999">暂无操作</p>
</slot>
</footer>
</div>
</template>
<!-- 父组件 -->
<BaseCard>
<template #header>邮件标题</template>
正文内容放这里
<template #footer>
<button>删除</button>
</template>
</BaseCard>
作用域插槽:子组件数据给父组件用
vue
<!-- 子组件:把数据暴露给插槽 -->
<ul>
<li v-for="(mail, index) in mails" :key="mail.id">
<slot :mail="mail" :index="index" :is-last="index === mails.length - 1" />
</li>
</ul>
<!-- 父组件:接收子组件暴露的数据,自定义渲染 -->
<MailListScoped :mails="mails">
<template #default="{ mail, index, isLast }">
<div>{{ index + 1 }}. {{ mail.subject }}</div>
</template>
</MailListScoped>
$slots 判断插槽是否传入
vue
<!-- 有传 header 插槽才显示标题区域,没传就不渲染 -->
<div v-if="$slots.header">
<slot name="header" />
</div>
新增文件:src/components/MailListScoped.vue
vue
<script setup lang="ts">
import type { MailV2 } from '@/types/mail'
/**
* 功能:支持作用域插槽的邮件列表组件
* 场景:让父组件自定义每封邮件的渲染方式
*/
interface Props {
mails: MailV2[]
loading?: boolean
}
const props = withDefaults(defineProps<Props>(), {
loading: false
})
</script>
<template>
<div>
<!-- loading 状态插槽,有默认内容 -->
<div v-if="props.loading">
<slot name="loading">
<p style="color:#999;text-align:center;padding:20px">加载中...</p>
</slot>
</div>
<!-- 空状态插槽,有默认内容 -->
<div v-else-if="props.mails.length === 0">
<slot name="empty">
<p style="color:#999;text-align:center;padding:20px">暂无邮件</p>
</slot>
</div>
<!-- 作用域插槽:把 mail 和 index 暴露给父组件 -->
<ul v-else style="list-style:none;padding:0">
<li v-for="(mail, index) in props.mails" :key="mail.id">
<slot
:mail="mail"
:index="index"
:is-last="index === props.mails.length - 1"
/>
</li>
</ul>
</div>
</template>
修改文件:src/App.vue(加入主题切换 + provide)
vue
<script setup lang="ts">
import { ref, provide } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { themeModeKey } from '@/types/injectionKeys'
import type { ThemeMode } from '@/types/injectionKeys'
const router = useRouter()
const route = useRoute()
// 主题模式,provide 给所有子组件
const themeMode = ref<ThemeMode>('light')
provide(themeModeKey, themeMode)
function toggleTheme() {
themeMode.value = themeMode.value === 'light' ? 'dark' : 'light'
}
</script>
<template>
<div
:style="{
display: 'flex',
height: '100vh',
background: themeMode === 'light' ? '#ffffff' : '#1a1a1a',
color: themeMode === 'light' ? '#333333' : '#ffffff',
}"
>
<!-- 左侧导航栏 -->
<div style="width:200px;border-right:1px solid #ddd;padding:20px;display:flex;flex-direction:column">
<h3 style="margin-bottom:16px">📧 邮件客户端</h3>
<ul style="list-style:none;padding:0;flex:1">
<li style="margin-bottom:8px">
<button
:style="{
width: '100%', padding: '8px 12px', textAlign: 'left',
cursor: 'pointer', border: 'none', borderRadius: '6px',
background: route.name === 'inbox' ? '#eee' : 'transparent',
}"
@click="router.push({ name: 'inbox' })"
>📥 收件箱</button>
</li>
<li style="margin-bottom:8px">
<button
:style="{
width: '100%', padding: '8px 12px', textAlign: 'left',
cursor: 'pointer', border: 'none', borderRadius: '6px',
background: route.name === 'sent' ? '#eee' : 'transparent',
}"
@click="router.push({ name: 'sent' })"
>📤 已发送</button>
</li>
</ul>
<!-- 主题切换按钮 -->
<button
style="padding:8px 12px;cursor:pointer;border:1px solid #ddd;border-radius:6px"
@click="toggleTheme"
>
{{ themeMode === 'light' ? '🌙 深色模式' : '☀️ 浅色模式' }}
</button>
</div>
<!-- 右侧内容区 -->
<div style="flex:1;padding:20px;overflow-y:auto">
<RouterView />
</div>
</div>
</template>
修改文件:src/views/MailDetailView.vue(使用 BaseCard)
vue
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { storeToRefs } from 'pinia'
import { useMailStore } from '@/stores/mailStore'
import BaseCard from '@/components/BaseCard.vue'
const route = useRoute()
const router = useRouter()
const mailStore = useMailStore()
const { mails } = storeToRefs(mailStore)
const mailId = computed(() => Number(route.params.id))
const mail = computed(() =>
mails.value.find(m => m.id === mailId.value) ?? null
)
function formatDate(date: Date | string): string {
return new Date(date).toLocaleDateString('zh-CN')
}
function handleDelete() {
if (!mail.value) return
mailStore.deleteMail(mail.value.id)
router.push({ name: 'inbox' })
}
</script>
<template>
<div>
<button style="margin-bottom:16px;cursor:pointer" @click="router.back()">
← 返回
</button>
<div v-if="mail">
<BaseCard>
<!-- header 插槽:邮件标题 + 时间 -->
<template #header>
<div style="display:flex;justify-content:space-between;align-items:center">
<span>{{ mail.subject }}</span>
<span style="font-size:12px;color:#999;font-weight:normal">
{{ formatDate(mail.createdAt) }}
</span>
</div>
</template>
<!-- 默认插槽:邮件正文 -->
<p style="color:#999;margin-bottom:12px">发件人:{{ mail.from }}</p>
<p style="line-height:1.8">{{ mail.body }}</p>
<!-- footer 插槽:操作按钮 -->
<template #footer>
<div style="display:flex;gap:8px">
<button @click="router.back()">← 返回</button>
<button style="color:red" @click="handleDelete">删除邮件</button>
</div>
</template>
</BaseCard>
</div>
<div v-else>
<BaseCard>
<template #header>邮件不存在</template>
<p style="color:#999">邮件可能已被删除</p>
<template #footer>
<button @click="router.push({ name: 'inbox' })">返回收件箱</button>
</template>
</BaseCard>
</div>
</div>
</template>
修改文件:src/views/InboxView.vue(最终版本)
vue
<script setup lang="ts">
import { computed, onMounted, provide, ref } from 'vue'
import { storeToRefs } from 'pinia'
import { useRouter } from 'vue-router'
import { useMailStore } from '@/stores/mailStore'
import { mailDensityKey } from '@/types/injectionKeys'
import type { MailDensity } from '@/types/injectionKeys'
import MailToolbar from '@/components/MailToolbar.vue'
import MailList from '@/components/MailList.vue'
import MailListScoped from '@/components/MailListScoped.vue'
const router = useRouter()
const mailStore = useMailStore()
const { mails, loading, unreadCount, hasUnread, selectedMail } = storeToRefs(mailStore)
const { fetchMails, markAsRead, markAllAsRead, $reset } = mailStore
// provide 显示密度给 MailItem 使用
const mailDensity = ref<MailDensity>('normal')
provide(mailDensityKey, mailDensity)
const useScoped = ref(false)
const keyword = ref('')
const showSearch = ref(false)
onMounted(() => {
fetchMails()
})
/**
* 功能:根据关键词过滤邮件列表
* 场景:搜索框输入关键词,实时过滤收件箱列表
*/
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)
)
})
function handleMailClick(mailId: number) {
router.push({ name: 'mail-detail', params: { id: mailId } })
}
function handleMarkRead(mailId: number) {
markAsRead(mailId)
}
function formatDate(date: Date | string): string {
return new Date(date).toLocaleDateString('zh-CN')
}
function closeSearch() {
showSearch.value = false
keyword.value = ''
}
</script>
<template>
<div>
<MailToolbar :unread-count="unreadCount" :has-unread="hasUnread">
<template #actions>
<select v-model="mailDensity" style="padding:4px 8px;border:1px solid #ddd;border-radius:4px">
<option value="compact">紧凑</option>
<option value="normal">正常</option>
<option value="comfortable">宽松</option>
</select>
<button @click="showSearch = !showSearch">🔍 搜索</button>
<button @click="useScoped = !useScoped">
{{ useScoped ? '切换普通列表' : '切换作用域列表' }}
</button>
<button @click="markAllAsRead">全部已读</button>
<button @click="$reset">重置</button>
</template>
</MailToolbar>
<!-- v-click-outside 点击外部关闭,v-focus 自动聚焦 -->
<div
v-if="showSearch"
v-click-outside="closeSearch"
style="margin-bottom:12px;position:relative"
>
<input
v-focus
v-model="keyword"
placeholder="搜索邮件主题..."
style="width:100%;padding:8px 12px;border:1px solid #1890ff;border-radius:6px;outline:none"
/>
<span
style="position:absolute;right:10px;top:50%;transform:translateY(-50%);cursor:pointer;color:#999"
@click="closeSearch"
>✕</span>
</div>
<!-- v-loading 加载时显示遮罩 -->
<div v-loading="loading" style="min-height:100px">
<!-- 普通列表 -->
<MailList
v-if="!useScoped"
:mails="filteredMails"
:loading="false"
:selected-id="selectedMail?.id ?? null"
@mail-click="handleMailClick"
@mail-mark-read="handleMarkRead"
/>
<!-- 作用域插槽列表 -->
<MailListScoped v-else :mails="filteredMails" :loading="false">
<template #empty>
<p style="text-align:center;padding:40px;color:#999">🎉 收件箱空空如也</p>
</template>
<template #default="{ mail, index, isLast }">
<div
:style="{
padding: '12px 16px',
borderBottom: isLast ? 'none' : '1px solid #eee',
cursor: 'pointer',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}"
@click="handleMarkRead(mail.id); handleMailClick(mail.id)"
>
<div style="display:flex;align-items:center;gap:10px">
<span style="color:#999;font-size:12px;min-width:20px">{{ index + 1 }}</span>
<span
v-if="!mail.isRead"
style="width:6px;height:6px;border-radius:50%;background:#1890ff;flex-shrink:0"
/>
<span v-else style="width:6px;flex-shrink:0" />
<!-- v-highlight 高亮搜索关键词 -->
<span
v-highlight="keyword"
:style="{ fontWeight: mail.isRead ? 'normal' : 'bold' }"
>{{ mail.subject }}</span>
</div>
<div style="font-size:12px;color:#999;flex-shrink:0">
{{ formatDate(mail.createdAt) }}
</div>
</div>
</template>
</MailListScoped>
</div>
</div>
</template>
新增文件:src/components/BaseCard.vue
vue
<script setup lang="ts">
/**
* 功能:通用卡片容器组件
* 场景:邮件详情、联系人信息等需要卡片样式的地方复用
*/
interface Props {
padding?: string
}
const props = withDefaults(defineProps<Props>(), {
padding: '16px'
})
</script>
<template>
<div style="border:1px solid #ddd;border-radius:8px;overflow:hidden">
<!-- 具名插槽 header:有传才显示 -->
<div
v-if="$slots.header"
style="padding:12px 16px;border-bottom:1px solid #ddd;font-weight:500"
>
<slot name="header" />
</div>
<!-- 默认插槽:内容区 -->
<div :style="{ padding: props.padding }">
<slot />
</div>
<!-- 具名插槽 footer:没传时显示默认内容 -->
<div style="padding:10px 16px;border-top:1px solid #ddd;background:#f9f9f9">
<slot name="footer">
<p style="color:#999;font-size:12px;margin:0">暂无操作</p>
</slot>
</div>
</div>
</template>
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'
/**
* 功能:自动聚焦指令
* 场景:搜索框、登录表单输入框打开时自动聚焦
* 用法:<input v-focus />
*/
export const vFocus: Directive<HTMLElement> = {
mounted(el) {
el.focus()
}
}
/**
* 功能:点击外部关闭指令
* 场景:下拉菜单、弹窗点击外部区域自动关闭
* 用法:<div v-click-outside="handleClose" />
*/
export const vClickOutside: Directive<HTMLElement, () => 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 遮罩指令
* 场景:邮件列表加载时显示半透明遮罩
* 用法:<div v-loading="isLoading" />
*/
export const vLoading: Directive<HTMLElement, boolean> = {
mounted(el, binding: DirectiveBinding<boolean>) {
el.style.position = 'relative'
if (binding.value) {
addMask(el)
}
},
updated(el, binding: DirectiveBinding<boolean>) {
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)
}
/**
* 功能:文字高亮指令
* 场景:搜索结果里高亮匹配的关键词
* 用法:<p v-highlight="keyword" />
*/
export const vHighlight: Directive<HTMLElement, string> = {
mounted(el, binding: DirectiveBinding<string>) {
highlight(el, binding.value)
},
updated(el, binding: DirectiveBinding<string>) {
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'),
`<mark style="background:#fff3a3;padding:0 2px">${keyword}</mark>`
)
}
修改文件: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
<!-- v-focus:搜索框自动聚焦 -->
<input v-focus v-model="keyword" placeholder="搜索邮件主题..." />
<!-- v-click-outside:点击外部关闭搜索框 -->
<div v-click-outside="closeSearch">
<input v-focus v-model="keyword" />
</div>
<!-- v-loading:加载时显示遮罩 -->
<div v-loading="loading" style="min-height:100px">
<MailList :mails="filteredMails" />
</div>
<!-- v-highlight:高亮搜索关键词 -->
<span v-highlight="keyword">{{ mail.subject }}</span>
搜索过滤: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":
vue
<!-- 普通列表用 filteredMails -->
<MailList
v-if="!useScoped"
:mails="filteredMails"
:loading="loading"
:selected-id="selectedMail?.id"
@mail-click="handleMailClick"
@mail-mark-read="handleMarkRead"
/>
<!-- 作用域列表也用 filteredMails -->
<MailListScoped
v-else
:mails="filteredMails"
:loading="loading"
>
这样搜索框输入「会议」,列表只显示包含「会议」的邮件,同时作用域列表里的主题文字也会高亮。
Day5 踩坑记录
坑一:点击搜索按钮,搜索框一闪而过
现象:点击「🔍 搜索」按钮,搜索框出现后立刻消失。
原因:点击按钮时事件会冒泡到 document,v-click-outside 检测到点击发生在搜索框外部(按钮不在搜索框里),立刻触发 closeSearch,搜索框刚出现就关掉了。
ini
1. 点击按钮 → showSearch = true → 搜索框出现
2. click 事件冒泡到 document
3. v-click-outside 判断:点击位置不在搜索框内
4. 触发 closeSearch → showSearch = false → 搜索框消失
解决:用 setTimeout 延迟一帧再挂载监听,让当次冒泡先结束:
typescript
export const vClickOutside: Directive<HTMLElement, () => 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:
typescript
// mailStore.ts 临时改长,验证完再改回 500
await new Promise(resolve => setTimeout(resolve, 2000))
接入真实后端 API 后,网络请求本身就有延迟,不需要再模拟。
坑三:搜索只高亮没有过滤
最初 keyword 只绑定到 v-highlight 做高亮显示,没有真正过滤列表。加上 filteredMails 计算属性之后,搜索才真正生效------过滤和高亮同时工作。
五种通信方式对比
这周学完之后,Vue3 组件通信的完整图谱:
| 方式 | 方向 | 适用场景 |
|---|---|---|
| Props | 父 → 子 | 父组件传数据给子组件 |
| Emit | 子 → 父 | 子组件通知父组件发生了什么 |
| provide / inject | 祖先 → 后代 | 跨多层级传数据,避免 Props 逐层透传 |
| slot | 父 → 子(结构) | 父组件自定义子组件内部的 HTML 结构 |
| Pinia | 任意组件 | 全局共享状态,需要持久化的数据 |
本周总结
最大的收获:理解了「职责分离」
拆完组件之后,每个文件的职责非常清晰:
MailItem.vue → 只负责展示一封邮件的样式
MailList.vue → 只负责渲染列表和透传事件
MailToolbar.vue → 只负责工具栏布局
InboxView.vue → 只负责协调数据和处理业务逻辑
改一个功能只需要改一个文件,不会牵一发动全身。
掌握的知识点自检:
- defineProps + interface:类型安全的 Props 定义
- withDefaults:给可选 Props 设置默认值
- Props 单向数据流:不能直接修改 Props
- defineEmits:定义子组件可以发出的事件
- InjectionKey + Symbol:类型安全的 provide/inject
- provide/inject vs Props vs Pinia 怎么选
- 默认插槽、具名插槽、作用域插槽的使用场景
-
$slots.xxx判断插槽是否传入 - 自定义指令的生命周期钩子
- unmounted 里清理事件监听,避免内存泄漏
下周计划
第四周:Vue3 进阶 + 性能优化
watch/watchEffect进阶用法nextTick原理和使用场景defineExpose暴露组件方法- 组件性能优化:
v-memo、shallowRef、markRaw - keep-alive 缓存组件
「Vue前端转全栈实战」系列持续更新,欢迎关注。 有问题欢迎评论区交流,遇到的问题和解决过程都会记录下来。