一聚教程网:一个值得你收藏的教程网站

最新下载

热门教程

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

时间:2026-07-21 11:03:48 编辑:袖梨 来源:一聚教程网

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


目录

  • 本周项目结构
  • 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 把所有东西写在一起,随着功能增加越来越难维护:

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

 复制代码拆分前:
InboxView.vue(列表 + 工具栏 + 搜索框全在一起,500行)拆分后:
InboxView.vue(只负责布局和数据协调)
  ├── MailToolbar.vue(工具栏)
  ├── MailList.vue(邮件列表)
  └── MailItem.vue(单封邮件)

判断标准:

 复制代码可以复用的 UI 片段 → 拆
逻辑相对独立的区块 → 拆
超过 150 行的组件  → 考虑拆

Props 基础写法

 复制代码// 推荐写法:泛型 + interface,类型安全
interface Props {
  subject: string
  from: string
  isRead: boolean
  createdAt: Date | string
  isSelected?: boolean   // ? 表示可选
}const props = defineProps<Props>()

withDefaults 设置默认值

 复制代码// 可选属性不传时会是 undefined,用 withDefaults 设置默认值
const props = withDefaults(defineProps<Props>(), {
  isSelected: false,    // 基础类型直接写值
  tags: () => [],       // 引用类型用函数返回,避免共享
})

Props 单向数据流

 复制代码//  不能直接修改 Props,会报错
props.isRead = true//  用本地变量复制一份
const localIsRead = ref(props.isRead)
localIsRead.value = true

新增文件:src/components/MailItem.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

 复制代码<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

 复制代码<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:

 复制代码父组件 InboxView
    ↓ Props(数据向下传)
子组件 MailItem
    ↑ Emit(事件向上传)

基础写法

 复制代码// 子组件:定义可以发出的事件
const emit = defineEmits<{
  click: [id: number]        // 事件名:参数类型
  markRead: [id: number]
  'update:isRead': [value: boolean]  // 带连字符用引号
}>()// 触发事件
emit('click', mail.id)
emit('markRead', mail.id)
 复制代码<!-- 父组件:监听事件 -->
<MailItem
  @click="handleMailClick"
  @mark-read="handleMarkRead"
/>

事件流向

点击邮件的完整事件链:

 复制代码MailItem 点击
  → emit('markRead', id) + emit('click', id)
      → MailList 接收,转发
          → emit('mailMarkRead', id) + emit('mailClick', id)
              → InboxView 接收
                  → markAsRead(id) + router.push(...)

修改文件:src/components/MailItem.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):

 复制代码InboxView
  ↓ :theme="theme"
  MailList(只是中转,自己不用)
    ↓ :theme="theme"
    MailItem(真正用到的地方)

provide / inject 解决这个问题:

 复制代码InboxView(provide 数据)
  ↓ 跨越中间层,直接注入
  MailItem(inject 直接取)

类型安全的写法

 复制代码// 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

 复制代码//  字符串 key:容易拼错,多个库用同名 key 会冲突
provide('theme', themeMode)
inject('Theme')  // 拼错了,取不到//  Symbol key:全局唯一,不会冲突
const themeModeKey = Symbol('themeMode')
provide(themeModeKey, themeMode)
inject(themeModeKey)  // 用同一个引用,不可能拼错

provide 和 inject

 复制代码// 父组件: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

三种插槽

默认插槽:传入简单内容

 复制代码<!-- 子组件 -->
<template>
  <div><slot /></div>
</template><!-- 父组件 -->
<Card><p>这里的内容放到 slot 位置</p></Card>

具名插槽:多个插入点

 复制代码<!-- 子组件 -->
<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>

作用域插槽:子组件数据给父组件用

 复制代码<!-- 子组件:把数据暴露给插槽 -->
<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 判断插槽是否传入

 复制代码<!-- 有传 header 插槽才显示标题区域,没传就不渲染 -->
<div v-if="$slots.header">
  <slot name="header" />
</div>

新增文件:src/components/MailListScoped.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)

 复制代码<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)

 复制代码<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(最终版本)

 复制代码<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

 复制代码<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 片段复用    → 组件

指令的生命周期

 复制代码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

 复制代码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 全局注册

 复制代码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 里使用

 复制代码<!-- 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 基于关键词实时过滤:

 复制代码/**
 * 功能:根据关键词过滤邮件列表
 * 场景:搜索框输入关键词,实时过滤收件箱列表
 *       同时匹配主题、发件人、正文三个字段
 */
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"

 复制代码<!-- 普通列表用 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 踩坑记录

坑一:点击搜索按钮,搜索框一闪而过

现象:点击「 搜索」按钮,搜索框出现后立刻消失。

原因:点击按钮时事件会冒泡到 documentv-click-outside 检测到点击发生在搜索框外部(按钮不在搜索框里),立刻触发 closeSearch,搜索框刚出现就关掉了。

 复制代码1. 点击按钮 → showSearch = true → 搜索框出现
2. click 事件冒泡到 document
3. v-click-outside 判断:点击位置不在搜索框内
4. 触发 closeSearch → showSearch = false → 搜索框消失

解决:用 setTimeout 延迟一帧再挂载监听,让当次冒泡先结束:

 复制代码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:

 复制代码// 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-memoshallowRefmarkRaw
  • keep-alive 缓存组件

「Vue前端转全栈实战」系列持续更新,欢迎关注。 有问题欢迎评论区交流,遇到的问题和解决过程都会记录下来。

热门栏目