Skip to content

11|script setup 与编译宏:魔法在编译期,运行时别到处找兔子 ​

第一章概览 / 11。目标:用 TypeScript 写清组件输入、输出和公开能力,理解编译宏为什么“不用 import”,以及它们的版本与作用域边界。

本页以 Vue 3.5.x、Vite、TypeScript 与 Vue Language Tools 2.1+ 为环境;LessonEditor.vue 是子组件文件,需由父组件传入必需 model 后运行。使用较低 Vue 版本时,先按表格逐项核对宏版本,不要只让编辑器“暂时不报红”。

script setup 到底省掉了什么 ​

<script setup> 是单文件组件的编译时语法糖:顶层声明可直接给模板使用,导入的组件可直接在模板使用,defineProps 等宏由编译器处理。它不是一段“运行时自动全局可用的 JavaScript”;把宏塞进普通 .ts 文件、条件语句或回调里,编译器不会派人来接。

能力宏 / API用来声明
输入defineProps父组件能传什么
输出事件defineEmits子组件会通知什么
双向模型defineModelv-model 的 prop + update 合同
公开实例defineExpose父组件 ref 可调用的少量能力
组件选项defineOptions如 inheritAttrs 等静态选项
插槽类型defineSlots父组件可提供的内容协议
运行时插槽访问useSlots()是否提供某槽位等运行时信息

一份类型完整但不臃肿的组件合同 ​

vue
<!-- LessonEditor.vue -->
<script setup lang="ts">
import { computed, useTemplateRef } from 'vue'

type Tone = 'neutral' | 'warning'
type Draft = { title: string; body: string }

defineOptions({ inheritAttrs: false })

interface Props {
  lessonId: string
  tone?: Tone
  tags?: string[]
}

const props = withDefaults(defineProps<Props>(), {
  tone: 'neutral',
  // 对数组/对象默认值返回工厂,避免组件实例共用同一份可变数据。
  tags: () => []
})

const emit = defineEmits<{
  save: [draft: Draft]
  cancel: []
}>()

const draft = defineModel<Draft>({ required: true })
const titleInput = useTemplateRef<HTMLInputElement>('titleInput')

const titleLength = computed(() => draft.value.title.trim().length)

function focusTitle() {
  titleInput.value?.focus()
}

function save() {
  emit('save', { ...draft.value, title: draft.value.title.trim() })
}

defineExpose({ focusTitle })

defineSlots<{
  default(props: { titleLength: number }): unknown
  footer?(): unknown
}>()
</script>

<template>
  <section :class="['lesson-editor', `lesson-editor--${props.tone}`]" v-bind="$attrs">
    <label>
      标题
      <input ref="titleInput" :value="draft.title" @input="draft = { ...draft, title: ($event.target as HTMLInputElement).value }" :aria-describedby="`${props.lessonId}-help`" />
    </label>
    <small :id="`${props.lessonId}-help`">已输入 {{ titleLength }} 个非空白字符</small>

    <textarea :value="draft.body" @input="draft = { ...draft, body: ($event.target as HTMLTextAreaElement).value }" aria-label="正文"></textarea>
    <slot :title-length="titleLength" />

    <footer v-if="$slots.footer"><slot name="footer" /></footer>
    <button type="button" @click="save">保存</button>
    <button type="button" @click="emit('cancel')">取消</button>
  </section>
</template>

这段示例故意把四种边界分开:props 是输入、model 是协同编辑的值、emits 是发生的动作、expose 是极少量命令能力。组件有边界,维护者才不用拿着探照灯四处找数据从哪儿飞来。

对象model还要区分“改内部字段”与“给model重新赋值”。draft.title = text 可能直接改共享对象内部,绕过model的更新事件。本例用 { ...draft, title: text } 创建新对象并赋给model,让setter发出 update:modelValue。父级若记录更新事件或做不可变历史,才能观察每次修改。模型是一个ref,不代表任何深层写入都会穿过它的setter。

每个宏的细节与边界 ​

宏常用写法深层规则 / 常见误会
defineProps<T>()类型声明或运行时 props 声明宏参数会被提升处理;不要引用 setup 内刚创建的局部变量。导入类型可用,运行时校验需在边界另做。
withDefaults()给 type-based props 提供默认值数组/对象用工厂函数。Vue 3.5+ 还支持响应式 props 解构默认值,但团队应统一版本与写法。
defineEmits<T>()事件名 → 元组载荷事件说“发生了什么”,不要把它伪装成让父组件执行内部细节的遥控器。
defineModel()const model = defineModel<T>()Vue 3.4+;它返回 ref。给子组件 default 时,父组件没传值可能与子组件默认值不同步,优先让父级提供初值。
defineExpose()defineExpose({ focus })script setup 组件默认不暴露内部;只公开稳定、命令式的小接口。
defineOptions()defineOptions({ inheritAttrs: false })Vue 3.3+;用于静态组件选项,不用它塞运行时状态。
defineSlots<T>()为 slot props 标类型Vue 3.3+;主要是类型提示,运行时判断仍使用 $slots / useSlots()。

Vue 3.5 的响应式 props 解构 ​

Vue 3.5 会在同一个 <script setup> 中,把从 defineProps() 解构出的变量访问编译回 props.xxx。因此下面的 label 会随父级 prop 更新,原生默认值也会按组件实例创建:

vue
<script setup lang="ts">
import { watchEffect } from 'vue'

const {
  label = '搜索',
  suggestions = []
} = defineProps<{
  label?: string
  suggestions?: string[]
}>()

watchEffect(() => {
  console.info(label, suggestions.length)
})
</script>

<template>
  <p>{{ label }}:{{ suggestions.length }} 条建议</p>
</template>

在 Vue 3.4 及更早版本,解构出的 label 是普通常量,父级更新后不会重新触发这段 effect;那时应保留 props.label,或使用 toRefs()。如果项目必须同时支持多个 Vue 3 小版本,继续使用 props.xxx + withDefaults() 通常更直白;若明确以 3.5 为基线,可以选择响应式解构,但要在代码评审中把版本前提写清楚。可变默认值只有在 withDefaults() 写法中需要工厂函数;原生解构的 suggestions = [] 会在每个组件实例执行。

深水区:宏在声明合同,普通代码在实现行为 ​

defineProps、defineEmits、defineModel、defineExpose、defineOptions 与 defineSlots 都由 SFC 编译器在 <script setup> 顶层识别;它们不是可以按条件调用、也不是普通工具函数。一个有用的阅读法是把组件分成两层:宏声明谁能输入、输出或访问什么;下面的 computed、函数、watcher 和模板实现这份合同如何兑现。

当一个组件有两个独立模型时,可以把名字写进合同,而不是用无名状态互相猜测:

vue
<!-- RangeInput.vue -->
<script setup lang="ts">
const start = defineModel<string>('start', { required: true })
const end = defineModel<string>('end', { required: true })

const emit = defineEmits<{ invalidRange: [] }>()

function validate() {
  if (start.value > end.value) emit('invalidRange')
}
</script>

<template>
  <input v-model="start" type="date" @change="validate" />
  <input v-model="end" type="date" @change="validate" />
</template>

这段示例不意味着每个相邻输入都该用多个 v-model;如果它们共享同一份业务对象或验证规则,一个明确的 prop + 一个 update 事件可能更直观。选择标准是父子之间真正需要协同的状态边界,而不是宏能否写出来。

一个 defineModel 的默认值陷阱 ​

vue
<!-- Child.vue:这看着方便,但可能造成父子初值不一致。 -->
<script setup lang="ts">
const count = defineModel<number>({ default: 1 })
</script>

<!-- Parent.vue:parentCount 初始是 undefined;child 可能显示 1。 -->
<script setup lang="ts">
import { ref } from 'vue'
const parentCount = ref<number>()
</script>
<template><Child v-model="parentCount" /></template>

更清楚的做法是由父组件初始化 ref(1),或把“未提供值时取默认值”的产品规则放在单一所有者处。双向绑定不是默认值协调员,它只负责按契约传递值和更新事件。

inheritAttrs: false:接管透传前先画好出口 ​

未声明的 attribute / listener 默认会落到组件根元素。多根组件、包装型输入控件或需要把属性送到内部 <input> 时,可以关闭自动继承,再用 $attrs 精确转发。这样做要把 class、style、aria-*、事件等需求都考虑进去;关掉不是为了“清净”,而是为了明确路由。

vue
<script setup lang="ts">
defineOptions({ inheritAttrs: false })
</script>

<template>
  <label class="field-shell">
    <span><slot name="label" /></span>
    <!-- 调用方传给组件的 placeholder、aria-*、onBlur 等落在真正 input 上。 -->
    <input v-bind="$attrs" />
  </label>
</template>

透传不是隐形的 props ​

$attrs 包含未被声明为 props 或 emits 的 attribute 与监听器。它适合把原生输入需要的 placeholder、autocomplete、aria-*、data-* 和少数事件送到正确元素;但如果组件逻辑依赖其中一个值,就应升级为显式 prop 或 emit 合同。否则调用方改一个拼写,组件悄悄失效,类型系统也帮不上忙。

也不要用 watch($attrs, …) 假设它是普通响应式对象;需要响应某个会影响组件行为的值,就将其声明为 prop。inheritAttrs: false 的目标是把 attribute 路由讲清楚,而不是绕开组件 API。

排错路线:宏、类型和 attrs 为什么各说各话 ​

  1. 确认编译位置与版本。 宏必须在 <script setup> 顶层,且项目 Vue 版本支持对应能力;报“函数不存在”时别先去运行时找 import。
  2. 核对组件合同。 prop 名、emit 名、命名 v-model 与 slot props 是否在父子两侧一致?把事件载荷写成类型能早点发现错位。
  3. 追踪透传终点。 关闭自动继承后,$attrs 是否真的落在用户交互的原生元素上?特别检查 aria-describedby、autocomplete、焦点事件。
  4. 区分静态类型与运行时数据。 vue-tsc 能验证组件用法,却不会验证服务器实际返回的 JSON;网络和用户输入边界仍需运行时校验。

开发体验:类型工具不是锦上添花 ​

  • 编辑器使用 Vue Language Features(Volar)获得模板类型检查、跳转和重构支持。
  • 在 CI / 本地增加 vue-tsc --noEmit,它会检查 .vue 模板与脚本的类型关系;tsc 单独运行不等同于完整 Vue 模板检查。
  • 用 TypeScript 声明 props、emits、slot props 和组件 ref;不过 API 响应、URL 参数、用户输入仍需要运行时校验。类型系统不会在用户电脑里拦住一条坏数据。

翻车博物馆 ​

现象原因修复
defineProps 在普通函数里报错宏只在 <script setup> 顶层由编译器识别移到顶层;普通逻辑用普通函数/API
组件 ref 看不到内部方法script setup 默认封闭有必要时 defineExpose({ stableMethod })
默认数组被多个实例一起修改默认值直接复用了可变对象withDefaults 中使用工厂函数
defineModel 子组件有默认值,父值却是 undefined双向模型初值没有自动回写协调父组件显式初始化,或统一由单一所有者定默认
以为 TS 会校验接口实际响应TS 只在编译期存在在网络/用户输入边界增加运行时校验

练习:写一个真正有边界的搜索框 ​

创建 src/components/SearchField.vue 与 src/App.vue。props 合同为 placeholder?: string、disabled?: boolean、label?: string;父级把初始 query 设为 '',通过必需的 defineModel<string>() 持有它。默认插槽只接收 { query: string }。

组件声明 submit(query: string) 事件,只在未禁用且 trim 后非空时发送;defineExpose 只公开 focus()。设置 inheritAttrs: false,把父级的 aria-describedby="search-help"、autocomplete="off" 与 data-testid 精确转发给真实 <input>。

参考答案:查看第一章练习答案中的第 11 题。

验收清单:如何判断练习完成 ​

  • 输入 ' vue ' 时 model 保留原草稿,提交事件收到 'vue';空白或 disabled 时事件计数不变。
  • 父组件通过 ref 只能调用公开的 focus(),而不能依赖搜索框的内部 DOM 或私有状态。
  • 三个透传 attribute 都落在真实 <input> 上;默认插槽收到当前 query,传错 slot prop 名时 vue-tsc 能指出合同不一致。

先完成练习,再从答案复盘 ​

到这里,你已经能把状态、模板、事件、表单、列表、插槽、样式和组件合同连成一条线。下一站再学习组件通信时,不会把 props、emits、slots、attrs 和 ref 当成五种“随便都能传数据”的管道;它们各自有门牌号,走对门会省下很多深夜排查。

先独立完成 01~13 的练习,再打开 第一章练习答案 对照行为、类型和无障碍细节。回到 Vue 学习路线 继续第二章,或回到 第一章概览 复盘。

用心记录每一步成长