Vue3 Composition API 是什么?与 Options API 对比及 setup 执行时机
很多刚接触 Vue3 的朋友都会懵,为啥好好的 Options API 不用,非要整个 Composition API 出来?换个角度看,这是 Vue 团队为了应对复杂组件逻辑搞出来的新玩法。Vue 3 最新稳定版是 3.4.x(2023 年 12 月发布的),核心特性从 3.0(2020 年 9 月)就定下来了,Composition API 就是那时候的核心成员。
咱们先回忆下 Options API 的写法:一个组件里,data 放数据,methods 放方法,computed 放计算属性,watch 放监听,mounted 放生命周期... 逻辑一复杂,比如一个“用户列表+筛选+分页”的组件,你会发现:相关的数据(userList、filter、page)在 data 里,处理筛选的方法在 methods 里,监听筛选变化的 watch 在 watch 里,生命周期里还要初始化请求。这些本来是一伙的逻辑,硬生生被拆成了好几块,找代码的时候得上下翻,这就是大家常说的“碎片化”问题。
Composition API 就是来解决这个的。它把同一功能的逻辑聚合在一起,用函数的方式组织代码。比如刚才的用户列表,你可以把“用户数据+筛选方法+分页逻辑”全写在一个函数里,组件里直接调用就行。而且它天然支持 TypeScript,类型推导比 Options API 顺溜多了,不用搞那些花里胡哨的装饰器。
这里必须提一下 setup() 函数,它是 Composition API 的入口。setup 执行时机在 beforeCreate 之前,这时候组件实例还没创建呢,所以你在 setup 里根本访问不到 this。很多新手刚上手会实际案例,比如在 setup 里写 this.dataName,直接报错,就是因为这时候 this 还不存在。
咱们看个最基础的 setup 示例,用的是 Vue 3.4.x 的写法:
<template>
<div>
<p>当前计数:{{ count }}</p>
<button @click="increment">加 1</button>
</div>
</template>
<script>
import { ref } from 'vue'
export default {
setup() {
// 用 ref 定义基本类型响应式数据(后面章节细讲)
const count = ref(0)
// 定义方法
const increment = () => {
count.value++
}
// 必须返回,模板才能访问到
return {
count,
increment
}
},
beforeCreate() {
console.log('beforeCreate 执行了')
}
}
</script>
你运行的时候会发现,setup 里的逻辑先执行,然后才是 beforeCreate。这就是为啥 setup 里拿不到 this——组件实例还没出生呢。
⚡ 效率提示:刚开始学别纠结“要不要完全放弃 Options API”,Vue3 是兼容的。小组件用 Options API 写也没问题,逻辑超过 3 个功能点的组件,再考虑用 Composition API 拆分,不然反而增加学习成本。另外,现在社区里大型项目基本都拥抱 Composition API 了,尤其是配合后面的 语法糖,写起来爽很多。
核心响应式 API 详解:ref、reactive 与 Vue3 响应式原理 (Proxy)
搞懂响应式是 Vue 的核心,Vue3 底层换成了 Proxy 实现,这比 Vue2 的 Object.defineProperty 强太多了。咱们先聊两个最常用的响应式 API:ref 和 reactive,很多新手会搞混啥时候用哪个,实际案例踩多了就明白了。
先说说 ref:它主要用来定义基本类型的响应式数据(比如数字、字符串、布尔值),当然也能定义对象,不过内部会转成 reactive。用 ref 定义的数据,访问和修改的时候要加 .value——这是新手最容易忘的!比如 ref(0) 定义的 count,改的时候得 count.value = 1,模板里不用加,Vue 帮你处理了。
再看 reactive:专门用来定义对象类型的响应式数据(数组、对象、Map 这些)。它直接基于 Proxy 包装对象,你访问属性的时候不用 .value,直接 obj.name 就行。但有个坑:reactive 定义的对象,如果你把它整个替换了(比如 obj = { name: '新值' }),响应式就丢了!所以别随便给 reactive 对象重新赋值。
为啥 Vue3 要用 Proxy?打个比方, Object.defineProperty 有硬伤:它只能监听单个属性的读写,数组的 push、splice 这些方法得额外处理,新增属性还得手动 Vue.set。Proxy 不一样,它能监听整个对象的任何操作,包括属性新增、删除、数组方法调用,甚至遍历都能监听,性能还更好。Vue 3.4.x 里对 Proxy 的响应式处理又做了优化,依赖追踪更精准了。
咱们写个完整例子对比下这俩:
<template>
<div>
<h3>ref 示例(基本类型)</h3>
<p>用户名:{{ username }}</p>
<button @click="changeUsername">改用户名</button>
<h3>reactive 示例(对象类型)</h3>
<p>用户信息:{{ userInfo.name }} - {{ userInfo.age }}</p>
<button @click="changeUserInfo">改用户信息</button>
<button @click="addUserProp">新增用户属性</button>
</div>
</template>
<script>
import { ref, reactive } from 'vue'
export default {
setup() {
// ref 定义基本类型
const username = ref('张三')
const changeUsername = () => {
// 这里必须加 .value!
username.value = '李四'
console.log('修改后的 username:', username.value)
}
// reactive 定义对象类型
const userInfo = reactive({
name: '王五',
age: 20
})
const changeUserInfo = () => {
// 直接修改属性,不用 .value
userInfo.name = '赵六'
userInfo.age = 21
}
const addUserProp = () => {
// reactive 可以直接新增属性,自动响应式
userInfo.gender = '男'
console.log('新增后的 userInfo:', userInfo)
}
return {
username,
changeUsername,
userInfo,
changeUserInfo,
addUserProp
}
}
}
</script>
你运行的时候会发现,新增的 gender 属性直接能响应,不用额外操作,这就是 Proxy 的功劳。如果换 Vue2 的 defineProperty,新增属性根本监听不到,得手动处理。
💡 经验总结:记住一个原则:基本类型用 ref,对象/数组用 reactive。如果非要用 ref 定义对象也行,比如 const user = ref({ name: '张三' }),改的时候得 user.value.name = '李四',多一层 .value 容易乱。另外,别给 reactive 对象解构!比如 const { name } = userInfo,解构出来的 name 就失去响应式了,要解构就用 toRefs(后面章节可以提一嘴,不过这里先记住别随便解构)。
计算属性与侦听器:computed、watch 与 watchEffect 实战区别
写组件的时候,经常要处理“派生数据”和“数据变化后的副作用”,这时候就用到 computed、watch 和 watchEffect 了。这三个很多新手分不清,尤其是 watch 和 watchEffect,面试还总考(比如问你俩的区别,后面咱们也提一嘴)。Vue3 的这三个 API 都是基于响应式依赖追踪的,比 Vue2 的写法更灵活。
先说 computed:它就是计算属性,用来派生新的状态,而且有缓存——只有依赖的响应式数据变了,它才会重新计算,不然一直用缓存的结果。比如你要根据商品价格和数量算总价,用 computed 就对了,别在模板里写复杂逻辑。
再看 watch:它是精准监听,你得明确指定要监听的数据源(比如一个 ref、一个 reactive 属性、甚至一个函数返回的值),而且可以拿到变化前后的值。适合那种“我就要盯着某个数据变,变了就做某件事”的场景,比如监听路由变化、监听筛选条件变化发请求。
然后是 watchEffect:它是自动追踪依赖,你不用指定监听源,函数里用到哪些响应式数据,它就自动监听哪些。执行的时候会先跑一遍函数收集依赖,依赖变了再重新跑。适合那种“依赖多个数据,只要其中一个变就执行”的场景,比如表单验证,只要任何一个表单字段变了就校验。
咱们写个完整例子,把这三个都用上,顺便对比区别:
<template>
<div>
<h3>computed 示例:计算总价</h3>
<p>商品价格:{{ price }}</p>
<p>购买数量:{{ count }}</p>
<p>总价(computed):{{ totalPrice }}</p>
<button @click="price++">涨价</button>
<button @click="count++">加数量</button>
<h3>watch 示例:监听数量变化</h3>
<p>watch 监听到的数量变化:{{ watchCountLog }}</p>
<h3>watchEffect 示例:自动追踪价格和数量</h3>
<p>watchEffect 执行日志:{{ effectLog }}</p>
</div>
</template>
<script>
import { ref, computed, watch, watchEffect } from 'vue'
export default {
setup() {
const price = ref(10)
const count = ref(2)
const watchCountLog = ref('暂无变化')
const effectLog = ref('初始执行')
// computed:计算总价,有缓存
const totalPrice = computed(() => {
console.log('computed 重新计算了')
return price.value * count.value
})
// watch:精准监听 count,拿到新旧值
watch(count, (newVal, oldVal) => {
watchCountLog.value = `数量从 ${oldVal} 变成了 ${newVal}`
console.log('watch 监听 count:', newVal, oldVal)
})
// watchEffect:自动追踪依赖(price 和 count 都用到)
watchEffect(() => {
effectLog.value = `当前价格 ${price.value},数量 ${count.value},总价 ${totalPrice.value}`
console.log('watchEffect 执行了,依赖变化了')
})
return {
price,
count,
totalPrice,
watchCountLog,
effectLog
}
}
}
</script>
你运行的时候点“涨价”按钮,会发现 watch 不会触发(因为没监听 price),但 watchEffect 会触发(因为它用到了 price)。点“加数量”的时候,watch 和 watchEffect 都会触发。而且你连续点两次涨价,computed 会重新计算两次,但如果你没改数量也没改价格,再点别的不会触发 computed,这就是缓存的作用。
📌 要点提醒:别滥用 watchEffect!因为它自动追踪依赖,有时候你不小心在函数里用了某个响应式数据,它就会监听,可能导致不必要的执行。如果明确知道要监听哪个数据,优先用 watch。另外,watch 监听 reactive 对象的属性时,记得写成函数返回的形式,比如 watch(() => userInfo.name, (newVal) => { ... }),不然监听不到。还有,watch 和 watchEffect 里如果有定时器、事件监听这些副作用,记得在回调里清理,比如 watchEffect((onCleanup) => { const timer = setInterval(...); onCleanup(() => clearInterval(timer)) }),避免内存泄漏。
<script setup> 语法糖与生命周期钩子:如何简化组件代码
前面咱们写的 setup 函数,是不是每次都要 return 一堆变量和方法?烦不烦?Vue3 后来出了个 语法糖,编译时的语法,不用写 export default 和 setup 函数,顶层的变量、函数自动暴露给模板,简直是懒人福音。现在 Vue3 4.x 的项目里,基本都用这个语法糖,代码量少一半。
先说说 怎么用:就是把原来的 标签改成 ,里面直接写 Composition API 的代码,不用包在 setup 函数里,也不用 return。比如之前定义 ref,直接 const count = ref(0),模板里直接用 {{ count }},不用 return。导入的组件也不用注册,直接用就行,比如 import Child from './Child.vue',模板里直接写 。
然后是生命周期钩子:Vue3 的生命周期和 Vue2 差不多,但名字变了点,比如 beforeMount 变成 onBeforeMount,mounted 变成 onMounted。重点是:这些钩子必须在 setup 里调用,而且不用写 options 里的那种钩子了。在 里直接导入用就行,比如 import { onMounted } from 'vue',然后 onMounted(() => { ... })。
咱们对比下原来的 setup 函数和 的写法,差距一目了然:
<!-- 原来的 setup 函数写法 -->
<template>
<div>
<p>{{ msg }}</p>
<button @click="changeMsg">改消息</button>
</div>
</template>
<script>
import { ref, onMounted } from 'vue'
export default {
setup() {
const msg = ref('Hello Vue3')
const changeMsg = () => {
msg.value = 'Hello Composition API'
}
onMounted(() => {
console.log('组件挂载了(旧写法)')
})
return {
msg,
changeMsg
}
}
}
</script>
<!-- <script setup> 语法糖写法 -->
<template>
<div>
<p>{{ msg }}</p>
<button @click="changeMsg">改消息</button>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
// 直接定义,自动暴露给模板
const msg = ref('Hello Vue3')
const changeMsg = () => {
msg.value = 'Hello Composition API'
}
// 生命周期钩子直接用
onMounted(() => {
console.log('组件挂载了(语法糖写法)')
})
</script>
你看,语法糖写法少了 export default、setup() 函数、return 语句,代码清爽多了。而且导入的组件不用注册,比如你导入个 import MyButton from './MyButton.vue',模板里直接 就能用,不用在 components 里注册。
再写个带生命周期和多个逻辑的完整例子,比如一个请求用户数据的组件:
<template>
<div>
<h3>用户列表</h3>
<div v-if="loading">加载中...</div>
<ul v-else>
<li v-for="user in userList" :key="user.id">{{ user.name }}</li>
</ul>
<p v-if="error">{{ error }}</p>
</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
// 响应式数据
const userList = ref([])
const loading = ref(true)
const error = ref('')
// 模拟请求
const fetchUsers = async () => {
loading.value = true
try {
// 模拟接口请求
await new Promise(resolve => setTimeout(resolve, 1000))
userList.value = [
{ id: 1, name: '张三' },
{ id: 2, name: '李四' }
]
} catch (err) {
error.value = '请求失败'
} finally {
loading.value = false
}
}
// 生命周期:挂载后请求数据
onMounted(() => {
console.log('组件挂载,开始请求用户数据')
fetchUsers()
})
// 生命周期:卸载时清理(比如取消请求)
onUnmounted(() => {
console.log('组件卸载了')
// 实际项目里这里可以取消未完成的请求
})
</script>
📖 学习建议:现在新项目直接用 就行,别再写旧的 setup 函数了,除非你要兼容特别老的写法。另外,生命周期钩子要按需导入,别全导进来,比如只用 onMounted 就只导这个。还有,有些开发者觉得 隐藏了组件实例细节,调试的时候不好找变量?其实你可以在浏览器里用 Vue Devtools,选中对组件,就能看到所有暴露的变量,和 Options API 的 data 一样清晰,不存在调试难的问题。另外,Vite 5+ 对 的热更新优化特别好,改了代码秒更,开发体验拉满。
5. 组合式函数 (Composables) 实战:像 React Hooks 一样复用逻辑
换个角度看,写 Vue 代码最烦的是什么?就是那种一个组件几千行,data、methods、computed 散落各处,找个变量找半天。Vue 2 时代我们靠 Mixins 来复用逻辑,但这玩意儿有个致命伤:来源不清晰,容易命名冲突。你引入一个 Mixin,不知道它到底给组件注入了啥,两个 Mixin 里都有 handleClick 咋办?
Vue 3 的 Composition API 彻底解决了这个问题。现在的 组合式函数 (Composables),就是 Vue 版的 React Hooks。它就是一个普通的 JavaScript 函数,内部利用 ref、reactive、onMounted 等 API 封装逻辑,然后返回你需要的状态和方法。
核心要点:Composables 的命名约定是以 use 开头,比如 useFetch、useUser。这样一看就知道这是个逻辑复用函数。
咱们来实战一个最常见的场景:数据请求。在真实项目里,你肯定不想在每个组件里都写一遍 loading、error、data 的处理逻辑吧?
下面是一个完整的 useFetch 组合式函数示例,支持加载状态、错误处理和手动触发:
// hooks/useFetch.js
import { ref } from 'vue'
/**
* 通用的数据请求 Hook
* @param {Function} service - 返回 Promise 的请求函数
*/
export function useFetch(service) {
const data = ref(null)
const loading = ref(false)
const error = ref(null)
// 执行请求的方法
const fetchData = async (...args) => {
loading.value = true
error.value = null
try {
const result = await service(...args)
data.value = result
} catch (err) {
error.value = err
console.error('请求挂了:', err)
} finally {
loading.value = false
}
}
// 返回响应式状态和执行函数
return {
data,
loading,
error,
fetchData
}
}
接下来看看怎么在组件里用它。这里我直接用 语法糖,这是 Vue 3.4.x 版本(2023年12月发布的稳定版)里最推荐的写法,比普通的 setup() 函数简洁太多了,不用写 return 暴露变量。
<template>
<div class="user-profile">
<div v-if="loading">加载中...</div>
<div v-else-if="error">出错了: {{ error.message }}</div>
<div v-else-if="user">
<h1>{{ user.name }}</h1>
<p>Email: {{ user.email }}</p>
</div>
<button @click="loadUser">重新加载</button>
</div>
</template>
<script setup>
import { onMounted } from 'vue'
import { useFetch } from './hooks/useFetch'
// 模拟一个 API 请求
const fetchUserApi = () => {
return new Promise((resolve) => {
setTimeout(() => {
resolve({ name: '张三', email: 'zhangsan@example.com' })
}, 1000)
})
}
// 调用组合式函数
const { data: user, loading, error, fetchData: loadUser } = useFetch(fetchUserApi)
// 组件挂载时自动加载
onMounted(() => {
loadUser()
})
</script>
🔧 实战技巧:写 Composables 的时候,一定要注意副作用的清理。比如你在 useFetch 里用了定时器或者监听了事件,记得在 onUnmounted 里销毁。还有,参数设计要灵活,尽量让 service 函数只负责请求,不要在这个 Hook 里写死 URL,这样复用性才高。
6. Vue3 进阶特性:Teleport 与 Pinia 状态管理集成
咱们做前端开发的,肯定都遇到过弹窗(Modal)定位难的问题。平时写组件,CSS 的层级(z-index)和父元素的 overflow: hidden 简直是噩梦。你明明想让弹窗盖满全屏,结果父组件加了个 position: relative 或者 transform,弹窗就被限制住了。
Vue 3 给了我们一个大杀器:Teleport。其实,这玩意儿就是“传送门”。它能把你组件里的模板内容,渲染到 DOM 树的任何地方,比如直接挂到 body 下面。这样就彻底脱离了当前组件的 DOM 层级限制。
配合 Vue 3 官方推荐的状态管理库 Pinia(现在谁还用 Vuex 啊,那是老黄历了),咱们可以做一个非常优雅的全局弹窗管理。
先看看怎么用 Teleport。注意 to 属性,这里指定了渲染的目标位置。
<template>
<button @click="showModal = true">打开弹窗</button>
<!-- 传送门:把这段 div 渲染到 body 标签下 -->
<Teleport to="body">
<div v-if="showModal" class="modal-overlay">
<div class="modal-content">
<h2>我是弹窗</h2>
<p>我直接挂在 body 下面,不怕层级问题了!</p>
<button @click="showModal = false">关闭</button>
</div>
</div>
</Teleport>
</template>
<script setup>
import { ref } from 'vue'
const showModal = ref(false)
</script>
<style scoped>
.modal-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0,0,0,0.5);
display: flex;
justify-content: center;
align-items: center;
}
.modal-content {
background: white;
padding: 20px;
border-radius: 8px;
}
</style>
光有 Teleport 还不够,咱们得把它和 Pinia 结合起来,做一个全局通用的弹窗系统。这样在任何组件里都能控制弹窗的开关。
先定义一个 Pinia Store。这里要提一下,Pinia 和 Composition API 简直是绝配,因为 Store 的定义也是用函数式的。
// stores/modal.js
import { defineStore } from 'pinia'
import { ref } from 'vue'
export const useModalStore = defineStore('modal', () => {
// 状态:控制显示
const isVisible = ref(false)
// 状态:弹窗内容(可以是动态组件名或者标题)
const modalContent = ref(null)
// 动作:打开弹窗
function openModal(content) {
modalContent.value = content
isVisible.value = true
}
// 动作:关闭弹窗
function closeModal() {
isVisible.value = false
modalContent.value = null
}
return { isVisible, modalContent, openModal, closeModal }
})
然后在你的 App.vue 或者根组件里,用 Teleport 渲染这个全局弹窗:
<!-- App.vue -->
<template>
<div id="app">
<h1>我的 Vue 3 应用</h1>
<router-view />
<!-- 全局弹窗出口 -->
<Teleport to="body">
<div v-if="modalStore.isVisible" class="global-modal">
<div class="box">
<h3>{{ modalStore.modalContent?.title || '提示' }}</h3>
<p>{{ modalStore.modalContent?.message }}</p>
<button @click="modalStore.closeModal()">知道了</button>
</div>
</div>
</Teleport>
</div>
</template>
<script setup>
import { useModalStore } from './stores/modal'
const modalStore = useModalStore()
</script>
现在,你在任何子组件里,只要引入 useModalStore,调用 openModal 就能弹窗了。
⚡ 效率提示:使用 Teleport 时,虽然 DOM 结构变了,但逻辑还是属于当前组件的。也就是说,Teleport 里面的事件处理、props 依然是在 Vue 的组件树里流转的,并不是真的变成了原生 DOM 操作。另外,Pinia 在 Vue 3.4+ 的环境下,配合 Vite 5+ 的热更新体验极佳,调试的时候记得装个 Pinia 的 DevTools 插件,排查状态问题一查一个准。
7. 常见问题与面试考点:Vue3 最新趋势 (Vapor Mode) 与最佳实践
面试的时候,面试官现在不光问你会不会用,还要问你为什么这么用,以及未来怎么发展。如果你还在背 Vue 2 的 Options API 那套,那肯定过不了关。咱们聊聊 Vue 3 现在的常见面试问题和未来的大招。
面试必问:ref 和 reactive 到底选哪个?
这是被问烂了的问题,但很多新手还是搞混。打个比方:
ref 适合定义基本类型(string, number, boolean),也适合定义对象。它本质上是通过一个 .value 属性来包装的。
reactive 只能定义对象类型(Object, Array)。它是基于 ES6 的 Proxy 实现的。
面试官可能会追问:Vue 3 的 Proxy 比 Vue 2 的 defineProperty 强在哪?
关键点:Vue 2 的 defineProperty 只能劫持属性的读取和设置,对于新增属性或者数组索引修改,它得用 $set 这种黑魔法。而 Vue 3 的 Proxy 是代理整个对象,你增删改查、甚至通过 length 改数组,它都能拦截到,性能更好,代码也更干净。
看个代码对比,感受一下区别:
import { ref, reactive, watch } from 'vue'
// 场景1:用 ref
const count = ref(0)
count.value++ // 注意这个 .value,在 <script setup> 里必须写
// 场景2:用 reactive
const state = reactive({
user: {
name: '李四',
age: 25
}
})
// 监听变化
watch(
() => state.user.age, // reactive 监听特定属性时,有时候需要写成函数形式
(newVal) => console.log('年龄变了:', newVal)
)
// 经验之谈点:reactive 重新赋值会丢失响应性
// let state = reactive({ count: 0 })
// state = { count: 1 } // 这样写就炸了,Proxy 代理失效
趋势前瞻:Vapor Mode(蒸汽模式)
咱们得聊聊未来。根据 Vue 核心团队的动向,2024-2026 年有个大招叫 Vapor Mode(蒸汽模式)。
这玩意儿是啥呢?换个角度看,就是无虚拟 DOM 模式。现在的 Vue 3 还是基于虚拟 DOM (VDOM) 做 diff 算法的。Vapor Mode 是一种实验性的编译策略,它会在编译阶段分析你的模板,直接生成操作真实 DOM 的命令式代码,跳过虚拟 DOM 这一层。
这有啥好处?
- 性能提升:省去了创建 VNode 和 Diff 计算的开销,特别是在低端设备上,提升非常明显。
- 包体积更小:如果你的组件用了 Vapor Mode,这部分代码可以不包含运行时的 VDOM 逻辑,打包体积能小不少。
虽然现在(Vue 3.4.x)还没正式默认开启,但了解这个趋势能让你在面试时显得非常专业。你可以说:“虽然现在主流还是 VDOM,但我关注到 Vue 正在探索 Vapor Mode,未来在性能敏感的场景会有更多选择。”
Composables 与 Mixin 的对比
面试官让你列举组合式函数优于 Mixin 的三个点,你可以这么答:
- 来源清晰:在组件里用
useXxx,一眼就能看出逻辑来自哪里;Mixin 则是隐式混入,看代码像变魔术。
- 命名冲突可控:Composables 返回的对象你可以随便重命名(比如
const { data: userData } = useFetch());Mixin 如果有两个同名变量,就直接冲突了。
- TypeScript 支持:Composables 是基于函数的,天然支持类型推导;Mixin 在 TS 里类型推断非常痛苦,经常是
any。
💡 经验总结:在大型项目中,不要为了用 Composition API 而用。如果你的组件逻辑很简单,就两三个状态,用 Options API 也没毛病。但一旦逻辑开始复杂,或者需要复用,果断上 Composables。另外,关于 是否过度简化的问题,社区确实有争议,觉得它隐藏了组件实例细节,但作为一名写了5年全栈的工程师,我建议:拥抱它。配合 Vite 5+ 的热更新,它的开发效率提升是实打实的,调试时多看 Vue DevTools 的组件树就行。