在不少 Vue3 入门教程和面试题里,setup 函数往往被包装成一个“入口”:写一个函数,把变量 return 出去,模板就能用。于是很多人第一阶段学下来,就记住了四条规则:用 ref 包基本类型、用 reactive 包对象、return 时别漏、模板里不加 .value。但真到了项目里,问题开始冒出来:config 对象改了,页面纹丝不动;props 里解构出来的字段,父组件一更新就变成旧值;明明 console.log 里有数据,模板却显示 undefined;从别人代码里复制来的一行<script setup>确实能跑,但自己一上手写 setup 函数就翻车。
这些问题几乎都指向同一个枢纽:setup 的返回值。它不是一个简单的数据出口,它是组件从“声明式配置”走向“组合式工厂函数”的关键枢纽,背后牵涉模板编译、响应式解包和组件生命周期三层机制。理解 setup 返回值,才算真正理解 Vue3 的组合式 API。这篇文章不会只讲“有哪几种返回值”,我会从机制、实操、排查、工程化四个角度,把返回值这件事拆开讲透。
1. 先认清 setup 在 Vue3 里的真实位置
1.1 setup 不是语法糖,而是组件的“构造函数”
在 Vue2 的 Options API 里,data、computed、methods、watch 是四个分开的选项,它们只是在同一个对象里并列存放。Vue3 的 setup 想做的是:把它们统一成一个正在执行的函数。setup 内部可以通过 ref/reactive 定义数据,通过 computed 定义派生状态,通过 watch/watchEffect 定义副作用,通过普通函数定义方法,然后统一 return 出来。
这里的关键变化是:以前是“Vue 帮你声明数据,你只给初始值”,现在是“你自己在函数里构造状态,然后手动暴露给模板”。这更像是把组件当成一个小型工厂函数,setup 是这个工厂的装配间,return 是出厂门口。组件最终展示什么、能响应什么,完全取决于你从装配间里递出来什么。
1.2 为什么 Vue3 要改成这样的设计
Options API 看起来结构清晰,为什么还要 setup?核心原因是逻辑复用和类型推导。在 Options API 里,一段业务逻辑可能被拆散到 data、computed、methods、watch 四个隔间里,想抽出来做逻辑复用非常麻烦,常见做法是 mixin,但 mixin 有命名冲突、来源不透明、隐式依赖等问题。setup 把同一段业务的状态、计算属性和方法都放在一起,天然适合抽取成 composable 函数。
另一个容易被忽略的点是类型推导。setup 返回值的数据就是普通变量,TypeScript 可以做完整的类型推导,不需要像 Vue2 那样依赖装饰器或额外的声明。你在 setup 里定义了什么类型,return 出去之后模板和 IDE 都能感知到。这种可推导性,在后端接口对接、大型项目重构时非常重要。
1.3 setup 的执行时机比 beforeCreate 更早,这很关键
setup 只会在组件创建前执行一次。它发生在 props 被解析之后、组件实例被正式创建之前。这带来两个直接影响:
- setup 内部拿不到 this,this 是 undefined;
- setup 里创建的响应式状态,从组件出生那一刻起就是响应式的,而不是像 data 那样由 Vue 在初始化阶段“扫描”出来的。
换句话说,setup 返回值不是“把数据交给 Vue 去扫描”,而是“你已经把数据造好了,Vue 只需要把它挂到渲染上下文里”。这决定了后续所有响应式规则。很多人不理解为什么 setup 里不能访问 this,其实就是因为组件实例还没创建完,this 还不存在。这不是 Vue 故意限制你,而是时序上确实没有 this 可用。
2. 返回对象、返回渲染函数,以及编译期的第三条路径
2.1 返回对象:最常用,也是大多数人理解的“返回值”
最常见的形式是返回一个对象:
import { ref, reactive } from 'vue' export default { setup() { const count = ref(0) const user = reactive({ name: 'Alice', age: 18 }) function increment() { count.value++ } return { count, user, increment } } }模板里可以直接使用count、user.name,事件里直接调用@click="increment"。
这表面看只是“把变量放进了 return 对象”,但背后 Vue 做的事是:把 return 结果合并到组件实例的渲染上下文里。模板编译器在解析count这个标识符时,会先到 setup 返回的 setupState 里查找。所以 return 的 key 必须和模板里访问的名字完全一致。这里有一个很典型的错误:在 return 对象里写了别名,模板里却用原名访问,结果拿不到值。比如const c = ref(0); return { count: c },模板里就该用count。如果你在模板里写c,就会报错。
2.2 返回渲染函数:完全掌控渲染逻辑
setup 也可以直接返回一个函数,这个函数就是渲染函数:
import { h } from 'vue' export default { props: ['level'], setup(props) { return () => h(`h${props.level}`, 'hello') } }这时候返回的不再是“数据上下文”,而是组件的渲染输出。也就是说,这个组件的渲染完全由这个函数决定,模板不会再被使用。这种写法常见于高阶组件、函数式封装、需要动态生成标签的场景。普通业务页面基本用不到,但理解它有助于理解 setup 返回值的本质:它既可以返回“数据”,也可以返回“渲染”。
从设计角度看,这正是组合式 API 的灵活之处——返回值不一定要绑定到模板上下文,它完全可以接管整个视图层。不过这也意味着,一旦你选择了返回渲染函数,就得放弃模板提供的声明式便利,需要对 h 函数非常熟悉才建议这么写。
2.3<script setup>其实是编译期的“自动返回值”
现在工程上更常见的是<script setup>写法:
<script setup> import { ref } from 'vue' const count = ref(0) const user = reactive({ name: 'Alice', age: 18 }) </script> <template> <p>{{ count }}</p> <p>{{ user.name }}</p> </template>你不需要写 return,但并不意味着“没有返回值”。编译器会把<script setup>里的顶层绑定自动收集起来,生成一个 setup 函数,并把所有顶层绑定作为返回值。这就是为什么你在<script setup>里 import 的组件、定义的变量、自定义指令都可以直接在模板中使用——它们在编译阶段就被放进了返回值。
理解这一层很关键。很多人从<script setup>开始学,就以为 Vue3 没有返回值这回事。实际上<script setup>只是帮你省掉了手动 return 这一步。当你需要调试某些边界问题,或者做一个工具库需要明确暴露状态时,仍然需要回到普通 setup 函数,手动控制返回值。
有一个注意点:在<script setup>中,默认情况下父组件通过 ref 拿到子组件实例时,访问不到内部状态,需要用 defineExpose 显式暴露。这其实也是“返回值”思路的延续——组合式 API 主张“显式暴露,隐式封闭”。你 return 什么,外界才能看到什么;你不说的,别人拿不到。这种封装比 Vue2 时代默认把所有方法都暴露出来要安全得多。
3. 返回值里的响应式规则,是新手最容易踩坑的一层
3.1 模板中的 ref 自动解包,但 setup 内部不会
const count = ref(0) return { count }模板里count直接是值,可以写count + 1,可以写@click="count++"。这里 Vue 对 setup 返回的对象做了一层 proxyRefs 包装:读取时自动返回.value,赋值时自动写回.value。所以模板里对 ref 的访问是双向的,读和写都不需要.value。
但在 setup 函数内部,count 是原始的 ref 对象,你必须写成count.value。这是很多新手最早混淆的点:把模板里的规则带到了 setup 内部,直接写count = 1,结果发现状态改了但页面不更新,或者干脆赋值失败。
关键记忆:模板里的“不加 .value”是编译器和 proxyRefs 帮你的,setup 内部没有这个待遇。
3.2 ref 和 reactive 混用时的规则,需要分场景记
在一个 reactive 对象里,如果某个属性是 ref,访问时会被自动解包:
import { ref, reactive } from 'vue' const count = ref(0) const state = reactive({ count }) console.log(state.count) // 0,自动解包但如果是数组或者 Map、Set 等容器,ref 不会自动解包:
const list = reactive([ref(1), ref(2)]) console.log(list[0].value) // 必须写 .value为什么这样设计?因为 reactive 对象的属性访问被 Proxy 拦截了,Vue 可以在 get 阶段顺手解包;而数组和集合的遍历语义更接近原生行为,Vue 选择不在容器元素上自动解包,以避免和原生数组行为产生歧义。更稳妥的记忆方式是:对象属性的 ref 自动解包,容器元素中的 ref 不自动解包。
还有一个高频坑:当你把 ref 赋值给 reactive 对象的属性后,如果后续又把一个新的 ref 赋给同一个属性,旧 ref 和新属性的关联会被切断。这就好比你把一本书放回书架的固定位置,又放了一本新书上去,旧书和新位置之间就不再有任何关系了。如果你还存在旧 ref 的引用,修改它的值不会影响 reactive 对象上的属性。
3.3 不是所有 return 出去的东西都适合直接 return
有几个高频问题值得单独提一下。
props 解构后返回,响应式丢失:
setup(props) { const { title } = props return { title } }这样返回的 title 是普通值,props 变化时模板不会更新。正确处理方式是用toRefs(props),把每个 props 字段包装成独立的 ref:
import { toRefs } from 'vue' setup(props) { const { title } = toRefs(props) return { title } }这样返回的 title 就是一个 ref,模板中直接使用title,响应式不会丢。
返回非响应式的局部变量,页面不会更新:
setup() { let data = getData() // 普通变量 return { data } }这里的 data 是静态值,后续任何修改都不会触发重新渲染。需要用 ref 或 reactive 包一层。
异步赋值后没有通知响应式系统:
setup() { const data = ref([]) fetch('/api/list').then(res => { data.value = res.data }) return { data } }这个写法是对的,因为 data 是 ref,data.value = res.data会触发更新。这里真正要警惕的是普通变量赋值,很多人写着写着就把 ref 解构出来变成普通变量,比如:
const data = ref([]) let list = data.value // list 是普通数组,不是响应式之后修改 list 里的内容,页面不会有任何反应。
这里整理一个“解包规则”小表,方便对照记忆:
| 场景 | 是否需要 .value | 示例 |
|---|---|---|
| setup 内部访问顶层 ref | 需要 | count.value++ |
| 模板中访问 setup 返回的 ref | 不需要 | {{ count }} |
| reactive 对象属性里的 ref | 不需要 | state.count |
| reactive 数组里的 ref | 需要 | list[0].value |
| toRefs 生成的 ref | 需要(setup 内) | title.value |
3.4 setup 返回值与组件更新流程的关系
当响应式数据变化时,Vue 会重新执行 render 函数,但不会重新执行 setup 函数。这是很多人没有意识到的:setup 只执行一次,返回值里包含的引用被保存下来,后续渲染读取的是同一份状态引用。
这意味着三件事:
- setup 里的初始化逻辑不会因为数据变化而重复执行;
- 如果你在 setup 里做了昂贵的计算,它只执行一次,后续不会自动重算;
- 如果有派生的状态,应该用 computed 而不是在 setup 里写函数调用。
很多新手会在 setup 里写类似这样的代码:
const showList = computed(() => list.value.length > 0) return { list, showList }这是对的,因为 computed 本身是响应式依赖,它只会在 list 变化时重新求值。如果你写成const showList = list.value.length > 0,那 showList 只是一次性求值的布尔值,list 再怎么变,showList 都不会更新。这个区别在返回值的语境下尤其重要:你 return 的是一个值,还是一个响应式的派生状态。
4. 模板取不到值、数据不更新,这类问题怎么排查
4.1 先从模板和 return 的对应关系开始查
当模板里出现 undefined,很多人的第一反应是去检查模板表达式,但实际上应该先回头确认三件事:
- return 的 key 是否和模板变量名完全一致;
- 是否在
<script setup>中漏写了 defineExpose(如果是从父组件访问子组件变量); - 模板表达式里的每一个变量,是否都是 setup 返回值或组件上下文中已有的属性。
一个看起来简单但很容易犯的错:在模板里写了personInfo.name,但 return 的对象里 key 是person,虽然模板里访问的personInfo也不存在,但你在 ide 里很容易被自动补全误导。这时候直接把 return 对象的名字和模板访问的名字逐一对照,通常能快速定位问题。
4.2 再查 ref/reactive 的选择和赋值方式
如果数据在页面上显示了,但改了以后怎么都不更新,大概率是响应式丢失。排查顺序可以这样来:
- 检查是不是把 ref 的
.value赋值给了另一个普通变量,然后修改那个普通变量; - 检查是不是在 reactive 对象里通过解构取出了属性,然后修改解构出来的普通变量;
- 检查是不是在 setup 内部对返回的 ref 直接赋值,而不是通过
.value; - 检查是不是组件实例被重新挂载,setup 重新执行到了初始状态。
这里有个常见误区:有人说“reactive 对象新增属性不是响应式的”。实际上 Vue3 的 reactive 基于 Proxy,新增属性也是响应式的,所以如果你用state.newKey = value的方式新增属性,页面会更新。但如果你用const newObj = state.newKey取出一个对象再修改它的内部字段,响应式链路仍然存在,前提是这个对象本身在 reactive 的代理范围内。真正的响应式丢失,几乎都发生在“把值解构/赋值到普通变量”这一步。
4.3 再看异步赋值和定时器场景
如果数据来自接口,最常见的翻车写法是:
setup() { let data = [] fetch('/api/list').then(res => { data = res.data }) return { data } }这里 data 没有用 ref/reactive 包,fetch 回来后的赋值是在普通变量上,不会触发渲染。正确写法:
const data = ref([]) fetch('/api/list').then(res => { data.value = res.data }) return { data }除了响应式问题,异步场景还有一个容易忽视的细节:组件卸载后,异步回调仍然执行了data.value = res.data。这不会直接报错,但在某些场景下会造成状态泄漏或警告。如果接口慢、组件切换快,就会出现“数据回来了,但组件已经不在了”的情况。工程上可以在组件卸载时用标志位或者借助生命周期钩子处理,但至少要知道这是异步场景里的正常现象。
4.4 看 setup 返回值是否被重新赋值覆盖
还有一种不太常见但一旦遇到非常难查的问题:setup 返回值里的 ref 被外部覆盖了。
比如父组件拿到子组件实例后,手动给实例挂了一个同名的非响应式属性:
childRef.value.someKey = 'text'如果子组件 setup 返回的对象里恰好也有someKey,这种动态挂载可能覆盖原有 ref 的引用关系,导致模板里读到的值变成普通字符串,响应式就断了。这种问题比较隐蔽,但遇到模板数据不更新、控制台又没有报错时,值得往这个方向看一眼。
总结一下排查框架:
| 现象 | 第一步检查 | 第二步检查 | 第三步检查 |
|---|---|---|---|
| 模板里显示 undefined | return key 名称和模板是否一致 | 拼写和大小写 | 是否在<script setup>中误用未暴露的变量 |
| 数据变化但页面不更新 | 是否用 ref/reactive 包了 | 赋值方式是否正确 | 是否有同名普通变量覆盖 |
| 接口数据不回显 | 是否为 ref 容器 | 异步赋值是否通过.value | 组件是否已卸载 |
| props 更新后不重渲染 | 是否解构了 props | 是否用 toRefs 处理 | 是否误用 computed 缓存了旧值 |
5. 从 setup 返回值到<script setup>:工程化写法的演变
5.1<script setup>不是“没有返回值”,而是“编译期自动生成返回值”
很多新手接触 Vue3 的第一个写法是<script setup>,因为它更简洁、更像写普通 JS。但有人会问:“如果 setup 有返回值,那<script setup>的返回值在哪?”
答案是:在编译阶段。
<script setup>里的所有顶层绑定——包括变量、函数、import 的组件和指令——都会被编译器收集起来,生成一个 setup 函数并把它们作为返回值暴露给模板。所以你在模板里才能直接使用这些绑定。这不是魔法,而是一种编译期的“自动 return”。
理解这一点有一个实际好处:当你在<script setup>里遇到“模板里访问不到”的诡异问题时,需要意识到编译器收集的是什么。例如,通过defineProps接收的 props,在模板中可以直接用字段名,是因为编译器把 props 也合并进了模板上下文;但如果父组件想通过 ref 访问子组件内部变量,就必须defineExpose显式暴露。这些规则本质上都是围绕“返回值暴露什么”展开的。
5.2 什么时候适合用普通 setup,什么时候用<script setup>
工程上我通常会按下面的标准判断:
- 大多数业务组件、页面组件:直接用
<script setup>,简洁、类型友好,模板自动获得所有顶层绑定。 - 需要精细控制渲染的组件:比如想用 setup 返回渲染函数,普通 setup 的写法更直观。虽然
<script setup>也能配合 h 函数使用,但普通写法更接近“setup 返回什么,组件就渲染什么”的心智模型。 - 需要动态控制暴露给父组件的内容:普通 setup 结合
expose更灵活;<script setup>则用defineExpose,两者都能做到,但普通 setup 可以在函数里根据条件决定是否暴露。 - 编写组件库或高阶组件:建议至少保留对普通 setup 的熟悉度,因为需要处理更多边界情况。
从学习路径来看,我建议先理解普通 setup 的返回值机制,再切换到<script setup>。如果你一上来就只写<script setup>,很容易把返回值这个核心概念当成本不存在的小细节。但实际项目里,很多组合式函数、高阶组件、动态渲染组件仍然需要你理解 setup 返回值的底层逻辑。
5.3 返回值思想:显式暴露、隐式封闭
组合式 API 有一个贯穿始终的设计哲学:显式暴露,隐式封闭。
- setup 里 return 什么,模板才能用什么;
- defineExpose 什么,父组件才能访问什么;
- composable 函数里 return 什么,调用方才能用什么。
这不是限制,而是安全感。它让组件之间的边界变得清晰:哪些内部状态是私有的,哪些是公开给外界的,一目了然。相比之下,Vue2 的 Options API 里,data 里的所有属性默认都会暴露在组件实例上,父组件可以随意访问子组件的内部状态,这在大型项目里很容易变成隐性耦合。setup 返回值的设计,本质上是把“可见性”这件事重新掌握在开发者手里。
6. 一个值得长期使用的判断框架
6.1 每次调试 setup 返回值,都按“来源→容器→暴露→消费”四段式检查
如果你把这几段经验沉淀下来,排除 setup 返回值问题可以有一个固定套路:
- 来源:这个数据到底来自本地初始化、props、路由、接口还是 store?来源不同,响应式处理方式不同。
- 容器:它是不是被 ref/reactive 包了?有没有在某个环节被解构成了普通变量?
- 暴露:setup 有没有把它 return 出去?
<script setup>有没有通过 defineExpose 暴露?key 名字是否和模板访问一致? - 消费:模板里取的是值还是引用?事件回调里赋值时有没有走
.value?父组件访问子组件属性时是否在暴露范围内?
这四个环节只要有一个断了,页面表现就会异常。大多数问题都可以在这个框架里定位到具体环节。
6.2 适用边界:这个框架适合谁,不适合谁
setup 返回值相关的这套机制,适合以下场景:
- 项目里已经在用 Composition API 组织逻辑;
- 需要把组件逻辑抽取成 composable 复用;
- 面试时需要讲清楚 Vue3 响应式核心;
- 需要精确控制父组件能访问子组件的哪些内容。
需要谨慎的场景:
- 如果团队刚迁移到 Vue3,成员全是 Vue2 经验,直接铺开
ref/reactive/toRefs这些概念,理解门槛会比较高。这时候建议先从<script setup>用起,遇到问题再回头对照 setup 返回值机制。 - 如果只是写一些非常简单的展示组件,setup 返回值的复杂度确实用不上,直接用
data选项或<script setup>简写都行。 - 在大型项目里大量使用“返回渲染函数”这种方式,调试成本会显著上升,不建议作为业务页面的默认选择。
6.3 最后一点建议
setup 返回值是理解 Vue3 的一把钥匙。不要只背“不写 .value”“return 啥用啥”这种口诀,而是去理解它背后那套“状态在组件工厂里被构造、被显式暴露、被模板消费”的机制。
口诀只能帮你应付小项目,机制才能帮你应付所有项目。下次在模板里遇到 undefined,或者在调试台上看到“数据变了,页面没动”的时候,可以试试回到 setup 的 return 那一行,沿着“来源 → 容器 → 暴露 → 消费”这条链走一遍。大多数问题,都会在这一行前后现出原形。