Vue3 script setup 的四层追问答到第三层才算过关

123 阅读4分钟

<script setup> 是编译期改写,不是运行时黑魔法。四层追问:绑定为什么能内联、编译链路长什么样、运行时差在哪、defineExpose 的暴露边界。多数人卡在第三层。

面试室里坐着个两年经验的前端,简历写着"熟练掌握 Vue3 组合式 API"。我问 <script setup> 了解多少,他张口就来:写代码更简洁,不用 return,开发很爽。

我接着问底层编译原理、对比传统 setup 的优势、组件属性暴露。他当场卡住。

这道题是 Vue3 面试的第一道分水岭。下面四层追问,能答到第三层才算合格。

不 return 模板也能拿到变量,但别停在这层

传统 setup 函数里,变量和方法必须 return 出去,模板才能访问。

export default {
  setup() {
    const count = ref(0)
    function add() { count.value++ }
    return { count, add }   // 不 return,模板就看不到
  }
}

<script setup> 不用手动 return,内部变量直接给模板用:

<script setup>
import { ref } from 'vue'
const count = ref(0)
function add() { count.value++ }
</script>

<template>
  <button @click="add">{{ count }}</button>
</template>

为什么不 return 模板也能拿到变量? 编译期把绑定直接内联进了 render 函数的作用域,压根不走运行时那套"返回对象"。

还有个常被问的点:<script setup> 能不能和普通 <script> 共存?能。普通 <script> 先执行,用来声明 name、inheritAttrs 这类 options;<script setup> 后执行。

它是编译器语法糖,不是运行时黑魔法

这一点三年开发也常说不清:编译器会把 <script setup> 里的代码编译成组件 setup 函数的内容,仅此而已。

编译阶段干三件事:

  • 顶层变量、函数自动暴露给模板;
  • import 进来的组件不需要在 components 里注册,编译阶段自动注册;
  • 模板直接引用闭包里的绑定,不经过 this。

编译链路大致是这样:

flowchart LR
 A[SFC 单文件源码] --> B[parse 解析]
 B --> C[compileScript 处理 setup]
 C --> D[收集顶层绑定]
 D --> E[生成 setup 函数体]
 E --> F[模板编译为 render]
 F --> G[绑定内联进 render 闭包]

坑在这里:顶层 await 会让组件变成异步组件。 副作用是父组件必须用 <Suspense> 兜底,渲染时机被推迟,SSR 下问题更明显。

普通 script 和 script setup 的执行顺序,以及顶层 await 带来的时序变化:

flowchart TD
 A[普通 script 先执行] --> B[script setup 后执行]
 B --> C{是否有顶层 await}
 C -->|否| D[同步 setup 直接渲染]
 C -->|是| E[组件变为异步组件]
 E --> F[必须由 Suspense 接管]

传统 setup 返回的对象,和 <script setup> 编译产出,在实例上的形态完全不同——前者挂到 $$setup 走代理,后者是纯闭包。

少写一个 return,差在闭包和代理上

说两者"只是少写 return"是最大的误区。差在哪:

维度传统 setupscript setup
返回对象每次实例化都构造并代理编译期确定,闭包直取
this 指向setup 内为 undefined,Options API 指向组件代理内部根本没有 this
作用域返回对象挂在实例上,外部可探测顶层绑定封闭在闭包,外部默认拿不到
运行时开销有返回对象与代理开销编译阶段优化,开销更低

闭包陷阱迁移到 <script setup> 一般不会复现,因为两者都是同一套闭包模型。真正容易翻车的是把 ref.value 解构出来当普通变量用。

ref、reactive 在两种写法里响应式行为完全一致——同一套响应式系统,差别只在暴露方式,不在响应式本身。

defineExpose:线上最容易栽的地方

父组件通过模板 ref 拿子组件实例。<script setup> 默认对外全封闭,内部属性一个都取不到,必须用 defineExpose 显式暴露。

<script setup>
import { ref } from 'vue'
const count = ref(0)
function reset() { count.value = 0 }

defineExpose({ count, reset })   // 推荐:暴露 ref 本身
</script>

父组件访问:

const childRef = ref(null)

function handle() {
  console.log(childRef.value.count)  // 自动解包,保持响应
  childRef.value.reset()             // 能调到子组件方法
}

暴露的数据流这样走:

flowchart TD
 A[子组件 script setup] --> B[默认对外封闭]
 A --> C[defineExpose 收集]
 C --> D[挂到实例 exposeProxy]
 D --> E[父组件 ref.value 访问]
 E --> F[暴露 ref 自动解包 保持响应]
 E --> G[暴露 value 快照 丢响应]

defineExpose 能暴露 ref、reactive、函数、普通值、组件实例。几个高频坑:

  • 暴露 count 和暴露 count.value 不是一回事。 前者父组件访问会自动解包且保持响应;后者是快照,丢了响应式。
  • 忘记写 defineExpose,父组件 childRef.value 拿到的实例上方法全是 undefined。先查子组件有没有显式暴露。
  • 混合写法下,普通 <script> 先执行,defineExpose 在 <script setup> 里生效。
  • 不要把响应式数据解构后再暴露,会丢响应;也不要在 defineExpose 里放持有大对象的响应式闭包,容易内存泄漏。

面试官考的不是你会不会写这个语法糖,而是你知不知道为什么它能这么写。同样一个 <script setup>,有人只会复制粘贴写业务,有人能聊编译、聊实例暴露、聊线上踩坑。

写在最后

回到开头:你觉得自己真的吃透 <script setup> 了吗?

给个具体的排查建议。下次遇到父组件 ref 拿不到子组件方法,先确认子组件有没有 defineExpose,再确认暴露的到底是 ref 还是 ref.value——大概率就是这两个里栽的。

你们在项目里用 <script setup> 踩过最离谱的坑是什么?是顶层 await 撞上 Suspense,还是解构丢失响应?评论区聊聊。

有用的话点个赞。