
在不少 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 内部拿不到 thisthis 是 undefinedsetup 里创建的响应式状态从组件出生那一刻起就是响应式的而不是像 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事件里直接调用clickincrement。这表面看只是“把变量放进了 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.3script 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可以写clickcount。这里 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 字段包装成独立的 refimport { 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 是 refdata.value res.data会触发更新。这里真正要警惕的是普通变量赋值很多人写着写着就把 ref 解构出来变成普通变量比如const data ref([]) let list data.value // list 是普通数组不是响应式之后修改 list 里的内容页面不会有任何反应。这里整理一个“解包规则”小表方便对照记忆场景是否需要 .value示例setup 内部访问顶层 ref需要count.value模板中访问 setup 返回的 ref不需要{{ count }}reactive 对象属性里的 ref不需要state.countreactive 数组里的 ref需要list[0].valuetoRefs 生成的 ref需要setup 内title.value3.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 的引用关系导致模板里读到的值变成普通字符串响应式就断了。这种问题比较隐蔽但遇到模板数据不更新、控制台又没有报错时值得往这个方向看一眼。总结一下排查框架现象第一步检查第二步检查第三步检查模板里显示 undefinedreturn key 名称和模板是否一致拼写和大小写是否在script setup中误用未暴露的变量数据变化但页面不更新是否用 ref/reactive 包了赋值方式是否正确是否有同名普通变量覆盖接口数据不回显是否为 ref 容器异步赋值是否通过.value组件是否已卸载props 更新后不重渲染是否解构了 props是否用 toRefs 处理是否误用 computed 缓存了旧值5. 从 setup 返回值到script setup工程化写法的演变5.1script 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 那一行沿着“来源 → 容器 → 暴露 → 消费”这条链走一遍。大多数问题都会在这一行前后现出原形。