不少讲 Vue 3 的资料把 <script setup> 说成一种新的响应式 API,这是误解。它既不是运行时特性,也不是新的组件写法,而是 Vue 3 单文件组件(SFC)中组合式 API 的编译时语法糖,由 @vue/compiler-sfc 在构建阶段把它转换成标准的 setup() 函数。浏览器最终执行的代码与手写 setup() 完全一致,运行时没有额外开销,它的价值在于把样板代码的复杂度转移到了编译阶段。
一、编译期完成的四步转换
1. 宏提取:编译器识别 defineProps、defineEmits 等宏调用,把参数转成组件的 props / emits 选项对象,并在运行时代码中删除这些函数调用本身。
2. 变量收集:通过 AST 静态分析 <template> 中引用了哪些标识符,自动把这些顶层绑定收集进 setup 的返回对象(编译中间态会有一份 __returned__ 绑定集合),从而省掉手写 return。
3. 组件注册:扫描 import 语句,判断导入的是否为组件,若是则自动加入 components 选项。
4. 代码转换:把用户写的顶层逻辑整体包裹进 setup() 函数体,注入自动生成的返回对象;遇到顶层 await 时把 setup 标记为 async,配合 Suspense 使用。
二、源码与编译产物的对照
先看一段使用 <script setup> 的源码:
- <script setup lang="ts">
- import MyButton from './MyButton.vue'
- const props = defineProps<{ msg: string }>()
- const emit = defineEmits<{ change: [val: string] }>()
- const data = await fetchData()
- const handleClick = () => emit('change', 'clicked')
- defineExpose({ handleClick })
- </script>
- <template>
- <MyButton @click="handleClick">{{ msg }}</MyButton>
- </template>
复制代码
经过 @vue/compiler-sfc 处理后,等价产物大致如下(简化示意):
- import MyButton from './MyButton.vue'
- import { resolveComponent as _resolveComponent } from 'vue'
- export default {
- props: { msg: { type: String, required: true } },
- emits: ['change'],
- components: { MyButton },
- async setup(__props, { expose, emit }) {
- const data = await fetchData()
- const handleClick = () => emit('change', 'clicked')
- expose({ handleClick })
- return {
- msg: __props.msg,
- MyButton,
- handleClick,
- data
- }
- }
- }
复制代码
可以看到,宏被翻译成了选项对象,import 被翻译成了 components,顶层变量被翻译成了 return 的键,defineExpose 被翻译成 expose 调用。整段代码在运行时与手写 setup 并无区别。
三、与普通 setup() 的逐项差异
执行阶段:普通 setup() 在组件实例创建后、渲染前执行,处在 beforeCreate 到 created 之间;<script setup> 的代码经编译后同样作为 setup() 的内容,在同一阶段执行。
代码结构:普通 setup 需要写成 export default { setup(props, ctx) { ... return {...} } };<script setup> 不需要 export default,顶层就是逻辑主体。
模板暴露:普通 setup 必须显式 return 模板用到的变量;<script setup> 的顶层声明默认自动暴露给模板,defineProps 等宏除外。
子组件注册:普通 setup 要在 components 选项里手动登记;<script setup> 中 import 即自动注册。
TypeScript 支持:普通 setup 需要额外写类型注解或泛型;<script setup lang="ts"> 原生支持,props 与 emits 的类型推导更准确。
性能表现:普通 setup 的模板依赖 <script> 上下文代理;<script setup> 的模板编译为同作用域渲染函数,减少代理开销,产物也更利于 Tree-shaking。
混合使用:<script setup> 可以与普通 <script> 共存,普通块用来声明 inheritAttrs、模块级单例等,setup 块写实例级逻辑。
四、一个高频误区:执行次数
网上常有“普通 <script> 只执行一次、<script setup> 每个实例都执行”的说法,这是把模块作用域和 setup 作用域混淆了。准确的说法是:两者在组件实例化时同步执行一次,执行时机完全一致。真正只执行一次的是普通 <script> 的顶层代码,因为它位于模块作用域,只在模块加载时运行;而 <script setup> 内的代码属于 setup 作用域,随每个组件实例创建而执行。
五、编译器宏不是普通函数
defineProps、defineEmits、defineExpose、defineOptions、defineSlots 都是编译器宏,不是真实的 JavaScript 函数,运行时并不存在。因此不能对它们解构、重命名或动态传参,只有 TypeScript 泛型写法是允许的。
另一条容易忽略的规则是实例封闭性:<script setup> 中定义的变量默认对模板公开,但父组件通过 ref 拿到子组件实例后并不能直接访问,必须由 defineExpose 显式暴露。这一点与 Options API 不同,属于有意的封装设计。
六、边界场景
<script setup> 只能用在 .vue 单文件组件中,在 .ts / .js 文件里无法使用,只能写标准 setup() 函数。
与普通 <script> 共存时,普通块只适合做三件事:声明全局类型、执行副作用、导出非组件内容;name、inheritAttrs 这类组件选项在 Vue 3.3+ 中需要用 defineOptions 宏声明。
由于编译器能精确追踪依赖变化,<script setup> 对 HMR 更友好;同时编译产物更精确,未使用的导入与变量更容易被剔除,打包体积通常小于手写的 setup。
七、工程实践建议
新项目默认使用 <script setup> + TypeScript + Volar(vue-official 插件)的组合,这是 Vue 官方文档的首选方案,也能获得完整的宏类型提示。只有在 SSR 水合、动态组件选项等少数需要回退到标准 setup 函数的场景,才考虑混写。理解它的编译本质,在遇到宏报错、HMR 失效、父组件拿不到子组件方法时,就能快速定位到是编译阶段还是暴露策略的问题,而不是把它当成运行时 bug 去排查。 |