第一章练习答案:把每道门都推开一次
第一章概览 / 练习答案。练习页只给任务、提示和验收;这里才放 01~13 的参考实现。它们不是唯一写法;更重要的是逐项核对行为、数据流和可访问性,而不是把代码当成可复制的咒语。
使用方式:先比行为,再比代码
每题先自己完成,再拿下面的实现做三件事:运行交互、比对验收点、解释每一项取舍。若两份代码都能跑,优先保留那份边界更清楚、名字更准确的版本。
| 对照维度 | 该问的问题 |
|---|---|
| 行为 | 用户操作后,状态与界面是否一起正确变化? |
| 数据流 | 输入、派生值和事件是否各有单一来源? |
| 语义 | 原生控件、标签和 ARIA 是否描述了真实交互? |
| 边界 | 列表身份、插槽、attrs、组件公开能力是否清楚? |
00|运行环境:从文件变化追到挂载
- 标题保存后变化来自Vite监听源文件并更新开发页面。普通
const没有因此变成响应式状态。 - 只改HTML为
id="reader",入口仍查找#app,找不到挂载容器,页面不会显示预期组件;开发控制台会给出挂载目标不存在的警告。此时应检查入口和DOM,而不是给组件加ref。 - 把
src/main.ts的调用同步改成createApp(App).mount('#reader'),与HTML相同,重新验证组件可见。实验结束可以把两侧同时恢复app。 const lessonCount: number = 'three'给number变量赋了string;npm run type-check应失败。改回3后再检查应通过。开发页面能显示字符串不构成类型通过证据,Vite转换TypeScript和类型检查是不同工作。
若步骤2没有出现预期警告,先确认浏览器打开的是当前项目端口、保存的是当前项目文件,而不是另一个开发服务。步骤4的证据是类型检查退出状态和诊断,不是只看编辑器是否有红线。
01|公告展示:先确定空值与文本合同
<!-- 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|外链按钮:禁用行为与禁用语义分工
<!-- 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|基础答案:手动连接输入事件
<!-- 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|快捷编辑器:让提交和卡片点击互不抢戏
<!-- 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|报名表:让每种控件回到它自己的值类型
<!-- 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|设置页:按状态生命周期选择条件渲染
<!-- 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><!-- 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><!-- 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|联系人簿:列表身份要跟业务对象走
<!-- 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><!-- 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 精准记忆
<!-- 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 后处理
<!-- 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|面板与数据列表:内容由父级提供,数据由子级显式交付
<!-- 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><!-- 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><!-- 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,样式边界是实现细节
<!-- 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 与具名的插槽根元素:
<!-- 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 各放其位
<!-- 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 是否落到真正的输入框:
<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 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 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 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 还可能透传到根节点,造成一次点击触发两次。
交付前自检
- 每题先运行一条错误路径:空内容、非法年龄、排序后编辑、禁用状态或验证失败。
- 再运行一条无障碍路径:用键盘 Tab、Enter、读出标签和错误信息。
- 最后解释一条取舍:为什么此处选择
v-if、业务 ID、nextTick、插槽或defineExpose。
完成这些后,回到 第一章概览 做一次整体复盘;答案页是校准工具,不是跳过练习的传送门。