如果你正在按学习计划推进 Vue 3,走到“第 59 天”这个节点,大概率已经开始接触 Composition API 了。我从 Vue 2 切到 Vue 3 时,最先的感受不是新语法多,而是旧习惯全在被挑战——选项式写法里那种“数据归数据、方法归方法”的舒适区,被 setup 一把拉了出来。配合 Teleport 和 Suspense 这两个新组件,Vue 3 彻底改变了“组件怎么写”和“组件怎么渲染”这两件最核心的事。
这篇内容主要围绕 Vue 3 新特性中的 Composition API、Teleport、Suspense 逐个拆解,适合目前正处于 Vue 进阶阶段、准备做生态整合的开发者。无论你是在跟学习计划走,还是准备把老项目从 Vue 2 迁过来,看完都能搞清楚这几个特性到底解决什么问题、实际代码该怎么组织、又会踩哪些文档里不会写的坑。
1. 先搞懂 Composition API 要解决的真问题
1.1 选项式写法最大的痛点:逻辑被拆得七零八落
很多人刚接触 Composition API 的时候,第一反应是“这不就是换个写法吗”。我刚学的时候也这么想,直到真拿 Vue 2 写了一个超过 500 行的组件,才意识到问题不在写法,而在组织方式。
选项式 API 强制把代码按类型归档:data 存数据、computed 存计算属性、watch 放监听器、methods 扔方法。如果是几个不相关的功能混在同一个组件里,比如一个弹窗组件里既有表单校验又有图片上传又有权限判断,你会发现同一个业务逻辑被拆成了四五块,分布在文件的不同位置。想完整理解一个功能,得把 data、computed、methods 里相关的片段来回拼起来读。
我当时维护过一个用户管理页面,光是一个“搜索用户并展示详情”的功能,就横跨了 data 里的 searchParams、userList、loading,methods 里的 fetchList、handleSearch、resetSearch,watch 里对 searchParams 的深度监听,还有 mounted 里的初始化调用。改一个搜索逻辑,得同时动四个区域,漏一个就出 bug。这就是选项式 API 的硬伤:Vue 2 的组件语法是按“类型”组织的,不是按“逻辑”组织的。
1.2 Composition API 的组织逻辑:按功能聚合,而不是按类型归档
Composition API 的核心思路,是把一个功能涉及的响应式数据、计算属性、监听器、方法全部放在一起,代码聚合的依据从“这是什么类型”变成了“这属于哪个功能”。
setup 是 Composition API 的入口,它发生在 props 解析之后、组件实例创建之前。这个时机很关键:你可以在这里拿到 props 和上下文(attrs、slots、emit),但拿不到 this。setup 返回的对象会暴露给模板,模板里能直接用这些变量和方法。
<script setup> import { ref, computed, watch } from 'vue' const searchKeyword = ref('') const userList = ref([]) const loading = ref(false) const filteredList = computed(() => { const keyword = searchKeyword.value.trim().toLowerCase() if (!keyword) return userList.value return userList.value.filter(user => user.name.toLowerCase().includes(keyword)) }) watch(searchKeyword, async (newVal) => { loading.value = true userList.value = await fetchSearchUsers(newVal) loading.value = false }) </script>可以拿书架来类比。选项式写法相当于图书馆按“文学类”“历史类”分成不同区域,但你研究一个特定课题时得来回跑好几个区;组合式写法相当于按“课题”打包,一个课题涉及的所有材料都装在一个文件盒里,拿起来就能用。
1.3 响应式底层重构:为什么 Proxy 带来了质的改变
Composition API 只是表象,Vue 3 真正的底气来自响应式系统重写。Vue 2 用Object.defineProperty劫持对象的现有属性,这导致两个经典坑:新增属性不是响应式的,数组按索引修改也不是响应式的。Vue 3 改用Proxy代理整个对象,新增属性、删除属性、数组索引操作全都能被拦截。
这里牵扯到 ref 和 reactive 的选择问题。reactive 直接返回一个代理对象,适合对象/数组这类引用类型。ref 则是把一个值包装成{ value: ... }的结构,基本类型也能获得响应式能力。为什么不干脆让所有值都变成代理?因为基本类型没法被 Proxy 拦截,必须通过一个包装结构来间接实现。
我在实际项目中总结了一条简单经验:在<script setup>里,几乎所有数据都用 ref,只有明确要表示一个深层次嵌套对象(比如表单数据、接口返回的整体结构)时才用 reactive。从 Vue 3.2 开始模板对 ref 有自动解包,ref.value的写法只在 script 逻辑里出现,模板里直接写变量名就够了。
要特别提醒一点:reactive 解构会丢失响应性。直接把reactive对象的属性解构出来,就相当于把一个普通值赋值给了新变量,后续修改不会触发更新。要保留响应性需要用到toRefs或者直接整套解构。
import { reactive, toRefs } from 'vue' const state = reactive({ count: 0, name: 'test' }) // 这样会丢失响应性 const { count } = state // 用 toRefs 才能保留 const { count: countRef, name: nameRef } = toRefs(state)1.4 setup 里没有 this,TypeScript 反而更好推断了
初切到 setup 时最不适应的地方是this不可用。Vue 2 里通过this.$store、this.$router取东西的习惯,到 Vue 3 得改成显式引入。开始的直觉会觉得麻烦,但写过一段时间后,我反而认为这是 Vue 3 对 TypeScript 友好的关键所在。
选项式写法里,this的类型是组件实例,它身上的自定义属性由 data、computed、methods 的返回类型合并而成,Vue 2 里想做精确的类型推导非常费劲。而在 setup 函数里,参数和返回值都是普通类型:props 是响应式对象,context 是固定结构,所有变量和方法都是显式声明的,TS 可以精确推导,不再需要魔法般的类型声明合并。
如果你正处在进阶阶段,建议从今天开始手写 setup 时直接考虑 TS。不为别的,就为“变量从哪来、返回什么类型”全部可查,写大项目时远远比靠记忆维护和读代码舒服。
2. 上手 Composition API:从零封装一个可复用的逻辑模块
2.1 一个完整示例:用户列表请求 + 筛选 + 分页
光聊概念不够,直接看一个能跑的示例。假设你要做一个用户列表页,功能包括远程搜索、分页加载、点击行查看详情。用选项式写,data 里至少会有userList、total、currentPage、pageSize、keyword、loading、selectedUser;methods 里会有fetchList、handleSearch、handlePageChange、handleRowClick;watch 要监听 keyword;mounted 要做首次加载。
同样的逻辑用组合式,我会把这些拆成两个独立模块:一个是列表数据逻辑,一个是详情弹窗逻辑。先看列表模块:
// usePagedList.js import { ref, watch } from 'vue' import { fetchUserList } from '@/api/user' export function usePagedList(fetchApi, initialParams = {}) { const list = ref([]) const total = ref(0) const currentPage = ref(1) const pageSize = ref(10) const loading = ref(false) const filters = ref({ ...initialParams }) const error = ref(null) async function loadList() { loading.value = true error.value = null try { const res = await fetchApi({ page: currentPage.value, pageSize: pageSize.value, ...filters.value, }) list.value = res.list total.value = res.total } catch (e) { error.value = e } finally { loading.value = false } } function search(params = {}) { currentPage.value = 1 filters.value = { ...filters.value, ...params } loadList() } function changePage(page) { currentPage.value = page loadList() } // 监听筛选条件变化,条件变了自动重新请求 watch(filters, loadList, { deep: true }) return { list, total, currentPage, pageSize, loading, error, search, changePage, loadList } }在组件里使用时,只需要一句调用:
<script setup> import { usePagedList } from '@/composables/usePagedList' import { fetchUserList } from '@/api/user' const { list, total, loading, search, changePage } = usePagedList(fetchUserList, { status: 'active' }) </script>这段代码的价值不只在于少写几行,而在于组件里不再出现任何“请求列表”的业务细节。以后另一个页面也需要列表+分页,直接把这个模块拿去用,改动只发生在接口地址和筛选字段上,不需要重写逻辑。
2.2 为什么要把逻辑抽成函数,以及抽取的准确时机
很多人在写组合式代码时有一个困惑:到底要不要把逻辑抽出去?我的判断标准很简单——某段逻辑是否会被第二个组件使用,或者这个组件本身已经大到难以阅读。
如果一个逻辑只在当前组件里用一次,写在 setup 里完全没问题,没必要为了“看起来像组合式”而强行抽取。我自己踩过的坑是过度抽。以前为了展示组合式的复用能力,把只有十几行的表单校验逻辑单独抽成一个函数,结果组件引用了四个 useXxx,代码跳来跳去,阅读成本反而高了。可复用逻辑的抽取有两条硬标准:被两处以上使用,或者这个逻辑确实足够自洽(比如一个倒计时 hook、一个拖拽 hook、一个分页 hook)。
如果你在项目中看到某个组件里已经有三个以上含义独立的“区块”,那就是适合抽取的信号。杭长而伸手,不如拆得干净。
2.3 computed 与 watch 的细节,以及一个高频踩坑点
组合式写法里计算属性和监听器的用法都变了,但逻辑和选项式一样:computed 只做计算,不产生副作用;watch 负责响应变化。
computed 是惰性求值的,只有在依赖发生变化且计算属性被访问时才会重新执行。这也是它和方法的区别:模板里调用方法每次渲染都会执行,计算属性只有依赖变化才会重算。如果你的模板里同一份处理逻辑用了两次,用 computed 能避免重复计算,用方法每次都跑两遍。
watch 有四个选项值得注意:immediate是否立即执行、deep是否深度监听、flush控制回调时机、以及once(3.4+ 支持)是否只执行一次。最常见的坑是监听 reactive 对象时,默认不会触发内部的深层变化。比如:
const form = reactive({ name: '', age: 0 }) // 这样监听不会触发,因为 form 是一个代理对象,但 watch 默认比较的是引用 watch(form, () => console.log('表单变化了')) // 需要显式开启 deep watch(form, () => console.log('表单变化了'), { deep: true })另外一个实用细节:如果 watch 监听的是一个 getter 函数,记得返回你要监听的具体字段,而不是整个对象,这样可以控制触发条件的粒度,也避免 deep 带来的性能开销。
watch( () => form.name, (newVal, oldVal) => { // 只在 name 变化时触发 }, { immediate: true } )2.4 生命周期与清理工作:别让定时器成为隐形 bug
组合式 API 的生命周期钩子都带上了on前缀:onMounted、onBeforeUnmount、onUpdated等。它们的执行时机和作用域跟选项式是匹配的,但有一点需要注意:钩子必须在 setup 的同步执行过程中注册,不能在await之后再去注册。
如果 setup 里有异步操作,比如:
setup() { const data = ref(null) const timer = null onMounted(async () => { await fetchData() // 组件初始化时不推荐在这里注册钩子,应该放在同步位置 }) }这里更关键的是清理工作。以前我在项目里写过这样一个 bug:组件初始化时启动了一个轮询定时器,但忘记在组件销毁前清理,导致页面切换后定时器还在后台请求接口,浪费资源还容易引起内存泄漏。现在我的习惯是每写一个 setInterval 或 addEventListener,就立刻在同一块代码里补上对应的清理逻辑:
import { ref, onMounted, onBeforeUnmount } from 'vue' const countdown = ref(0) let timer = null onMounted(() => { timer = setInterval(() => { countdown.value -= 1 }, 1000) }) onBeforeUnmount(() => { if (timer) { clearInterval(timer) timer = null } })这算是最容易形成肌肉记忆的规范之一。真踩过内存泄漏的坑之后,才会理解为什么官方文档反复强调在onBeforeUnmount里做清理。
2.5<script setup>语法糖:日常开发首选
从 Vue 3.2 开始,<script setup>已经成为组合式写法的默认选择。它最大的优势是少写一层 setup 函数,顶层变量自动导出给模板,组件导入也可以直接用。
<script setup> import { ref } from 'vue' import ChildComponent from './ChildComponent.vue' const msg = ref('hello') defineProps({ title: String, }) defineEmits(['update:title']) </script>defineProps和defineEmits是编译宏,不需要 import,直接在<script setup>里使用。父组件传进来的 props 不需要像在 setup 函数里那样手动接收,模板里直接用就好。如果需要在逻辑里访问 props,可以直接定义变量接收:
const props = defineProps({ title: String, })<script setup>基本覆盖了日常开发的全部场景。我印象中真正需要回到普通 setup 写法的场景很少,比如你要在渲染函数中动态使用组件时,可能在普通 setup 里更直观。但 99% 的组件,<script setup>都是最优解。
3. Teleport:让组件渲染到 DOM 树之外的指定容器
3.1 经典痛点:弹窗和遮罩被父容器裁切
Composition API 解决的是“逻辑怎么组织”,Teleport 解决的是“DOM 节点往哪里挂”。一个成熟的组件库在实现弹窗类组件时,几乎都会遇到同一个问题:组件写在哪里,DOM 就渲染在哪里。
遇到父容器设置了overflow: hidden或transform属性,弹窗一弹出来,要么被裁切,要么定位基准错乱。项目里最经典的案例是做了一个带滚动区域的卡片,卡片里有个详情弹窗。弹窗一打开,右侧和下侧直接被裁掉,弹窗里的按钮根本点不到。当时的应急方案是把弹窗改到 body 层,通过手动管理 DOM 节点来渲染,后续的状态联动、事件冒泡都变成手工处理,复杂得很。
Teleport 就是专门解决这个问题的:组件模板这部分内容,最终会渲染到指定的 DOM 节点下,而不是留在自身组件树里。
3.2 to 属性、disabled 属性与一个完整弹窗示例
Teleport 的用法很简单,用to指定目标容器即可:
<template> <teleport to="body"> <div class="modal-overlay"> <div class="modal-content"> <slot /> </div> </div> </teleport> </template>除了字符串选择器,to也支持传 DOM 元素对象。disabled属性可以控制是否启用传送,为 true 时就渲染在当前位置。这给你留了一个灵活的出口:某些场景下需要弹窗保留在当前父级里,就不用写两套组件。
完整弹窗示例,我把样式和交互写完整一点,方便直接抄:
<template> <teleport to="body" :disabled="!teleportEnabled"> <transition name="fade"> <div v-if="visible" class="modal-overlay" @click.self="$emit('update:visible', false)" > <div class="modal-panel"> <div class="modal-header"> <slot name="title">默认标题</slot> <button class="modal-close" @click="$emit('update:visible', false)">×</button> </div> <div class="modal-body"> <slot /> </div> </div> </div> </transition> </teleport> </template> <script setup> defineProps({ visible: { type: Boolean, default: false, }, teleportEnabled: { type: Boolean, default: true, }, }) defineEmits(['update:visible']) </script>这个组件在父组件里可以这样用:
<Modal :visible="showDetail" @update:visible="showDetail = $event"> <p>弹窗内容放在这里</p> </Modal>把遮罩和面板全部通过 teleport 挂到 body 下,无论父组件层级有多深、样式多复杂,弹窗都以 body 为定位上下文渲染,不会被裁切,z-index 也容易统一控制。
3.3 隐藏细节:事件冒泡还在,CSS 选择器要留个心眼
Teleport 有一点很容易被误读:DOM 节点搬家了,但组件逻辑关系没变——它仍然是父组件的子组件,props、事件、插槽照常工作,事件冒泡也按照组件树走。这一点很友好,但也带来了陷阱:如果你在 CSS 里用后代选择器,比如.parent-container .modal-content,在 Teleport 把 modal 移出父组件 DOM 树之后,这条规则就匹配不上了,样式会突然失效。
我在一个项目里就遇到过这个问题。弹窗本来样式正常,加了 Teleport 之后突然变丑,排查半天才发现是父组件里写了类似.detail-box开头的一大串后代选择器,一移到 body 下面,样式全断。解决办法是把弹窗自身的样式写清楚,不要在外部用后代选择器去限定弹窗内部的结构。
还要注意目标容器必须在挂载时已经存在。如果你把to指向一个动态创建的节点,而这个节点在和 Teleport 同一时刻才被创建,就会报错。规范的写法是在挂载完成生命周期之后再去创建或操作目标容器。
多个 Teleport 指向同一个目标时,它们会按照代码顺序依次追加。如果你在实现通知队列,需要控制多条通知在容器里的先后位置,这个顺序就是稳定的,不会乱跳。
3.4 别滥用 Teleport:什么情况下不该用它
Teleport 不是越多越好。如果一个弹窗在大多数情况下就会出现在当前组件区域内,这时把它移动到 body 反而会让 DOM 层级变得不直观。我的个人标准是:只有当元素出现“被父级裁剪”“z-index 被嵌套层级压制”这类实际问题时,才考虑用 Teleport;仅仅因为“组件库都这么用”而去套用,很容易把一个本来没问题的地方引入额外复杂度。
另外一个更实际的建议:把 Teleport 和过渡动画结合时,如果要让 transition 在 Teleport 内正常工作,需要把<transition>也放在 teleport 内部,像上面示例那样,包在<teleport>里。这个顺序反过来会出现动画不生效的情况。
4. Suspense 与异步编排:把加载状态从业务代码中剥离
4.1 传统方案的问题:每个异步组件都要自己管 loading
Suspense 解决的问题和前面两类不太一样:它处理的是异步渲染的编排问题。在 Vue 3 之前,一个页面里如果有三个异步组件,每个都要自己维护一个 loading 状态和错误状态,页面级加载逻辑会被这些零散的标志位搞得难以维护。
比如一个用户详情页,要展示基础信息、订单列表、最近活动三块,每一块都对应一个异步请求。传统写法要在页面组件里维护basicLoading、ordersLoading、activityLoading三个布尔值,还要处理请求失败的兜底。等三个请求都返回之后,才能统一显示页面主体。这个“等待全部完成”的逻辑,写起来相当繁琐。
Suspense 提供了一种更自然的声明式方案:在<Suspense>内部放异步组件,在fallback插槽里放等待时的占位内容,框架负责在依赖的异步任务全部完成或者失败后自动切换视图。
4.2 Suspense 的工作原理与适用条件
Suspense 能拦截两类异步依赖:一类是通过defineAsyncComponent声明的异步组件,另一类是 setup 顶层使用了 await 的组件。也就是说,如果一个组件的 setup 是 async 的,它会被 Suspense 视为异步依赖;如果组件里只是走了普通的数据请求但没有在 setup 顶层 await,Suspense 不会感知到,它仍然会直接渲染该组件。
这意味着,如果你只想用 Suspense 做“数据请求完再显示”的效果,就必须让请求逻辑发生在 setup 的顶层 await 中,而不是 onMounted 里。这一点我在实战中一开始搞反了:请求写在 onMounted 里,Suspense 的 fallback 闪了一下就消失,但实际上内容还没有就绪。后来改成在 setup 顶层 await 数据请求,组件才会真正被 Suspense 挂起。
组件进入挂起状态后,Suspense 会渲染 fallback 指定的占位内容。当所有异步依赖都 resolve 后,再渲染默认插槽里的正式内容。默认情况下,Suspense 还会等待所有嵌套的异步依赖,包括子组件里的异步组件。若某个异步依赖 reject,Suspense 会触发@reject事件(在较新版本中已由onErrorCaptured接管一部分场景),需要在事件回调里做错误通知。
4.3 完整示例:Profile 页面的异步加载编排
假设要做一个用户资料页,由异步组件组成:
<template> <Suspense @fallback="showFallback = true" @resolve="showFallback = false" @reject="handleReject"> <template #default> <div class="profile-page"> <UserBasicInfo /> <UserOrders /> <UserRecentActivity /> </div> </template> <template #fallback> <div class="profile-skeleton"> <div class="skeleton-block skeleton-avatar"></div> <div class="skeleton-block skeleton-line"></div> <div class="skeleton-block skeleton-line short"></div> </div> </template> </Suspense> </template>UserBasicInfo 内部的结构:
<script setup> import { getUserBasicInfo } from '@/api/user' const info = await getUserBasicInfo(userId) </script> <template> <div class="basic-info"> <h2>{{ info.name }}</h2> <p>{{ info.bio }}</p> </div> </template>UserOrders 内部的结构:
<script setup> import { getUserOrders } from '@/api/order' const orders = await getUserOrders(userId) </script> <template> <div class="orders"> <div v-for="order in orders" :key="order.id">{{ order.title }}</div> </div> </template>页面渲染流程变成:进入页面 → Suspense 检测到 UserBasicInfo 和 UserOrders 的 setup 都有 await → 显示 fallback 骨架屏 → 所有异步请求完成 → 统一替换为 default 内容。这个流程完全由框架接管,我不需要在任何一个组件里写 mounted 里的 loading 判断,也不需要手动控制“三个请求都完成才显示”。
4.4 使用 Suspense 要注意的四个真实坑
第一,Suspense 要有合理的嵌套粒度。如果把整个页面都包在一个大 Suspense 里,只要任何一个异步组件慢了,整页就卡在骨架屏。合理的拆分方式是按“区块”来,比如头部一个 Suspense、列表一个 Suspense、侧栏一个 Suspense,互不拖累。
第二,fallback 的内容不要太寒酸也不要太华丽。太简单导致用户觉得页面卡了,太复杂导致每次进入都有一大块动画在闪。我一般用骨架屏,静态占位,不添加复杂动画,既自然又不至于吸引注意。
第三,不要轻易在异步 setup 里发无法取消的请求。当组件被销毁但请求还没返回时,组件可能已经卸载,这时更新响应式数据会出警告或造成状态污染。建议在 onBeforeUnmount 中配合请求取消机制(比如使用 AbortController 或请求库的 cancel 能力)做清理。
第四,异步组件会延长组件的挂载时间,从用户体验来看合理,但如果有“首屏统计埋点”需求,要注意它统计的时间点会变化。这也是我在实际项目中踩过的:埋点放在 onMounted,后来因为异步 setup 导致挂载延迟,导致统计数据比预期晚了几百毫秒。
4.5 什么时候值得用 Suspense,什么时候不必硬上
Suspense 适合的场景是:多个异步组件需要在同一时机展示,且加载过程有统一的占位区。如果你只有一个异步组件、一个 loading 状态,直接用 v-if 可能在可读性上更直接,没有必要为了用而用。
我的经验是把 Suspense 当作一种编排工具,它解决的是“好几个异步任务全部完成后才展示”的同步问题,而不是“单个请求的 loading 显示”问题。把握住这个定位,就能避免写出包了一层又一层、出了 bug 反而不好排查的代码。
5. 第 59 天的生态整合路线:Vue 3 周边该怎么配
5.1 路由、状态、构建工具:新一代组合怎么选
到第 59 天这个阶段,光会写组件是不够的,要把整个项目的工程链路打通。Vue 3 的生态对应关系是这样的:
| 职责 | Vue 2 时代的常用选择 | Vue 3 时代的对应方案 |
|---|---|---|
| 路由 | Vue Router 3 | Vue Router 4 |
| 状态管理 | Vuex 3 / 4 | Pinia(官方推荐) |
| 构建工具 | Webpack + vue-cli | Vite |
| 开发者工具 | Vue Devtools 6 | Vue Devtools 7+ |
Vue Router 4 的用法和 3 基本类似,主要变化是创建方式改为 createRouter + createWebHistory/createWebHashHistory。项目里如果不需要兼容老旧浏览器,直接选 createWebHistory,URL 更干净。
Pinia 是我切到 Vue 3 之后最推荐优先接的生态。相比 Vuex,它去掉了 mutation 的概念,直接在 store 里定义 state 和 action,写法接近简洁的模块化代码。TypeScript 支持也更好。迁移成本很低,旧项目从 Vuex 迁移到 Pinia 的常见做法是保留 Vuex 的 namespaced module,再在组件里逐个替换为 Pinia store,不需要一次性全改。
Vite 的开发体验胜在两点:依赖预构建让你第一次冷启动不至于太慢;模块热替换(HMR)的粒度比 Webpack 更细。如果你还没用 Vite,我可以负责任地说,正式开发体验完全是两代工具的感觉。
5.2 老项目迁移时最容易遗忘的 API 变化清单
很多从 Vue 2 升到 Vue 3 的团队,最大的阻力不是新特性学不会,而是旧 API 消失后代码静默失效。整理一份高频变化清单,对第 59 天这个阶段的整合很有帮助:
全局 API 迁移。
Vue.prototype.$xxx改为app.config.globalProperties.$xxx。这意味着所有在原型上挂载的工具函数、常量、第三方实例,都需要通过app.config.globalProperties挂载。v-model 改造。组件上
v-model默认对应modelValue和update:modelValue,不再支持.sync修饰符,用v-model:title="xxx"替代。过滤器 filters 被移除。Vue 3 中不再支持
| 过滤器,需要用计算属性或方法代替。这是迁移时最常见的报错点,因为你可能已经习惯在模板里写price | formatPrice。事件 API 改变。
$on、$once、$off被移除,全局事件总线需要自己基于mitt这类库实现,或者走状态管理。$listeners 被合并到 $attrs。以前要通过
v-on="$listeners"透传的写法,现在只需关注$attrs,同时在组件上显式声明inheritAttrs: false可以避免非 prop 属性落到根节点上。
5.3 组合式函数与第三方库的协作模式
到了生态整合阶段,组合式函数和第三方库的配合模式一定要掌握。很多库已经提供了官方或社区的组合式封装,比如统计数据埋点、消息推送、权限校验,都可以封装成 useXxx 形式,在组件里直接调用。
一个常见的模式是把某个库的实例初始化逻辑放进组合式函数,然后在组件的 onMounted 里初始化、在 onBeforeUnmount 里销毁。
// useAnalytics.js import { onMounted, onBeforeUnmount } from 'vue' import Analytics from 'some-analytics-sdk' export function useAnalytics(config) { let instance = null onMounted(() => { instance = new Analytics(config) instance.init() }) onBeforeUnmount(() => { if (instance) instance.destroy() }) return { trackEvent: (eventName, payload) => instance?.track(eventName, payload), getInstance: () => instance, } }这个模式的妙处在于:组件只需要在 setup 顶部调用const { trackEvent } = useAnalytics({ appId: 'xxx' }),初始化、销毁、实例访问全部被封装隐藏。在多个页面需要埋点的场景下,代码重复量非常低。
5.4 从第 59 天往后,我建议的动手路径
学到这个阶段,再刷文档的意义已经不大了。真正能让知识沉淀下来的是动手做一个整合项目:一个包含路由、状态管理、异步数据请求、弹窗交互、组合式函数抽离的中型应用。把所有技能放进去交叉碰撞,坑踩一遍,才谈得上是“会用了”。
我给自己定过一个标准:Vue 3 的进阶阶段,至少完成三个从零到一的“小系统”练习,而不是追求模块数量多。一个可以练习 Composition API 的业务模块,一个可以练习 Teleport 和 Suspense 的复杂弹窗/异步页面,一个可以练习 Pinia 和权限控制的管理后台。把这些做完,方法论才算落地。
另外,我会建议你养成写组合式函数笔记的习惯。每抽出一个可复用函数,就在本地文档里记录适用场景、边界条件和踩过的坑。三个月后再看,那就是你最有价值的个人技术资产,比任何转载的教程都贴近你的实际需求。
我之前写过一个搜索防抖的组合式函数,头两个月里它在五个组件里被复用了。没有笔记的话,每次都要重新回忆实现细节;有了笔记,两分钟就完成一次复用。这种积累,是第 59 天这个阶段最值得做的事。