<script setup> 编译转换:宏展开、顶层绑定暴露与限制
<script setup> 不是一个运行时特性——浏览器里没有任何代码知道它的存在。它是编译器在构建时执行的一次完整的 AST 变换:你写的每一行 <script setup> 代码,最终都会被重写成一个普通的 setup() 函数。理解这个变换,你就理解了为什么 defineProps 不需要 import,为什么默认情况下父组件拿不到你的内部方法,以及为什么顶层 await 是合法的。
Level 1 · 你需要知道的(1-3年经验)
它的本质:编译时转换
<script setup> 是 Vue 3 编译器在处理 .vue 文件时的一个特殊模式。编译器会将 <script setup> 的内容提取、分析、重组,最终生成一个标准的组件对象,其中包含 setup() 函数。
原始 <script setup>:
<script setup>
import { ref } from 'vue'
const count = ref(0)
function increment() { count.value++ }
</script>
<template>
<button @click="increment">{{ count }}</button>
</template>
编译器输出(简化):
import { ref, defineComponent } from 'vue'
export default defineComponent({
setup() {
const count = ref(0)
function increment() { count.value++ }
// 顶层变量和函数自动暴露给模板
return { count, increment }
}
})
关键行为:<script setup> 顶层的所有变量、函数、import 的内容,都自动暴露给模板使用——不需要手动 return。
编译宏:只存在于编译期的"函数"
defineProps、defineEmits、defineExpose、defineOptions、defineSlots、withDefaults 这些函数,在运行时并不存在。它们是编译器宏(Compiler Macros)——编译器识别这些特定的函数调用,提取信息后将整个调用替换掉。
这就是为什么你不需要 import 它们:
<script setup>
// ✅ 不需要 import defineProps
const props = defineProps({
title: String,
count: { type: Number, default: 0 }
})
// ❌ 尝试 import 会报错(在某些版本)或被忽略
// import { defineProps } from 'vue' // 不需要
</script>
defineProps 的两种声明方式
运行时声明:带校验,支持 required/default/validator
const props = defineProps({
title: { type: String, required: true },
count: { type: Number, default: 0 },
items: { type: Array as PropType<string[]>, default: () => [] },
status: {
type: String,
validator: (v: string) => ['active', 'inactive'].includes(v)
}
})
类型声明(TypeScript):类型安全,但无运行时校验
interface Props {
title: string
count?: number
items?: string[]
}
const props = defineProps<Props>()
// 等价于上面,但不会生成运行时 validator 代码
两者不能混用:
// ❌ 错误:不能同时使用类型参数和运行时声明
const props = defineProps<{ title: string }>({ count: Number })
withDefaults:为类型声明添加默认值
interface Props {
title: string
count?: number
tags?: string[]
config?: { theme: 'light' | 'dark' }
}
const props = withDefaults(defineProps<Props>(), {
count: 0,
tags: () => [], // 引用类型必须用工厂函数
config: () => ({ theme: 'light' })
})
// props.count 类型变为 number(不再是 number | undefined)
defineEmits 的类型声明
// 对象形式(推荐):支持参数名文档化
const emit = defineEmits<{
change: [newVal: string, oldVal: string]
submit: [formData: FormData]
close: [] // 无参数
}>()
// 使用
emit('change', 'new', 'old')
defineExpose:显式暴露给父组件
<script setup> 默认不暴露任何内部状态——父组件通过 ref 拿到的是一个空对象:
<!-- ChildComponent.vue -->
<script setup>
import { ref } from 'vue'
const count = ref(0)
const internalMethod = () => console.log('internal')
// 只有 expose 的内容对父组件可见
defineExpose({
count, // 暴露 ref
reset: () => { count.value = 0 } // 暴露方法
})
// internalMethod 不可见
</script>
<!-- ParentComponent.vue -->
<script setup>
import { ref, onMounted } from 'vue'
import ChildComponent from './ChildComponent.vue'
const childRef = ref<InstanceType<typeof ChildComponent>>()
onMounted(() => {
console.log(childRef.value?.count) // 访问暴露的 ref
childRef.value?.reset() // 调用暴露的方法
// childRef.value?.internalMethod() // undefined — 未暴露
})
</script>
<template>
<ChildComponent ref="childRef" />
</template>
Level 2 · 它是怎么运行的(3-5年经验)
编译输出的完整对比
同样的功能,三种写法的编译产物对比:
写法一:<script setup>(最简洁)
<script setup lang="ts">
import { ref, computed } from 'vue'
interface Props { title: string; count?: number }
const props = withDefaults(defineProps<Props>(), { count: 0 })
const emit = defineEmits<{ change: [value: number] }>()
const doubled = computed(() => props.count * 2)
function handleClick() { emit('change', props.count + 1) }
</script>
编译输出(Vite/volar 生成):
import { ref, computed, defineComponent, withDefaults } from 'vue'
export default defineComponent({
props: {
title: { type: String, required: true },
count: { type: Number, default: 0 }
},
emits: ['change'],
setup(props, { emit }) {
const doubled = computed(() => props.count * 2)
function handleClick() { emit('change', props.count + 1) }
return { doubled, handleClick }
}
})
注意:编译器从 TypeScript 接口 Props 提取了运行时 props 定义(required: true 对应非可选字段,default: 0 来自 withDefaults)。
顶层 await:自动变成异步组件
<script setup>
// 顶层 await 合法
const data = await fetch('/api/data').then(r => r.json())
</script>
编译输出中,组件的 setup 函数变成 async:
export default {
async setup() {
const data = await fetch('/api/data').then(r => r.json())
return { data }
}
}
使用顶层 await 的组件自动成为异步组件,必须配合 <Suspense> 使用,否则父组件需要等待其 resolve。
defineOptions:解决 <script setup> 无法设置组件名的问题
Vue 3.3 之前,要给 <script setup> 组件设置 name,需要同时写两个 <script> 块:
<!-- 旧方式(繁琐) -->
<script>
export default { name: 'MyComponent', inheritAttrs: false }
</script>
<script setup>
// ...
</script>
Vue 3.3 引入了 defineOptions:
<script setup>
defineOptions({
name: 'MyComponent',
inheritAttrs: false
})
// ...
</script>
<script setup> 的编译流程图
输入:.vue 文件
│
▼
Vue 编译器(@vue/compiler-sfc)解析
│
├─ 解析 <template> → AST
├─ 解析 <script setup> → AST
└─ 解析 <style> → CSS
<script setup> AST 分析:
│
├─ 识别编译宏调用
│ ├─ defineProps() → 提取 props 定义,删除该调用
│ ├─ defineEmits() → 提取 emits 定义,删除该调用
│ ├─ defineExpose() → 生成 expose 调用,删除该调用
│ └─ defineOptions()→ 合并到组件选项,删除该调用
│
├─ 收集顶层绑定
│ (所有 const/let/var/function/class/import)
│
└─ 生成 setup() 函数
├─ 函数体 = <script setup> 内容(去除宏调用)
└─ return { ...所有顶层绑定 }(模板引用的那些)
最终输出:标准 CommonJS/ESM 组件对象
顶层绑定的暴露规则
并非所有顶层绑定都会出现在 return 中——编译器只暴露模板实际引用的绑定(通过模板 AST 分析):
<script setup>
import { ref } from 'vue'
const a = ref(1) // 模板中用到
const b = ref(2) // 模板中没用到
function fn() {} // 模板中用到
// helper 是导入的函数,模板中没用到
import { helper } from './utils'
</script>
<template>
{{ a }} <!-- 用到了 a 和 fn -->
<button @click="fn">click</button>
</template>
编译输出:
setup() {
const a = ref(1)
const b = ref(2)
function fn() {}
// b 和 helper 不在模板中使用,但仍然出现在 return 中
// (实际编译器会暴露所有顶层绑定,而不是只暴露模板引用的)
return { a, b, fn, helper }
}
实际行为:<script setup> 编译器暴露所有顶层绑定(安全侧),而不是做精确的模板引用分析(这会更复杂)。defineExpose 的作用是控制"通过 ref 从父组件访问的内容",而不是控制模板访问。
defineModel():Vue 3.4 的新宏
Vue 3.4 引入了 defineModel,简化了自定义组件双向绑定的实现:
<!-- 旧方式(Vue 3.4 之前) -->
<script setup>
const props = defineProps(['modelValue'])
const emit = defineEmits(['update:modelValue'])
function update(val) {
emit('update:modelValue', val)
}
</script>
<!-- 新方式(Vue 3.4+) -->
<script setup>
const model = defineModel<string>()
// model 是一个可读写的 Ref
// 读取:model.value
// 修改:model.value = 'new'(自动 emit update:modelValue)
</script>
defineModel 编译后生成 props + emit + computed 的组合:
// 编译输出
setup(props, { emit }) {
const model = useModel(props, 'modelValue')
return { model }
}
Level 3 · 设计文档与源码(资深开发者)
@vue/compiler-sfc 的处理流程
<script setup> 的编译由 packages/compiler-sfc/src/compileScript.ts 处理:
// packages/compiler-sfc/src/compileScript.ts(主函数签名)
export function compileScript(
sfc: SFCDescriptor, // 解析后的 .vue 文件描述符
options: SFCScriptCompileOptions,
): SFCScriptBlock {
// 1. 判断是否有 <script setup>
const { script, scriptSetup, source, filename } = sfc
// 2. 解析 <script setup> 的 AST
const scriptSetupAst = parse(scriptSetup.content, {
plugins: ['typescript', ...],
sourceType: 'module',
})
// 3. 走过 AST,识别宏调用
for (const node of scriptSetupAst.body) {
if (isCallOf(node, DEFINE_PROPS)) {
genPropsFromSetup(node, ...)
}
if (isCallOf(node, DEFINE_EMITS)) {
genEmitsFromSetup(node, ...)
}
// ...
}
// 4. 生成最终代码
return {
content: generateCode(...),
// ...
}
}
defineProps 类型提取的实现
当 defineProps<Props>() 使用类型声明时,编译器需要从 TypeScript 类型中提取运行时 props 配置:
// packages/compiler-sfc/src/script/defineProps.ts
function resolveRuntimePropsFromType(
node: TSTypeLiteral | TSInterfaceBody,
): PropsDestructureDecl {
const props: Record<string, PropTypeData> = {}
for (const member of node.members) {
if (member.type === 'TSPropertySignature') {
const key = member.key.name
const isRequired = !member.optional // 没有 ? 修饰符 → required: true
// 从 TypeScript 类型推断运行时类型
const runtimeType = inferRuntimeType(member.typeAnnotation?.typeAnnotation)
props[key] = {
key,
required: isRequired,
type: runtimeType, // String, Number, Boolean, Array, Object, Function...
}
}
}
return props
}
这就是为什么类型声明模式能产生运行时 props 定义(required 信息),但无法产生 validator 和精确的 default——这些需要运行时值,类型信息里没有。
withDefaults 的类型合并
withDefaults(defineProps<Props>(), defaults) 在类型层面做了什么:
// Vue 类型定义
type DefineProps<T, Defaults extends Partial<T>> = Readonly<
Omit<T, keyof Defaults> & {
[K in keyof Defaults]-?: T[K] extends undefined
? Defaults[K]
: T[K]
}
>
对于有默认值的属性,TypeScript 去掉了 undefined(因为它一定有值),对于没有默认值的属性保持原始类型。这是纯类型层面的变换,编译器将 defaults 对象合并到生成的 props 定义中。
限制:不能使用 Options API 特有属性
<script setup> 内部无法访问 $options、$listeners(已废弃)等 Options API 专有属性:
<script setup>
// ❌ 无法访问
const options = getCurrentInstance()?.type.$options // undefined
// ❌ $listeners 在 Vue 3 不存在(已合并到 $attrs)
</script>
以及宏只能在顶层调用,不能在嵌套函数或条件语句中:
<script setup>
// ❌ 错误:宏调用必须在顶层
function setup() {
const props = defineProps({ ... }) // SyntaxError
}
// ❌ 错误:条件宏调用
if (import.meta.env.DEV) {
defineOptions({ name: 'DevComponent' }) // SyntaxError
}
</script>
同时使用 <script> 和 <script setup>
某些场景下需要两个 script 块并存:
<!-- 需要 Options API 配置(如 inheritAttrs)+ Composition API -->
<script>
// 仅用于放置无法在 <script setup> 中表达的配置
export default {
inheritAttrs: false,
// name: 'MyComp' // Vue 3.3+ 可以用 defineOptions 代替
}
</script>
<script setup>
import { useAttrs } from 'vue'
const attrs = useAttrs()
</script>
两个 script 块会被合并——<script> 的 export default 对象与 <script setup> 生成的 setup 函数合并,<script setup> 中的 setup() 覆盖 <script> 中的同名选项。
Level 4 · 边界与陷阱(全体适用)
陷阱 1:类型声明 vs 运行时声明的校验差异
// 运行时声明:有校验,浏览器控制台会显示警告
const props = defineProps({
status: {
type: String,
validator: (v: string) => ['a', 'b', 'c'].includes(v)
}
})
// 父组件传入 'invalid':[Vue warn]: Invalid prop: custom validator...
// 类型声明:只有类型,没有运行时校验
const props = defineProps<{ status: 'a' | 'b' | 'c' }>()
// 父组件传入 'invalid':没有警告!TypeScript 只在编译期检查
对于需要运行时校验的场景(用户输入、API 数据),使用运行时声明(或两种结合,见下文)。
陷阱 2:withDefaults 中引用类型不用工厂函数
// ❌ 错误:所有组件实例共享同一个数组对象!
const props = withDefaults(defineProps<{ tags?: string[] }>(), {
tags: [] // 这个 [] 被多个实例共享
})
// ✅ 正确:工厂函数确保每个实例有自己的数组
const props = withDefaults(defineProps<{ tags?: string[] }>(), {
tags: () => []
})
这和 Options API 中 data() 必须是函数的原因相同。
陷阱 3:defineExpose 暴露 ref 时的自动解包
const count = ref(0)
defineExpose({ count })
父组件通过 childRef.value.count 访问时,得到的是 ref 对象本身,而不是其 .value:
// ParentComponent.vue
const child = ref<InstanceType<typeof Child>>()
console.log(child.value?.count) // 是 Ref<number>,不是 number
console.log(child.value?.count.value) // 才是 number
但在模板中访问时,由于 Vue 的自动解包,{{ childRef.count }} 会自动展开 ref。这种不一致容易造成混淆。
陷阱 4:顶层 await 必须配合 Suspense
<!-- ❌ 使用顶层 await 但没有 Suspense 包裹 -->
<script setup>
const data = await fetch('/api').then(r => r.json())
</script>
<!-- 父组件中必须包裹 Suspense -->
<template>
<Suspense>
<template #default>
<AsyncComponent /> <!-- 有顶层 await 的组件 -->
</template>
<template #fallback>
<LoadingSpinner />
</template>
</Suspense>
</template>
忘记 Suspense 包裹不会报错,但组件会在 Promise resolve 之前渲染(此时 data 是 undefined),导致渲染错误或意外行为。
陷阱 5:在 <script setup> 中使用 this
<script setup> 内部没有 this——setup 函数是普通函数,不是方法:
<script setup>
// ❌ this 是 undefined(严格模式)或 global(非严格模式)
console.log(this) // undefined
// ✅ 使用 getCurrentInstance()
import { getCurrentInstance } from 'vue'
const instance = getCurrentInstance()
// 但 getCurrentInstance() 只在开发/调试中使用,不在生产代码中
</script>
陷阱 6:defineProps 解构的响应性问题(Vue 3.3 之前)
// Vue 3.3 之前:解构 props 失去响应性
const { title, count } = defineProps<{ title: string; count: number }>()
// title 和 count 是静态值,props 变化不会更新它们
// 必须通过 props.xxx 访问
const props = defineProps<{ title: string; count: number }>()
const doubled = computed(() => props.count * 2) // 响应性正常
Vue 3.3 引入了响应式 props 解构(Reactive Props Destructure)——解构的 props 变量在访问时自动追踪:
// Vue 3.3+:解构仍然响应式
const { title, count = 0 } = defineProps<{ title: string; count?: number }>()
// count 默认值可以直接在解构中设置(替代 withDefaults)
// title 和 count 在模板中是响应式的
章节小结
-
编译时变换,非运行时特性:
<script setup>由@vue/compiler-sfc在构建时将其内容转换成标准setup()函数,产生的代码在运行时与普通 Composition API 完全等价,没有任何额外运行时开销。 -
编译宏不存在于运行时:
defineProps、defineEmits、defineExpose、defineOptions、defineModel等是编译器识别并处理的特殊标记,不需要 import,也无法在运行时调用——调用栈里看不到它们。 -
类型声明模式牺牲运行时校验换取 TypeScript 体验:类型声明方式的
defineProps<T>()让 TypeScript 能精确推断 props 类型,但不产生validator;运行时声明模式有完整校验但类型推断较弱。生产应用应根据场景选择,或混合使用。 -
默认封闭:
<script setup>不向父组件暴露任何内容:父组件通过 templateRef 拿到的组件实例,默认是一个空对象。需要通过defineExpose()显式声明对外可见的内容,这实现了真正的封装。 -
顶层 await 使组件成为异步组件:使用顶层
await的<script setup>组件必须在父组件中用<Suspense>包裹,否则在 Promise resolve 前组件就会渲染(数据为 undefined),产生运行时错误。