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

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 踩坑记录

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

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

原因:点击按钮时事件会冒泡到 documentv-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-memoshallowRefmarkRaw
  • keep-alive 缓存组件

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

相关推荐
虚惊一场16 小时前
一条 Markdown 渲染管线的全部细节:Worker、源行标注与按需加载
前端·javascript·vue.js
zithern_juejin17 小时前
深入理解 Vue 响应式原理:从 Object.defineProperty 到 Proxy 的全面进化
vue.js
OpenTiny社区19 小时前
TinyRobot v0.5.0 新版本强在哪?
前端·vue.js·github
sugar__salt19 小时前
Vue.js 前置知识:ES6+ 核心特性完全指南
前端·javascript·vue.js·vue·es6
Kel20 小时前
Node.js 单线程高并发:从内核中断到 JS 回调的完整技术栈
设计模式·架构·node.js
腻害兔21 小时前
【若依项目-产品经理视角】深度拆解 RuoYi-Vue-Pro 认证与权限:RBAC + 数据权限,这套“门禁系统“到底怎么设计的?
java·vue.js·人工智能·产品经理·ai编程
李明卫杭州1 天前
Vue2 数组响应式为什么总"不更新"?深入源码对比 Vue3 的 Proxy 改造
前端·javascript·vue.js
Dontla1 天前
WIP(Work In Progress的缩写,意思是“正在进行中的工作“或“未完成的工作)landing WIP、WIP Limit
全栈
霸道流氓气质1 天前
SpringBoot+Vue通过ModbusTCP协议实现PLC 设备连接、重连实时控制
vue.js·spring boot·后端