02|第二章:组件模型与通信练习答案
第二章:组件模型与通信纵览 / 参考答案。先比较可观察行为,再比较代码结构;能解释失败原因比逐字一致更重要。
对照方法
每题按三个问题复盘:输入和状态由谁拥有?一次操作经过哪些边界?失败分支是否真的被触发?以下实现均假定放在 Vite Vue 3.5 + TypeScript 项目的 src/,除第 01 题外沿用项目已有 main.ts。
01|组件注册与解析
对应正文:组件注册与解析。
<script setup lang="ts">
import { ref } from 'vue'
const count = ref(0)
</script>
<template>
<button type="button" @click="count++">{{ count }}</button>
</template><template>
<span>全局基础徽标</span>
</template>import { createApp } from 'vue'
import App from './App.vue'
import BaseBadge from './BaseBadge.vue'
const app = createApp(App)
app.component('BaseBadge', BaseBadge)
app.mount('#app')<script setup lang="ts">
import LocalCounter from './LocalCounter.vue'
</script>
<template>
<LocalCounter />
<LocalCounter />
<BaseBadge />
</template>两个 <LocalCounter> 对应两个实例,所以第一项点三次后是 3 / 0。BaseBadge 从应用级注册表解析。把模板名临时改成 MissingCounter 时,既没有局部导入也没有全局映射,开发环境应报告解析失败;若没有警告,先检查项目是否启用了自动导入插件。
02|Props 输入契约
对应正文:Props 输入契约。
<script setup lang="ts">
const props = withDefaults(defineProps<{
label: string
count: number
showZero?: boolean
}>(), {
showZero: true
})
</script>
<template>
<p v-if="props.count < 0" role="alert">库存数据错误</p>
<p v-else-if="props.count > 0">
{{ props.label }}:剩余 {{ props.count }} 件
</p>
<p v-else-if="props.showZero">已售罄</p>
</template><script setup lang="ts">
import { ref } from 'vue'
import StockBadge from './StockBadge.vue'
const stock = ref(2)
</script>
<template>
<StockBadge label="Vue 手册" :count="stock" />
<button type="button" @click="stock++">补货</button>
<button type="button" @click="stock = 0">清空</button>
<button type="button" @click="stock = -1">模拟错误</button>
<StockBadge label="隐藏零库存" :count="0" :show-zero="false" />
</template>showZero 未传时由 withDefaults 得到 true。负数走独立错误分支,子组件没有用 props.count = 0 篡改输入;按钮更新的是父级 stock。隐藏零库存的实例不渲染可见根元素,这是合同允许的结果。
03|Emits 输出事件
对应正文:Emits 输出事件。
<script setup lang="ts">
const props = defineProps<{
value: number
min: number
max: number
}>()
const emit = defineEmits<{
change: [value: number]
blocked: [boundary: 'min' | 'max']
}>()
function requestChange(delta: number) {
const next = props.value + delta
if (next < props.min) {
emit('blocked', 'min')
} else if (next > props.max) {
emit('blocked', 'max')
} else {
emit('change', next)
}
}
</script>
<template>
<button type="button" @click="requestChange(-1)">-</button>
<output>{{ props.value }}</output>
<button type="button" @click="requestChange(1)">+</button>
</template><script setup lang="ts">
import { ref } from 'vue'
import QuantityStepper from './QuantityStepper.vue'
const quantity = ref(2)
const blocked = ref<Array<'min' | 'max'>>([])
</script>
<template>
<QuantityStepper
:value="quantity"
:min="1"
:max="3"
@change="quantity = $event"
@blocked="blocked.push($event)"
/>
<p>阻止记录:{{ blocked.join(', ') || '无' }}</p>
</template>操作“加、加、减”的状态依次为 3 → 3 → 2,第二次加只产生一次 blocked('max')。若子组件直接写 props.value++,既违反只读输入,也绕过了父级决定是否接受变化的职责。
04|属性透传
对应正文:属性透传。
<script setup lang="ts">
defineOptions({ inheritAttrs: false })
defineProps<{
label: string
help: string
}>()
</script>
<template>
<label>
<span>{{ label }}</span>
<input v-bind="$attrs" />
</label>
<p id="search-help">{{ help }}</p>
</template><script setup lang="ts">
import SearchField from './SearchField.vue'
</script>
<template>
<SearchField
label="站内搜索"
help="输入至少两个字符"
id="site-search"
name="q"
placeholder="Vue"
aria-describedby="search-help"
data-testid="search-input"
class="wide"
/>
</template>label、help 被 props 声明消费,其余键只在 <input> 上出现。开发者工具中可检查 document.querySelectorAll('#site-search').length === 1。只删除 v-bind="$attrs" 后,inheritAttrs: false 使input静默丢失这些属性;同时删掉 defineOptions 恢复默认继承后,才会出现多根警告;把 attrs 同时绑定到两个根则可能制造重复 id,也不满足合同。
05|事件监听器与属性继承
对应正文:事件监听器与属性继承。
<script setup lang="ts">
defineOptions({ inheritAttrs: false })
const props = defineProps<{ label: string }>()
const emit = defineEmits<{
click: [event: MouseEvent]
}>()
</script>
<template>
<button
type="button"
v-bind="$attrs"
:aria-label="props.label"
@click="emit('click', $event)"
>
<span aria-hidden="true">★</span>
{{ props.label }}
</button>
</template><script setup lang="ts">
import { ref } from 'vue'
import IconButton from './IconButton.vue'
const clicks = ref(0)
const focuses = ref(0)
const disabled = ref(false)
</script>
<template>
<IconButton
label="收藏"
id="favorite"
class="round"
data-testid="favorite"
:disabled="disabled"
@click="clicks++"
@focus="focuses++"
/>
<button type="button" @click="disabled = !disabled">切换禁用</button>
<p>click={{ clicks }}, focus={{ focuses }}</p>
</template>声明过的 click 不在 $attrs 中,只由 emit 触发一次;未声明的 focus 随 attrs 绑定为原生监听器。原生 disabled 按钮不会产生用户点击。若删掉 emits 声明,父级 onClick 又进入 $attrs,原生透传与 $emit 会让同一监听器可能执行两次。
06|插槽协议
对应正文:插槽协议。
<script setup lang="ts" generic="T extends { id: string; message: string; read: boolean }">
import { computed } from 'vue'
const props = defineProps<{ items: T[] }>()
const unreadCount = computed(() =>
props.items.filter(item => !item.read).length
)
defineSlots<{
default?(props: { item: T; index: number }): unknown
empty?(): unknown
actions?(props: { unreadCount: number }): unknown
}>()
</script>
<template>
<ul v-if="props.items.length">
<li v-for="(item, index) in props.items" :key="item.id">
<slot :item="item" :index="index">{{ item.message }}</slot>
</li>
</ul>
<div v-else>
<slot name="empty">暂无通知</slot>
</div>
<footer v-if="$slots.actions">
<slot name="actions" :unread-count="unreadCount" />
</footer>
</template><script setup lang="ts">
import { ref } from 'vue'
import NotificationList from './NotificationList.vue'
interface Notice { id: string; message: string; read: boolean }
const seed: Notice[] = [
{ id: 'n1', message: '构建完成', read: false },
{ id: 'n2', message: '审查通过', read: true }
]
const items = ref<Notice[]>([...seed])
</script>
<template>
<NotificationList :items="items">
<template #default="{ item, index }">
{{ index + 1 }}. {{ item.message }}{{ item.read ? '' : '(未读)' }}
</template>
<template #empty>收件箱是空的</template>
<template #actions="{ unreadCount }">未读 {{ unreadCount }} 条</template>
</NotificationList>
<button type="button" @click="items = []">清空</button>
<button type="button" @click="items = [...seed]">恢复</button>
</template>有数据时默认作用域插槽收到每项与下标,actions 得到 1;清空后显示自定义 empty,actions 仍得到 0。若父模板直接读取子组件的 unreadCount 而未通过 slot prop 接收,查找发生在父作用域,不满足协议。
07|组件 v-model
对应正文:组件 v-model。
<script setup lang="ts">
const firstName = defineModel<string>('firstName', { required: true })
const [lastName, modifiers] = defineModel<string, 'normalize'>('lastName', {
required: true,
set(value) {
if (!modifiers.normalize) return value
const trimmed = value.trim()
return trimmed
? trimmed[0].toUpperCase() + trimmed.slice(1)
: ''
}
})
</script>
<template>
<label>名 <input v-model="firstName" /></label>
<label>姓 <input v-model="lastName" /></label>
</template><script setup lang="ts">
import { ref } from 'vue'
import FullNameEditor from './FullNameEditor.vue'
const firstName = ref('Ada')
const lastName = ref('lovelace')
</script>
<template>
<FullNameEditor
v-model:first-name="firstName"
v-model:last-name.normalize="lastName"
/>
<p>{{ firstName }} {{ lastName }}</p>
</template>姓氏输入 ' hopper ' 时,子组件的 model setter 发出 'Hopper',父级 lastName 更新后再向下传。输入全空格得到空字符串,不会访问不存在的首字符。两个模型都要求父级初始化,因此没有子级默认值与父级 undefined 的失同步。
08|provide / inject
对应正文:provide / inject。
import { inject, type InjectionKey, type Ref } from 'vue'
export type Locale = 'zh-CN' | 'en-US'
export interface LocaleContext {
locale: Readonly<Ref<Locale>>
setLocale(next: Locale): void
}
export const localeKey: InjectionKey<LocaleContext> = Symbol('locale')
export function useLocale() {
const context = inject(localeKey)
if (!context) throw new Error('LocaleButton 必须位于 LocaleProvider 内')
return context
}<script setup lang="ts">
import { provide, readonly, ref } from 'vue'
import { localeKey, type Locale } from './locale-context'
const props = defineProps<{ initial: Locale }>()
const locale = ref<Locale>(props.initial)
function setLocale(next: Locale) {
locale.value = next
}
provide(localeKey, { locale: readonly(locale), setLocale })
</script>
<template>
<section :lang="locale"><slot /></section>
</template><script setup lang="ts">
import { useLocale } from './locale-context'
const { locale, setLocale } = useLocale()
</script>
<template>
<button
type="button"
@click="setLocale(locale === 'zh-CN' ? 'en-US' : 'zh-CN')"
>
{{ locale }}
</button>
</template><script setup lang="ts">
import LocaleButton from './LocaleButton.vue'
import LocaleProvider from './LocaleProvider.vue'
</script>
<template>
<LocaleProvider initial="zh-CN">
<p>外层:<LocaleButton /></p>
<LocaleProvider initial="en-US">
<p>内层:<LocaleButton /></p>
</LocaleProvider>
</LocaleProvider>
</template>内层按钮找到最近的内层 provider,外层按钮找到外层 provider,二者状态独立。消费者得到只读 ref,只能通过 provider 的 setLocale 修改。把 LocaleButton 移到 provider 外会立即抛出装配错误,而不是使用一个语义不明的默认值。
09|组件实例暴露
对应正文:组件实例暴露。
<script setup lang="ts">
import { useTemplateRef } from 'vue'
const input = useTemplateRef<HTMLInputElement>('input')
function focus() {
input.value?.focus()
}
function selectAll() {
input.value?.select()
}
defineExpose({ focus, selectAll })
</script>
<template>
<input ref="input" inputmode="numeric" aria-label="验证码" />
</template><script setup lang="ts">
import { ref, useTemplateRef } from 'vue'
import CodeInput from './CodeInput.vue'
const visible = ref(true)
const codeInput = useTemplateRef<InstanceType<typeof CodeInput>>('codeInput')
</script>
<template>
<CodeInput v-if="visible" ref="codeInput" />
<button type="button" @click="codeInput?.focus()">聚焦</button>
<button type="button" @click="codeInput?.selectAll()">全选</button>
<button type="button" @click="visible = !visible">显示/隐藏</button>
</template>隐藏后组件被卸载,codeInput 回到 null,可选链让命令安全地不执行;重新显示会得到新实例。父级类型里只有两个公开方法。删掉 defineExpose 后,<script setup> 组件实例不再提供它们,说明私有 DOM ref 没有泄漏。
10|动态、缓存与异步组件
对应正文:动态、缓存与异步组件。
三个面板可以保持最小:
<script setup lang="ts">
import { ref } from 'vue'
const draft = ref('')
</script>
<template><label>昵称 <input v-model="draft" /></label></template><template><p>安全设置</p></template><template><p>审计记录已加载</p></template><template><p role="status">审计记录加载中…</p></template><template><p role="alert">审计记录加载失败</p></template>import { defineAsyncComponent } from 'vue'
import PanelError from './PanelError.vue'
import PanelLoading from './PanelLoading.vue'
export const AsyncAuditPanel = defineAsyncComponent({
loader: () => import('./AuditPanel.vue'),
loadingComponent: PanelLoading,
errorComponent: PanelError,
delay: 200,
timeout: 5_000,
onError(_error, retry, fail, attempts) {
if (attempts <= 2) retry()
else fail()
}
})<script setup lang="ts">
import { computed, ref } from 'vue'
import { AsyncAuditPanel } from './AsyncAuditPanel'
import ProfilePanel from './ProfilePanel.vue'
import SecurityPanel from './SecurityPanel.vue'
const panels = {
profile: ProfilePanel,
security: SecurityPanel,
audit: AsyncAuditPanel
} as const
type PanelName = keyof typeof panels
const panelNames = Object.keys(panels) as PanelName[]
const active = ref<PanelName>('profile')
const currentPanel = computed(() => panels[active.value])
</script>
<template>
<nav>
<button v-for="name in panelNames" :key="name" @click="active = name">
{{ name }}
</button>
</nav>
<KeepAlive :max="3">
<component :is="currentPanel" />
</KeepAlive>
</template>在资料页输入后切走再回来,KeepAlive 恢复同一缓存实例。审计组件的 loader 只在首次进入审计页时启动。验证失败时,把 loader 临时改为 () => Promise.reject(new Error('offline')):应重试两次后显示 PanelError;随后恢复真实 import。若外层另有 Suspense,需要决定由哪一层控制 loading/error,不能把局部选项未显示误判为 loader 没执行。
11|递归组件
对应正文:递归组件。
export interface Comment {
id: string
author: string
text: string
replies?: Comment[]
}<script setup lang="ts">
import type { Comment } from './comment-types'
const props = withDefaults(defineProps<{
comment: Comment
depth?: number
maxDepth?: number
}>(), {
depth: 0,
maxDepth: 2
})
const emit = defineEmits<{ select: [id: string] }>()
</script>
<template>
<li>
<button type="button" @click="emit('select', props.comment.id)">
{{ props.comment.author }}:{{ props.comment.text }}
</button>
<ul v-if="props.comment.replies?.length && props.depth < props.maxDepth">
<CommentNode
v-for="reply in props.comment.replies"
:key="reply.id"
:comment="reply"
:depth="props.depth + 1"
:max-depth="props.maxDepth"
@select="emit('select', $event)"
/>
</ul>
<small v-else-if="props.comment.replies?.length">更多回复未展开</small>
</li>
</template><script setup lang="ts">
import { ref } from 'vue'
import CommentNode from './CommentNode.vue'
import type { Comment } from './comment-types'
const selected = ref('未选择')
const root: Comment = {
id: 'c1',
author: 'Ada',
text: '第一层',
replies: [{
id: 'c2',
author: 'Lin',
text: '第二层',
replies: [{
id: 'c3',
author: 'Grace',
text: '第三层',
replies: [{ id: 'c4', author: 'Edsger', text: '不会展开' }]
}]
}]
}
</script>
<template>
<ul><CommentNode :comment="root" @select="selected = $event" /></ul>
<p>选中:{{ selected }}</p>
</template>深度 0、1、2 的三层评论会渲染,c3 下方显示“更多回复未展开”,c4 不创建实例。点击 c3 时,事件从第三层发给第二层,再由第二层发给第一层,最后到 App;删掉任一层模板中的转发,深层点击就中断。空 replies 不生成空列表,稳定 id 作为 key 保持节点身份。
章末复盘
- props、emits、slots、attrs、inject 和 expose 是否各自只承担一种责任?
- 动态切换、缓存停用、异步拒绝和递归深度是否都有可观察结果?
- 是否曾通过强制非空、默认值、全局注册或隐式透传掩盖真正的边界错误?
能在不看答案的情况下解释这些实现的中间过程,再回到 第二章导读 做过站检查。