如果你正在按学习计划推进 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、loadingmethods 里的 fetchList、handleSearch、resetSearchwatch 里对 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 里没有 thisTypeScript 反而更好推断了初切到 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、selectedUsermethods 里会有fetchList、handleSearch、handlePageChange、handleRowClickwatch 要监听 keywordmounted 要做首次加载。同样的逻辑用组合式我会把这些拆成两个独立模块一个是列表数据逻辑一个是详情弹窗逻辑。先看列表模块// 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控制回调时机、以及once3.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.5script 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]) /scriptdefineProps和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 tobody div classmodal-overlay div classmodal-content slot / /div /div /teleport /template除了字符串选择器to也支持传 DOM 元素对象。disabled属性可以控制是否启用传送为 true 时就渲染在当前位置。这给你留了一个灵活的出口某些场景下需要弹窗保留在当前父级里就不用写两套组件。完整弹窗示例我把样式和交互写完整一点方便直接抄template teleport tobody :disabled!teleportEnabled transition namefade div v-ifvisible classmodal-overlay click.self$emit(update:visible, false) div classmodal-panel div classmodal-header slot nametitle默认标题/slot button classmodal-close click$emit(update:visible, false)×/button /div div classmodal-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 :visibleshowDetail update:visibleshowDetail $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 传统方案的问题每个异步组件都要自己管 loadingSuspense 解决的问题和前面两类不太一样它处理的是异步渲染的编排问题。在 Vue 3 之前一个页面里如果有三个异步组件每个都要自己维护一个 loading 状态和错误状态页面级加载逻辑会被这些零散的标志位搞得难以维护。比如一个用户详情页要展示基础信息、订单列表、最近活动三块每一块都对应一个异步请求。传统写法要在页面组件里维护basicLoading、ordersLoading、activityLoading三个布尔值还要处理请求失败的兜底。等三个请求都返回之后才能统一显示页面主体。这个“等待全部完成”的逻辑写起来相当繁琐。Suspense 提供了一种更自然的声明式方案在Suspense内部放异步组件在fallback插槽里放等待时的占位内容框架负责在依赖的异步任务全部完成或者失败后自动切换视图。4.2 Suspense 的工作原理与适用条件Suspense 能拦截两类异步依赖一类是通过defineAsyncComponent声明的异步组件另一类是 setup 顶层使用了 await 的组件。也就是说如果一个组件的 setup 是 async 的它会被 Suspense 视为异步依赖如果组件里只是走了普通的数据请求但没有在 setup 顶层 awaitSuspense 不会感知到它仍然会直接渲染该组件。这意味着如果你只想用 Suspense 做“数据请求完再显示”的效果就必须让请求逻辑发生在 setup 的顶层 await 中而不是 onMounted 里。这一点我在实战中一开始搞反了请求写在 onMounted 里Suspense 的 fallback 闪了一下就消失但实际上内容还没有就绪。后来改成在 setup 顶层 await 数据请求组件才会真正被 Suspense 挂起。组件进入挂起状态后Suspense 会渲染 fallback 指定的占位内容。当所有异步依赖都 resolve 后再渲染默认插槽里的正式内容。默认情况下Suspense 还会等待所有嵌套的异步依赖包括子组件里的异步组件。若某个异步依赖 rejectSuspense 会触发reject事件在较新版本中已由onErrorCaptured接管一部分场景需要在事件回调里做错误通知。4.3 完整示例Profile 页面的异步加载编排假设要做一个用户资料页由异步组件组成template Suspense fallbackshowFallback true resolveshowFallback false rejecthandleReject template #default div classprofile-page UserBasicInfo / UserOrders / UserRecentActivity / /div /template template #fallback div classprofile-skeleton div classskeleton-block skeleton-avatar/div div classskeleton-block skeleton-line/div div classskeleton-block skeleton-line short/div /div /template /Suspense /templateUserBasicInfo 内部的结构script setup import { getUserBasicInfo } from /api/user const info await getUserBasicInfo(userId) /script template div classbasic-info h2{{ info.name }}/h2 p{{ info.bio }}/p /div /templateUserOrders 内部的结构script setup import { getUserOrders } from /api/order const orders await getUserOrders(userId) /script template div classorders div v-fororder in orders :keyorder.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 3Vue Router 4状态管理Vuex 3 / 4Pinia官方推荐构建工具Webpack vue-cliVite开发者工具Vue Devtools 6Vue Devtools 7Vue Router 4 的用法和 3 基本类似主要变化是创建方式改为 createRouter createWebHistory/createWebHashHistory。项目里如果不需要兼容老旧浏览器直接选 createWebHistoryURL 更干净。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:titlexxx替代。过滤器 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 天这个阶段最值得做的事。