第 22 章

<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

编译宏:只存在于编译期的"函数"

definePropsdefineEmitsdefineExposedefineOptionsdefineSlotswithDefaults 这些函数,在运行时并不存在。它们是编译器宏(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 之前渲染(此时 dataundefined),导致渲染错误或意外行为。

陷阱 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 在模板中是响应式的

章节小结

  1. 编译时变换,非运行时特性<script setup>@vue/compiler-sfc 在构建时将其内容转换成标准 setup() 函数,产生的代码在运行时与普通 Composition API 完全等价,没有任何额外运行时开销。

  2. 编译宏不存在于运行时definePropsdefineEmitsdefineExposedefineOptionsdefineModel 等是编译器识别并处理的特殊标记,不需要 import,也无法在运行时调用——调用栈里看不到它们。

  3. 类型声明模式牺牲运行时校验换取 TypeScript 体验:类型声明方式的 defineProps<T>() 让 TypeScript 能精确推断 props 类型,但不产生 validator;运行时声明模式有完整校验但类型推断较弱。生产应用应根据场景选择,或混合使用。

  4. 默认封闭:<script setup> 不向父组件暴露任何内容:父组件通过 templateRef 拿到的组件实例,默认是一个空对象。需要通过 defineExpose() 显式声明对外可见的内容,这实现了真正的封装。

  5. 顶层 await 使组件成为异步组件:使用顶层 await<script setup> 组件必须在父组件中用 <Suspense> 包裹,否则在 Promise resolve 前组件就会渲染(数据为 undefined),产生运行时错误。

本章评分
4.5  / 5  (7 评分)

💬 留言讨论