05|v-once 与 v-memo
第七章 / 05。目标:把“跳过更新”写成可验证的语义合同,而不是凭感觉贴优化指令。
两种跳过条件
v-once:首次渲染后,整个子树以后都当作静态内容。它仍会读取第一次的运行时值,但后续值变化不会反映到 DOM。v-memo="[a, b]":比较本次与上次依赖数组;每一项按Object.is比较均未变时,跳过该子树的 VNode 创建和更新。
普通静态 HTML 已由编译器自动优化。v-once 用于“值来自运行时,但合同规定首次值永久展示”;v-memo 只用于已测得的昂贵大子树或大列表微优化。
可重放实验:故意漏掉依赖
把完整组件放入第一章 Vite 项目的 src/App.vue,每轮实验刷新恢复初始状态。
<script setup lang="ts">
import { ref } from 'vue'
const build = ref('2026.09')
const selectedId = ref('a')
const locale = ref<'zh' | 'en'>('zh')
const rows = [{ id: 'a', zh: '甲', en: 'A' }, { id: 'b', zh: '乙', en: 'B' }]
</script>
<template>
<p v-once data-test="build">首次构建号:{{ build }}</p>
<button @click="build = '2026.10'">改构建号</button>
<button @click="selectedId = selectedId === 'a' ? 'b' : 'a'">改选中项</button>
<button @click="locale = locale === 'zh' ? 'en' : 'zh'">改语言</button>
<ul>
<li
v-for="row in rows"
:key="row.id"
v-memo="[row.id === selectedId]"
:aria-current="row.id === selectedId ? 'true' : undefined"
>
{{ locale === 'zh' ? row.zh : row.en }}
</li>
</ul>
</template>构建号状态会变但 DOM 保持首次值,这是 v-once 的合同。切换语言时,列表文本可能不更新:memo 数组遗漏了 locale。修成 v-memo="[row.id === selectedId, locale]" 后才满足当前模板的依赖合同。
对 v-for 使用 v-memo 时应与 v-for 放在同一元素上;Vue 会从 item 的 key 推断身份,不需要把 row.id 再塞入 memo 数组。若数组元素每次都是新对象,引用身份总是变化,也不会命中跳过。
一次漏依赖为什么会被另一次更新掩盖
初始时 a 行依赖是 [true],b 行是 [false]。只把 locale 从 zh 改成 en,两行的依赖数组都未变化,所以渲染器复用旧子树,文字仍是甲、乙。随后把选中项从 a 改成 b,两行依赖分别变为 [false]、[true],这次都重新创建子树,才读到当前的 en,显示 A、B。于是“多点几次就好了”并不证明依赖完整。
增加 dense 后同理:只修改密度,应该立即更新 class,不应等另一次选中变化。要找全依赖,需要列出子树实际会变化的输出:选中属性、语言文字、密度 class,再分别让每个输入单独变化。key 解决这一行是谁,memo 解决这一行这次是否有必要重新计算;二者不能互相替代。
先证明行为等价,再比较成本
- 移除
v-memo,锁定正确的文本、选中状态、焦点和事件行为。 - 用同一数据规模和同一操作录制基线,记录组件更新时间线或自定义 mark。
- 添加完整依赖,再跑相同行为断言。
- 比较多轮数据的分布和环境,不承诺固定倍数;若差异落在噪声内,删除优化。
练习:找出缺失依赖
给上面的列表再增加 dense: boolean,它控制每行 class。初始 memo 仍只有选中状态。先手动切换并记录实际文本/class;完成 测试环境 后回访,写出会失败的自动化断言,分别证明改变 locale 和 dense 时 DOM 变陈旧;然后补全最小依赖数组,并证明只切换选中项时未受影响。答案必须解释为何不把 row 整个对象放入数组。
参考答案:第 7 章第 05 题。