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 | 子组件会通知什么 |
| 双向模型 | defineModel | v-model 的 prop + update 合同 |
| 公开实例 | defineExpose | 父组件 ref 可调用的少量能力 |
| 组件选项 | defineOptions | 如 inheritAttrs 等静态选项 |
| 插槽类型 | defineSlots | 父组件可提供的内容协议 |
| 运行时插槽访问 | useSlots() | 是否提供某槽位等运行时信息 |
一份类型完整但不臃肿的组件合同
<!-- 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 更新,原生默认值也会按组件实例创建:
<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 和模板实现这份合同如何兑现。
当一个组件有两个独立模型时,可以把名字写进合同,而不是用无名状态互相猜测:
<!-- 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 的默认值陷阱
<!-- 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-*、事件等需求都考虑进去;关掉不是为了“清净”,而是为了明确路由。
<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 为什么各说各话
- 确认编译位置与版本。 宏必须在
<script setup>顶层,且项目 Vue 版本支持对应能力;报“函数不存在”时别先去运行时找 import。 - 核对组件合同。 prop 名、emit 名、命名
v-model与 slot props 是否在父子两侧一致?把事件载荷写成类型能早点发现错位。 - 追踪透传终点。 关闭自动继承后,
$attrs是否真的落在用户交互的原生元素上?特别检查aria-describedby、autocomplete、焦点事件。 - 区分静态类型与运行时数据。
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 学习路线 继续第二章,或回到 第一章概览 复盘。