Skip to content

03|第三章:响应式系统练习答案 ​

第三章:响应式系统纵览 / 答案基线:Vue 3.5+、Node 20+

练习答案怎样运行 ​

除第05题明确拆成浏览器模块与测试外,每题的“可运行答案”都是独立的ES module。将一个代码块保存为 answer-xx.mjs,在已经安装本项目依赖的仓库根目录运行:

bash
node answer-xx.mjs

成功时打印该题的通过消息;失败时 Node 的严格断言会令进程以非零状态退出。不要把 console.assert 当作测试替代品:某些环境中它只打印错误而不抛异常。

01|外部快照只在根替换时发布 ​

关键是让第三方对象保持原始身份,同时把唯一更新入口收回 action。shallowReadonly 只限制 ref 根入口,不深度代理快照。

js
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 依赖被移除;写回同值不触发。

js
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|在单一边界归一化代理身份 ​

js
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 缓存 ​

js
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项目中。

js
// 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 }
}
js
// 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时拒绝;取消会停止计时器。

js
// 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再复制一份:

vue
<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让测试决定连接何时返回,因此不依赖计时器快慢。

js
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 的日志顺序 ​

js
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|让组合式函数可安全解构 ​

js
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 ​

js
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 元素的触发矩阵 ​

js
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 ​

js
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|用事件证据定位解构断边 ​

js
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 是否都有停止或清理入口?
  • 练习合同与答案接受的输入、输出、版本和触发次数是否一致?

用心记录每一步成长