Skip to content

第一章练习答案:把每道门都推开一次 ​

第一章概览 / 练习答案。练习页只给任务、提示和验收;这里才放 01~13 的参考实现。它们不是唯一写法;更重要的是逐项核对行为、数据流和可访问性,而不是把代码当成可复制的咒语。

使用方式:先比行为,再比代码 ​

每题先自己完成,再拿下面的实现做三件事:运行交互、比对验收点、解释每一项取舍。若两份代码都能跑,优先保留那份边界更清楚、名字更准确的版本。

对照维度该问的问题
行为用户操作后,状态与界面是否一起正确变化?
数据流输入、派生值和事件是否各有单一来源?
语义原生控件、标签和 ARIA 是否描述了真实交互?
边界列表身份、插槽、attrs、组件公开能力是否清楚?

00|运行环境:从文件变化追到挂载 ​

  1. 标题保存后变化来自Vite监听源文件并更新开发页面。普通 const 没有因此变成响应式状态。
  2. 只改HTML为 id="reader",入口仍查找 #app,找不到挂载容器,页面不会显示预期组件;开发控制台会给出挂载目标不存在的警告。此时应检查入口和DOM,而不是给组件加ref。
  3. 把 src/main.ts 的调用同步改成 createApp(App).mount('#reader'),与HTML相同,重新验证组件可见。实验结束可以把两侧同时恢复app。
  4. const lessonCount: number = 'three' 给number变量赋了string;npm run type-check 应失败。改回3后再检查应通过。开发页面能显示字符串不构成类型通过证据,Vite转换TypeScript和类型检查是不同工作。

若步骤2没有出现预期警告,先确认浏览器打开的是当前项目端口、保存的是当前项目文件,而不是另一个开发服务。步骤4的证据是类型检查退出状态和诊断,不是只看编辑器是否有红线。

01|公告展示:先确定空值与文本合同 ​

vue
<!-- src/App.vue -->
<script setup lang="ts">
const title: string | null = '维护通知'
const body = '<em>请保存</em>\n稍后恢复'
const priority = 7
const publishedAt = '2026-09-12T09:30:00+08:00'

function formatTime(iso: string) {
  return new Intl.DateTimeFormat('zh-CN', {
    dateStyle: 'medium', timeStyle: 'short', timeZone: 'Asia/Shanghai'
  }).format(new Date(iso))
}
</script>

<template>
  <article>
    <h1>{{ title ?? '未命名公告' }}</h1>
    <time :datetime="publishedAt">{{ formatTime(publishedAt) }}</time>
    <p class="body">{{ body }}</p>
    <p>{{ priority >= 8 ? '高优先级' : '普通' }}</p>
    <code v-pre>{{ title }}</code>
  </article>
</template>

<style scoped>
.body { white-space: pre-wrap; }
</style>

第一组显示“维护通知”和“普通”;第二组显示“未命名公告”和“高优先级”;第三组标题空白、级别为“普通”。?? 只为null/undefined兜底,|| 会错误覆盖第三组空字符串。正文不会出现斜体元素,换行属于CSS展示;datetime 中保存机器可读时间,formatTime 只生成给人看的文本。字面量区域被 v-pre 跳过编译。

02|外链按钮:禁用行为与禁用语义分工 ​

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

const props = withDefaults(defineProps<{
  href: string
  external?: boolean
  disabled?: boolean
  tone?: 'primary' | 'muted'
}>(), {
  external: false,
  disabled: false,
  tone: 'primary'
})

const classes = computed(() => [
  'external-link-button',
  `external-link-button--${props.tone}`,
  { 'is-disabled': isDisabled.value }
])

const safeHref = computed(() => {
  if (/^\/(?!\/)/.test(props.href)) return props.href
  try {
    const url = new URL(props.href)
    if (url.protocol === 'http:' || url.protocol === 'https:') return props.href
  } catch {
    // 无法解析的字符串由禁用分支处理。
  }
  return undefined
})

const isDisabled = computed(() => props.disabled || safeHref.value === undefined)
const opensNewTab = computed(() =>
  props.external && !isDisabled.value
)

</script>

<template>
  <a
    :href="isDisabled ? undefined : safeHref"
    :class="classes"
    :aria-disabled="String(isDisabled)"
    :role="isDisabled ? 'link' : undefined"
    :tabindex="isDisabled ? 0 : undefined"
    :target="opensNewTab ? '_blank' : undefined"
    :rel="opensNewTab ? 'noopener noreferrer' : undefined"
  >
    <slot>打开资源</slot>
  </a>
</template>

<a> 没有原生 disabled;aria-disabled 只表达语义,不会自动阻止导航。答案同时移除禁用/非法 URL 的 href并保留键盘焦点;没有href的元素本身没有链接导航行为。若只加灰色 class 或 aria-disabled,鼠标、键盘或上下文菜单仍可能进入原地址;若把 javascript: 留在 href,则根本没有守住输入合同。

03|基础答案:手动连接输入事件 ​

vue
<!-- src/App.vue -->
<script setup lang="ts">
import { ref } from 'vue'
const draft = ref('')
const lastSubmitted = ref('尚未提交')
function input(event: Event) {
  draft.value = (event.target as HTMLTextAreaElement).value
}
function submit() {
  const text = draft.value.trim()
  if (!text) return
  lastSubmitted.value = text
  draft.value = ''
}
</script>
<template>
  <label>内容
    <textarea :value="draft" @input="input" @keydown.ctrl.enter.exact.prevent="submit" />
  </label>
  <button type="button" @click="submit">提交</button>
  <p>{{ lastSubmitted }}</p>
</template>

输入事件只负责保存原草稿;提交函数负责trim和空值早退。两个触发入口共用同一函数。普通Enter未被拦截,精确修饰符排除多余系统键。下面的卡片答案仅用于完成第04~06篇后的回访。

03|快捷编辑器:让提交和卡片点击互不抢戏 ​

vue
<!-- QuickEditor.vue -->
<script setup lang="ts">
import { ref } from 'vue'

type Result = { id: number; text: string }

const draft = ref('')
const results = ref<Result[]>([])
const previewingId = ref<number | null>(null)
let nextId = 1

function submit() {
  const text = draft.value.trim()
  if (!text) return

  results.value.unshift({ id: nextId++, text })
  draft.value = ''
}

function remove(id: number) {
  results.value = results.value.filter((result) => result.id !== id)
  if (previewingId.value === id) previewingId.value = null
}
</script>

<template>
  <form @submit.prevent="submit">
    <label>
      内容
      <textarea v-model="draft" @keydown.ctrl.enter.exact.prevent="submit" />
    </label>
    <button type="submit">提交</button>
  </form>

  <ul>
    <li v-for="result in results" :key="result.id">
      <article
        :class="{ previewing: previewingId === result.id }"
        @click="previewingId = result.id"
      >
        <p>{{ result.text }}</p>
        <button type="button" @click.stop="previewingId = result.id">预览</button>
        <button type="button" @click.stop="remove(result.id)">删除</button>
      </article>
    </li>
  </ul>
</template>

没有为普通 Enter 写处理器,所以 textarea 保留换行;Ctrl+Enter 用 .exact 排除 Shift 或 Alt。nextId 在创建数据时分配身份,连续提交不会因同一毫秒碰撞。卡片空白区域支持指针预览,原生“预览”按钮提供同等键盘入口;两个内部按钮用 .stop 避免再次触发卡片。@scroll.passive.prevent 不能使用:一个承诺不取消滚动,另一个要求取消。

04|报名表:让每种控件回到它自己的值类型 ​

vue
<!-- SignupForm.vue -->
<script setup lang="ts">
import { computed, reactive, ref } from 'vue'

const form = reactive({
  name: '',
  age: '' as number | '',
  interests: [] as string[],
  attendance: 'online' as 'online' | 'onsite'
})
const submitting = ref(false)
const failNext = ref(false)
const submittedPayload = ref('')
const submitError = ref('')
const errors = reactive<{ name?: string; age?: string }>({})

const ageIsValid = computed(() =>
  typeof form.age === 'number' &&
  Number.isInteger(form.age) &&
  form.age >= 16 &&
  form.age <= 120
)

async function submit() {
  if (submitting.value) return

  errors.name = form.name.trim() ? undefined : '请输入姓名。'
  errors.age = ageIsValid.value
    ? undefined
    : '年龄须为 16 到 120 的整数。'
  if (errors.name || errors.age) return

  const payload = {
    name: form.name.trim(),
    age: form.age,
    interests: [...form.interests],
    attendance: form.attendance
  }
  const shouldFail = failNext.value
  submitting.value = true
  submittedPayload.value = ''
  submitError.value = ''
  try {
    await new Promise<void>((resolve, reject) => {
      window.setTimeout(() => {
        if (shouldFail) reject(new Error('模拟报名失败'))
        else resolve()
      }, 300)
    })
    submittedPayload.value = JSON.stringify(payload)
  } catch {
    submitError.value = '报名失败,请重试。'
  } finally {
    submitting.value = false
  }
}
</script>

<template>
  <form :aria-busy="submitting" @submit.prevent="submit">
    <label for="name">姓名</label>
    <input id="name" v-model="form.name" :aria-describedby="errors.name ? 'name-error' : undefined" />
    <p v-if="errors.name" id="name-error" role="alert">{{ errors.name }}</p>

    <label for="age">年龄</label>
    <input id="age" v-model.number="form.age" type="number" min="16" max="120" :aria-describedby="errors.age ? 'age-error' : undefined" />
    <p v-if="errors.age" id="age-error" role="alert">{{ errors.age }}</p>

    <fieldset>
      <legend>兴趣</legend>
      <label><input v-model="form.interests" type="checkbox" value="Vue" />Vue</label>
      <label><input v-model="form.interests" type="checkbox" value="TypeScript" />TypeScript</label>
    </fieldset>

    <fieldset>
      <legend>参与方式</legend>
      <label><input v-model="form.attendance" type="radio" value="online" />线上</label>
      <label><input v-model="form.attendance" type="radio" value="onsite" />现场</label>
    </fieldset>

    <label><input v-model="failNext" type="checkbox" />下一次请求失败</label>
    <button :disabled="submitting">{{ submitting ? '提交中…' : '报名' }}</button>
    <p v-if="submittedPayload" role="status">报名成功:{{ submittedPayload }}</p>
    <p v-if="submitError" role="alert">{{ submitError }}</p>
  </form>
</template>

校验成功后立刻生成 payload 快照,数组用展开复制;异步任务始终使用该份已校验输入。请求中仍可编辑表单,但新草稿不会混进在途提交。验证变体:姓名Ada、年龄16提交后立即清空姓名、改年龄15,成功载荷仍应为Ada和16,下一次提交才拒绝新草稿。姓名使用普通 v-model 保留输入草稿,载荷在提交边界 trim。.number 在清空时会得到空字符串,不能把状态写成只含 number | undefined;年龄校验先确认类型再检查整数范围。若把空值用 Number('') 转换,会错误得到 0。提交失败由 catch 显示,finally 恢复状态,逻辑早退和按钮禁用共同防重。

05|设置页:按状态生命周期选择条件渲染 ​

vue
<!-- src/components/HelpDrawer.vue -->
<script setup lang="ts">
import { ref } from 'vue'

const query = ref('')
</script>

<template>
  <aside>
    <label>查帮助 <input v-model="query" /></label>
    <p>当前草稿:{{ query || '空' }}</p>
  </aside>
</template>
vue
<!-- src/components/DeleteConfirm.vue -->
<script setup lang="ts">
import { ref } from 'vue'

const confirmation = ref('')
const emit = defineEmits<{ confirm: []; cancel: [] }>()
</script>

<template>
  <section role="dialog" aria-modal="true" aria-label="删除账户确认">
    <p>请输入 DELETE;取消后本地草稿应随组件一起卸载。</p>
    <input v-model="confirmation" aria-label="确认文字" />
    <button
      type="button"
      :disabled="confirmation !== 'DELETE'"
      @click="emit('confirm')"
    >
      确认删除
    </button>
    <button type="button" @click="emit('cancel')">取消</button>
  </section>
</template>
vue
<!-- src/App.vue -->
<script setup lang="ts">
import { ref } from 'vue'
import DeleteConfirm from './components/DeleteConfirm.vue'
import HelpDrawer from './components/HelpDrawer.vue'

const showExperimental = ref(false)
const showHelp = ref(false)
const showDeleteConfirm = ref(false)
const deleted = ref(false)

function deleteAccount() {
  deleted.value = true
  showDeleteConfirm.value = false
}
</script>

<template>
  <section>
    <button @click="showExperimental = !showExperimental">实验功能</button>
    <aside v-if="showExperimental">
      首屏不创建;关闭后也会从 DOM 中移除。
    </aside>

    <button @click="showHelp = !showHelp">帮助</button>
    <HelpDrawer v-show="showHelp" />

    <button @click="showDeleteConfirm = true">删除账户</button>
    <DeleteConfirm
      v-if="showDeleteConfirm"
      @confirm="deleteAccount"
      @cancel="showDeleteConfirm = false"
    />
    <p v-if="deleted" role="status">账户已删除。</p>
  </section>
</template>

实验区域与确认框用 v-if,首屏不创建,关闭后卸载;确认文字放在 DeleteConfirm 内,因此重新创建会自然归零。帮助抽屉用 v-show,组件一直挂载,所以本地 query 保留。若把确认文字提升到 App,单纯切换 v-if 不会替父级重置它,这正是题目要求拆文件观察的原因。

06|联系人簿:列表身份要跟业务对象走 ​

vue
<!-- src/components/ContactRow.vue -->
<script setup lang="ts">
import { ref } from 'vue'

defineProps<{
  contact: {
    id: string
    name: string
    tags: string[]
    favorite: boolean
  }
}>()

const note = ref('')
</script>

<template>
  <li>
    <strong>{{ contact.name }}</strong>
    <span>({{ contact.id }} / {{ contact.tags.join('、') }})</span>
    <label>备注 <input v-model="note" /></label>
  </li>
</template>
vue
<!-- src/App.vue -->
<script setup lang="ts">
import { computed, ref } from 'vue'
import ContactRow from './components/ContactRow.vue'

type Contact = { id: string; name: string; tags: string[]; favorite: boolean }

const favoritesOnly = ref(false)
const direction = ref<'asc' | 'desc'>('asc')
const contacts = ref<Contact[]>([
  { id: 'u-2', name: '北川', tags: ['TypeScript'], favorite: false },
  { id: 'u-1', name: '安娜', tags: ['Vue'], favorite: true },
  { id: 'u-3', name: '陈墨', tags: ['Vue', 'CSS'], favorite: true }
])

const visibleContacts = computed(() => {
  const filtered = contacts.value
    .filter((contact) => !favoritesOnly.value || contact.favorite)

  return [...filtered].sort((a, b) => {
    const result = a.name.localeCompare(b.name, 'zh-CN')
    return direction.value === 'asc' ? result : -result
  })
})

const sourceOrder = computed(() => contacts.value.map(({ id }) => id).join(','))
</script>

<template>
  <label><input v-model="favoritesOnly" type="checkbox" />仅看收藏</label>
  <button type="button" @click="direction = direction === 'asc' ? 'desc' : 'asc'">
    切换排序
  </button>
  <p>源 id 顺序:{{ sourceOrder }}</p>
  <ul>
    <ContactRow
      v-for="contact in visibleContacts"
      :key="contact.id"
      :contact="contact"
    />
  </ul>
</template>

筛选后再复制排序,所以源顺序始终是 u-2,u-1,u-3。ContactRow 的 note 是组件局部状态;升降序移动时,稳定 id 让同一实例跟着联系人走。若改成 index,Vue 会按新位置复用旧实例,重点联系 就可能落到另一位联系人。

07|仪表盘:先测量,再让 v-memo 精准记忆 ​

vue
<!-- ScoreDashboard.vue -->
<script setup lang="ts">
import { ref } from 'vue'

type Row = { id: string; name: string; score: number }

const selectedId = ref<string | null>(null)
const generatedAt = ref(new Date().toISOString())
const rows = ref<Row[]>(Array.from({ length: 500 }, (_, index) => ({
  id: `row-${index}`,
  name: `成员 ${index + 1}`,
  score: index % 100
})))

function selectTarget() {
  selectedId.value = 'row-249'
}

function increaseTargetScore() {
  const target = rows.value[249]
  if (target) target.score += 1
}

function refreshGeneratedAt() {
  generatedAt.value = new Date().toISOString()
}
</script>

<template>
  <button type="button" @click="selectTarget">选择第 250 行</button>
  <button type="button" @click="increaseTargetScore">第 250 行加 1 分</button>
  <button type="button" @click="refreshGeneratedAt">刷新报表时间</button>
  <p v-once>导出快照:{{ generatedAt }}</p>
  <p>实时区域:{{ generatedAt }}</p>
  <ul>
    <li
      v-for="row in rows"
      :key="row.id"
      v-memo="[row.id, row.name, row.score, selectedId === row.id]"
      :class="{ selected: selectedId === row.id }"
      @click="selectedId = row.id"
    >
      {{ row.name }}:{{ row.score }} 分
    </li>
  </ul>
</template>

先移除 v-memo 测同一组选择操作;当前机器没有可重复热点就不要保留优化。若保留,依赖数组必须包含所有可见输出:删掉 row.score 后点击“加 1 分”会得到陈旧分数。v-once 快照与实时区域并排,刷新后一个固定、一个变化;若产品要求快照也更新,失败原因就是选错了指令。

08|错误字段:状态先更新,DOM 后处理 ​

vue
<!-- FocusInvalidField.vue -->
<script setup lang="ts">
import { nextTick, reactive, ref, useTemplateRef } from 'vue'

const form = reactive({ name: '', email: '' })
const errors = reactive<{ name?: string; email?: string }>({})
const submitted = ref(false)
const nameInput = useTemplateRef<HTMLInputElement>('nameInput')
const emailInput = useTemplateRef<HTMLInputElement>('emailInput')

async function submit() {
  submitted.value = false
  errors.name = form.name.trim().length >= 2
    ? undefined
    : '姓名至少需要 2 个字符。'
  errors.email = /^[^\s@]+@[^\s@]+$/.test(form.email)
    ? undefined
    : '请输入有效的邮箱地址。'

  const firstInvalid = errors.name ? nameInput : errors.email ? emailInput : null
  if (!firstInvalid) {
    submitted.value = true
    return
  }

  await nextTick()
  firstInvalid.value?.focus()
  firstInvalid.value?.scrollIntoView({ block: 'center' })
}
</script>

<template>
  <form @submit.prevent="submit">
    <label for="name">姓名</label>
    <input
      id="name"
      ref="nameInput"
      v-model="form.name"
      :aria-describedby="errors.name ? 'name-error' : undefined"
    />
    <p v-if="errors.name" id="name-error" role="alert">{{ errors.name }}</p>

    <label for="email">邮箱</label>
    <input
      id="email"
      ref="emailInput"
      v-model.trim="form.email"
      :aria-describedby="errors.email ? 'email-error' : undefined"
    />
    <p v-if="errors.email" id="email-error" role="alert">{{ errors.email }}</p>
    <button>保存</button>
    <p v-if="submitted" role="status">保存成功。</p>
  </form>
</template>

错误对象是事实来源,两个 DOM ref 只执行 focus 与 scroll。两项为空时姓名排在前;姓名合法而邮箱非法时才聚焦邮箱。合法提交不等待 DOM,也不抢焦点。若目标分支在 nextTick() 前消失,可选链安全结束;去掉可选链并用非空断言,会把正常的条件变化变成运行时错误。

09|面板与数据列表:内容由父级提供,数据由子级显式交付 ​

vue
<!-- AppPanel.vue -->
<template>
  <section class="panel">
    <header v-if="$slots.toolbar"><slot name="toolbar" /></header>
    <div class="panel__body"><slot>暂无内容。</slot></div>
    <footer v-if="$slots.footer"><slot name="footer" /></footer>
  </section>
</template>
vue
<!-- DataList.vue -->
<script setup lang="ts" generic="T extends { id: string | number }">
defineProps<{ items: T[] }>()
defineSlots<{
  default(props: { item: T; index: number }): unknown
  empty?(): unknown
}>()
</script>

<template>
  <p v-if="items.length === 0"><slot name="empty">列表为空。</slot></p>
  <ul v-else>
    <li v-for="(item, index) in items" :key="item.id">
      <slot :item="item" :index="index" />
    </li>
  </ul>
</template>
vue
<!-- src/App.vue -->
<script setup lang="ts">
import { ref } from 'vue'
import AppPanel from './components/AppPanel.vue'
import DataList from './components/DataList.vue'

const suffix = '!'
const contacts = ref<Array<{ id: string; name: string }>>([])

function loadContacts() {
  contacts.value = [
    { id: 'u-1', name: '安娜' },
    { id: 'u-2', name: '北川' }
  ]
}
</script>

<template>
  <button type="button" @click="loadContacts">载入两项</button>
  <AppPanel>
    <template #toolbar><button type="button">筛选</button></template>
    <DataList :items="contacts">
      <template #default="{ item, index }">
        {{ index }}. {{ item.name }}{{ suffix }}
      </template>
      <template #empty>还没有联系人。</template>
    </DataList>
    <template #footer>共 {{ contacts.length }} 位联系人</template>
  </AppPanel>
</template>

items 是 props,面板区域与 DataList 的空状态由 slots 分发,item、index 是显式 slot props。空数组不渲染空 ul;有数据时索引从 0 开始。父级插槽能读取自己的 suffix,但子组件未传出的局部变量在父级不存在;这不是运行偶然,而是插槽渲染作用域合同。

10|资料卡:主题是公开 API,样式边界是实现细节 ​

vue
<!-- ResourceCard.vue -->
<script setup lang="ts">
type Theme = { accent: string; surface: string }

const props = withDefaults(defineProps<{
  title: string
  url: string
  theme?: Theme
  featured?: boolean
}>(), {
  theme: () => ({ accent: '#0f766e', surface: '#f0fdfa' }),
  featured: false
})
</script>

<template>
  <article
    class="resource-card"
    :class="{ 'resource-card--featured': props.featured }"
  >
    <p v-if="props.featured" class="resource-card__badge">推荐</p>
    <h2>{{ props.title }}</h2>
    <slot>暂未提供说明。</slot>
    <a :href="props.url" target="_blank" rel="noopener noreferrer">阅读资源</a>
  </article>
</template>

<style scoped>
.resource-card {
  --accent: v-bind('props.theme.accent');
  --surface: v-bind('props.theme.surface');
  border: 2px solid var(--accent);
  background: var(--surface);
  padding: 1rem;
}
.resource-card--featured { box-shadow: 0 0.5rem 1.5rem color-mix(in srgb, var(--accent), transparent 75%); }
:slotted(.resource-link) { color: var(--accent); }
</style>

下面的父组件同时覆盖主题变化、状态 class 与具名的插槽根元素:

vue
<!-- src/App.vue -->
<script setup lang="ts">
import { ref } from 'vue'
import ResourceCard from './components/ResourceCard.vue'

const featured = ref(false)
const theme = ref({ accent: '#0f766e', surface: '#f0fdfa' })

function switchTheme() {
  theme.value = { accent: '#7c3aed', surface: '#f5f3ff' }
}
</script>

<template>
  <button type="button" @click="switchTheme">切换主题</button>
  <label><input v-model="featured" type="checkbox" />推荐</label>

  <ResourceCard
    title="Vue 模板资料"
    url="/study-notes/frontend/vue/"
    :featured="featured"
    :theme="theme"
  >
    <a class="resource-link" href="/learning-paths/frontend/vue">继续学习</a>
  </ResourceCard>
</template>

父级通过 theme 这个公开 prop 影响颜色;数组/对象默认值用工厂避免实例共享。:slotted(.resource-link) 只匹配父级传入的插槽根节点,不会递归接管其所有后代。非法 CSS 值通常会让对应声明失效,但“浏览器忽略了”不是输入校验;不可信主题仍应在进入组件前按允许格式拒绝或回退。

11|搜索框:把输入、动作、命令与 attrs 各放其位 ​

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

defineOptions({ inheritAttrs: false })

const props = withDefaults(defineProps<{
  placeholder?: string
  disabled?: boolean
  label?: string
}>(), {
  placeholder: '搜索…',
  disabled: false
})

const query = defineModel<string>({ required: true })
const emit = defineEmits<{ submit: [query: string] }>()
const input = useTemplateRef<HTMLInputElement>('input')
const labelText = computed(() => props.label ?? '搜索')

function submit() {
  const normalizedQuery = query.value.trim()
  if (props.disabled || !normalizedQuery) return
  emit('submit', normalizedQuery)
}

function focus() {
  input.value?.focus()
}

defineExpose({ focus })
defineSlots<{ default(props: { query: string }): unknown }>()
</script>

<template>
  <form class="search-field" @submit.prevent="submit">
    <label>
      <span>{{ labelText }}</span>
      <input
        ref="input"
        v-model="query"
        :placeholder="placeholder"
        :disabled="disabled"
        v-bind="$attrs"
      />
    </label>
    <button :disabled="disabled">搜索</button>
    <slot :query="query" />
  </form>
</template>

父级可这样验证 attrs 是否落到真正的输入框:

vue
<script setup lang="ts">
import { ref, useTemplateRef } from 'vue'
import SearchField from './components/SearchField.vue'

const query = ref('')
const submits = ref<string[]>([])
const searchField = useTemplateRef<InstanceType<typeof SearchField>>('searchField')

function runSearch(value: string) {
  submits.value.push(value)
}
</script>

<template>
<SearchField
  ref="searchField"
  v-model="query"
  aria-describedby="search-help"
  autocomplete="off"
  data-testid="search-input"
  @submit="runSearch"
>
  <template #default="{ query: currentQuery }">
    <small id="search-help">当前输入:{{ currentQuery }}</small>
  </template>
</SearchField>
<button type="button" @click="searchField?.focus()">聚焦搜索框</button>
<output>提交:{{ submits.join('、') || '无' }}</output>
</template>

这里 props 是配置,model 保留原始输入草稿,submit 只发送非空规范化值,默认 slot 是附加内容,focus() 是唯一公开命令。若删除空白早退,题目的事件计数会增加;若漏掉 v-bind="$attrs",三个 attribute 都到不了真实 input;若把组件 model 设默认值而父级仍是 undefined,父子初值可能不同步。

12|旧搜索组件:先锁定行为,再迁移边界 ​

vue
<!-- Vue 2.7: src/components/LegacySearch.vue -->
<script>
function searchCatalog(query) {
  const delay = query === 'slow' ? 1200 : 300
  return new Promise((resolve, reject) => {
    window.setTimeout(() => {
      if (query === 'fail') reject(new Error('模拟网络失败'))
      else resolve([`${query} 入门`, `${query} 实战`])
    }, delay)
  })
}

export default {
  name: 'LegacySearch',
  inheritAttrs: false,
  props: {
    value: { type: String, default: '' }
  },
  data() {
    return {
      phase: 'idle',
      errorMessage: '',
      results: [],
      requestToken: 0
    }
  },
  computed: {
    isLoading() {
      return this.phase === 'loading'
    },
    canSubmit() {
      return this.value.trim().length > 0 && !this.isLoading
    }
  },
  beforeDestroy() {
    this.requestToken += 1
  },
  methods: {
    updateValue(event) {
      this.$emit('input', event.target.value)
    },
    async submit() {
      const query = this.value.trim()
      if (!query) {
        this.phase = 'error'
        this.errorMessage = '请输入搜索词。'
        return
      }
      if (this.isLoading) return

      const token = ++this.requestToken
      this.phase = 'loading'
      this.errorMessage = ''
      this.results = []

      try {
        const results = await searchCatalog(query)
        if (token !== this.requestToken) return
        this.results = results
        this.phase = 'success'
      } catch (error) {
        if (token !== this.requestToken) return
        this.phase = 'error'
        this.errorMessage = error instanceof Error
          ? error.message
          : '搜索失败,请重试。'
      } finally {
        if (token === this.requestToken && this.phase === 'loading') {
          this.phase = 'idle'
        }
      }
    }
  }
}
</script>

<template>
  <form :aria-busy="String(isLoading)" @submit.prevent="submit">
    <label for="legacy-search">搜索课程</label>
    <input
      id="legacy-search"
      :value="value"
      :disabled="isLoading"
      :aria-describedby="errorMessage ? 'legacy-search-error' : undefined"
      v-bind="$attrs"
      @input="updateValue"
    >
    <button :disabled="!canSubmit">{{ isLoading ? '搜索中…' : '搜索' }}</button>
    <p v-if="errorMessage" id="legacy-search-error" role="alert">
      {{ errorMessage }}
    </p>
    <ul v-else-if="results.length">
      <li v-for="result in results" :key="result">{{ result }}</li>
    </ul>
  </form>
</template>

Vue 2 的 model 仍由 value/input 成对组成;组件不复制一份可能过期的 query。空输入在方法入口失败,loading 早退防止程序性重复调用,finally 只收束当前 token,beforeDestroy 让迟到结果失效。若只禁用按钮,测试或其他入口仍能重复调用;若销毁时不失效 token,慢结果仍会尝试写旧实例。token 不会终止底层计时器或网络,真实 API 还要接入取消能力。

13|模板迁移契约:把隐式写法改成显式合同 ​

vue
<!-- Vue 3.5: src/components/PriceEditor.vue -->
<script setup lang="ts">
defineOptions({ inheritAttrs: false })

const props = withDefaults(defineProps<{ currency?: string }>(), {
  currency: 'CNY'
})
const amount = defineModel<string>({ required: true })
const open = defineModel<boolean>('open', { required: true })
const emit = defineEmits<{ activate: [event: MouseEvent] }>()

function formatMoney(value: string) {
  const parsed = Number(value)
  if (!value.trim() || !Number.isFinite(parsed)) return '—'

  return new Intl.NumberFormat('zh-CN', {
    style: 'currency',
    currency: props.currency
  }).format(parsed)
}
</script>

<template>
  <section @click="emit('activate', $event)">
    <label>金额 <input v-model="amount" inputmode="decimal" v-bind="$attrs" /></label>
    <p>展示值:{{ formatMoney(amount) }}</p>
    <button type="button" @click="open = !open">
      {{ open ? '收起说明' : '展开说明' }}
    </button>
    <p v-if="open">金额以字符串草稿保存,提交边界再校验。</p>
  </section>
</template>
vue
<!-- Vue 3.5: src/App.vue -->
<script setup lang="ts">
import { ref } from 'vue'
import PriceEditor from './components/PriceEditor.vue'

const amount = ref('12.50')
const open = ref(false)
const focusCount = ref(0)
const activateCount = ref(0)
</script>

<template>
  <PriceEditor
    v-model="amount"
    v-model:open="open"
    @focus="focusCount += 1"
    @activate="activateCount += 1"
  />
  <output>
    amount={{ amount }};focus={{ focusCount }};activate={{ activateCount }}
  </output>
</template>

这个实现逐项兑现题目:默认 model 替代 value/input,命名 model 替代 .sync,普通函数替代 filter,显式 activate 替代 .native。未声明的 focus listener 留在 $attrs 并被转发到 input;声明过的 activate 从 attrs 中排除,只由组件发送一次。

四类失败各有不同原因:

  • 只改父模板、不改子事件名:model 只能向下传,不能回写。
  • 删除 .sync 却不改成 v-model:open:父级不再接收 update:open。
  • 忘记 v-bind="$attrs":focus 监听器与 inputmode、ARIA 等一起丢失。
  • 手工 $emit('click') 又遗漏 click 的 emits 声明:父级 listener 还可能透传到根节点,造成一次点击触发两次。

交付前自检 ​

  1. 每题先运行一条错误路径:空内容、非法年龄、排序后编辑、禁用状态或验证失败。
  2. 再运行一条无障碍路径:用键盘 Tab、Enter、读出标签和错误信息。
  3. 最后解释一条取舍:为什么此处选择 v-if、业务 ID、nextTick、插槽或 defineExpose。

完成这些后,回到 第一章概览 做一次整体复盘;答案页是校准工具,不是跳过练习的传送门。

用心记录每一步成长