04|第四章练习答案
第四章概览 / 参考答案。先按正文合同实现,再比较行为;代码结构不同不等于错误,遗漏失败与清理路径才是。
使用方式
每题按“实现 → 失败路径 → 验收证据”给出一条可行解。若你的实现公开面更大,先问多出来的状态或命令是否真是调用方需要;若更短,确认没有靠全局单例、浏览器隐式依赖或未清理资源换取篇幅。
01|setup 的执行模型
<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>
<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 及更早版本改成:
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|组合式函数
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
}
}关键断言:
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|生命周期
<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><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 迁移
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|模板引用与组合式函数
<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 兼容分支只替换声明:
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 与轮次检查:
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 必须按句柄保存多条回调:
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
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不能写入。验收时重点检查这四处:
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 清理改用回调参数:
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 关联,后者表示数据项跨重排仍保持的业务身份,不能互换。
局部、不可避免的时区文本可写:
<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+ 与兼容分支。