Skip to content

02|<script setup>:编译边界与每实例执行 ​

第四章概览 / 02。目标:区分编译宏、模块作用域和组件实例作用域,不把语法糖误解成新的运行时。

它到底编译成什么 ​

<script setup> 的内容会被编译成组件 setup() 的主体。因此顶层变量、函数和导入可以直接在模板中使用,也因此这些代码会在每个组件实例创建时执行。普通 <script> 或独立模块的顶层代码通常在模块装载时执行一次。

vue
<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,显式缩小公开面:

ts
function reset() {
  count.value = start
}

defineExpose({ reset })

defineExpose() 必须在 await 前调用;在等待后才暴露的属性不会进入公共实例。多数父子交互仍应使用 props / emits,因为组件 ref 会把父组件绑到子组件实现细节。

编译宏的四条限制 ​

defineProps、defineEmits、defineExpose、defineModel 等不是普通运行时函数:它们只在 <script setup> 中可用、无需导入、会在编译后消失,并且必须出现在编译器可识别的位置。

defineProps() 和 defineEmits() 的选项会被提升到模块作用域,因此不能引用 setup 局部变量:

ts
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 更新:

ts
const { step } = defineProps<{ step: number }>()

旧版使用 props.step,需要默认值时配合 withDefaults:

ts
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>:

vue
<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 和资源清理仍优先在第一次等待前同步登记。

ts
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>。

用心记录每一步成长