上一章我们把组件的基础概念、模板语法和样式作用域过了一遍,这一章开始进入 Vue 3 真正“重头戏”的部分:组合式 API(Composition API)和生命周期函数。理解这两个东西,基本就拿到了 Vue 3 组件开发的入场券。很多刚从 Options API 转过来的同学会觉得很别扭,其实不用急,组合式 API 并没有抛弃 Vue 的响应式、模板语法和组件设计思路,它只是把“按选项组织代码”改成了“按逻辑组织代码”。我会结合自己实际开发中遇到的情况,把这章内容尽量讲透,顺带把生命周期函数从创建、挂载、更新到销毁的整套流程梳理清楚,看完这一篇,你再回去看项目里的业务组件,会有完全不一样的感觉。
这一章适合所有想把 Vue 3 用明白的人,不管是刚学完基础语法的新手,还是从 Vue 2 迁过来的老开发,都建议认真过一遍。我会把 setup 语法糖、ref 和 reactive 的选择、onMounted 这类钩子的使用时机、以及组件通信的底层逻辑一起串起来讲,让它们不再是孤立的知识点,而是一套能直接落到项目里的开发方法论。
1. 组合式 API 到底解决了什么问题
1.1 Options API 的痛点与新方案的诞生
在 Vue 2 时代,我们写组件基本是固定在 data、computed、methods、watch、mounted 这样的“格子”里。组件简单的时候还好,一旦业务复杂,同一个功能的代码会被拆散到各个选项中,比如某个搜索功能,它的数据在 data 里,请求逻辑在 methods 里,监听器在 watch 里,初始化在 mounted 里。当你要维护这段功能时,得在各个选项之间来回跳,这还不算最痛苦,最痛苦的是多个功能混在一起时,代码可读性直线下降。
我接手过一个老项目,一个表格页面里有搜索、筛选、分页、导出、批量操作五六套逻辑,全部堆在一个几百行的 Vue 2 组件里。每次改需求,我得先花半小时把代码在脑子里“重新组装”一遍,才知道哪段 data 对应哪段 methods。这就是 Options API 在大型组件里的固有缺陷:按选项强制分块,而不是按逻辑归属分块。
组合式 API 的思路就是反过来的,它让你可以把同一个功能的数据、计算属性、方法、监听器全部写在一个“块”里。比如做搜索功能,你就把 searchKeyword、searchResult、handleSearch 写在一起,做分页就再写一个分页块,两个功能之间互不干扰,改起来也不用到处跳。
除了代码组织,组合式 API 还有一个巨大优势:逻辑复用。Vue 2 时代做逻辑复用主要靠 mixin,但 mixin 的毛病大家都懂,命名冲突、数据来源不透明、多个 mixin 叠加时根本不知道某个属性来自哪里。组合式 API 里的自定义 hook(也就是用函数封装逻辑)天然避开了这些问题,因为一个函数就是一个独立的作用域,参数传递明确,返回什么一目了然。
1.2 setup 函数的定位与执行时机
要说组合式 API,必须先说 setup。它是组合式 API 的入口函数,所有的组合式逻辑都在 setup 里完成。但很多初学者第一次看到 setup 会懵:它到底什么时候执行?跟 data、mounted 这些是什么关系?
搞清楚这点很关键。setup 是在组件实例创建之前执行的,执行时机比 beforeCreate 还要早。也就是说,在这个阶段,组件实例还不存在,你访问不到 this,也访问不到 data、computed、methods 里的内容。这也解释了为什么在 setup 里不能再用 this.xxx 这种写法了,不是因为 Vue 3 删了 this,而是 setup 执行时实例压根还没初始化完。
如果你用模板语法(也就是我们在 Vue 3 里最常用的<script setup>),那 setup 就是组件的顶层作用域,相当于把整个组件的逻辑直接写在 setup 函数体里。日常开发中,我基本都是用<script setup>,它比手动写 setup 函数再 return 一堆东西要清爽得多,少写很多样板代码。
关于 setup 的执行时机还有一点值得注意:setup 同步代码执行时,组件还没有挂载到 DOM 上。所以你在 setup 里直接操作 DOM 节点是拿不到的,要想操作 DOM,得等 onMounted 之类的生命周期钩子触发之后再做。这个顺序问题我后面细讲,这里先有个印象就行。
2. 组合式 API 的核心构成:ref、reactive、computed 与 watch
2.1 ref 和 reactive 怎么选
要讲组合式 API 就绕不开响应式数据。Vue 3 里创建响应式数据最基础的两个 API 就是 ref 和 reactive。很多新手一开始会纠结到底用哪个,我直接给结论:绝大多数场景用 ref,这没有任何问题。一个价值观很朴素的理由是:ref 写起来更统一,不管是基本类型还是对象类型,都通过.value来读写,逻辑上不用来回切换。
ref 的核心原理是:把原始数据包装成一个带 value 属性的响应式对象。你在 JS 里访问它必须写.value,但在模板里 Vue 会自动帮你解包,直接写变量名就行。这一点很容易踩坑,我会在后面的常见问题里专门讲。
reactive 则是直接把一个对象变成响应式对象,不需要.value,读属性直接 obj.xxx。它更适合管理一个“结构化”的数据集合,比如表单对象、页面筛选条件。但 reactive 有两个比较麻烦的限制:第一,它只能用于对象、数组这类引用类型,基本类型不适用;第二,如果你直接对 reactive 对象整体重新赋值,比如state = {...},会丢失响应式,因为赋值操作把原来的代理对象替换掉了。这个问题在工作中特别容易遇到,很多人排查半天才发现是这里出了问题。
我的个人习惯是:组件内部零散的数据、表单字段、简单对象,一律 ref;如果是聚合型的数据结构,比如一个包含若干子对象和数组的配置对象,用 reactive 会更顺手。但说实话,全用 ref 也完全可行,甚至团队规范里统一用 ref 会更省心,因为心智负担小。
2.2 computed 在组合式 API 里的用法
computed 在组合式 API 里就是个函数,接收一个 getter,返回一个“不可直接修改的响应式 ref”。大多数人用它最多的场景就是派生数据:比如购物车总价、筛选后的列表、用户的完整名称拼接。
这里想多说一句的是:computed 是会缓存结果的,它只在依赖的响应式数据发生变化时才重新计算。这跟 methods 里的普通函数不一样,后者每次渲染都会执行。所以如果某个值是多个响应式数据经过计算得来的,优先用 computed,不要在模板里写复杂表达式,也不要用方法去算。模板里的表达式应该是“描述性”的,而不是“逻辑性”的。
在script setup里使用 computed 也很自然,直接const total = computed(...),模板里拿 total 就是计算后的值。注意不要在 computed 的 getter 里做副作用操作,比如发请求、改 DOM,这些行为应该放在 watch 或事件处理函数里,computed 只负责“算”。
2.3 watch 和 watchEffect:一个监听,一个自动感知
watch 的用法和 Vue 2 差不多,监听一个或多个响应式数据源,数据变化时执行回调。组合式 API 里 watch 接收的第一个参数可以是 ref、reactive 的某个属性,也可以是一个 getter 函数。
跟 Vue 2 不同的一点是,Vue 3 里如果你监听 reactive 对象的属性,需要写成 getter 形式,比如:
watch(() => state.count, (newVal, oldVal) => { console.log('count 变了', newVal, oldVal); });直接用watch(state.count, ...)是不行的,因为state.count只是当前的值,不是一个响应式引用,watch 拿不到监听目标。这一点特别容易踩坑。
watch 还有一个 immediate 选项,设为 true 时会在初始化时立即执行一次回调,适合“进入页面就要拉一次数据”的场景。默认情况下 watch 是懒执行的,只在数据变化时触发。
watchEffect 是 Vue 3 新增的 API,它不指定监听谁,而是自动追踪你回调函数里用到了哪些响应式数据,只要用到的一变,回调就重新执行。它非常适合做“联动”操作,比如某个响应式数据变化后自动发请求、自动设置状态。但要注意,watchEffect 一旦开始就会立即执行一次,这一点跟带 immediate 的 watch 类似,用的时候心里有数就行。
3. 生命周期函数:挂载顺序、对应关系和注意事项
3.1 每个钩子的触发时机与对应关系
Vue 3 的生命周期钩子和 Vue 2 基本一一对应,只是命名发生了变化,加了on前缀,并且只能在 setup 内部同步调用。我把它们的对应关系整理成了一张表,建议收藏:
| 组合式 API(Vue 3) | 选项式 API(Vue 2/3 Options) | 触发时机 |
|---|---|---|
| setup() | beforeCreate / created | 组件实例创建前,data 和 methods 还未初始化 |
| onBeforeMount | beforeMount | 组件挂载到 DOM 之前,模板编译完成 |
| onMounted | mounted | 组件挂载完成后,可以访问 DOM |
| onBeforeUpdate | beforeUpdate | 响应式数据变化,重新渲染之前 |
| onUpdated | updated | 重新渲染完成后,DOM 已更新 |
| onBeforeUnmount | beforeDestroy | 组件卸载之前,实例仍可用 |
| onUnmounted | destroyed | 组件卸载完成,实例销毁 |
| onActivated / onDeactivated | activated / deactivated | keep-alive 组件激活 / 停用时 |
| onErrorCaptured | errorCaptured | 后代组件抛出错误时 |
这里想强调一下 setup 和 beforeCreate / created 的关系。在选项式写法里,created 是最早执行的生命周期钩子;而在组合式写法里,setup 就相当于把 beforeCreate 和 created 一起取代了。你在 setup 里做的事情(初始化数据、定义方法、创建监听),就相当于 Vue 2 里在 created 里做的事情。所以我建议对旧项目做迁移时,直接把原来 created 里的逻辑挪进 setup 的开头就行。
3.2 setup 里写 onMounted 的细节
在实际开发中,onMounted 是我用得最多的钩子,没有之一。页面数据请求、DOM 操作、图表初始化、第三方库的挂载,基本都放在这里。但有几个细节容易忽略:
第一,onMounted 里的回调函数可以随意使用 setup 作用域里定义的变量,因为闭包机制会捕获它们。而且这个作用域关系是在注册时就已经确定的,不受执行顺序影响。
第二,setup 里可以注册多个 onMounted。Vue 会按照注册顺序依次执行。这个特性很适合把不同的初始化逻辑拆开写,比如:
onMounted(() => { // 拉列表数据 fetchList(); }); onMounted(() => { // 初始化图表 initChart(); });两个初始化逻辑互不干扰,代码清爽,也方便单测时单独验证。
第三,一个非常重要的点:不要在 onMounted 里再嵌套引入副作用但又不同步执行业务逻辑。很多人习惯在 onMounted 里写axios.get(...).then(...),这没问题。但如果你在 then 回调里去 setState 修改响应式数据,要注意这个更新是异步的,渲染更新不一定立刻发生。需要“拿到数据后立刻操作 DOM”的场景,必须等 DOM 更新完成,这时候用 nextTick 才会稳妥。
3.3 onUpdated 和 onBeforeUnmount:常被忽略但其实很有用
很多人对 onUpdated 没什么好感,因为它会在每次数据变化重新渲染后触发,频繁得让人想不起来用。但有两类场景它很关键:一是需要“根据 DOM 变化做后续处理”的时候,比如第三方插件需要在 DOM 结构变化后重算尺寸;二是调试的时候,想确认某次数据更新到底有没有导致组件重新渲染。
不过我还是建议谨慎使用 onUpdated,因为频繁触发的情况下容易连带产生性能问题。如果只是想监听某个具体数据的变化,用 watch 更精准,不会因为无关数据变化而白白执行回调。
onBeforeUnmount 则是清理“现场”的关键钩子。定时器、事件监听、第三方实例、WebSocket 连接,这些都要在这里清理。我见过不少线上 bug 就是定时器没清、事件没解绑,导致组件销毁后回调还在执行,控制台报错、内存泄漏、接口重复请求,各种怪象都来了。写组件时养成一个好习惯:凡是在 setup 里创建的定时器、监听器、订阅器,都要对应在 onBeforeUnmount 里清理。这对长列表页面、弹窗组件、频繁切换的路由页面尤其重要。
4. 组件通信基础:从手动传值到响应式同步
4.1 父子组件通信:props 下传、事件上抛
说完了生命周期,得把组件通信的基础补上。在一个组件系统里,几乎没有哪个组件是完全独立的,总要有数据从父组件传进来,或者把内部状态变化告诉父组件。Vue 3 中最常见也是最基础的通信方式,就是 props 和自定义事件。
父传子很简单,在子组件里用 defineProps 声明接收的属性,然后在父组件模板里通过绑定方式传入。这里要注意 defineProps 接收的类型和默认值,最好都写清楚,既能当文档用,又能在编译期尽早发现问题:
<!-- 子组件 --> <script setup> const props = defineProps({ title: { type: String, required: true }, count: { type: Number, default: 0 } }); </script>子传父则靠 emit。先用 defineEmits 声明事件名称,再在适当时候调用,传参给父组件。这里有一个新手很容易犯的错误:把 props 直接拿来修改,比如在子组件里写props.count++。这是不被允许也不建议的,props 是单向数据流,子组件不应该去改父组件传下来的值。如果确实需要修改,正确的做法是复制一份到本地数据,或者干脆把它交还给父组件去改。
对于“半受控”的组件,我一般会在本地维护一个变量,用 watch 同步 props 的变化,再通过 emit 把更新抛回父组件,形成一种“单向往下传、事件往回抛”的数据流闭环。这是 Vue 组件通信的精髓,理解了这层,后面再去看状态管理工具就轻松很多。
4.2 non-props 属性和 $attrs:透传背后的设计逻辑
还有一个很多人容易忽略的点:那些没有在 defineProps 里声明的属性,最终会被放在 $attrs 上,并且默认透传到子组件的最外层元素上。比如你给一个按钮组件传了一个 class,但组件内部没有用 props 接收 class,它依然会应用到按钮根元素上。
从项目层面看,这个机制非常实用。它保证了组件的“可扩展性”:你不需要为每个可能的 HTML 属性都声明一遍 props,只要组件没有吃掉它,它就能自动往下传。不过如果根元素不想接收这些透传属性,可以通过defineOptions({ inheritAttrs: false })关闭自动继承,然后手动决定把它们放到哪里。我在封装表单控件、弹窗这类组件时经常用到这个技巧,因为它能让组件内部结构更可控。
4.3 provide / inject:跨层通信的正确打开方式
当组件嵌套层级很深时,一层一层传 props 和 emit 会非常痛苦,代码里全是中间层组件“中转”数据。Vue 3 的 provide / inject 就是来解决这个问题的。
父组件用 provide 提供数据,任意深度的后代组件用 inject 接收,不用关心中间有多少层。听起来很像状态管理工具,但它没有全局的概念,只对组件树上的“提供者”和“注入者”生效,作用域更受控、更清晰。
使用上有一个值得注意的细节:provide 的值如果不是 ref 或 reactive,那它是非响应式的。也就是说,父组件提供的普通对象/字符串,后面改了,子组件并不会有感知。所以我一般会直接 provide 一个 ref 或 reactive 对象过去,这样数据的更新能自然传递到注入端:
// 父组件 const globalConfig = ref({ theme: 'dark' }); provide('config', globalConfig); // 后代组件 const config = inject('config');配合 readonly 可以进一步防止子组件意外修改注入数据,从设计上养成单向数据流的习惯。这个思路在小型组件库里特别实用,比引入 Pinia 要轻量得多。
5. 动态组件与异步组件:按需加载的关键手段
5.1 用 component 标签实现动态切换
很多管理后台的页面结构都是“一个容器 + 多个可切换的子组件”,比如 Tab 切换、步骤条、弹窗内多步骤表单。如果用 v-if 写一堆分支,代码很啰嗦,而且子组件的状态管理很麻烦。Vue 内建的<component>组件可以完美解决这个需求,它接收一个:is属性,动态绑定要渲染的组件:
<component :is="currentTab" :data="payload" @change="onChange" />is的值可以是组件对象,也可以是一个注册过的组件名。这种方式让组件切换变成纯数据驱动,比如 currentTab 改一下,界面就自动切换。配合 keep-alive 包裹,还能保留各个组件的状态,避免切换时组件被销毁重建:
<keep-alive> <component :is="currentTab" /> </keep-alive>这里要注意区分:动态组件不是异步组件,它只是“运行时决定渲染哪个组件”的机制,真正需要按需加载时还要结合下一节的异步组件特性。
5.2 defineAsyncComponent 与代码分包
在一个大型项目里,把所有组件统统打进一个 bundle 是非常不明智的,首屏体积会暴增,加载时间肉眼可见地变长。Vue 3 的 defineAsyncComponent 可以配合 import 函数实现组件级代码分割:
const UserProfile = defineAsyncComponent(() => import('./components/UserProfile.vue') );这样 UserProfile 的代码会被单独打包成一个 chunk,只在它确实要渲染时才去下载。在管理系统里,像编辑弹窗、详情抽屉、图表大屏这类不进入首屏的组件,用这种方式处理收益非常明显。实测下来,首屏体积能减少 30% 以上,前提是你把静态导入确实需要首屏渲染的资源排除在外。
异步组件还有一个很容易被忽略的细节:异步加载期间页面会出现空白或闪烁。如果异步加载长时间没完成,更好的做法是提供 loading 和 error 状态,defineAsyncComponent 也支持这些配置项。我给很多同事说的一句话是:异步组件不是“用完就忘”的性能优化技巧,它配置项里的 loadingComponent、delay、timeout 都是生产环境必须考虑的因素。
5.3 场景联动:动态组件 + 异步加载 + 生命周期
动态组件和异步组件不是彼此独立的,它们经常一起出现在复杂场景里。比如一个表单页面,根据用户选择的表单类型,动态加载对应的表单组件,并传入不同的数据。在这种场景下,组件的生命周期会随切换而触发 onUnmounted / onBeforeUnmount,状态会被重置,所以如果你希望切换到某个页面时临时保留数据,就得靠 keep-alive 或者在父组件里缓存数据,否则每次切回来都是“全新状态”。
还有一点经验是:动态组件上绑定的事件和 props 跟普通组件是完全一样的,所以你可以把动态组件当作一个“普通组件变量”来理解——它只是“谁来渲染”不确定,渲染出来的组件该有的生命周期、通信方式、响应式逻辑统统照旧。想清楚这个,动态组件用起来会很顺手。
6. 常见问题与排查技巧实录
6.1 ref 在模板里到底要不要 .value
不少从 Vue 2 转过来的同学,刚用 ref 时容易陷入混乱:JS 里写 ref.value,模板里又直接写名字,一会儿有点、一会儿没点,特别容易出低级错误。记住一条铁律:模板里不需要 .value,Vue 会自动解包;setup 里的 JS 代码中读写 ref 都要加 .value。
还有一个容易忽略的例外:在模板里,如果你把 ref 对象作为函数的参数传入,需要写 ref.value,因为函数接收的是值,不是响应式引用。比如:
<button @click="handleClick(count)">点击</button>这里 handleClick 收到的是 count 的值,如果你想把 count 这个“引用”传进去让别人修改,就需要传 count 本身,而不是 count.value。这两种用法语义完全不同,写之前想清楚是要“读值”还是“传引用”。
6.2 reactive 整体赋值丢失响应式
这是我在实际项目中遇到最多的问题之一。很多人在设置一个对象类型数据时习惯直接整体赋值:
let state = reactive({ list: [] }); // 请求回来之后直接覆盖 state = { list: result };然后列表不刷新了,页面还是空的。原因我之前提过:reactive 返回的是原始对象的代理,你对整个 state 变量重新赋值,相当于把原来那个代理对象从变量上解绑了,新对象并没有被代理,当然不是响应式的。
解决办法也很简单,要么改成 ref 并用.value,要么只修改对象的某个属性而不是整体覆盖,要么通过Object.assign(state, newData)把新数据合并到原来的响应式对象中。习惯了这套规则之后就基本不会踩坑了。
6.3 生命周期钩子“没执行”的排查方向
很多人问为什么 onMounted 里的代码没执行。排查思路其实很明确:先确认钩子是在 setup 内部直接同步调用注册的,不要把它写在 setTimeout、Promise 回调或者某个方法里,否则 Vue 拿不到正确的组件实例上下文,注册会静默失败。
另外一个常见原因是组件本身处于 keep-alive 里,这种情况下首次进入触发的是 onMounted 和 onActivated,但从别的页面切回来再次进入时只触发 onActivated,不会重复触发 onMounted。很多人会困惑“为什么我的 onMounted 只跑了一次”,其实 activate 没收到的钩子就去 onActivated / onDeactivated 里找,这是 keep-alive 的设计使然。
最后再提醒一个容易忽略的点:异步组件的加载状态会直接影响生命周期触发顺序。如果你看到 onMounted 一直没执行,检查一下父级是不是用了动态组件且组件还在 pending 加载中,等组件真实挂载完成,钩子自然会触发。
6.4 组件销毁时定时器为什么还在跑
这个问题的本质就是没清理资源。很多人的代码长这样:
onMounted(() => { setInterval(() => { loadData(); }, 5000); });然后页面上长期挂着这个组件,定时器就一直执行,即使组件已经销毁(比如 v-if 切走了、路由跳走了),定时器依然在跑,因为循环引用的闭包还在。这时候要在 onBeforeUnmount 里 clearInterval。顺便说一个更隐蔽的坑:如果你在 onUpdated 里注册了事件监听,而这个监听器没有被移除,它也会累积。用“注册即清理”的原则去写,基本能避开 90% 的资源泄漏问题。
我个人实际开发中觉得最顺手的组合是:setup 里先声明数据,再按功能块写逻辑,初始化请求统一放 onMounted,dialog 这类临时组件的资源都集中在 onBeforeUnmount 里处理。这套打法稳定跑了很久,属于可以直接“抄作业”的模板。
声明一下:以上内容是基于 Vue 3 官方文档和我的实战经验整理的常见操作;如果你用的是非规范写法(比如直接操作 DOM 绕开响应式),部分结论可能不适用,但推荐的正常路径下,这套体系完全够用。