02|<script setup>:编译边界与每实例执行
第四章概览 / 02。目标:区分编译宏、模块作用域和组件实例作用域,不把语法糖误解成新的运行时。
它到底编译成什么
<script setup> 的内容会被编译成组件 setup() 的主体。因此顶层变量、函数和导入可以直接在模板中使用,也因此这些代码会在每个组件实例创建时执行。普通 <script> 或独立模块的顶层代码通常在模块装载时执行一次。
<script setup lang="ts">
import { computed, ref } from 'vue'
interface Props {
start?: number
step?: number
}
// Vue 3.5+:解构后的 props 在同一个 <script setup> 中保持响应式。
const { start = 0, step = 1 } = defineProps<Props>()
const emit = defineEmits<{
change: [value: number]
}>()
const count = ref(start)
const effectiveStep = computed(() => (step > 0 ? step : 1))
function increment() {
count.value += effectiveStep.value
emit('change', count.value)
}
</script>
<template>
<button type="button" @click="increment">
{{ count }}(步长 {{ effectiveStep }})
</button>
</template>这个 SFC 可直接放进 Vue 3.5 项目运行。父组件动态改变 step 时,effectiveStep 会更新;count 则只把 start 当初值,不会被后续 prop 更新重置。两个 <StepCounter> 实例各自拥有一份 count。
顶层暴露不是公共实例暴露
顶层绑定“对模板可见”只发生在编译后的渲染作用域。使用 <script setup> 的组件公共实例默认关闭,父组件通过模板 ref 看不到这些绑定。若确实需要命令式 API,显式缩小公开面:
function reset() {
count.value = start
}
defineExpose({ reset })defineExpose() 必须在 await 前调用;在等待后才暴露的属性不会进入公共实例。多数父子交互仍应使用 props / emits,因为组件 ref 会把父组件绑到子组件实现细节。
编译宏的四条限制
defineProps、defineEmits、defineExpose、defineModel 等不是普通运行时函数:它们只在 <script setup> 中可用、无需导入、会在编译后消失,并且必须出现在编译器可识别的位置。
defineProps() 和 defineEmits() 的选项会被提升到模块作用域,因此不能引用 setup 局部变量:
const minLength = ref(3)
// 编译失败:运行时 props 选项被提升后看不到 setup 局部变量。
defineProps({
query: {
type: String,
validator: value => value.length >= minLength.value
}
})这里的 ref 已从 Vue 导入;它在每次 setup 中创建,无法提升为共享 props 选项。反过来,const minLength = 3 这类可静态提升的字面量常量在当前编译器中可以被引用,不能拿它当失败反例。把校验常量导入模块,或把依赖实例状态的判断放进普通 computed / 事件处理器。编译期类型也不等于任意运行时校验:类型声明能生成有限的 props 运行时信息,但无法执行 TypeScript 条件类型的完整语义。
版本分支:3.5 的响应式 props 解构
本篇主例使用 Vue 3.5+ 的响应式 props 解构。Vue 3.4 及更早版本中,下面的 step 是一次普通解构,watch / computed 不会跟随 prop 更新:
const { step } = defineProps<{ step: number }>()旧版使用 props.step,需要默认值时配合 withDefaults:
const props = withDefaults(defineProps<Props>(), {
start: 0,
step: 1
})
const effectiveStep = computed(() => (props.step > 0 ? props.step : 1))使用 withDefaults 给数组或对象设默认值时,旧版应返回工厂函数,避免多个实例共享同一可变默认对象;3.5 的原生解构默认值会为每个实例求值,不需要这个包装。
模块作用域共享:可用,但要承认它是共享
同一 SFC 可以同时包含普通 <script> 与 <script setup>:
<script lang="ts">
let nextInstanceNumber = 0
</script>
<script setup lang="ts">
const instanceNumber = ++nextInstanceNumber
</script>nextInstanceNumber 是模块级共享状态,instanceNumber 是每实例快照。该实验适合观察作用域,不适合保存用户数据:SSR 进程会复用模块,模块级可变数据可能跨请求泄漏。
顶层 await 与同步注册
顶层 await 会把组件编译成异步 setup(),通常要由 <Suspense> 承接。编译器会恢复等待后的活动实例上下文,这是 <script setup> 的特殊能力;普通异步函数没有这个保证。为了让所有权一眼可见,生命周期、watcher 和资源清理仍优先在第一次等待前同步登记。
onUnmounted(stopConnection)
const data = await loadInitialData()浏览器 API 仍不应因为“写在 <script setup>”就直接执行。模块在 SSR 中也会被求值,实例 setup 在服务端渲染中也会运行;window、DOM ref 与监听器应进入 onMounted() 或显式客户端分支。
练习:带版本合同的步进器
完善主例 StepCounter.vue:
- props 为
start?: number与step?: number,默认分别为0、1。 start仅作每实例初值;step在父组件修改后立即用于下一次点击。- 非正数
step按1处理;每次点击发送change,载荷是新值。 - 暴露唯一命令
reset(),把计数恢复为当前start。 - 同时写出 Vue 3.5 主实现与 Vue 3.4 的 props 访问变体,并用两个实例证明局部状态没有共享。
参考答案:查看第 02 题。
自检
能区分“模板可见”“父组件实例可见”“模块共享”三种边界,并能解释宏为什么不能引用 setup 局部变量,才算真正理解 <script setup>。