Skip to content

07|渲染器、编译器与性能练习答案 ​

第七章 / 参考答案。先对照行为合同,再比较实现;性能题先保正确,再谈分布。

01|记录一条 patch 轨迹 ​

ts
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 保存,这样能够直接观察身份是否被错误复用。

vue
<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是空串draftb
index否draft空串a

index 的旧下标1对应 b,新下标1却对应 a。Vue 在同一个 DOM 上更新标签与 data-id,没有代码要求它清空非受控输入的 value。业务身份与位置身份因此分离。

完成第九章测试环境后,新建 src/KeyRows.spec.ts,运行 npx vitest run src/KeyRows.spec.ts:

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|受控编译对照 ​

js
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 展开三份快照。快照和计数器特意不用响应式值:它们是观测记录,若把更新钩子中的计数再渲染到同一个组件,会制造额外更新,改变实验本身。

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

ts
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,先用完整版本检查行为,再按下文逐项删除依赖制造错误。每次只变一个条件,避免“改选中项顺便刷新了旧语言”掩盖漏依赖。

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:

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:

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 的异步依赖:

vue
<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 被替换后已经不存在,因此一次点击只创建一次新依赖。

vue
<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”误当成“新树已提交”。

ts
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|窗口不越界 ​

ts
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。它同时锁定具体边界值与多组输入下的不变量,防止“永远返回空窗口”也通过范围断言。

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、焦点元素和空结果;任一行为不同,性能比较无效。

最低结论格式:

text
环境:浏览器/版本、设备、生产构建、缓存状态
输入:1,000 行的生成种子与固定查询词
操作:聚焦 → 输入 → 等 Vue 提交 → 等下一帧
证据:两组原始样本、DOM 节点数、Performance trace
判断:分布是否稳定分离;否则写“当前证据不足”
限制:未覆盖的设备、网络与真实用户数据

中位数可减少极端值影响,但不能隐藏样本;不要承诺换一台设备仍有同样收益。

答案页自检 ​

  • 每题的输入、观察点和失败条件与正文一致。
  • 异步题明确等待 Vue 队列或外部 Promise,失败分支不会被成功结果误判。
  • 性能题保留环境和原始样本,没有固定收益承诺。

用心记录每一步成长