Vue3 进入组合式 API 时代之后,setup函数就成了每个组件里绕不开的核心入口。很多刚接触 Vue3 的同学最大的困惑不是setup怎么定义,而是setup的返回值到底能写什么、不能写什么。返回值写错了,模板拿不到数据、事件触发不了、父组件通信失败,这些问题排查起来往往比想象中更隐蔽。
这篇文章就把setup的返回值彻底拆开讲清楚:返回值可以是一个对象、一个函数,甚至可以返回render函数;返回对象时模板怎么取数据,返回函数时又有什么限制;为什么有人说return {}是 Vue3 装饰器,有人说直接写<script setup>更快。看完之后你会发现,setup返回值其实并不难,关键是要把“渲染上下文”“响应式代理”“解包规则”这几个概念对齐。
1. 核心能力速览
| 能力项 | 说明 |
|---|---|
| 适用版本 | Vue 3.x,组合式 API 核心语法 |
| 核心作用 | 将组件内部的数据、方法、计算属性暴露给模板或渲染函数 |
| 返回值类型 | 对象、函数、render函数 |
| 对象返回值 | 模板可访问属性、方法、计算属性,响应式数据自动解包 |
| 函数返回值 | 直接返回渲染函数,需要手动调用h()创建虚拟节点 |
| 常用组合 | ref、reactive、computed、watch、provide、inject、onMounted等 |
| 高频注意点 | 返回ref时模板自动解包,reactive对象不会二次解包,props不可直接赋值 |
| 最佳入门方式 | 先理解setup返回值,再对比<script setup>语法糖 |
| 适用场景 | Vue3 组件封装、中后台系统、组件库开发、面试核心考点 |
这张表不是花架子。setup的返回值直接决定了组件能否正常渲染、模板能否读到数据、事件能否正常触发。文章后面所有内容都围绕这张表展开。
2. 适用场景与使用边界
2.1 适合谁
- 正在从 Vue2 选项式 API 迁移到 Vue3 的开发者
- 想彻底搞懂组合式 API 运行原理的进阶学习者
- 需要封装可复用业务组件的团队
- 参与 Vue3 项目维护,经常看到
setup(props, context)写法的人 - 准备 Vue3 面试,想系统整理“setup 返回值”相关知识点的求职者
2.2 能解决什么问题
setup返回值是在组件的“逻辑定义阶段”和“模板渲染阶段”之间建立连接。它能解决以下典型问题:
- 模板中取不到
ref定义的数据,原因可能是没有正确 return。 - 模板中修改数据不触发视图更新,原因可能是返回了普通对象而不是响应式数据。
- 父组件调用子组件方法失败,原因可能是子组件
setup没有把方法暴露出去,或者使用了<script setup>时必须用defineExpose。 - 希望用
render函数完全控制渲染结果,却不清楚怎么在setup里返回。 - 项目里混用
setup写法与<script setup>写法,导致组织样式不统一。
2.3 不适合什么场景
不要把setup当成“万能逻辑层”。如果出现以下情况,说明思路有问题:
- 把所有业务逻辑全部塞进一个
setup函数,导致组件几千行,应当用组合式函数拆分。 - 在
setup中频繁操作 DOM,项目绝大多数需求都能通过模板声明式渲染完成。 - 强行在
setup返回值里暴露大量内部依赖对象,增加组件耦合度。
2.4 开发与调试边界
setup是组件实例创建阶段最先执行的逻辑之一。要注意:此时组件实例尚未完全创建,不能使用this。这是 Vue2 选项式代码迁移到 Vue3 时最常见的错误。调试时可以用console.log观察参数,但要确保日志输出的是响应式代理对象而不是原始对象,否则容易出现“值变了,视图没变”的误导判断。
3. 环境准备与基础写法
3.1 环境检查
学习setup返回值不需要特别重的环境,一个基于 Vite 的 Vue3 项目足够了。可以先确认本地环境:
node -v npm -v如果还没有项目,可以按通用方式创建:
# 通用示例,实际安装提示以当前脚手架版本为准 npm create vue@latest进入对话式引导后,选择 Vue3 相关选项,不需要勾选额外插件也能跑通。如果你用的是 Vue CLI,同样可以创建一个 Vue3 项目,核心语法不受影响。
3.2 setup 的基本形态
setup是组件选项之一,定义顺序和生命周期无关,但它在组件实例创建时最先执行。
<script> import { ref } from 'vue' export default { setup(props, context) { const count = ref(0) function increment() { count.value++ } return { count, increment } } } </script> <template> <div> <p>{{ count }}</p> <button @click="increment">+1</button> </div> </template>这段代码是setup返回值最经典的形态:返回一个对象,对象里包含一个ref数据和一个方法。模板中count不需要写.value,这是 Vue3 模板自动解包的结果。
3.3 返回值暴露给谁
setup返回值只对“当前组件模板”和“当前组件的渲染函数”可见。它不会自动暴露给父组件。父组件能不能接触子组件里的数据和方法,取决于子组件采用哪种写法:
- 普通
setup返回的对象在开发工具中可以观察到,父组件可以通过模板ref访问到暴露的方法和属性。 <script setup>默认所有绑定对外部关闭,需要显式使用defineExpose才能被父组件访问。
这个差异在后面的章节会专门提到。
4. setup 返回值详解:对象返回的完整使用规则
4.1 返回 ref 数据
ref类型返回值在模板中会自动解包,这是最常见的写法。
<script> import { ref } from 'vue' export default { setup() { const title = ref('Vue3 setup 返回值') const count = ref(1) return { title, count } } } </script> <template> <h1>{{ title }}</h1> <p>当前次数:{{ count }}</p> </template>这里有两个细节值得注意:
- 模板中的
count是解包后的值,但setup内部操作数据仍然要写count.value。 - 如果返回的是一个嵌套对象,例如
const obj = reactive({ detail: ref(1) }),模板中obj.detail不会自动解包内部嵌套的ref。这个规则容易踩坑,建议遇到嵌套结构时先打印出来确认。
4.2 返回 reactive 数据
reactive适合对象类型的数据。返回reactive对象后,模板直接访问属性即可。
<script> import { reactive } from 'vue' export default { setup() { const user = reactive({ name: '张三', age: 18 }) function updateName() { user.name = '李四' } return { user, updateName } } } </script> <template> <p>姓名:{{ user.name }}</p> <p>年龄:{{ user.age }}</p> <button @click="updateName">改名字</button> </template>有人会问:user是响应式代理对象,模板里改user.name是否触发更新?可以的。reactive返回的就是 Proxy 代理后的对象,模板中直接用.name访问没问题,赋值操作也会触发响应式更新。
4.3 返回 computed
computed是setup返回值里经常用到的一类。返回计算属性后,模板中直接使用值,不需要额外解包。
<script> import { ref, computed } from 'vue' export default { setup() { const price = ref(10) const number = ref(3) const total = computed(() => price.value * number.value) return { price, number, total } } } </script> <template> <p>单价:{{ price }}</p> <p>数量:{{ number }}</p> <p>总价:{{ total }}</p> </template>computed返回的是一个ComputedRef对象,和ref类似,模板中自动解包。这里要注意的是,在setup内部访问total.value才能拿到计算后的值,模板里直接写total即可。
4.4 返回普通函数
函数是setup返回值里最常见的“方法暴露”方式。
<script> export default { setup() { function logMessage(msg) { console.log('自定义日志:', msg) } function handleClick() { logMessage('按钮点击') } return { handleClick } } } </script> <template> <button @click="handleClick">点击</button> </template>模板中使用返回的函数,this指向由 Vue 绑定,内部不需要也不应该依赖this。在setup中正常使用闭包、模块函数、组合式函数返回的函数即可。
4.5 返回普通数据对象的误区
很多初学者会以为把普通对象直接 return 后,模板里修改值也能更新视图。这是错的。
<script> export default { setup() { // 错误示例:普通对象不具备响应式能力 const fakeUser = { name: '王五' } function changeName() { // 视图不会自动更新 fakeUser.name = '赵六' } return { fakeUser, changeName } } } </script> <template> <p>{{ fakeUser.name }}</p> <button @click="changeName">改名</button> </template>点击按钮后,fakeUser.name可能在内存中变了,但视图不会更新。如果这个数据要参与页面渲染且需要响应式变化,必须使用ref或reactive。
4.6 返回值不是把所有东西都暴露
有人会把props、context、内部服务实例、请求实例等全部返回出去,模板里又用不到。这不推荐。返回值越多,模板上下文越冗杂,越难维护。更稳妥的做法是:模板需要的才返回,工具函数尽量拆分到utils目录或组合式函数中。
5. setup 返回值与模板渲染绑定
5.1 为什么模板中 ref 不需要 .value
很多人第一次看到“模板中不用写 .value”会惊讶。这是 Vue3 对响应式对象做 Unwrap Ref 的结果。模板编译过程中,Vue 会检测当前渲染上下文中的数据,如果是ref类型,模板渲染时就自动取.value。
这意味着,模板里写{{ count }},实际读取的逻辑等价于count.value。如果在模板里写{{ count.value }},反而可能渲染异常,因为模板已经把count解包成值了,再取.value就成了从基本类型上取属性。
5.2 返回 reactive 对象的属性访问
返回reactive对象后,模板中可以用user.name,也可以先在setup中解构出来再返回:
<script> import { reactive } from 'vue' export default { setup() { const user = reactive({ name: '张三', age: 18 }) return { name: user.name, age: user.age } } } </script>但这种写法会丢失响应性,因为解构出来的是原始值。更推荐返回整个reactive对象,或者在解构时使用toRefs。
<script> import { reactive, toRefs } from 'vue' export default { setup() { const user = reactive({ name: '张三', age: 18 }) return { ...toRefs(user) } } } </script>toRefs会把 reactive 对象里的每个属性转成ref,模板中依然可以直接访问name、age,同时保持响应性。
5.3 setup 返回值中的事件处理
返回的函数可以直接绑定到模板事件上,也可以接收参数和事件对象。
<script> export default { setup() { function handleChange(event) { console.log('change 事件', event.target.value) } function handleParam(msg) { console.log('自定义参数:', msg) } return { handleChange, handleParam } } } </script> <template> <input @change="handleChange" /> <button @click="handleParam('来自模板的参数')">传参</button> </template>5.4 v-model 与 setup 返回值
setup返回值中只要有响应式数据和对应更新方法,就能实现v-model的效果。这里有一个常见误区:直接给返回的ref赋一个新值,然后在模板里通过v-model="count"绑定。在组合式 API 中,v-model对ref是支持的。
<script> import { ref } from 'vue' export default { setup() { const keyword = ref('') return { keyword } } } </script> <template> <input v-model="keyword" placeholder="请输入关键词" /> <p>当前输入:{{ keyword }}</p> </template>注意:v-model="keyword"的操作实际上是在模板编译时转化为对keyword.value的读写。所以这里不需要手动写keyword.value。
6. setup 返回值与 render 函数
6.1 返回函数的基本规则
setup除了返回对象,也可以返回一个函数。这个函数会被当作渲染函数执行。
<script> import { h, ref } from 'vue' export default { setup() { const count = ref(0) function increment() { count.value++ } return () => { return h('div', [ h('p', `count: ${count.value}`), h('button', { onClick: increment }, '+1') ]) } } } </script>这段代码没有使用模板,页面上会渲染出一个计数器和按钮。点击按钮后,count变化,渲染函数重新执行,页面更新。
6.2 返回函数的注意点
- 返回函数后,模板和
render不能同时使用。如果组件中既写了<template>,又让setup返回渲染函数,最终以setup返回的渲染函数为准,但最好避免这种混淆写法。 - 渲染函数直接返回虚拟节点时,需要手动引入
h函数。 - 渲染函数里访问响应式数据要写
.value,因为这里已经是 JavaScript 执行环境,不是模板环境。 - 如果项目主要是模板写法,没必要为了“炫技”改成
render函数。render函数适合对渲染流程有完全控制需求的场景,比如实现自定义组件、动态组件、渲染第三方数据结构等。
6.3 返回对象和返回函数的选择
| 对比项 | 返回对象 | 返回渲染函数 |
|---|---|---|
| 模板支持 | 支持 | 不支持 |
| 可读性 | 适合大多数业务组件 | 适合库、高阶组件 |
| 开发效率 | 高 | 低 |
| 可维护性 | 高 | 中 |
| 使用场景 | 普通业务、表单、列表 | 通用组件、虚拟滚动、动态节点 |
实际开发中 99% 的组件都应该使用返回对象或<script setup>。返回渲染函数更多是组件库作者的日常工作。
7. setup 返回值与父子组件通信
7.1 props:返回值的只读边界
setup的第一个参数是props。props是组件外部传入的响应式数据,但在setup内部不可直接赋值。
<script> export default { props: { title: { type: String, default: '' } }, setup(props) { console.log(props.title) // 错误:props 是只读的 props.title = '新的标题' return {} } } </script>正确做法是:如果想让父组件传入的数据在子组件中“修改后显示”,可以基于 props 创建局部 ref:
<script> import { ref, watch } from 'vue' export default { props: { title: { type: String, default: '' } }, setup(props) { const localTitle = ref(props.title) watch(() => props.title, (val) => { localTitle.value = val }) return { localTitle } } } </script>7.2 emit:通过 context 暴露事件
setup的第二个参数context里包含emit。emit函数不一定要在setup内部直接使用,也可以把 emit 包装成方法后返回给模板。
<script> export default { emits: ['update:name'], setup(props, { emit }) { function changeName() { emit('update:name', '新的名字') } return { changeName } } } </script> <template> <button @click="changeName">更新名字</button> </template>父组件监听:
<template> <Child @update:name="handleNameChange" /> </template> <script> export default { methods: { handleNameChange(val) { console.log('子组件传来的:', val) } } } </script>这里的关键点:setup返回值不包含emit,但返回的方法闭包中已经捕获了context.emit,所以模板中点击按钮可以正常触发父组件的监听。
7.3 attrs 与 slots
context.attrs包含非 props 属性,比如class、id、自定义属性等。context.slots包含插槽内容。这两个东西也可以经过处理后返回给模板,但更常见的做法是在setup中执行逻辑,模板里直接用$attrs、$slots访问。
<script> export default { setup(props, { attrs, slots }) { console.log('attrs:', attrs) console.log('slots:', slots) return {} } } </script>7.4 子组件方法暴露给父组件
普通setup返回对象里的方法,父组件通过模板 ref 可以访问到。例如:
<template> <Child ref="childRef" /> <button @click="callChildMethod">调用子组件方法</button> </template> <script> import { ref } from 'vue' export default { setup() { const childRef = ref(null) function callChildMethod() { childRef.value.sayHello() } return { childRef, callChildMethod } } } </script>子组件:
<template> <div>子组件</div> </template> <script> export default { setup() { function sayHello() { console.log('hello from child') } return { sayHello } } } </script>父组件拿到childRef.value后,就能访问子组件setup返回值中的sayHello方法。
8. script setup 语法糖与 setup 返回值的关系
8.1 本质是语法糖
<script setup>是普通setup的编译期语法糖。编译器会把<script setup>中的顶层绑定自动暴露给模板,省去手写return的步骤。
<script setup> import { ref } from 'vue' const count = ref(0) function increment() { count.value++ } </script> <template> <p>{{ count }}</p> <button @click="increment">+1</button> </template>这段代码等价于普通setup返回{ count, increment }的写法。模板中直接使用顶层变量即可。
8.2 需要显式暴露给父组件时用 defineExpose
<script setup>默认不会把顶层绑定作为组件实例属性暴露给父组件。父组件通过模板 ref 访问不到子组件中的 count 或 increment。需要暴露时:
<script setup> import { ref } from 'vue' const count = ref(0) function increment() { count.value++ } defineExpose({ count, increment }) </script>对应普通setup写法的含义就是:返回值中只有这里定义的对象会被外部访问到。
8.3 什么时候考虑普通 setup
- 需要兼容不支持
<script setup>的旧工具链时。 - 需要动态控制
setup逻辑,比如根据条件返回不同内容时。 - 团队历史项目中大量使用普通
setup,且短期内不打算统一重构时。 - 需要在同一个组件中对比两套写法、理解语法糖底层原理时。
新项目默认优先考虑<script setup>,它更简洁,类型推导也更友好。
9. 常见问题与排查方法
| 问题现象 | 可能原因 | 排查方式 | 解决方案 |
|---|---|---|---|
| 模板中读取不到 setup 返回的数据 | 返回值写漏了,变量名拼写不一致 | 检查 return 对象和模板变量名 | 补全返回值,确保命名一致 |
| 模板中修改数据不更新视图 | 返回的是普通对象,不是 ref/reactive | 在 setup 中打印数据类型 | 改用 ref 或 reactive |
| 模板中渲染出 [object Object] | 直接渲染了响应式代理对象 | 检查模板绑定表达式 | 绑定具体属性,或使用 JSON.stringify 调试 |
| setup 中无法使用 this | 组合式 API 设计如此 | 检查代码是否有 this | 用 ref/reactive 替代实例属性 |
| props 赋值报错 | props 是只读的 | 查看控制台警告 | 创建局部 ref 或 emit 到父组件 |
| 父组件调用子组件方法失败 | 子组件使用了 script setup 且未 defineExpose | 查看子组件代码 | 添加 defineExpose |
| 渲染函数不生效 | setup 返回了函数且同时存在 template | 检查组件结构 | 二选一,使用 h 函数 |
| 嵌套 reactive 对象中 ref 取不到值 | 嵌套 ref 不会自动解包 | 打印对象结构 | 避免嵌套 ref,或手动 .value |
| 普通 setup 返回的 ref 在模板中显示 undefined | 模板中误加了 .value | 检查模板表达式 | 去掉 .value |
| 组件内事件绑定后不触发 | 方法未 return | 检查 return 对象 | 把方法加入返回值 |
9.1 定位思路
遇到setup返回值相关的问题,先按照下面的步骤排查:
- 查看浏览器控制台是否有 Vue 警告。
- 在
setup末尾打console.log(returnValue),确认返回值内容。 - 在模板中添加临时调试文本,比如
{{ count }}、{{ JSON.stringify(obj) }}。 - 用 Vue Devtools 查看组件实例属性,确认
setup返回是否有效。 - 如果涉及父子通信,先确认子组件是否被父组件正确渲染,再确认 emit 事件名是否一致。
- 如果是
<script setup>项目,检查是否有defineExpose缺失。
9.2 运行时依赖问题
Vue3 项目的setup写法依赖组合式 API 相关模块,比如@vue/runtime-core。项目整体通过 Vite 或 Vue CLI 构建时,一般不需要单独安装额外依赖。如果发现ref、reactive等函数导入失败,优先检查:
npm ls vue以及package.json中 Vue 大版本是否为 3.x。
10. 最佳实践与使用建议
10.1 返回结构保持精简
不要把所有内部变量都 return。模板里用不到的内部计算值、临时变量、工具函数不要暴露到渲染上下文中。这样可以减少模板命名冲突,也让 Devtools 的组件树更清爽。
10.2 逻辑按职责拆分
setup越大,返回值越难维护。建议把可复用逻辑提取成组合式函数:
// useCounter.js import { ref } from 'vue' export function useCounter(initial = 0) { const count = ref(initial) function increment() { count.value++ } function decrement() { count.value-- } return { count, increment, decrement } }组件中这样使用:
<script setup> import { useCounter } from './useCounter' const { count, increment } = useCounter(10) </script> <template> <p>{{ count }}</p> <button @click="increment">加 1</button> </template>这是setup返回值思路的自然延伸:先在组合式函数内部组织逻辑,再把需要渲染的数据和方法暴露给组件。
10.3 优先使用 ref 还是 reactive
小技巧:单个基本类型值用ref,一组关联属性用reactive。但如果团队偏好统一风格,也可以全部使用ref并通过storeToRefs、toRefs等工具做结构转换。重点是保持一致,不要让同一个组件里一半 ref 一半 reactive 且没有规则。
10.4 与 TypeScript 结合
普通setup返回对象时,TypeScript 能自动推断返回值类型。使用<script setup>时类型体验更好,可以直接定义:
<script setup lang="ts"> import { ref } from 'vue' interface User { name: string age: number } const user = ref<User>({ name: '', age: 0 }) </script>10.5 版本升级时的兼容性
如果项目从 Vue2 迁移到 Vue3,setup返回值是一个需要重点测试的部分。原有data、methods、computed、watch中的数据回填到setup返回对象时,容易出现响应性丢失。建议迁移时先按最小案例跑通,再逐步扩大业务范围。
11. 总结与下一步
setup的返回值本质上解决了一件事:把组件的 JavaScript 逻辑层和模板渲染层连接起来。返回对象时,模板可以通过变量名访问响应式数据、方法、计算属性;返回函数时,你可以完全接管渲染过程。理解返回值,就理解了 Vue3 组合式 API 的骨架。
建议先跑一遍 3 个最小案例:返回 ref 和 reactive 对象、返回 computed 和方法、在子组件中通过 defineExpose 暴露方法给父组件。这三个案例能把 90% 日常开发中遇到的setup返回值场景覆盖到。最容易踩的坑是漏写返回值、模板中给 ref 加.value、以及<script setup>下没有使用defineExpose。这三个坑在排查时最值得优先检查。
下一步可以继续深入的方向:把setup返回值与provide/inject结合做跨层通信、用组合式函数整理复杂业务、把普通setup写法迁移到<script setup>并验证类型推导效果。学会返回值之后,再看watch、computed、生命周期钩子在setup中的写法,整个 Vue3 组件开发体系就串起来了。