07|渲染器、编译器与性能练习答案
第七章 / 参考答案。先对照行为合同,再比较实现;性能题先保正确,再谈分布。
01|记录一条 patch 轨迹
import { h, render } from 'vue'
export function tracePatch(host: HTMLDivElement) {
if (host.childElementCount !== 0) throw new Error('host 必须为空')
render(h('button', { key: 'a' }, '保存'), host)
const first = host.firstElementChild
render(h('button', { key: 'a' }, '已保存'), host)
const second = host.firstElementChild
render(h('button', { key: 'b' }, '重试'), host)
const third = host.firstElementChild
const result = {
reused: first === second,
replaced: second !== third,
finalText: third?.textContent,
afterUnmount: -1
}
render(null, host)
result.afterUnmount = host.childElementCount
return result
}断言整个对象等于 { reused: true, replaced: true, finalText: '重试', afterUnmount: 0 }。若把第二次 key 改掉,reused 必须失败;这能排除“只检查最终文本”的错误实现。
02|判断重排后的身份
先在第一章的 Vite 项目中新建 src/KeyRows.vue。输入框没有 v-model 或 :value,草稿由真实 DOM 保存,这样能够直接观察身份是否被错误复用。
<script setup lang="ts">
import { ref } from 'vue'
const props = defineProps<{ strategy: 'id' | 'index' }>()
const rows = ref([
{ id: 'a', label: 'Alpha' },
{ id: 'b', label: 'Beta' },
{ id: 'c', label: 'Gamma' }
])
function reorder() {
const byId = new Map(rows.value.map(row => [row.id, row]))
rows.value = ['c', 'a', 'b'].map(id => byId.get(id)!)
}
</script>
<template>
<button @click="reorder">改为 c,a,b</button>
<ul>
<li v-for="(row, index) in rows"
:key="props.strategy === 'id' ? row.id : index" :data-id="row.id">
{{ row.label }}<input :aria-label="`${row.label} 草稿`">
</li>
</ul>
</template>App.vue 可以同时挂载 <KeyRows strategy="id" /> 与 <KeyRows strategy="index" />,分别输入、重排,两个实例各自保留初始状态。记下原 b 输入元素引用,再按业务 id 查找,结果是:
| key 策略 | 新 b 是否仍是原 b 元素 | 新 a 的值 | 新 b 的值 | 原 b 元素现在属于谁 |
|---|---|---|---|---|
| id | 是 | 空串 | draft | b |
| index | 否 | draft | 空串 | a |
index 的旧下标1对应 b,新下标1却对应 a。Vue 在同一个 DOM 上更新标签与 data-id,没有代码要求它清空非受控输入的 value。业务身份与位置身份因此分离。
完成第九章测试环境后,新建 src/KeyRows.spec.ts,运行 npx vitest run src/KeyRows.spec.ts:
import { mount } from '@vue/test-utils'
import { expect, test } from 'vitest'
import KeyRows from './KeyRows.vue'
test.each(['id', 'index'] as const)('%s key 的身份观测', async strategy => {
const wrapper = mount(KeyRows, { props: { strategy } })
try {
const input = (id: string) => wrapper.get<HTMLInputElement>(`[data-id="${id}"] input`)
const originalB = input('b').element
await input('b').setValue('draft')
await wrapper.get('button').trigger('click')
expect(wrapper.findAll('li').map(li => li.attributes('data-id'))).toEqual(['c', 'a', 'b'])
expect(input('b').element === originalB).toBe(strategy === 'id')
expect(input('b').element.value).toBe(strategy === 'id' ? 'draft' : '')
expect(input('a').element.value).toBe(strategy === 'id' ? '' : 'draft')
expect(originalB.closest('li')?.dataset.id).toBe(strategy === 'id' ? 'b' : 'a')
} finally {
wrapper.unmount()
}
})两次独立挂载很重要:在同一实例中切换 key 策略,会混入一次身份替换,不能代表两种策略从相同初始条件出发。只比较最终标签也不够,两种策略都能正确显示 c,a,b。
故意重置的例子是 <Editor :key="documentId" />:打开另一份文档时创建新编辑器,局部草稿按新文档初始化。这个决策要求旧文档草稿已保存或允许丢弃;key 改变负责重建实例,本身不负责保存草稿。
03|受控编译对照
import { compile } from '@vue/compiler-dom'
import assert from 'node:assert/strict'
function inspect(template) {
const { code } = compile(template, { mode: 'module', hoistStatic: true })
return {
code,
cachedStatics: (code.match(/\bCACHED\b/g) ?? []).length +
(code.match(/const _hoisted_/g) ?? []).length,
dynamicMarkers: code.match(/\b(?:TEXT|CLASS|PROPS)\b/g) ?? []
}
}
const a = inspect('<div><span>fixed</span><b>{{ count }}</b></div>')
const b = inspect('<div><span>{{ label }}</span><b>{{ count }}</b></div>')
assert.ok(a.dynamicMarkers.includes('TEXT'))
assert.ok(a.cachedStatics > b.cachedStatics)
assert.ok(b.dynamicMarkers.filter(x => x === 'TEXT').length >=
a.dynamicMarkers.filter(x => x === 'TEXT').length)
console.table({ A: a.cachedStatics, B: b.cachedStatics })这里不快照完整 code,因为辅助函数名和代码布局会随编译器版本变化;断言只锁定本题关心的动态标记关系。
04|证明队列去重
保存为 src/BatchingLab.vue,可以直接在 App.vue 中导入挂载。点击一次后在 Console 展开三份快照。快照和计数器特意不用响应式值:它们是观测记录,若把更新钩子中的计数再渲染到同一个组件,会制造额外更新,改变实验本身。
<script setup lang="ts">
import { nextTick, onUpdated, ref, watch } from 'vue'
const firstName = ref('Ada')
const lastName = ref('Lovelace')
const age = ref(20)
const output = ref<HTMLOutputElement | null>(null)
let updatedCalls = 0
let snapshots: Array<{ state: (string | number)[]; dom: string; updatedCalls: number }> = []
const syncStates: (string | number)[][] = []
let running = false
onUpdated(() => { updatedCalls++ })
watch([firstName, lastName, age], values => {
syncStates.push([...values])
}, { flush: 'sync' })
function snapshot() {
return {
state: [firstName.value, lastName.value, age.value],
dom: output.value!.textContent!,
updatedCalls
}
}
async function run() {
if (running) return
running = true
try {
syncStates.length = 0
snapshots = [snapshot()]
firstName.value = firstName.value === 'Ada' ? 'Grace' : 'Ada'
lastName.value = lastName.value === 'Lovelace' ? 'Hopper' : 'Lovelace'
age.value++
snapshots.push(snapshot())
await nextTick()
snapshots.push(snapshot())
console.log({ snapshots, syncStates: syncStates.map(values => [...values]) })
} finally {
running = false
}
}
defineExpose({ readTrace: () => ({ snapshots, syncStates }) })
</script>
<template>
<button @click="run">修改三个值</button>
<output ref="output">{{ firstName }}|{{ lastName }}|{{ age }}</output>
</template>首次点击的三个快照分别是:旧状态与旧 DOM,三个新状态与旧 DOM,三个新状态与新 DOM。updatedCalls 依次是0、0、1。同步 watcher 依次读到 ['Grace','Lovelace',20]、['Grace','Hopper',20]、['Grace','Hopper',21];它证明三次写入都发生了,合并的是组件更新任务。
第九章回访可新建 src/BatchingLab.spec.ts,运行 npx vitest run src/BatchingLab.spec.ts。这里 defineExpose 仅为观测实验开放轨迹,业务组件一般通过公开 props、事件和 DOM 测试。
import { mount } from '@vue/test-utils'
import { nextTick } from 'vue'
import { expect, test } from 'vitest'
import BatchingLab from './BatchingLab.vue'
test('三次同步观察,一次组件提交', async () => {
const wrapper = mount(BatchingLab)
try {
await wrapper.get('button').trigger('click')
await nextTick()
const { snapshots, syncStates } = wrapper.vm.readTrace()
expect(snapshots).toEqual([
{ state: ['Ada', 'Lovelace', 20], dom: 'Ada|Lovelace|20', updatedCalls: 0 },
{ state: ['Grace', 'Hopper', 21], dom: 'Ada|Lovelace|20', updatedCalls: 0 },
{ state: ['Grace', 'Hopper', 21], dom: 'Grace|Hopper|21', updatedCalls: 1 }
])
expect(syncStates).toEqual([
['Grace', 'Lovelace', 20], ['Grace', 'Hopper', 20], ['Grace', 'Hopper', 21]
])
} finally {
wrapper.unmount()
}
})若在三次赋值之间各插入 await nextTick(),会出现三个提交,最终计数断言应该失败。若只把 watcher 改成默认时机,中间值断言应该失败,但组件仍可只提交一次。两种变化区分了观察副作用的时机与 DOM 提交的时机。
05|找出缺失依赖
保存为 src/MemoRows.vue,先用完整版本检查行为,再按下文逐项删除依赖制造错误。每次只变一个条件,避免“改选中项顺便刷新了旧语言”掩盖漏依赖。
<script setup lang="ts">
import { ref } from 'vue'
const selectedId = ref('a')
const locale = ref<'zh' | 'en'>('zh')
const dense = ref(false)
const rows = [{ id: 'a', zh: '甲', en: 'A' }, { id: 'b', zh: '乙', en: 'B' }]
</script>
<template>
<button data-action="locale" @click="locale = locale === 'zh' ? 'en' : 'zh'">改语言</button>
<button data-action="dense" @click="dense = !dense">改密度</button>
<button data-action="select" @click="selectedId = selectedId === 'a' ? 'b' : 'a'">改选中项</button>
<ul>
<li v-for="row in rows" :key="row.id"
v-memo="[row.id === selectedId, locale, dense]"
:class="{ dense }" :aria-current="row.id === selectedId ? 'true' : undefined">
{{ locale === 'zh' ? row.zh : row.en }}
</li>
</ul>
</template>在 App.vue 导入挂载后,三个按钮应分别改变文字、class 和选中行。完成第九章环境后,新建 src/MemoRows.spec.ts,运行 npx vitest run src/MemoRows.spec.ts:
import { mount } from '@vue/test-utils'
import { expect, test } from 'vitest'
import MemoRows from './MemoRows.vue'
test('语言变化必须更新两行', async () => {
const wrapper = mount(MemoRows)
try {
await wrapper.get('[data-action=locale]').trigger('click')
expect(wrapper.findAll('li').map(row => row.text())).toEqual(['A', 'B'])
} finally { wrapper.unmount() }
})
test('密度变化必须更新两行 class', async () => {
const wrapper = mount(MemoRows)
try {
await wrapper.get('[data-action=dense]').trigger('click')
expect(wrapper.findAll('li').map(row => row.classes())).toEqual([['dense'], ['dense']])
} finally { wrapper.unmount() }
})
test('只切换选中项保持已有语言和密度', async () => {
const wrapper = mount(MemoRows)
try {
await wrapper.get('[data-action=locale]').trigger('click')
await wrapper.get('[data-action=dense]').trigger('click')
await wrapper.get('[data-action=select]').trigger('click')
expect(wrapper.findAll('li').map(row => row.attributes('aria-current'))).toEqual([undefined, 'true'])
expect(wrapper.findAll('li').map(row => row.text())).toEqual(['A', 'B'])
expect(wrapper.findAll('li').map(row => row.classes())).toEqual([['dense'], ['dense']])
} finally { wrapper.unmount() }
})把 memo 改成 [row.id === selectedId],前两个用例都应该失败;分别只删除 locale 或 dense,则对应的独立用例失败。最后恢复完整依赖。不能只测试第三个用例,因为切换选中状态会使两行的 memo 都失效,可能顺带掩盖前面的陈旧文字和 class。
本例 row 数据不变,把整个 row 放进数组既不能代替 locale/dense,也不会更准确地表达跳过条件。如果以后允许原地改 row.zh,row 引用不变,仍可能错误跳过;应纳入实际影响输出的字段,或采用明确的不可变替换策略重新设计依赖。反过来,每次都构造新 row 引用又会让缓存总失效。memo 不会自动深入比较对象字段。
06|验证停用与淘汰
用测试框架的 fake timers:挂载 A,输入草稿并推进 1 秒;切到 B 后再推进 2 秒,A 的计数不应增长;切回 A,输入和计数仍是离开时的值。随后按 A→B→C→A 访问,在 max=2 下最久未访问的 A 已卸载,最后一次 A 是新实例。
生命周期 trace 至少应包含首次 mounted,activated、切走 deactivated、切回 activated,以及 A 切走时的 deactivated、随后淘汰时的 unmounted(不要要求淘汰时重复停用)。每个用例恢复真实计时器并卸载 wrapper,避免资源泄漏污染后续用例。
把正文父级另存为 src/KeepAliveApp.vue,CachePane 保持同目录。自动化回访 src/KeepAlive.spec.ts:
import { mount } from '@vue/test-utils'
import { expect, test, vi } from 'vitest'
import App from './KeepAliveApp.vue'
test('停用暂停、返回保留、淘汰重建', async () => {
vi.useFakeTimers()
const wrapper = mount(App)
try {
const go = async (name: string) => {
const button = wrapper.findAll('button').find(x => x.text() === name)!
await button.trigger('click')
}
await wrapper.get('input').setValue('draft')
await vi.advanceTimersByTimeAsync(1000)
expect(wrapper.get('output').text()).toBe('计数:1')
await go('B')
await vi.advanceTimersByTimeAsync(2000)
await go('A')
expect(wrapper.get('input').element.value).toBe('draft')
expect(wrapper.get('output').text()).toBe('计数:1')
await go('B'); await go('C'); await go('A')
expect(wrapper.get('input').element.value).toBe('')
expect(wrapper.get('output').text()).toBe('计数:0')
expect(wrapper.get('pre').text()).toContain('A:unmounted')
} finally {
wrapper.unmount()
vi.useRealTimers()
}
})07|可观察的 Suspense 边界
这是完成第九章测试环境后的回访答案。先保存 src/AsyncReport.vue;顶层 await 是本题真正交给 Suspense 的异步依赖:
<script setup lang="ts">
const props = defineProps<{ load: () => Promise<string> }>()
const body = await props.load()
</script>
<template><article data-report>{{ body }}</article></template>src/SuspenseHost.vue 把错误放在 Suspense 外处理。一次重试通过新的 key 重建整条异步 setup;同一个按钮在错误 UI 被替换后已经不存在,因此一次点击只创建一次新依赖。
<script setup lang="ts">
import { onErrorCaptured, ref } from 'vue'
import AsyncReport from './AsyncReport.vue'
const props = defineProps<{ load: () => Promise<string> }>()
const revision = ref(0)
const error = ref('')
const trace = ref<string[]>([])
onErrorCaptured(reason => {
error.value = reason instanceof Error ? reason.message : String(reason)
return false
})
function retry() {
error.value = ''
revision.value++
}
</script>
<template>
<template v-if="error">
<p role="alert">{{ error }}</p>
<button @click="retry">重试</button>
</template>
<Suspense
v-else
:key="revision"
timeout="0"
@pending="trace.push('pending')"
@fallback="trace.push('fallback')"
@resolve="trace.push('resolve')"
>
<AsyncReport :load="props.load" />
<template #fallback><p role="status">加载中</p></template>
</Suspense>
<output data-trace>{{ trace.join(',') }}</output>
</template>src/SuspenseHost.spec.ts 使用环境页的 deferred helper。测试同时观察 DOM 和事件轨迹,避免把“Promise 已 settle”误当成“新树已提交”。
import { flushPromises, mount } from '@vue/test-utils'
import { nextTick } from 'vue'
import { expect, test, vi } from 'vitest'
import SuspenseHost from './SuspenseHost.vue'
import { deferred } from './test/deferred'
test('pending、失败、一次重试与 resolve 形成完整轨迹', async () => {
const first = deferred<string>()
const second = deferred<string>()
const load = vi.fn()
.mockReturnValueOnce(first.promise)
.mockReturnValueOnce(second.promise)
const wrapper = mount(SuspenseHost, { props: { load } })
expect(wrapper.get('[role=status]').text()).toBe('加载中')
await nextTick()
expect(wrapper.get('[data-trace]').text()).toBe('pending,fallback')
expect(wrapper.find('[data-report]').exists()).toBe(false)
first.reject(new Error('offline'))
await flushPromises()
expect(wrapper.get('[role=alert]').text()).toBe('offline')
expect(wrapper.find('[data-report]').exists()).toBe(false)
await wrapper.get('button').trigger('click')
expect(load).toHaveBeenCalledTimes(2)
expect(wrapper.get('[role=status]').text()).toBe('加载中')
second.resolve('报告正文')
await flushPromises()
expect(wrapper.get('[data-report]').text()).toBe('报告正文')
expect(wrapper.find('[role=status]').exists()).toBe(false)
expect(wrapper.find('[role=alert]').exists()).toBe(false)
expect(wrapper.get('[data-trace]').text()).toBe('pending,fallback,pending,fallback,resolve')
})运行 npx vitest run src/SuspenseHost.spec.ts。若删除 :key="revision",重试不能建立新的 async setup;若错误分支意外渲染正文,拒绝后的负断言会失败。删除 timeout="0" 主要改变边界已经 resolved 后发生根替换时的回退时机;初次渲染没有旧内容可保留,仍会显示 fallback。
08|窗口不越界
type Input = {
length: number; rowHeight: number; viewportHeight: number
scrollTop: number; overscan: number
}
export function getWindow(x: Input) {
if (!Object.values(x).every(Number.isFinite) ||
!Number.isInteger(x.length) || x.length < 0 ||
!Number.isInteger(x.overscan) || x.overscan < 0 ||
x.rowHeight <= 0 || x.viewportHeight < 0) {
throw new RangeError('窗口参数非法')
}
const totalHeight = x.length * x.rowHeight
if (!Number.isFinite(totalHeight)) throw new RangeError('总高度溢出')
const top = Math.min(Math.max(0, x.scrollTop), Math.max(0, totalHeight - x.viewportHeight))
const start = Math.max(0, Math.floor(top / x.rowHeight) - x.overscan)
const end = Math.min(x.length, Math.ceil((top + x.viewportHeight) / x.rowHeight) + x.overscan)
return { start, end, offsetY: start * x.rowHeight, totalHeight }
}保存上面的函数为 src/getWindow.ts。完成第九章测试环境后,把下列断言保存为 src/getWindow.spec.ts,运行 npx vitest run src/getWindow.spec.ts。它同时锁定具体边界值与多组输入下的不变量,防止“永远返回空窗口”也通过范围断言。
import { expect, test } from 'vitest'
import { getWindow } from './getWindow'
test('完整行、部分行、底部与空列表', () => {
const base = { length: 10, rowHeight: 20, viewportHeight: 50, scrollTop: 0, overscan: 0 }
expect(getWindow(base)).toEqual({ start: 0, end: 3, offsetY: 0, totalHeight: 200 })
expect(getWindow({ ...base, scrollTop: 25 })).toEqual({ start: 1, end: 4, offsetY: 20, totalHeight: 200 })
expect(getWindow({ ...base, scrollTop: 999 })).toEqual({ start: 7, end: 10, offsetY: 140, totalHeight: 200 })
expect(getWindow({ ...base, length: 0 })).toEqual({ start: 0, end: 0, offsetY: 0, totalHeight: 0 })
expect(getWindow({ ...base, scrollTop: -8 })).toEqual(getWindow(base))
for (const length of [0, 1, 10, 100]) {
for (const scrollTop of [-10, 0, 25, 150, 9999]) {
for (const overscan of [0, 1, 4]) {
const x = getWindow({ ...base, length, scrollTop, overscan })
expect(x.start).toBeGreaterThanOrEqual(0)
expect(x.end).toBeGreaterThanOrEqual(x.start)
expect(x.end).toBeLessThanOrEqual(length)
}
}
}
})
test('拒绝非法输入与总高度溢出', () => {
const base = { length: 10, rowHeight: 20, viewportHeight: 50, scrollTop: 0, overscan: 0 }
const invalid = [
{ length: -1 }, { length: 1.5 }, { rowHeight: 0 },
{ viewportHeight: -1 }, { scrollTop: Infinity }, { overscan: 0.5 },
{ length: 2, rowHeight: Number.MAX_VALUE }
]
for (const patch of invalid) expect(() => getWindow({ ...base, ...patch })).toThrow(RangeError)
})首次学习不必提前安装测试工具,可以把固定输入传给函数、打印并推演对应行。可变行高下 index * rowHeight 不再等于真实 offset,需要累计尺寸或测量缓存,因此不能沿用该公式。
09|选择水合时机
| 区域 | 选择 | 触发、验证与失败回退 |
|---|---|---|
| 首屏搜索框 | 立即水合 | 键盘输入是关键交互;测首次输入到下一帧并验证文字不丢;脚本失败时保留原生表单提交或明确不可用说明 |
| 视口下方评论 | visible | 进入观察区后接管;记录chunk请求但不要求无预取,验证接管前后标记;加载失败保留SSR评论摘要并显示重试入口 |
| 仅桌面图表 | media query | 匹配桌面条件后水合;验证触发条件和首次交互;不匹配或加载失败时保留静态数据摘要 |
| 静态版权 | 无客户端脚本 | SSG/SSR HTML 已足够;检查无事件合同、无动态依赖,脚本失败不影响版权文字 |
评论区的首次操作可选“进入视口后展开评论”,记录进入观察区到 ready 的耗时,同时验证正文与展开结果;图表的首次操作可选“桌面宽度下切换时间范围”,记录满足媒体条件到可交互的耗时,并确认未满足条件时无需操作;版权无需客户端交互,可检验关闭 JavaScript 后文字仍存在、该区域不依赖专用交互脚本。这些量分别对应接管时机与功能合同,不应都替换成页面总加载时间。
搜索框即使 LCP 很早,也可能因 JS 长任务或延迟水合无法输入,所以还要记录交互延迟、事件是否重放及输入值是否保留。
10|可证伪的性能报告
报告应保留原始样本,例如 baselineMs 与 candidateMs 两个数组,而不是先四舍五入成一个百分比。用同一脚本检查筛选结果 id 序列、选中 id、焦点元素和空结果;任一行为不同,性能比较无效。
最低结论格式:
环境:浏览器/版本、设备、生产构建、缓存状态
输入:1,000 行的生成种子与固定查询词
操作:聚焦 → 输入 → 等 Vue 提交 → 等下一帧
证据:两组原始样本、DOM 节点数、Performance trace
判断:分布是否稳定分离;否则写“当前证据不足”
限制:未覆盖的设备、网络与真实用户数据中位数可减少极端值影响,但不能隐藏样本;不要承诺换一台设备仍有同样收益。
答案页自检
- 每题的输入、观察点和失败条件与正文一致。
- 异步题明确等待 Vue 队列或外部 Promise,失败分支不会被成功结果误判。
- 性能题保留环境和原始样本,没有固定收益承诺。