Skip to content

02|第二章:组件模型与通信练习答案 ​

第二章:组件模型与通信纵览 / 参考答案。先比较可观察行为,再比较代码结构;能解释失败原因比逐字一致更重要。

对照方法 ​

每题按三个问题复盘:输入和状态由谁拥有?一次操作经过哪些边界?失败分支是否真的被触发?以下实现均假定放在 Vite Vue 3.5 + TypeScript 项目的 src/,除第 01 题外沿用项目已有 main.ts。

01|组件注册与解析 ​

对应正文:组件注册与解析。

vue
<script setup lang="ts">
import { ref } from 'vue'
const count = ref(0)
</script>

<template>
  <button type="button" @click="count++">{{ count }}</button>
</template>
vue
<template>
  <span>全局基础徽标</span>
</template>
ts
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')
vue
<script setup lang="ts">
import LocalCounter from './LocalCounter.vue'
</script>

<template>
  <LocalCounter />
  <LocalCounter />
  <BaseBadge />
</template>

两个 <LocalCounter> 对应两个实例,所以第一项点三次后是 3 / 0。BaseBadge 从应用级注册表解析。把模板名临时改成 MissingCounter 时,既没有局部导入也没有全局映射,开发环境应报告解析失败;若没有警告,先检查项目是否启用了自动导入插件。

02|Props 输入契约 ​

对应正文:Props 输入契约。

vue
<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>
vue
<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 输出事件。

vue
<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>
vue
<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|属性透传 ​

对应正文:属性透传。

vue
<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>
vue
<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|事件监听器与属性继承 ​

对应正文:事件监听器与属性继承。

vue
<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>
vue
<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|插槽协议 ​

对应正文:插槽协议。

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

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

ts
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
}
vue
<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>
vue
<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>
vue
<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|组件实例暴露 ​

对应正文:组件实例暴露。

vue
<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>
vue
<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|动态、缓存与异步组件 ​

对应正文:动态、缓存与异步组件。

三个面板可以保持最小:

vue
<script setup lang="ts">
import { ref } from 'vue'
const draft = ref('')
</script>
<template><label>昵称 <input v-model="draft" /></label></template>
vue
<template><p>安全设置</p></template>
vue
<template><p>审计记录已加载</p></template>
vue
<template><p role="status">审计记录加载中…</p></template>
vue
<template><p role="alert">审计记录加载失败</p></template>
ts
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()
  }
})
vue
<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|递归组件 ​

对应正文:递归组件。

ts
export interface Comment {
  id: string
  author: string
  text: string
  replies?: Comment[]
}
vue
<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>
vue
<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 是否各自只承担一种责任?
  • 动态切换、缓存停用、异步拒绝和递归深度是否都有可观察结果?
  • 是否曾通过强制非空、默认值、全局注册或隐式透传掩盖真正的边界错误?

能在不看答案的情况下解释这些实现的中间过程,再回到 第二章导读 做过站检查。

用心记录每一步成长