03|第三章:响应式系统练习答案
第三章:响应式系统纵览 / 答案基线:Vue 3.5+、Node 20+
练习答案怎样运行
除第05题明确拆成浏览器模块与测试外,每题的“可运行答案”都是独立的ES module。将一个代码块保存为 answer-xx.mjs,在已经安装本项目依赖的仓库根目录运行:
node answer-xx.mjs成功时打印该题的通过消息;失败时 Node 的严格断言会令进程以非零状态退出。不要把 console.assert 当作测试替代品:某些环境中它只打印错误而不抛异常。
01|外部快照只在根替换时发布
关键是让第三方对象保持原始身份,同时把唯一更新入口收回 action。shallowReadonly 只限制 ref 根入口,不深度代理快照。
import assert from 'node:assert/strict'
import {
isReadonly,
nextTick,
shallowReadonly,
shallowRef,
watch
} from 'vue'
function createDocumentStore(initial) {
const inner = shallowRef(initial)
const published = []
watch(inner, (snapshot) => published.push(snapshot.version))
function replace(next) {
if (!Number.isInteger(next.version) || next.version <= inner.value.version) {
throw new RangeError('next.version must be a larger integer')
}
inner.value = next
}
return {
state: shallowReadonly(inner),
published,
replace
}
}
const first = { version: 1, text: 'draft' }
const store = createDocumentStore(first)
assert.equal(store.state.value, first)
assert.equal(isReadonly(store.state), true)
store.state.value.text = 'local mutation'
await nextTick()
assert.deepEqual(store.published, [])
const second = { version: 2, text: 'published' }
store.replace(second)
await nextTick()
assert.equal(store.state.value, second)
assert.deepEqual(store.published, [2])
assert.throws(
() => store.replace({ version: 2, text: 'stale' }),
RangeError
)
assert.equal(store.state.value, second)
console.log('answer-01 passed')失败路径不会替换当前快照;原地修改也不会伪装成一个已发布版本。若业务要求禁止任何内层修改,第三方快照本身还应冻结或使用不可变数据结构。
02|动态依赖会随分支重建
预测输出是 ['Ada', '89', '91']:第一次改 score 时当前分支未读取它;切换模式后旧 name 依赖被移除;写回同值不触发。
import assert from 'node:assert/strict'
import { nextTick, reactive, watchEffect } from 'vue'
const state = reactive({ mode: 'name', name: 'Ada', score: 90 })
const output = []
const stop = watchEffect(() => {
output.push(state.mode === 'name' ? state.name : String(state.score))
})
state.score = 89
await nextTick()
assert.deepEqual(output, ['Ada'])
state.mode = 'score'
await nextTick()
assert.deepEqual(output, ['Ada', '89'])
state.name = 'Grace'
await nextTick()
assert.deepEqual(output, ['Ada', '89'])
state.score = 91
await nextTick()
state.score = 91
await nextTick()
assert.deepEqual(output, ['Ada', '89', '91'])
stop()
console.log('answer-02 passed')这里的失败断言很重要:如果修改未读取字段后数组增长,说明 effect 订阅过宽或测试没有等待正确的刷新边界。
03|在单一边界归一化代理身份
import assert from 'node:assert/strict'
import { reactive, toRaw } from 'vue'
function createIdentityCache() {
const cache = new Map()
return {
get: (key) => cache.get(toRaw(key)),
set(key, value) {
cache.set(toRaw(key), value)
},
get size() {
return cache.size
}
}
}
const raw = { id: 1 }
const proxy = reactive(raw)
const cache = createIdentityCache()
cache.set(raw, 'raw')
assert.equal(cache.get(proxy), 'raw')
cache.set(proxy, 'proxy')
assert.equal(cache.get(raw), 'proxy')
assert.equal(cache.size, 1)
const sameFieldsA = { id: 2 }
const sameFieldsB = { id: 2 }
cache.set(sameFieldsA, 'A')
cache.set(sameFieldsB, 'B')
assert.equal(cache.get(sameFieldsA), 'A')
assert.equal(cache.get(sameFieldsB), 'B')
assert.equal(cache.size, 3)
console.log('answer-03 passed')toRaw 只消除同一对象的 raw/proxy 双身份,不会把字段相同的不同对象错误合并。若实体有稳定 ID,Map<number, Value> 通常比对象身份缓存更易跨序列化和 API 边界。
04|用计数证明 computed 缓存
import assert from 'node:assert/strict'
import { computed, ref } from 'vue'
const items = ref([
{ id: 'tea', price: 10, quantity: 2 },
{ id: 'cake', price: 8, quantity: 1 }
])
const unrelated = ref(false)
let runs = 0
const summary = computed(() => {
runs++
const ranked = [...items.value].sort(
(a, b) => b.price * b.quantity - a.price * a.quantity
)
return {
total: ranked.reduce(
(sum, item) => sum + item.price * item.quantity,
0
),
ranked
}
})
const originalOrder = items.value.map((item) => item.id)
assert.equal(runs, 0)
assert.equal(summary.value.total, 28)
assert.equal(summary.value.total, 28)
assert.equal(runs, 1)
assert.deepEqual(items.value.map((item) => item.id), originalOrder)
unrelated.value = true
assert.equal(summary.value.total, 28)
assert.equal(runs, 1)
items.value[0].quantity = 3
assert.equal(runs, 1)
assert.equal(summary.value.total, 38)
assert.equal(runs, 2)
items.value = []
assert.equal(summary.value.total, 0)
assert.deepEqual(summary.value.ranked, [])
assert.equal(runs, 3)
console.log('answer-04 passed')排序使用副本,因此成功路径没有偷偷改写 source;无关 ref 没被 getter 读取,不会让缓存失效。
05|草稿保存:去抖、取消、最新结果获胜
下面先把可测试的状态机写成组合式函数。练习中的 500ms 是产品合同;测试传入 5ms 只为缩短运行时间。
本题是多文件例外:浏览器模块与Node验证分开,避免把 node:assert 导入网页。将以下文件保存在同一个安装了Vue的ES module项目中。
// useDraftSaver.js
import { computed, ref, watch } from 'vue'
export function createDraftSaver(saveDraft, debounceMs = 500) {
const articleId = ref('article-1')
const title = ref('')
const body = ref('')
const status = ref('unsaved')
const wordCount = computed(() => body.value.trim().length)
let runId = 0
const stop = watch(
[articleId, title, body],
([id, nextTitle, nextBody], _old, onCleanup) => {
const currentRun = ++runId
const controller = new AbortController()
status.value = 'unsaved'
const timer = setTimeout(async () => {
status.value = 'saving'
try {
await saveDraft(
{ id, title: nextTitle, body: nextBody },
controller.signal
)
if (currentRun === runId && !controller.signal.aborted) status.value = 'saved'
} catch {
if (currentRun === runId && !controller.signal.aborted) {
status.value = 'failed'
}
}
}, debounceMs)
onCleanup(() => {
clearTimeout(timer)
controller.abort(new DOMException('stale draft', 'AbortError'))
})
},
{ flush: 'sync' }
)
return { articleId, title, body, status, wordCount, stop }
}// draft-saver-test.mjs
import assert from 'node:assert/strict'
import { createDraftSaver } from './useDraftSaver.js'
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
function pause(ms, signal) {
return new Promise((resolve, reject) => {
const timer = setTimeout(resolve, ms)
signal.addEventListener('abort', () => {
clearTimeout(timer)
reject(signal.reason)
}, { once: true })
})
}
const completed = []
async function fakeSave(draft, signal) {
await pause(draft.id === 'old' ? 30 : 8, signal)
if (draft.body === 'fail') throw new Error('save failed')
completed.push(draft)
}
const draft = createDraftSaver(fakeSave, 5)
draft.articleId.value = 'old'
draft.title.value = 'old title'
draft.body.value = 'old body'
await wait(8)
draft.articleId.value = 'new'
draft.title.value = 'new title'
draft.body.value = 'latest'
await wait(30)
assert.deepEqual(completed, [
{ id: 'new', title: 'new title', body: 'latest' }
])
assert.equal(draft.status.value, 'saved')
assert.equal(draft.wordCount.value, 6)
draft.body.value = 'fail'
await wait(20)
assert.equal(draft.status.value, 'failed')
assert.equal(completed.length, 1)
draft.stop()
console.log('answer-05 passed')执行 node draft-saver-test.mjs 验证成功、失败与快速换文章。下面补齐浏览器使用的服务:它只模拟保存,正文为fail时拒绝;取消会停止计时器。
// draft-api.js
export function saveDraft(_draft, signal) {
return new Promise((resolve, reject) => {
const abort = () => {
clearTimeout(timer)
reject(signal.reason)
}
const timer = setTimeout(() => {
signal.removeEventListener('abort', abort)
if (_draft.body === 'fail') reject(new Error('模拟保存失败'))
else resolve()
}, 300)
signal.addEventListener('abort', abort, { once: true })
if (signal.aborted) abort()
})
}这个浏览器例子统一使用JavaScript SFC与上面的两个JS模块;将三者放入src。组件只需把状态映射到可访问UI,完整字段不由watcher再复制一份:
<script setup>
// src/App.vue;两个js模块也放在src目录
import { createDraftSaver } from './useDraftSaver.js'
import { saveDraft } from './draft-api.js'
const draft = createDraftSaver(saveDraft, 500)
</script>
<template>
<form @submit.prevent>
<label>标题 <input v-model="draft.title.value" /></label>
<label>正文 <textarea v-model="draft.body.value" /></label>
<p>字符数:{{ draft.wordCount.value }}</p>
<p role="status" aria-live="polite">
{{ {
unsaved: '未保存',
saving: '保存中',
saved: '已保存',
failed: '保存失败'
}[draft.status.value] }}
</p>
</form>
</template>第05题补充:停止先于连接到达
以下代码独立保存为 late-connection.mjs,无需服务或DOM。resolveConnection让测试决定连接何时返回,因此不依赖计时器快慢。
import assert from 'node:assert/strict'
import { nextTick, ref, watch } from 'vue'
const userId = ref('a')
let resolveConnection
let closed = 0
const connect = () => new Promise(resolve => { resolveConnection = resolve })
const stop = watch(userId, async (_id, _old, onCleanup) => {
let invalidated = false
let connection
onCleanup(() => {
invalidated = true
connection?.close()
})
const opened = await connect()
if (invalidated) opened.close()
else connection = opened
})
userId.value = 'b'
await nextTick()
stop()
resolveConnection({ close() { closed++ } })
await Promise.resolve()
assert.equal(closed, 1)
stop()
assert.equal(closed, 1)
console.log('late-connection passed')若把onCleanup挪到await后,第一次stop发生时没有登记项,断言得到0。修正版同步登记标志,迟到连接自己负责关闭,重复stop不重复关闭。
06|pre、post、sync 的日志顺序
import assert from 'node:assert/strict'
import { nextTick, ref, watch } from 'vue'
const source = ref(0)
const events = []
watch(source, (value) => events.push(`pre:` + value))
watch(source, (value) => events.push(`post:` + value), { flush: 'post' })
watch(source, (value) => events.push(`sync:` + value), { flush: 'sync' })
source.value = 1
source.value = 2
source.value = 2
events.push('stack')
await nextTick()
assert.deepEqual(events, [
'sync:1',
'sync:2',
'stack',
'pre:2',
'post:2'
])
console.log('answer-06 passed')第三次写 2 被 Object.is 判为同值。sync 不批处理,pre/post 各在自己的队列阶段只消费最终值。
07|让组合式函数可安全解构
import assert from 'node:assert/strict'
import { reactive, toRef, toRefs } from 'vue'
function usePagination() {
const state = reactive({ page: 1, pageSize: 20 })
const { page, pageSize } = toRefs(state)
const cursor = toRef(state, 'cursor', 'start')
return {
page,
pageSize,
cursor,
next: () => state.page++,
state
}
}
const brokenState = reactive({ page: 1 })
const { page: snapshot } = brokenState
brokenState.page = 2
assert.equal(snapshot, 1)
const pagination = usePagination()
pagination.next()
assert.equal(pagination.page.value, 2)
pagination.page.value = 7
assert.equal(pagination.state.page, 7)
assert.equal(pagination.cursor.value, 'start')
pagination.cursor.value = 'next-page'
assert.equal(pagination.state.cursor, 'next-page')
console.log('answer-07 passed')toRefs 只处理调用时已有的可枚举字段;cursor 尚不存在,所以单独用带默认值的 toRef。
08|一个可重启且可幂等停止的 scope
import assert from 'node:assert/strict'
import {
computed,
effectScope,
onScopeDispose,
ref,
watch
} from 'vue'
function createPriceService(source) {
let activeScope
let closeCount = 0
const published = []
function stop() {
activeScope?.stop()
activeScope = undefined
}
function start() {
stop()
activeScope = effectScope()
activeScope.run(() => {
const taxed = computed(() => source.value * 1.2)
watch(taxed, (value) => published.push(value), {
immediate: true,
flush: 'sync'
})
onScopeDispose(() => {
closeCount++
})
})
}
return {
start,
stop,
published,
get closeCount() {
return closeCount
}
}
}
const price = ref(100)
const service = createPriceService(price)
service.start()
price.value = 200
assert.deepEqual(service.published, [120, 240])
service.start()
assert.equal(service.closeCount, 1)
price.value = 300
assert.deepEqual(service.published, [120, 240, 240, 360])
service.stop()
assert.equal(service.closeCount, 2)
price.value = 400
assert.deepEqual(service.published, [120, 240, 240, 360])
service.stop()
assert.equal(service.closeCount, 2)
console.log('answer-08 passed')重启时出现第二个 240 是新 scope 的 immediate 初始发布;之后 300 只发布一次,证明旧 watcher 已停止。
09|Map、Set 与 ref 元素的触发矩阵
import assert from 'node:assert/strict'
import { isRef, nextTick, reactive, ref, watchEffect } from 'vue'
const prices = reactive(new Map([['tea', 10]]))
const runs = { tea: 0, keys: 0, values: 0 }
watchEffect(() => {
runs.tea++
prices.get('tea')
})
watchEffect(() => {
runs.keys++
;[...prices.keys()]
})
watchEffect(() => {
runs.values++
;[...prices.values()]
})
prices.set('tea', 12)
await nextTick()
assert.deepEqual(runs, { tea: 2, keys: 1, values: 2 })
prices.set('tea', 12)
await nextTick()
assert.deepEqual(runs, { tea: 2, keys: 1, values: 2 })
prices.set('coffee', 20)
await nextTick()
assert.deepEqual(runs, { tea: 2, keys: 2, values: 3 })
prices.delete('coffee')
await nextTick()
assert.deepEqual(runs, { tea: 2, keys: 3, values: 4 })
const selected = reactive(new Set())
let selectedRuns = 0
watchEffect(() => {
selectedRuns++
;[...selected]
})
selected.add('ada')
await nextTick()
selected.add('ada')
await nextTick()
assert.equal(selectedRuns, 2)
const count = ref(1)
const refs = reactive(new Map([['count', count]]))
assert.equal(isRef(refs.get('count')), true)
assert.equal(refs.get('count').value, 1)
console.log('answer-09 passed')修改已有值不会改变 key 迭代,但会影响 values 迭代;新增与删除同时改变 keys 和 values。重复 Set add 没有语义变化。
10|把 Vue 2 补丁收敛成可删除 adapter
import assert from 'node:assert/strict'
function setField(setter, target, key, value) {
setter(target, key, value)
}
function replaceAt(items, index, value) {
if (!Number.isInteger(index) || index < 0 || index >= items.length) {
throw new RangeError('index out of range')
}
items.splice(index, 1, value)
}
const notified = []
const vue2Setter = (target, key, value) => {
target[key] = value
notified.push(key)
}
const profile = { name: 'Ada' }
setField(vue2Setter, profile, 'name', 'Grace')
setField(vue2Setter, profile, 'email', 'grace@example.com')
assert.deepEqual(profile, {
name: 'Grace',
email: 'grace@example.com'
})
assert.deepEqual(notified, ['name', 'email'])
const items = ['a', 'b']
replaceAt(items, 1, 'B')
assert.deepEqual(items, ['a', 'B'])
const beforeFailure = [...items]
assert.throws(() => replaceAt(items, 2, 'C'), RangeError)
assert.deepEqual(items, beforeFailure)
function setFieldVue3(target, key, value) {
target[key] = value
}
setFieldVue3(profile, 'active', true)
assert.equal(profile.active, true)
console.log('answer-10 passed')Vue 3 迁移后可删除 setField 及注入 setter 的机械测试,但应保留“新增字段后视图更新”和“越界不修改输入”等行为回归测试。
11|用事件证据定位解构断边
import assert from 'node:assert/strict'
import { nextTick, reactive, toRef, watchEffect } from 'vue'
const state = reactive({ enabled: true, count: 0 })
const { count: snapshot } = state
let brokenRuns = 0
const brokenTracks = []
const stopBroken = watchEffect(
() => {
brokenRuns++
void snapshot
},
{ onTrack: (event) => brokenTracks.push(event.key) }
)
state.count++
await nextTick()
assert.equal(brokenRuns, 1)
assert.deepEqual(brokenTracks, [])
stopBroken()
const count = toRef(state, 'count')
let fixedRuns = 0
const events = []
const stopFixed = watchEffect(
() => {
fixedRuns++
void count.value
},
{
onTrack: (event) => events.push(['track', event.type, event.key]),
onTrigger: (event) => events.push(['trigger', event.type, event.key])
}
)
state.count++
await nextTick()
assert.equal(fixedRuns, 2)
assert.equal(events.some((event) =>
event[0] === 'track' && event[1] === 'get' && event[2] === 'count'
), true)
assert.equal(events.some((event) =>
event[0] === 'trigger' && event[1] === 'set' && event[2] === 'count'
), true)
stopFixed()
const runsAtStop = fixedRuns
state.count++
await nextTick()
assert.equal(fixedRuns, runsAtStop)
console.log('answer-11 passed')错误版没有 track 事件,所以问题发生在依赖收集阶段;修复版出现 get:count 和 set:count,停止后无新运行,依赖、触发和清理三段都有证据。
交付前自检
- 每段断言是否从本章目录根运行,而不是依赖浏览器全局变量?
- 失败路径是否断言“没有错误写入”,而不只断言抛错?
- watcher、timer、请求与 scope 是否都有停止或清理入口?
- 练习合同与答案接受的输入、输出、版本和触发次数是否一致?