摘要: 通过前几篇的学习,已经掌握了Vue 3的组件化、路由、状态管理以及逻辑复用。但要想在真实项目中游刃有余,还需要掌握一些能够显著提升用户体验和代码质量的高级特性。本文将围绕四个核心利器展开:Teleport传送门 让你把组件渲染到DOM的任意位置,彻底解决模态框、弹出层的z-index和overflow裁剪问题;异步组件与Suspense 结合,实现组件的按需加载和优雅的加载中状态,让首屏渲染速度飞起;自定义指令 让你直接控制底层DOM,封装常见的交互行为(如自动聚焦、点击外部关闭);最后,我们将Vue与TypeScript深度结合,为组件Props/Emits、模板引用、依赖注入提供更严格的类型安全。综合案例中,我们会给Todo应用加上Teleport确认框、异步加载设置页、用自定义指令关闭弹窗,让整个应用的专业度再上一个台阶。
一、Teleport传送门:让组件"穿越"DOM
1.1 为什么需要Teleport?
开发中经常遇到这样的场景:一个模态框(Modal)或下拉菜单,在组件内部编写时,由于父元素的CSS样式(如overflow: hidden、z-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>
当disabled为true时,内容会被渲染在原地,就像没有<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-if、v-for、v-model等),但当内置指令不满足需求时,你可以创建自定义指令来直接操作DOM。
3.1 指令钩子函数
一个自定义指令是一个对象,包含若干生命周期钩子(与组件类似,但更精细化):
javascript
const myDirective = {
created(el, binding, vnode, prevVnode) {}, // 元素创建时
mounted(el, binding) {}, // 挂载到DOM
updated(el, binding) {}, // 组件更新
beforeUnmount(el) {}, // 卸载前
// 以及 beforeMount, beforeUpdate, unmounted
}
最常用的是mounted和updated,很多时候逻辑相同,可以使用函数简写(相当于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",也通过defineProps和defineEmits添加了类型。但要让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处理加载中与错误状态,优化首屏性能。 -
自定义指令通过
mounted、updated等钩子封装DOM行为,可全局或局部注册。 -
TypeScript与Vue深度结合:用
InstanceType标注组件ref、InjectionKey为provide/inject提供类型、扩展ComponentCustomProperties处理全局属性。 -
实战案例将确认弹窗传送、设置页异步加载、点击外部关闭、依赖注入类型安全整合进Todo应用。
如果这篇文章帮你解决了实操上的困惑,别忘记点击点赞、分享 ,也可以留言告诉我你遇到的其它问题,我会尽快回复。动手练习是掌握编程最快的方法,请务必亲手敲一遍本文的所有示例代码,并截图保存你的成果。你的关注是我坚持原创和细节共享的力量来源,谢谢大家。