Skip to content

04|第四章练习答案 ​

第四章概览 / 参考答案。先按正文合同实现,再比较行为;代码结构不同不等于错误,遗漏失败与清理路径才是。

使用方式 ​

每题按“实现 → 失败路径 → 验收证据”给出一条可行解。若你的实现公开面更大,先问多出来的状态或命令是否真是调用方需要;若更短,确认没有靠全局单例、浏览器隐式依赖或未清理资源换取篇幅。

01|setup 的执行模型 ​

vue
<script lang="ts">
import { defineComponent, onMounted, onUnmounted, ref } from 'vue'

export default defineComponent({
  props: {
    label: { type: String, required: true },
    intervalMs: { type: Number, default: 1000 }
  },
  setup(props) {
    const elapsed = ref(0)
    const paused = ref(false)
    let timer: ReturnType<typeof setInterval> | undefined

    function start() {
      if (timer !== undefined || paused.value) return
      timer = setInterval(() => {
        elapsed.value += 1
        console.log(props.label, elapsed.value)
      }, props.intervalMs)
    }

    function stop() {
      if (timer === undefined) return
      clearInterval(timer)
      timer = undefined
    }

    function toggle() {
      paused.value = !paused.value
      paused.value ? stop() : start()
    }

    onMounted(start)
    onUnmounted(stop)

    return { elapsed, paused, toggle }
  }
})
</script>

<template>
  <p>{{ label }}: {{ elapsed }}</p>
  <button type="button" @click="toggle">
    {{ paused ? '继续' : '暂停' }}
  </button>
</template>

把父组件写成两个时钟并给不同 label;点击一个的暂停键,另一个继续增长,即证明状态与 timer 在每次 setup() 调用内隔离。测试时传 :interval-ms="20",卸载后再等至少两个间隔,日志数应保持不变。

失败实现通常把 timer 放到模块顶层,导致一个实例暂停另一个;或在 setup 根部启动 timer,使 SSR 也创建资源而没有卸载清理。这里 onMounted(start) / onUnmounted(stop) 同步登记,回调才归属当前实例。

02|<script setup> ​

vue
<script setup lang="ts">
import { computed, ref } from 'vue'

interface Props {
  start?: number
  step?: number
}

const { start = 0, step = 1 } = defineProps<Props>()
const emit = defineEmits<{ change: [value: number] }>()

const count = ref(start)
const effectiveStep = computed(() => step > 0 ? step : 1)

function increment() {
  count.value += effectiveStep.value
  emit('change', count.value)
}

function reset() {
  count.value = start
}

defineExpose({ reset })
</script>

<template>
  <button type="button" @click="increment">
    {{ count }}(步长 {{ effectiveStep }})
  </button>
</template>

这是 Vue 3.5+ 写法。step 的响应性来自编译器对 defineProps() 解构的转换;count 只在实例创建时取一次 start,而 reset() 会读取当前 start。父组件把 step 从 2 改为 3 后,下一次点击应增加 3。

Vue 3.4 及更早版本改成:

ts
const props = withDefaults(defineProps<Props>(), { start: 0, step: 1 })
const count = ref(props.start)
const effectiveStep = computed(() => props.step > 0 ? props.step : 1)
function reset() { count.value = props.start }

两个实例各自创建 count。不要把 count 放进普通 <script>;那会变成模块共享。defineExpose 放在同步顶层、任何 await 前,只暴露 reset,不把内部 ref 整包交给父组件。

03|组合式函数 ​

ts
import {
  computed,
  readonly,
  ref,
  toValue,
  type MaybeRefOrGetter
} from 'vue'

export function useBoundedNumber(
  initial: number,
  minSource: MaybeRefOrGetter<number>,
  maxSource: MaybeRefOrGetter<number>
) {
  const initialMin = toValue(minSource)
  const initialMax = toValue(maxSource)
  if (initialMin > initialMax || initial < initialMin || initial > initialMax) {
    throw new RangeError('initial 必须位于有效的初始范围内')
  }

  const value = ref(initial)
  const error = ref<string | null>(null)
  const min = computed(() => toValue(minSource))
  const max = computed(() => toValue(maxSource))

  function set(next: number): boolean {
    if (min.value > max.value) {
      error.value = 'min 不能大于 max'
      return false
    }
    if (next < min.value || next > max.value) {
      error.value = `值必须位于 ${min.value}..${max.value}`
      return false
    }
    value.value = next
    error.value = null
    return true
  }

  return {
    value: readonly(value),
    error: readonly(error),
    min,
    max,
    set
  }
}

关键断言:

ts
const upper = ref(10)
const first = useBoundedNumber(2, 0, upper)
const second = useBoundedNumber(8, 0, upper)

if (!first.set(3) || first.value.value !== 3) throw new Error('合法写入失败')
if (first.set(11) || first.value.value !== 3) throw new Error('越界不应改值')
if (second.value.value !== 8) throw new Error('两次调用发生串值')

upper.value = 5
if (second.set(6) || second.error.value === null) throw new Error('响应式上限未生效')

let threw = false
try { useBoundedNumber(20, 0, 10) } catch (error) { threw = error instanceof RangeError }
if (!threw) throw new Error('非法初值必须抛 RangeError')

边界改变后,已有 value 不会自动夹紧;合同只规定后续 set。若业务需要自动修正,应新增 watcher 并写清“范围变化会改写当前值”,不能暗中改变语义。

04|生命周期 ​

vue
<script setup lang="ts">
import {
  onActivated,
  onDeactivated,
  onErrorCaptured,
  onMounted,
  onUnmounted,
  ref
} from 'vue'
import ThrowingChild from './ThrowingChild.vue'

const ticks = ref(0)
const events = ref<string[]>([])
const failed = ref(false)
let timer: ReturnType<typeof setInterval> | undefined

function record(event: string) { events.value.push(event) }
function online() { record('online') }

function start() {
  if (timer !== undefined) return
  window.addEventListener('online', online)
  timer = setInterval(() => { ticks.value += 1 }, 20)
  record('start')
}

function stop() {
  if (timer === undefined) return
  clearInterval(timer)
  timer = undefined
  window.removeEventListener('online', online)
  record('stop')
}

onMounted(() => { record('mounted'); start() })
onActivated(() => { record('activated'); start() })
onDeactivated(() => { record('deactivated'); stop() })
onUnmounted(() => { record('unmounted'); stop() })

onErrorCaptured((error, _instance, info) => {
  failed.value = true
  record(`error:${info}:${String(error)}`)
  return false
})
</script>

<template>
  <p>ticks: {{ ticks }}</p>
  <p v-if="failed" role="alert">子区域失败</p>
  <ThrowingChild v-else />
  <pre>{{ events.join('\n') }}</pre>
</template>
vue
<script setup lang="ts">
function fail() { throw new Error('boom') }
</script>
<template><button type="button" @click="fail">触发错误</button></template>

父组件分别用 <LifecycleProbe v-if="show" /> 与 <KeepAlive><LifecycleProbe v-if="show" /></KeepAlive>。普通分支会卸载并丢失 ticks;KeepAlive 会停用、保留实例,再激活继续。start / stop 幂等,所以初次 mounted + activated 不会建立两份资源。

删除 onErrorCaptured 的 return false,再配置 app.config.errorHandler,全局处理器应收到同一错误。SSR 中 mounted、activated、unmounted 都不运行,HTML 中 ticks 保持 0。

05|Options / Composition 迁移 ​

ts
import { computed, onMounted, onScopeDispose, readonly, ref } from 'vue'

interface Runtime {
  target?: Window
  intervalMs?: number
}

export function useNetworkStatus(runtime: Runtime = {}) {
  const target = runtime.target ??
    (typeof window === 'undefined' ? undefined : window)
  const intervalMs = runtime.intervalMs ?? 1000
  const online = ref(true)
  const checks = ref(0)
  let timer: ReturnType<typeof setInterval> | undefined

  function syncOnline() {
    online.value = target?.navigator.onLine ?? true
  }

  function start() {
    if (!target || timer !== undefined) return
    syncOnline()
    target.addEventListener('online', syncOnline)
    target.addEventListener('offline', syncOnline)
    timer = setInterval(() => { checks.value += 1 }, intervalMs)
  }

  function stop() {
    if (timer !== undefined) {
      clearInterval(timer)
      timer = undefined
    }
    target?.removeEventListener('online', syncOnline)
    target?.removeEventListener('offline', syncOnline)
  }

  onMounted(start)
  onScopeDispose(stop)

  return {
    online: readonly(online),
    checks: readonly(checks),
    label: computed(() => online.value ? `在线:${checks.value}` : '离线'),
    start,
    stop
  }
}

迁移后的组件只需在同步 setup 阶段调用它。测试 fake target 记录 add/remove 的 listener 身份:重复 start 后每种事件只有一个监听器;scope 结束后为零。把 add/remove 改成两个匿名箭头,身份断言就会失败。

每次调用创建 refs、timer 和 syncOnline;共享的只有调用方显式传入的 target。SSR 不提供 target 时 start 是安全空操作,不访问 navigator,也不创建 timer。这里保留了旧组件的用户可见文本和时序,同时把隐含的 this 依赖改成显式闭包。

06|模板引用与组合式函数 ​

vue
<script setup lang="ts">
import { nextTick, ref, useTemplateRef, watchEffect } from 'vue'

const visible = ref(false)
const query = ref('')
const input = useTemplateRef<HTMLInputElement>('filter-input')

async function openAndFocus(): Promise<boolean> {
  visible.value = true
  await nextTick()
  if (!input.value) return false
  input.value.focus()
  return document.activeElement === input.value
}

function close() {
  visible.value = false
}

watchEffect((onCleanup) => {
  const element = input.value
  if (!element || typeof ResizeObserver === 'undefined') return

  const observer = new ResizeObserver(entries => {
    console.log('width', entries[0]?.contentRect.width)
  })
  observer.observe(element)
  onCleanup(() => observer.disconnect())
}, { flush: 'post' })
</script>

<template>
  <button type="button" @click="openAndFocus">打开</button>
  <button type="button" @click="close">关闭</button>
  <label v-if="visible">
    关键词
    <input ref="filter-input" v-model="query" />
  </label>
</template>

3.4 兼容分支只替换声明:

ts
const input = ref<HTMLInputElement | null>(null)

模板仍写 ref="input",而不是 filter-input;若保留模板名称,则变量也应按旧版绑定规则对应并在普通 setup 中 return。主实现中 watchEffect 同步创建并归属当前实例;v-if 关闭时 ref 变回 null,本轮 cleanup 会断开旧 observer,卸载时 watcher 停止也会清理。

浏览器测试应断言 await openAndFocus() 返回 true 且 document.activeElement 是输入框;close + await nextTick() 后断言 input 为 null、disconnect 调用一次。SSR 首屏 visible 为 false,事件函数不执行,因此不读取 document。

07|资源清理 ​

实现主体与正文一致,关键是用 shallowRef<T> 保存泛型数据引用,并同时保留 Abort 与轮次检查:

ts
import { onMounted, onScopeDispose, readonly, ref, shallowRef } from 'vue'

export function useAutoRefresh<T>(
  fetcher: (signal: AbortSignal) => Promise<T>,
  options: { intervalMs?: number; target?: Window } = {}
) {
  const intervalMs = options.intervalMs ?? 30_000
  const target = options.target ??
    (typeof window === 'undefined' ? undefined : window)
  if (intervalMs <= 0) throw new RangeError('intervalMs 必须大于 0')

  const data = shallowRef<T | null>(null)
  const error = ref<Error | null>(null)
  const loading = ref(false)
  let timer: ReturnType<typeof setInterval> | undefined
  let controller: AbortController | undefined
  let runId = 0

  async function refresh() {
    controller?.abort()
    const ownController = new AbortController()
    const ownRun = ++runId
    controller = ownController
    loading.value = true
    error.value = null

    try {
      const result = await fetcher(ownController.signal)
      if (ownRun === runId) data.value = result
    } catch (cause) {
      if (!ownController.signal.aborted && ownRun === runId) {
        error.value = cause instanceof Error ? cause : new Error(String(cause))
      }
    } finally {
      if (ownRun === runId) {
        loading.value = false
        controller = undefined
      }
    }
  }

  function onFocus() { void refresh() }

  function start() {
    if (!target || timer !== undefined) return
    target.addEventListener('focus', onFocus)
    timer = setInterval(() => void refresh(), intervalMs)
    void refresh()
  }

  function stop() {
    controller?.abort()
    controller = undefined
    runId += 1
    if (timer !== undefined) {
      clearInterval(timer)
      timer = undefined
    }
    target?.removeEventListener('focus', onFocus)
    loading.value = false
  }

  onMounted(start)
  onScopeDispose(stop)
  return {
    data: readonly(data),
    error: readonly(error),
    loading: readonly(loading),
    refresh,
    stop
  }
}

受控测试按这个顺序:挂载宿主,取得第一次 signal;手动 refresh,断言第一次 signal 已 aborted;先完成第二次请求,data 变成 second;再让第一次拒绝,data 与 loading 不变。随后 unmount,断言当前 signal aborted、interval 清除、focus listener 移除;再派发 focus,fetcher 调用次数不增加。

普通失败使用未 aborted 的 signal 拒绝,error 应保存;Abort 拒绝不能写 error。SSR 渲染中 onMounted 不执行,target 为 undefined,初始值稳定。若测试只断言 Abort 被调用,没有断言旧 finally 与最终 data,就仍可能放过竞态错误。

08|可测试性 ​

实现沿用正文 useTicker.mjs。练习要求两个实例共用 fake clock,因此 fake 必须按句柄保存多条回调:

js
import assert from 'node:assert/strict'
import { effectScope } from 'vue'
import { useTicker } from './useTicker.mjs'

function createClock() {
  let nextHandle = 1
  let clears = 0
  const callbacks = new Map()

  return {
    setInterval(callback) {
      const handle = nextHandle++
      callbacks.set(handle, callback)
      return handle
    },
    clearInterval(handle) {
      if (callbacks.delete(handle)) clears += 1
    },
    tick(handle) { callbacks.get(handle)?.() },
    tickAll() { for (const callback of callbacks.values()) callback() },
    get activeCount() { return callbacks.size },
    get clears() { return clears }
  }
}

const clock = createClock()
const firstScope = effectScope()
const secondScope = effectScope()
const first = firstScope.run(() => useTicker(clock, 20))
const second = secondScope.run(() => useTicker(clock, 20))

assert.ok(first && second)
first.start()
first.start()
second.start()
assert.equal(clock.activeCount, 2)

clock.tickAll()
assert.equal(first.ticks.value, 1)
assert.equal(second.ticks.value, 1)

first.stop()
first.stop()
assert.equal(clock.activeCount, 1)
clock.tickAll()
assert.equal(first.ticks.value, 1)
assert.equal(second.ticks.value, 2)

secondScope.stop()
assert.equal(clock.activeCount, 0)
assert.equal(clock.clears, 2)
assert.throws(() => useTicker(clock, 0), RangeError)

console.log('two isolated ticker scopes passed')

第一次 scope 的显式 stop 已清理资源,所以之后 firstScope.stop() 应仍是空操作;第二次 scope.stop 则触发 onScopeDispose(stop)。删除实现中的这行后,activeCount 会保持 1,测试应失败。这比只检查 clearInterval 曾被调用更能证明资源已经不可触发。

09|SSR composables ​

js
import assert from 'node:assert/strict'
import { createSSRApp, h, inject, onMounted, onScopeDispose, readonly, ref } from 'vue'
import { renderToString } from '@vue/server-renderer'

const RequestStateKey = Symbol('request-state')

function createRequestState(requestId, userName) {
  return {
    requestId: readonly(ref(requestId)),
    userName: readonly(ref(userName))
  }
}

function useRequestState() {
  const state = inject(RequestStateKey)
  if (!state) throw new Error('缺少请求级状态')
  return state
}

function useViewport() {
  const width = ref(0)
  function update() { width.value = window.innerWidth }
  onMounted(() => {
    update()
    window.addEventListener('resize', update)
  })
  onScopeDispose(() => {
    if (typeof window !== 'undefined') window.removeEventListener('resize', update)
  })
  return { width: readonly(width) }
}

const Root = {
  setup() {
    const state = useRequestState()
    const { width } = useViewport()
    return () => h('p', `${state.requestId.value}:${state.userName.value}:${width.value}`)
  }
}

async function renderRequest(requestId, userName) {
  const app = createSSRApp(Root)
  app.provide(RequestStateKey, createRequestState(requestId, userName))
  return renderToString(app)
}

const [a, b] = await Promise.all([
  renderRequest('req-a', 'Alice'),
  renderRequest('req-b', 'Bob')
])

assert.match(a, /req-a:Alice:0/)
assert.doesNotMatch(a, /req-b|Bob/)
assert.match(b, /req-b:Bob:0/)
assert.doesNotMatch(b, /req-a|Alice/)
console.log('SSR request isolation passed')

稳定复现错误单例可用一个受控门:A 先把模块变量写为 Alice 后等待;B 再写 Bob 并完成;最后释放 A,A 会错误读到 Bob。恢复为 renderRequest() 内创建 state 后,并发顺序不再影响结果。

服务端与客户端首次 render 都以 width 0 生成结构;mounted 后更新 width 是正常响应式更新,不是 hydration 过程中的首屏不一致。若客户端 setup 一开始读取 window.innerWidth,两端首轮文本就不同,测试应把它视为失败。

10|Vue 3.5 新 API ​

正文的 SearchField.vue 已给出完整实现;cleanup不仅Abort,还使当前轮次失效,卸载后晚到的结果与finally不能写入。验收时重点检查这四处:

ts
const fieldId = useId()
const input = useTemplateRef<HTMLInputElement>('search-input')

watch(keyword, async rawValue => {
  const currentRun = ++runId
  const controller = new AbortController()
  onWatcherCleanup(() => {
    controller.abort()
    if (currentRun === runId) runId += 1
  }) // 必须在 await 前
  // 请求、HTTP 检查、runId 保护和非 Abort 错误处理
})

Vue 3.4 的模板 ref 替代写法是 const input = ref<HTMLInputElement | null>(null) 并让模板写 ref="input"。watcher 清理改用回调参数:

ts
watch(keyword, async (rawValue, _oldValue, onCleanup) => {
  const currentRun = ++runId
  const controller = new AbortController()
  onCleanup(() => {
    controller.abort()
    if (currentRun === runId) runId += 1
  })
  // ...
})

useId() 生成 label / input 关联 ID;列表仍写 :key="item.id"。前者表示组件渲染中的唯一 DOM 关联,后者表示数据项跨重排仍保持的业务身份,不能互换。

局部、不可避免的时区文本可写:

vue
<time data-allow-mismatch="text">{{ localTime }}</time>

认证按钮数量不同则不能写 data-allow-mismatch="children" 来消音,因为这代表服务端和客户端使用了不同权限状态。应把认证状态按请求隔离并序列化到客户端。

受控三请求测试应断言:前两条 signal 均 aborted;第三条结果最终显示;前两条即使随后拒绝也不能改 error / loading;卸载会 Abort 第三条;两个 SearchField 实例的 runId、results 与 error 互不影响。仅在网络面板看到 cancel,不足以证明旧 finally 没有抢写。

交付前复盘 ​

  • 生命周期、composable、watcher 与模板 ref 都在同步 setup 调用链中建立归属。
  • 每次调用创建的状态与资源句柄在函数内部;模块级共享只用于明确的全局合同。
  • 请求、监听器、计时器、observer 和订阅都有对应释放动作与失败断言。
  • SSR 每请求创建 app / store,首轮输出确定,浏览器 API 延后到 mounted。
  • useTemplateRef、onWatcherCleanup、useId、data-allow-mismatch 均明确标注 3.5+ 与兼容分支。

用心记录每一步成长