Vue.js从零到精通系列(七):高级特性实战——Teleport、异步组件、自定义指令与TypeScript深度结合

摘要: 通过前几篇的学习,已经掌握了Vue 3的组件化、路由、状态管理以及逻辑复用。但要想在真实项目中游刃有余,还需要掌握一些能够显著提升用户体验和代码质量的高级特性。本文将围绕四个核心利器展开:Teleport传送门 让你把组件渲染到DOM的任意位置,彻底解决模态框、弹出层的z-index和overflow裁剪问题;异步组件与Suspense 结合,实现组件的按需加载和优雅的加载中状态,让首屏渲染速度飞起;自定义指令 让你直接控制底层DOM,封装常见的交互行为(如自动聚焦、点击外部关闭);最后,我们将Vue与TypeScript深度结合,为组件Props/Emits、模板引用、依赖注入提供更严格的类型安全。综合案例中,我们会给Todo应用加上Teleport确认框、异步加载设置页、用自定义指令关闭弹窗,让整个应用的专业度再上一个台阶。


一、Teleport传送门:让组件"穿越"DOM

1.1 为什么需要Teleport?

开发中经常遇到这样的场景:一个模态框(Modal)或下拉菜单,在组件内部编写时,由于父元素的CSS样式(如overflow: hiddenz-index层叠上下文)限制,导致弹窗被裁剪或者层级不正确。传统解决方案是把弹窗的DOM挪到<body>下,但这会破坏组件的内聚性------弹窗的逻辑在组件A里,DOM却在组件A之外,管理起来很痛苦。

Vue 3的<Teleport>组件完美解决了这个矛盾:你可以把组件内的某部分模板"传送"到DOM中的任意位置,同时保持逻辑上的父子关系、响应式数据和事件传递完全不变

1.2 基本用法

<Teleport>接收一个to属性,指定目标CSS选择器或DOM元素。内部的模板将被渲染到该目标下。

javascript 复制代码
<template>
  <div>
    <h2>我的页面</h2>
    <button @click="showModal = true">打开弹窗</button>
​
    <!-- 弹窗内容被传送到 body 下 -->
    <Teleport to="body">
      <div v-if="showModal" class="modal-overlay">
        <div class="modal">
          <p>这是一个模态框</p>
          <button @click="showModal = false">关闭</button>
        </div>
      </div>
    </Teleport>
  </div>
</template>
​
<script setup lang="ts">
import { ref } from 'vue'
const showModal = ref(false)
</script>

渲染后的DOM结构会变成:

javascript 复制代码
<body>
  <div id="app">...</div>
  <!-- 下面这个是从组件内传送过来的 -->
  <div class="modal-overlay">...</div>
</body>

弹窗的DOM脱离了组件的DOM树,但showModal仍然是响应式的,点击关闭按钮依然能修改组件内部的ref,事件通信完全不受影响。

1.3 禁用Teleport

在某些特殊情况下(比如你想根据屏幕大小决定是否传送),可以使用disabled属性动态关闭传送:

javascript 复制代码
<Teleport to="body" :disabled="isMobile">
  <div v-if="show" class="tooltip">提示信息</div>
</Teleport>

disabledtrue时,内容会被渲染在原地,就像没有<Teleport>一样。

1.4 多个Teleport目标的顺序

如果有多个<Teleport>同时传送到同一个目标,它们会按照挂载顺序依次追加。


二、异步组件与Suspense:按需加载与优雅占位

2.1 为什么需要异步组件?

随着应用变大,把所有组件都打包在一个文件中会导致首屏加载缓慢。之前我们在路由配置中已经使用了动态导入() => import(...)来拆分页面级别的chunk。但在非路由场景(如一个巨大的图表组件、富文本编辑器),我们也希望按需加载。

Vue提供了defineAsyncComponent函数,让你可以将任何组件定义为异步组件,只有在它被渲染时才会加载对应的代码。

2.2 基本用法

javascript 复制代码
import { defineAsyncComponent } from 'vue'
​
const HeavyChart = defineAsyncComponent(() => import('./HeavyChart.vue'))

在模板中像普通组件一样使用:

javascript 复制代码
<template>
  <HeavyChart v-if="showChart" :data="chartData" />
  <button @click="showChart = true">显示图表</button>
</template>

首次点击"显示图表"时,浏览器才会请求HeavyChart.vue对应的chunk,在此之前完全不占用网络带宽。

2.3 处理加载中和加载失败

defineAsyncComponent可以传入一个完整的选项对象,配置加载中、错误时的显示组件,以及延迟时间:

javascript 复制代码
import { defineAsyncComponent, h } from 'vue'
import LoadingSpinner from './LoadingSpinner.vue'
import ErrorDisplay from './ErrorDisplay.vue'
​
const HeavyChart = defineAsyncComponent({
  loader: () => import('./HeavyChart.vue'),
  loadingComponent: LoadingSpinner,   // 加载中显示的组件
  errorComponent: ErrorDisplay,       // 加载失败显示的组件
  delay: 200,                         // 200ms后才显示加载组件(避免闪烁)
  timeout: 10000                      // 10秒超时
})

2.4 Suspense:协调多个异步依赖

<Suspense>是一个内置组件,用于在等待多个异步组件或异步依赖(如带有async setup的组件)完成时,显示统一的后备内容。它的两个插槽:

  • default:最终渲染的内容

  • fallback:加载中显示的占位内容

javascript 复制代码
<template>
  <Suspense>
    <!-- 异步内容 -->
    <template #default>
      <AsyncDashboard />
    </template>
    <!-- 加载中 -->
    <template #fallback>
      <div class="loading-screen">加载中,请稍候...</div>
    </template>
  </Suspense>
</template>

AsyncDashboard组件本身可能使用了async setup(例如await一个API请求),或者内部包含了异步子组件。<Suspense>会等待所有异步任务完成后才渲染default插槽。

对于路由级别的加载,Vue Router也支持直接结合<Suspense>

javascript 复制代码
<router-view v-slot="{ Component }">
  <Suspense>
    <component :is="Component" />
    <template #fallback>
      加载页面中...
    </template>
  </Suspense>
</router-view>

注意:<Suspense>目前仍处于实验性阶段,但已经相当稳定,适合非关键业务场景使用。


三、自定义指令:封装底层DOM操作

Vue提供了丰富的内置指令(v-ifv-forv-model等),但当内置指令不满足需求时,你可以创建自定义指令来直接操作DOM。

3.1 指令钩子函数

一个自定义指令是一个对象,包含若干生命周期钩子(与组件类似,但更精细化):

javascript 复制代码
const myDirective = {
  created(el, binding, vnode, prevVnode) {},   // 元素创建时
  mounted(el, binding) {},                      // 挂载到DOM
  updated(el, binding) {},                      // 组件更新
  beforeUnmount(el) {},                         // 卸载前
  // 以及 beforeMount, beforeUpdate, unmounted
}

最常用的是mountedupdated,很多时候逻辑相同,可以使用函数简写(相当于mounted + updated):

javascript 复制代码
app.directive('focus', (el) => {
  el.focus()
})

3.2 编写一个v-focus指令

自动聚焦输入框是最常用的指令之一:

javascript 复制代码
// src/directives/focus.ts
import type { Directive } from 'vue'
​
export const vFocus: Directive<HTMLInputElement> = {
  mounted(el: HTMLInputElement) {
    el.focus()
  }
}

在组件内局部注册:

javascript 复制代码
<script setup lang="ts">
import { vFocus } from '../directives/focus'
</script>
​
<template>
  <input v-focus placeholder="页面加载后自动聚焦" />
</template>

或全局注册在main.ts

javascript 复制代码
import { vFocus } from './directives/focus'
app.directive('focus', vFocus)

3.3 实现v-click-outside(点击外部关闭)

这个指令常用于模态框、下拉菜单,当点击目标元素外部时触发回调:

javascript 复制代码
// src/directives/clickOutside.ts
import type { Directive, DirectiveBinding } from 'vue'
​
export const vClickOutside: Directive = {
  mounted(el: HTMLElement, binding: DirectiveBinding) {
    const handler = (event: MouseEvent) => {
      if (!el.contains(event.target as Node)) {
        binding.value() // 调用绑定的函数
      }
    }
    el.__clickOutsideHandler__ = handler
    document.addEventListener('click', handler)
  },
  beforeUnmount(el: HTMLElement) {
    if (el.__clickOutsideHandler__) {
      document.removeEventListener('click', el.__clickOutsideHandler__)
      delete el.__clickOutsideHandler__
    }
  }
}

使用:

javascript 复制代码
<div v-click-outside="closeModal" class="modal">
  点外面关闭我
</div>

注意:el.__clickOutsideHandler__是我们自定义的属性,用来在卸载时移除事件。为了类型安全,可以在全局类型文件中扩展HTMLElement接口。

3.4 指令的参数与修饰符

指令可以接收参数和修饰符,与v-bind:attr类似:

javascript 复制代码
<div v-my-directive:arg.modifier="value"></div>

在钩子函数中通过binding获取:

  • binding.value → 传入的值

  • binding.arg → 参数(如:arg

  • binding.modifiers → 修饰符对象(如{ modifier: true }

例如,一个可配置延迟的v-delay-tooltip

javascript 复制代码
mounted(el, binding) {
  const delay = binding.arg ? parseInt(binding.arg) : 500
  // 使用 delay 设置 tooltip
}
<button v-delay-tooltip:1000="'保存'">保存</button>

四、Vue与TypeScript深度结合

我们已经使用了lang="ts",也通过definePropsdefineEmits添加了类型。但要让TypeScript的威力最大化,还有一些进阶用法值得掌握。

4.1 为模板引用(Template Refs)类型标注

当使用ref获取组件或DOM元素引用时,需要明确类型:

javascript 复制代码
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import MyInput from './MyInput.vue'
​
const inputRef = ref<InstanceType<typeof MyInput> | null>(null)
// 或者对于原生元素
const divRef = ref<HTMLDivElement | null>(null)
​
onMounted(() => {
  inputRef.value?.focus() // 假设 MyInput 暴露了 focus 方法
  console.log(divRef.value?.offsetHeight)
})
</script>
​
<template>
  <MyInput ref="inputRef" />
  <div ref="divRef">内容</div>
</template>

InstanceType<typeof MyInput>可以获取组件的实例类型,包括暴露的方法和属性。

4.2 为Props和Emits提供纯类型声明

我们已经用过:

javascript 复制代码
interface Props {
  msg: string
  count?: number
}
const props = withDefaults(defineProps<Props>(), { count: 0 })
​
const emit = defineEmits<{
  (e: 'update', value: string): void
}>()

这比运行时校验更严格,而且提供编辑器智能提示。当父组件传入错误类型时,编译阶段就会报错。

4.3 为provide/inject提供类型安全

Vue 3的依赖注入也支持TypeScript。通过InjectionKey创建类型化的注入键:

javascript 复制代码
// types.ts
import type { InjectionKey, Ref } from 'vue'
​
export const todoFilterKey: InjectionKey<Ref<string>> = Symbol('todoFilter')

在提供方:

javascript 复制代码
import { provide, ref } from 'vue'
import { todoFilterKey } from './types'
​
const filter = ref('all')
provide(todoFilterKey, filter)

在注入方:

javascript 复制代码
import { inject } from 'vue'
import { todoFilterKey } from './types'
​
const filter = inject(todoFilterKey) // 类型为 Ref<string> | undefined
if (!filter) throw new Error('todoFilter 未提供')

这样就不会出现字符串键名的拼写错误,并且类型完全确定。

4.4 扩展全局属性和类型

当你通过app.config.globalProperties添加全局方法(如$http$formatDate)后,TypeScript不会自动识别。需要在类型声明文件中扩展ComponentCustomProperties

javascript 复制代码
// src/types/global.d.ts
import type { AxiosInstance } from 'axios'
​
declare module 'vue' {
  interface ComponentCustomProperties {
    $http: AxiosInstance
    $formatDate: (date: Date) => string
  }
}

这样在组件的模板或setup中通过getCurrentInstance()?.proxy调用时都会有类型提示。


五、综合案例:Todo应用的"高级化"改造

现在我们把上述四个高级特性应用到Todo应用中,让它变得更加专业。

5.1 Teleport 实现删除确认弹窗 ConfirmModal.vue

TodoItem.vue中,点击删除按钮后,我们希望弹出一个居中的确认框,防止误删。该弹窗通过Teleport传送至body

新增文件:src/components/ConfirmModal.vue

javascript 复制代码
<template>
  <!-- Teleport挂载到body根节点,脱离父组件样式遮挡 -->
  <Teleport to="body">
    <!-- 遮罩层,绑定外部点击关闭指令 -->
    <div
      v-if="visible"
      class="modal-mask"
      v-click-outside="handleOutsideClose"
    >
      <div class="modal-box" @click.stop>
        <h3>{{ title }}</h3>
        <p class="tip">{{ content }}</p>
        <div class="modal-btns">
          <button class="cancel" @click="$emit('cancel')">取消</button>
          <button class="confirm" @click="$emit('confirm')">确认</button>
        </div>
      </div>
    </div>
  </Teleport>
</template>

<script setup lang="ts">
defineProps<{
  visible: boolean
  title: string
  content: string
}>()

defineEmits<{
  (e: 'confirm'): void
  (e: 'cancel'): void
}>()

const handleOutsideClose = () => {
  emit('cancel')
}
</script>

<style scoped>
.modal-mask {
  position: fixed;
  inset: 0;
  background: rgba(0, 0, 0, 0.55);
  display: flex;
  align-items: center;
  justify-content: center;
  z-index: 9999;
}
.modal-box {
  background: #fff;
  border-radius: 12px;
  padding: 28px 32px;
  min-width: 360px;
}
.modal-box h3 {
  margin: 0 0 12px;
  color: #2d3748;
}
.tip {
  color: #718096;
  margin-bottom: 24px;
}
.modal-btns {
  display: flex;
  gap: 12px;
  justify-content: flex-end;
}
.cancel {
  padding: 8px 18px;
  border: 1px solid #e2e8f0;
  border-radius: 8px;
  background: #fff;
  cursor: pointer;
}
.confirm {
  padding: 8px 18px;
  border: none;
  border-radius: 8px;
  background: #dc2626;
  color: white;
  cursor: pointer;
}
</style>

修改1:TodoItem.vue 接入弹窗状态

不再直接emit删除,而是抛出待删除id,由父组件控制弹窗显隐

javascript 复制代码
<script setup lang="ts">
import type { Todo } from '../types'
const props = defineProps<{
  todo: Todo
  goDetail?: () => void
}>()

// 必须把 defineEmits 返回值赋值给 emit 变量
const emit = defineEmits<{
  (e:'toggle', id:number):void
  (e:'openDelModal', id:number):void
}>()
</script>

<template>
  <li class="todo-item" :class="{done: todo.done}">
    <input
      type="checkbox"
      :checked="todo.done"
      @change.stop="emit('toggle', todo.id)"
    >
    <span class="text" @click="goDetail">
      {{ todo.text }}
    </span>
    <!-- 不再直接触发删除,唤起弹窗 -->
    <button class="del" @click.stop="emit('openDelModal', todo.id)">删除</button>
  </li>
</template>

<style scoped>
.todo-item {
  display: flex;
  align-items: center;
  gap: 12px;
  padding: 14px 16px;
  background: #f7fafc;
  border-radius: 10px;
}
input {
  width: 18px;
  height: 18px;
  accent-color: #48bb78;
}
.text {
  flex: 1;
  word-break: break-all;
  cursor: pointer;
}
.done .text {
  text-decoration: line-through;
  color: #a0aec0;
}
.del {
  border: none;
  padding: 6px 10px;
  background: #fef2f2;
  color: #dc2626;
  border-radius: 6px;
  cursor: pointer;
}
</style>

修改2:TodoListPage.vue 挂载弹窗、处理删除逻辑

javascript 复制代码
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { useTodoStore } from '../stores/todo'
import { TodoFilterType } from '../types'
import TodoHeader from '../components/TodoHeader.vue'
import TodoInput from '../components/TodoInput.vue'
import TodoList from '../components/TodoList.vue'
import TodoItem from '../components/TodoItem.vue'
import TodoFooter from '../components/TodoFooter.vue'
import ConfirmModal from '../components/ConfirmModal.vue'

const router = useRouter()
const store = useTodoStore()

const delModalVisible = ref(false)
const pendingDelId = ref<number | null>(null)

const openDelModal = (id: number) => {
  pendingDelId.value = id
  delModalVisible.value = true
}
const confirmDelete = () => {
  if (pendingDelId.value !== null) {
    store.removeTodo(pendingDelId.value)
  }
  delModalVisible.value = false
  pendingDelId.value = null
}
const cancelDelete = () => {
  delModalVisible.value = false
  pendingDelId.value = null
}
</script>

<template>
  <div class="page-wrap">
    <div class="card">
      <TodoHeader
        :active-count="store.activeCount"
        :total="store.total"
      />
      <TodoInput @add="store.addTodo" />

      <!-- 新增筛选按钮组 -->
      <div class="filter-btns">
        <button
          :class="{active: store.filterType === 'all'}"
          @click="store.setFilter('all')"
        >全部</button>
        <button
          :class="{active: store.filterType === 'active'}"
          @click="store.setFilter('active')"
        >未完成</button>
        <button
          :class="{active: store.filterType === 'completed'}"
          @click="store.setFilter('completed')"
        >已完成</button>
      </div>

      <!-- 循环筛选后的列表 filteredTodos -->
      <TodoList :todos="store.filteredTodos">
        <template #item="{ todo }">
          <TodoItem
            :todo="todo"
            :go-detail="() => router.push({name:'Detail', params:{id:todo.id}})"
            @toggle="store.toggleTodo"
            @open-del-modal="openDelModal"
          />
        </template>
      </TodoList>

      <TodoFooter
        v-if="store.todos.length"
        :is-all-checked="store.viewAllDone" 
        @check-all="store.checkAll"
        @un-check-all="store.unCheckAll"
        @clear-completed="store.clearCompleted"
      />
    </div>

    <ConfirmModal
      :visible="delModalVisible"
      title="确认删除任务"
      content="删除后该任务无法恢复,确定继续?"
      @confirm="confirmDelete"
      @cancel="cancelDelete"
    />
  </div>
</template>

<style scoped>
.page-wrap {
  display: flex;
  justify-content: center;
  padding: 40px 20px;
}
.card {
  width: 100%;
  max-width: 520px;
  background: #fff;
  padding: 32px;
  border-radius: 16px;
  box-shadow: 0 8px 24px rgba(0,0,0,0.08);
}
.filter-btns {
  display: flex;
  gap: 8px;
  margin-bottom: 16px;
}
.filter-btns button {
  padding: 6px 12px;
  border: 1px solid #e2e8f0;
  border-radius: 6px;
  background: #f7fafc;
  cursor: pointer;
}
.filter-btns button.active {
  background: #4299e1;
  color: white;
  border-color: #4299e1;
}
</style>

修改3:TodoDetailPage.vue 详情页删除同样接入弹窗

javascript 复制代码
<script setup lang="ts">
import { computed, watch, ref } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useTodoStore } from '../stores/todo'
import ConfirmModal from '../components/ConfirmModal.vue'

const router = useRouter()
const route = useRoute()
const store = useTodoStore()

const tid = computed(() => Number(route.params.id))
const todo = computed(() => store.getTodoById(tid.value))

// 弹窗状态
const delModalVisible = ref(false)

const backList = () => router.push('/list')
const openDelModal = () => delModalVisible.value = true
const confirmDel = () => {
  store.removeTodo(tid.value)
  backList()
}

watch(
  () => route.params.id,
  () => !todo.value && backList(),
  { immediate: true }
)
</script>

<template>
  <div class="page-wrap">
    <div class="detail-card" v-if="todo">
      <h2>任务详情</h2>
      <div class="row">
        <label>ID:</label>
        <span>{{ todo.id }}</span>
      </div>
      <div class="row">
        <label>内容:</label>
        <span :class="{done: todo.done}">{{ todo.text }}</span>
      </div>
      <div class="row">
        <label>状态:</label>
        <input
          type="checkbox"
          :checked="todo.done"
          @change="store.toggleTodo(todo.id)"
        >
        <span>{{ todo.done ? '已完成' : '未完成' }}</span>
      </div>

      <div class="btns">
        <button @click="backList">← 返回列表</button>
        <button class="danger" @click="openDelModal">删除该任务</button>
      </div>
    </div>

    <div class="no-data" v-else>
      任务不存在或已删除
      <button @click="backList">返回列表</button>
    </div>

    <!-- 删除弹窗 -->
    <ConfirmModal
      :visible="delModalVisible"
      title="确认删除"
      content="确定要删除当前这条任务吗?"
      @confirm="confirmDel"
      @cancel="delModalVisible = false"
    />
  </div>
</template>

<style scoped>
.page-wrap {
  display: flex;
  justify-content: center;
  padding: 60px 20px;
}
.detail-card {
  width: 100%;
  max-width: 480px;
  background: #fff;
  border-radius: 16px;
  padding: 32px;
  box-shadow: 0 8px 24px rgba(0,0,0,0.08);
}
.row {
  margin: 18px 0;
  display: flex;
  align-items: center;
  gap: 12px;
}
.row label {
  width: 80px;
  color: #718096;
}
.done {
  text-decoration: line-through;
  color: #a0aec0;
}
.btns {
  margin-top: 30px;
  display: flex;
  gap: 14px;
}
button {
  padding: 10px 18px;
  border-radius: 8px;
  border: none;
  cursor: pointer;
}
.danger {
  background: #fef2f2;
  color: #dc2626;
}
.no-data {
  text-align: center;
  padding: 60px 0;
  color: #718096;
}
</style>

5.2 异步加载设置页(defineAsyncComponent)

新增:src/components/CleanAnimation.vue(模拟重量级清理动画组件)

javascript 复制代码
<script setup lang="ts">
import { ref, onMounted } from 'vue'
const barRef = ref<HTMLDivElement>()
const loaded = ref(false)

// 模拟2秒后清理完毕
onMounted(() => {
  setTimeout(() => {
    loaded.value = true
    if (barRef.value) {
      // 移除动画,进度条定格
      barRef.value.style.animation = 'none'
      barRef.value.style.width = '100%'
      barRef.value.style.background = '#48bb78'
    }
  }, 2000)
})
</script>
<template>
  <div class="clear-anim">
    <h3>数据清理动画组件(大体积/复杂动画)</h3>
    <p>正在执行本地存储深度清理...</p>
    <div class="progress-bar" ref="barRef"></div>
    <p v-if="loaded">✅ 清理完成</p>
  </div>
</template>
<style scoped>
.progress-bar {
  height: 6px;
  width: 0%;
  background: #e2e8f0;
  margin-top:10px;
  margin-bottom: 16px;
  border-radius:3px;
  animation: loading 1.8s infinite alternate;
}
@keyframes loading {
  from { width: 0%; background:#4299e1; }
  to { width:100%; background:#48bb78; }
}
</style>

新增:src/components/LoadingSpinner.vue(加载旋转器)

javascript 复制代码
<template>
  <div class="loading-spinner">
    <div class="spinner"></div>
    <p class="loading-text">{{ text }}</p>
  </div>
</template>

<script setup lang="ts">
defineProps<{
  text?: string
}>()
</script>

<style scoped>
.loading-spinner {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  padding: 40px;
}

.spinner {
  width: 40px;
  height: 40px;
  border: 4px solid #f3f3f3;
  border-top: 4px solid #3498db;
  border-radius: 50%;
  animation: spin 1s linear infinite;
}

.loading-text {
  margin-top: 16px;
  color: #666;
  font-size: 14px;
}

@keyframes spin {
  0% { transform: rotate(0deg); }
  100% { transform: rotate(360deg); }
}
</style>

修改 SettingPage.vue,异步导入该组件

javascript 复制代码
<script setup lang="ts">
import { useRouter } from 'vue-router'
import { defineAsyncComponent, ref } from 'vue'
import { useTodoStore } from '../stores/todo'
import LoadingSpinner from '../components/LoadingSpinner.vue'

const router = useRouter()
const store = useTodoStore()
const showHeavyComp = ref(false)

// 异步懒加载重量级清理组件
const DataClearAnim = defineAsyncComponent(() =>
  import('../components/CleanAnimation.vue')
)

const clearAll = () => {
  if(confirm('确定清空所有任务?不可恢复!')) {
    store.todos = []
    store.nextId = 1
  }
}
const clearStorage = () => {
  localStorage.removeItem('todo-data')
  alert('本地存储已清空,刷新页面生效')
}
</script>

<template>
  <div class="page-wrap">
    <div class="set-card">
      <h2>系统设置</h2>
      <p>当前总任务数量:{{ store.total }}</p>

      <div class="btn-group">
        <button @click="clearAll">清空全部任务</button>
        <button @click="clearStorage">清除浏览器本地缓存</button>
        <button @click="$router.push('/list')">返回任务列表</button>
        <button @click="showHeavyComp = true">加载高级深度清理组件</button>
      </div>

      <!-- 异步组件,点击后才加载 -->
      <Suspense v-if="showHeavyComp">
  <template #default>
    <DataClearAnim />
  </template>
  <!-- 替换为旋转加载组件,自定义提示文字 -->
  <template #fallback>
    <LoadingSpinner text="高级清理组件加载中,请稍候..." />
  </template>
</Suspense>
    </div>
  </div>
</template>

<style scoped>
.page-wrap {
  display: flex;
  justify-content: center;
  padding: 60px 20px;
}
.set-card {
  width: 100%;
  max-width: 480px;
  background: #fff;
  border-radius: 16px;
  padding: 32px;
  box-shadow: 0 8px 24px rgba(0,0,0,0.08);
}
.btn-group {
  margin-top: 25px;
  display: flex;
  flex-direction: column;
  gap: 12px;
}
button {
  padding: 12px;
  border: 1px solid #e2e8f0;
  border-radius: 8px;
  background: #f7fafc;
  cursor: pointer;
}
</style>

5.3 封装 v-click-outside 自定义指令

新增指令文件:src/directives/clickOutside.ts

javascript 复制代码
import type { Directive, DirectiveBinding } from 'vue'

const vClickOutside: Directive = {
  mounted(el: HTMLElement, binding: DirectiveBinding<() => void>) {
    // 绑定全局点击事件
    el.__clickOutsideHandler__ = (e: MouseEvent) => {
      // 点击元素外部才执行回调
      if (!el.contains(e.target as HTMLElement)) {
        binding.value()
      }
    }
    document.addEventListener('click', el.__clickOutsideHandler__)
  },
  unmounted(el: HTMLElement) {
    // 销毁移除监听,防止内存泄漏
    document.removeEventListener('click', el.__clickOutsideHandler__)
  }
}

export default vClickOutside

全局注册指令(main.ts 修改)

javascript 复制代码
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
import App from './App.vue'
import router from './router'
import vClickOutside from './directives/clickOutside'
import './style.css'

const app = createApp(App)

const pinia = createPinia()
pinia.use(piniaPluginPersistedstate)
app.use(pinia)

app.use(router)

// 全局注册自定义指令,任意组件可用 v-click-outside
app.directive('click-outside', vClickOutside)

app.mount('#app')

5.4 TS 严格类型加固 + 新增任务筛选(全部/已完成/未完成)

5.4.1 types.ts 小幅加固(可选,原有够用)
javascript 复制代码
export interface Todo {
  readonly id: number
  text: string
  done: boolean
}
​
// 新增筛选枚举,类型约束筛选类型
export enum TodoFilterType {
  ALL = 'all',
  ACTIVE = 'active',
  COMPLETED = 'completed'
}
5.4.2 stores/todo.ts 完整重构:严格TS + 筛选计算属性
javascript 复制代码
import { ref, computed } from 'vue'
import { defineStore } from 'pinia'
import type { Todo } from '../types'
import { TodoFilterType } from '../types'

export const useTodoStore = defineStore('todo', () => {
  // State 严格标注类型
  const todos = ref<Todo[]>([])
  const nextId = ref<number>(1)
  // 当前筛选类型
  const filterType = ref<TodoFilterType>(TodoFilterType.ALL)

  // Getters
  const total = computed(() => todos.value.length)
  const activeCount = computed(() => todos.value.filter(t => !t.done).length)
  const completedCount = computed(() => todos.value.filter(t => t.done).length)
  const allDone = computed(() => todos.value.length > 0 && activeCount.value === 0)
  const getTodoById = computed(() => (id: number): Todo | undefined => todos.value.find(t => t.id === id))
// 新增:当前筛选视图内的任务是否全部已完成
const viewAllDone = computed(() => {
  // 当前筛选后的列表
  const list = filteredTodos.value
  if (list.length === 0) return false
  // 每一项done都为true
  return list.every(item => item.done)
})
  // 筛选后的任务列表(新增)
  const filteredTodos = computed((): Todo[] => {
    switch (filterType.value) {
      case TodoFilterType.ACTIVE:
        return todos.value.filter(item => !item.done)
      case TodoFilterType.COMPLETED:
        return todos.value.filter(item => item.done)
      default:
        return [...todos.value]
    }
  })

  // Actions 严格入参类型
  function addTodo(text: string): void {
    const trimmed = text.trim()
    if (!trimmed) return
    todos.value.push({
      id: nextId.value++,
      text: trimmed,
      done: false
    })
  }

  function toggleTodo(id: number): void {
    const target = todos.value.find(item => item.id === id)
    if (target) target.done = !target.done
  }

  function removeTodo(id: number): void {
    todos.value = todos.value.filter(item => item.id !== id)
  }

  function clearCompleted(): void {
    todos.value = todos.value.filter(item => !item.done)
  }

  function checkAll(): void {
    todos.value.forEach(item => item.done = true)
  }

  function unCheckAll(): void {
    todos.value.forEach(item => item.done = false)
  }

  // 新增:修改筛选类型
  function setFilter(type: TodoFilterType): void {
    filterType.value = type
  }

  return {
    todos,
    nextId,
    filterType,
    total,
    activeCount,
    completedCount,
    allDone,
    getTodoById,
    filteredTodos,
    addTodo,
    toggleTodo,
    removeTodo,
    clearCompleted,
    checkAll,
    unCheckAll,
    viewAllDone,
    setFilter
  }
}, {
  persist: true
})
5.4.3 TodoListPage.vue 增加筛选按钮,切换视图
javascript 复制代码
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { useTodoStore } from '../stores/todo'
import { TodoFilterType } from '../types'
import TodoHeader from '../components/TodoHeader.vue'
import TodoInput from '../components/TodoInput.vue'
import TodoList from '../components/TodoList.vue'
import TodoItem from '../components/TodoItem.vue'
import TodoFooter from '../components/TodoFooter.vue'
import ConfirmModal from '../components/ConfirmModal.vue'

const router = useRouter()
const store = useTodoStore()

const delModalVisible = ref(false)
const pendingDelId = ref<number | null>(null)

const openDelModal = (id: number) => {
  pendingDelId.value = id
  delModalVisible.value = true
}
const confirmDelete = () => {
  if (pendingDelId.value !== null) {
    store.removeTodo(pendingDelId.value)
  }
  delModalVisible.value = false
  pendingDelId.value = null
}
const cancelDelete = () => {
  delModalVisible.value = false
  pendingDelId.value = null
}
</script>

<template>
  <div class="page-wrap">
    <div class="card">
      <TodoHeader
        :active-count="store.activeCount"
        :total="store.total"
      />
      <TodoInput @add="store.addTodo" />

      <!-- 新增筛选按钮组 -->
      <div class="filter-btns">
        <button
          :class="{active: store.filterType === 'all'}"
          @click="store.setFilter('all')"
        >全部</button>
        <button
          :class="{active: store.filterType === 'active'}"
          @click="store.setFilter('active')"
        >未完成</button>
        <button
          :class="{active: store.filterType === 'completed'}"
          @click="store.setFilter('completed')"
        >已完成</button>
      </div>

      <!-- 循环筛选后的列表 filteredTodos -->
      <TodoList :todos="store.filteredTodos">
        <template #item="{ todo }">
          <TodoItem
            :todo="todo"
            :go-detail="() => router.push({name:'Detail', params:{id:todo.id}})"
            @toggle="store.toggleTodo"
            @open-del-modal="openDelModal"
          />
        </template>
      </TodoList>

      <TodoFooter
        v-if="store.todos.length"
        :is-all-checked="store.viewAllDone" 
        @check-all="store.checkAll"
        @un-check-all="store.unCheckAll"
        @clear-completed="store.clearCompleted"
      />
    </div>

    <ConfirmModal
      :visible="delModalVisible"
      title="确认删除任务"
      content="删除后该任务无法恢复,确定继续?"
      @confirm="confirmDelete"
      @cancel="cancelDelete"
    />
  </div>
</template>

<style scoped>
.page-wrap {
  display: flex;
  justify-content: center;
  padding: 40px 20px;
}
.card {
  width: 100%;
  max-width: 520px;
  background: #fff;
  padding: 32px;
  border-radius: 16px;
  box-shadow: 0 8px 24px rgba(0,0,0,0.08);
}
.filter-btns {
  display: flex;
  gap: 8px;
  margin-bottom: 16px;
}
.filter-btns button {
  padding: 6px 12px;
  border: 1px solid #e2e8f0;
  border-radius: 6px;
  background: #f7fafc;
  cursor: pointer;
}
.filter-btns button.active {
  background: #4299e1;
  color: white;
  border-color: #4299e1;
}
</style>

5.5 功能完整测试

测试1:Teleport 删除弹窗

添加多条任务,点击任意任务【删除】;

点【确认】:任务被删除;点【取消】:无操作;

详情页点击删除,同样弹出确认弹窗,逻辑一致。

测试2:设置页异步懒加载重量级组件

进入设置页;

点击【加载高级深度清理组件】;

控制台Network面板可看到 异步请求加载,Suspense 显示加载,加载完成展示动画组件。

测试3:任务筛选功能

全部:展示所有任务;

未完成:只展示未勾选任务;

已完成:只展示已勾选任务; 切换筛选按钮实时更新列表,Pinia持久化后刷新页面筛选状态保留。

测试4:持久化校验

刷新浏览器、关闭重开页面,任务列表、勾选状态、筛选条件全部保存,不会丢失。


六、总结

本篇我们攻克了Vue 3的四个高级特性:Teleport解决了组件脱离文档流但逻辑内聚的问题;异步组件与Suspense配合实现了按需加载和优雅的加载状态管理;自定义指令让DOM操作有了标准化封装;TypeScript深度结合则从Props、Emits、模板引用到依赖注入全方位提升了代码的类型安全与可维护性。通过改造Todo应用,你将理论迅速转化为实践,感受到这些特性带来的开发体验飞跃。

  • Teleport将模板片段传送到任意DOM位置,保持逻辑关系不变,适合模态框、通知等场景。

  • defineAsyncComponent实现组件按需加载,配合Suspense处理加载中与错误状态,优化首屏性能。

  • 自定义指令通过mountedupdated等钩子封装DOM行为,可全局或局部注册。

  • TypeScript与Vue深度结合:用InstanceType标注组件ref、InjectionKey为provide/inject提供类型、扩展ComponentCustomProperties处理全局属性。

  • 实战案例将确认弹窗传送、设置页异步加载、点击外部关闭、依赖注入类型安全整合进Todo应用。


如果这篇文章帮你解决了实操上的困惑,别忘记点击点赞、分享 ,也可以留言告诉我你遇到的其它问题,我会尽快回复。动手练习是掌握编程最快的方法,请务必亲手敲一遍本文的所有示例代码,并截图保存你的成果。你的关注是我坚持原创和细节共享的力量来源,谢谢大家。

相关推荐
qq4356947011 小时前
Vue05
前端·vue.js
qq_422152571 小时前
PDF 解密工具怎么选?2026 年文档密码移除方案与注意事项
java·前端·pdf
YHHLAI1 小时前
前端工程化调用 AI 多模态生图模型:Qwen Image Demo 实战
前端·人工智能
英勇无比的消炎药1 小时前
收藏备用TinyVue开发高频踩坑问题合集
vue.js
To_OC1 小时前
我一直以为 Ajax 是个黑盒,直到我写了这 50 行代码
前端·后端·全栈
用户059540174461 小时前
RAG 记忆层踩坑实录:用户偏好凭空消失,我排查了 4 小时,最后用 LangChain + Chroma 搭了套自动化回归测试
前端·css
英勇无比的消炎药1 小时前
体积瘦身TinyVue打包优化与按需加载实践
vue.js·前端工程化
程序猿阿伟2 小时前
《Chrome隔离机制的维度落地指南》
前端·chrome
用户054324329702 小时前
AI 生成的代码怎么在前端安全预览 + 一键运行:sandbox iframe 实战 🔒
前端