第六章|路由、URL 与应用导航练习答案
每题对应正文合同。先独立完成,再比较可观察结果。下面
.mjs文件放在已安装vue、vue-router的练习项目中,用node 文件名.mjs执行;Node 的assert成功时不输出,失败会抛错并以非零状态结束。
涉及 SFC 的文件放进第一章的练习环境,在 App.vue 导入使用。布局缓存、焦点和真实滚动先按给出的浏览器操作验证;要自动化这些交互,先完成第九章测试环境再回访。Node 的路由解析测试不替代浏览器行为测试。
01|文件、年月与兜底匹配
component 使用空组件,因为这次只测解析,不挂载页面。年月正则只校验形状,99 仍能匹配两位月份;若业务要求1–12,应另加业务校验。
import assert from 'node:assert/strict'
import { createRouter, createMemoryHistory } from 'vue-router'
const Empty = { template: '<p />' }
const router = createRouter({
history: createMemoryHistory('/admin/'),
routes: [
{ path: '/files/:path+', name: 'files', component: Empty },
{ path: '/reports/:year(\\d{4})/:month(\\d{2})?', name: 'report', component: Empty },
{ path: '/:pathMatch(.*)*', name: 'not-found', component: Empty },
],
})
assert.deepEqual(router.resolve('/files/guide/vue').params.path, ['guide', 'vue'])
assert.equal(router.resolve('/files').name, 'not-found')
assert.equal(router.resolve('/reports/2026').name, 'report')
assert.equal(router.resolve('/reports/2026/09').params.month, '09')
assert.equal(router.resolve('/reports/26/09').name, 'not-found')
assert.equal(router.resolve('/reports/2026/9').name, 'not-found')
assert.equal(router.resolve({ name: 'files', params: { path: ['guide', 'vue'] } }).href,
'/admin/files/guide/vue')2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
+ 要求至少一段,? 允许整段缺失,catch-all 吸收不满足前两种结构的地址。/admin/ 传给 history;浏览器部署还要配置构建资源 base 与服务器深链回退。这三个位置必须一致,不能拿 memory history 的 href 断言证明服务器已配置好。
02|可访问链接与保留草稿
这部分在读过第05篇后回访。父记录用独立名称 settings;不要把同名父链接写成两个互不相关的路径。
import { createRouter, createWebHistory } from 'vue-router'
import SettingsLayout from './SettingsLayout.vue'
import SettingsPage from './SettingsPage.vue'
export const router = createRouter({
history: createWebHistory(),
routes: [{
path: '/settings', name: 'settings', component: SettingsLayout,
children: [
{ path: 'profile', name: 'profile', component: SettingsPage, props: { title: '资料' } },
{ path: 'security', name: 'security', component: SettingsPage, props: { title: '安全' } },
],
}],
})2
3
4
5
6
7
8
9
10
11
12
13
14
<script setup lang="ts">
import { RouterLink, RouterView } from 'vue-router'
</script>
<template>
<nav aria-label="设置导航">
<RouterLink :to="{ name: 'settings' }">设置</RouterLink>
<RouterLink :to="{ name: 'profile' }">资料</RouterLink>
<RouterLink v-slot="{ href, navigate, isExactActive }" custom :to="{ name: 'security' }">
<a :href="href" :aria-current="isExactActive ? 'page' : undefined" @click="navigate">
安全 <span aria-label="1条提示">1</span>
</a>
</RouterLink>
</nav>
<RouterView v-slot="{ Component, route }">
<KeepAlive :max="2"><component :is="Component" :key="String(route.name)" /></KeepAlive>
</RouterView>
</template>2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<script setup lang="ts">
import { ref } from 'vue'
defineProps<{ title: string }>()
const draft = ref('')
</script>
<template><label>{{ title }}<input v-model="draft" /></label></template>2
3
4
5
6
根 App 只需 <RouterView />。在资料输入 profile-draft,切安全输入另一串,再返回资料,原文本应仍存在。因为两个路由故意使用同一个组件,按 route name 设 key 才能得到两份缓存;query 改变不新建实例。
在 /settings/profile 观察:设置链接含 active,资料含 exact-active且 aria-current="page",安全无 aria-current。用Tab和Enter访问,用修饰键新标签打开,地址必须与href一致。停用缓存页面不会清除输入;退出登录要显式重置敏感状态或销毁承载缓存的布局,不能认为切走就完成清理。
03|保存成功后再判断导航结果
将依赖作为参数是为了用真实 memory router 和可控保存函数测试;页面只把自己的 refs 与动作传入。
import { isNavigationFailure, NavigationFailureType } from 'vue-router'
export function createSubmitFlow({ router, save, clearDraft, loading }) {
return async function submitAndOpen(id) {
loading.value = true
try {
await save()
const failure = await router.push({ name: 'detail', params: { id } })
if (!failure) {
clearDraft()
return 'opened'
}
if (isNavigationFailure(failure, NavigationFailureType.aborted)) return 'blocked'
if (isNavigationFailure(failure, NavigationFailureType.cancelled)) return 'cancelled'
if (isNavigationFailure(failure, NavigationFailureType.duplicated)) return 'duplicated'
return 'error'
} catch {
return 'error'
} finally {
loading.value = false
}
}
}
export function safeRedirect(raw, origin) {
if (typeof raw !== 'string' || !raw.startsWith('/') || raw.startsWith('//')) return '/'
try {
const url = new URL(raw, origin)
return url.origin === origin ? url.pathname + url.search + url.hash : '/'
} catch { return '/' }
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
import assert from 'node:assert/strict'
import { createRouter, createMemoryHistory } from 'vue-router'
import { createSubmitFlow, safeRedirect } from './submit.mjs'
const Empty = { render: () => null }
const router = createRouter({ history: createMemoryHistory(), routes: [
{ path: '/', component: Empty },
{ path: '/detail/:id', name: 'detail', component: Empty },
] })
await router.push('/')
let mode = 'allow'
let entered
let release
router.beforeEach(async to => {
if (mode === 'block') return false
if (mode === 'throw') throw new Error('guard failed')
if (to.params.id === 'slow') {
entered()
await new Promise(resolve => { release = resolve })
}
})
router.onError(() => {}) // 本测试预期异常;生产应记录
const loading = { value: false }
let clears = 0
const submit = createSubmitFlow({ router, save: async () => {}, loading,
clearDraft: () => { clears += 1 } })
assert.equal(await submit('1'), 'opened')
assert.equal(clears, 1)
assert.equal(await submit('1'), 'duplicated')
mode = 'block'
assert.equal(await submit('2'), 'blocked')
mode = 'throw'
assert.equal(await submit('2'), 'error')
mode = 'allow'
const started = new Promise(resolve => { entered = resolve })
const slow = submit('slow')
await started
await router.push('/detail/fast')
release()
assert.equal(await slow, 'cancelled')
assert.equal(clears, 1)
assert.equal(loading.value, false)
assert.equal(safeRedirect('//evil.example', 'https://app.example'), '/')
assert.equal(safeRedirect('/orders/42', 'https://app.example'), '/orders/42')2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
取消、重复、异常都保留草稿,finally 覆盖每条退出路径。登录成功使用 router.replace(safeRedirect(route.query.redirect, location.origin))。页面还应禁用重复提交,避免两个保存动作共享一个loading时互相抢写;导航取消不等于已发送的保存请求被取消。
04|URL规范化与分页
export function parseProductLocation(route) {
const first = value => Array.isArray(value) ? value[0] : value
const rawPage = Number(first(route.query.page) ?? 1)
const tag = route.query.tag
return {
category: typeof route.params.category === 'string' && route.params.category
? route.params.category : 'all',
page: Number.isSafeInteger(rawPage) && rawPage > 0 ? rawPage : 1,
tags: (Array.isArray(tag) ? tag : [tag]).filter(value => typeof value === 'string'),
sort: first(route.query.sort) === 'price' ? 'price' : 'rating',
}
}
export function setPage(router, route, nextPage) {
const valid = Number.isSafeInteger(nextPage) && nextPage > 0
const target = { path: route.path, query: { ...route.query, page: String(valid ? nextPage : 1) } }
return valid ? router.push(target) : router.replace(target)
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import assert from 'node:assert/strict'
import { parseProductLocation, setPage } from './product-location.mjs'
const route = { path: '/products/books', params: { category: 'books' },
query: { page: '-2', tag: ['vue', null, 'router'], sort: 'unknown' } }
assert.deepEqual(parseProductLocation(route), {
category: 'books', page: 1, tags: ['vue', 'router'], sort: 'rating',
})
const calls = []
const router = { push: x => calls.push(['push', x]), replace: x => calls.push(['replace', x]) }
setPage(router, route, 2)
setPage(router, route, 0)
assert.equal(calls[0][0], 'push')
assert.equal(calls[1][0], 'replace')
assert.equal(calls[1][1].path, '/products/books')
assert.deepEqual(calls[1][1].query.tag, route.query.tag)
assert.equal(calls[1][1].query.page, '1')2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
路由记录配置 path:'/products/:category?',指向下面页面。这里使用确定性本地结果,专注观察路由复用;接API时替换load并保留signal合同。
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
// TypeScript项目可把product-location.mjs保存为.ts并为route添加所需类型。
import { parseProductLocation } from './product-location.mjs'
const route = useRoute()
const filters = computed(() => parseProductLocation(route))
const result = ref('')
console.log('product setup')
watch(filters, async (input, _old, onCleanup) => {
let active = true
onCleanup(() => { active = false })
const next = await Promise.resolve(`${input.category}:${input.page}:${input.sort}`)
if (active) result.value = next
}, { immediate: true })
</script>
<template><p>{{ result }}</p></template>2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
页面在setup中只创建一次watch,每轮cleanup使旧回调失去写入许可。切books→games时记录一次setup、两次请求,最后一次卸载移除监听器。这个浏览器验证检查实例复用;上面的纯函数测试只证明解析和history选择。
05|嵌套路由布局与局部 404
import SectionLayout from './SectionLayout.vue'
import SectionPage from './SectionPage.vue'
import SectionNotFound from './SectionNotFound.vue'
export const sectionRoutes = [{
path: '/sections/:sectionId', component: SectionLayout, props: true,
children: [
{ path: '', redirect: to => ({ name: 'section-overview', params: to.params }) },
{ path: 'overview', name: 'section-overview', component: SectionPage, props: true },
{ path: 'items', name: 'section-items', component: SectionPage, props: true },
{ path: ':pathMatch(.*)*', name: 'section-missing', component: SectionNotFound },
],
}]2
3
4
5
6
7
8
9
10
11
12
<script setup lang="ts">
import { ref, watch } from 'vue'
import { RouterLink, RouterView } from 'vue-router'
const props = defineProps<{ sectionId: string }>()
const sectionName = ref('')
console.log('layout setup')
watch(() => props.sectionId, id => {
// 确定性本地数据源;接真实请求时使用前文的取消与轮次保护。
sectionName.value = `区段 ${id}`
console.log('load section', id)
}, { immediate: true })
</script>
<template>
<h1>{{ sectionName }}</h1>
<RouterLink :to="{ name: 'section-overview', params: { sectionId } }">概览</RouterLink>
<RouterLink :to="{ name: 'section-items', params: { sectionId } }">条目</RouterLink>
<RouterView />
</template>2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<script setup lang="ts">
defineProps<{ sectionId: string }>()
</script>
<template><p>当前区段:{{ sectionId }}</p></template>2
3
4
SectionNotFound.vue 内容是 <template><p>当前区段内页面不存在</p></template>。依次打开/sections/a、/sections/a/items、/sections/b/items、/sections/b/unknown:第一次最终 URL 是 overview;前两步 layout setup 只执行一次;第三步重新读取区段 b;第四步仍有区段标题,而子出口显示局部 404。
memory router 中可断言未知 child 的 matched.length === 2 且末项 name 为 section-missing。若 items 改成绝对 /items/:sectionId,URL 不再含 /sections,但 matched 仍有父布局;不能只写 /items 而忽略父布局必需的 sectionId。
06|同一记录装配三栏
import assert from 'node:assert/strict'
import { createRouter, createMemoryHistory } from 'vue-router'
const Detail = { render: () => null }
const Sidebar = { render: () => null }
const Helper = { render: () => null }
function makeRouter(path, includeHelper = true) {
return createRouter({ history: createMemoryHistory(), routes: [{
path, name: 'order-workbench',
components: { default: Detail, sidebar: Sidebar, ...(includeHelper ? { helper: Helper } : {}) },
props: { default: route => ({ id: String(route.params.id) }), sidebar: { cacheKey: 'orders' }, helper: { compact: true } },
}] })
}
const router = makeRouter('/orders/:id')
const to = router.resolve({ name: 'order-workbench', params: { id: '42' } })
assert.equal(to.href, '/orders/42')
assert.equal(to.matched[0].components.sidebar, Sidebar)
assert.deepEqual(to.matched[0].props.default(to), { id: '42' })
assert.equal(makeRouter('/purchases/:id').resolve({ name: 'order-workbench', params: { id: '42' } }).href, '/purchases/42')
assert.equal(makeRouter('/orders/:id', false).resolve('/orders/42').matched[0].components.helper, undefined)
assert.throws(() => router.resolve({ name: 'does-not-exist' }))2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
运行布局时可使用以下本地小组件;共享列表在App的setup创建,由provider持有。列表数据仅由App初始化一次,Sidebar只读,不重复请求详情。
<script setup lang="ts">
import { provide, readonly, ref } from 'vue'
import { RouterView } from 'vue-router'
const orders = ref([{ id: '41', label: '订单41' }, { id: '42', label: '订单42' }])
provide('orders', readonly(orders))
</script>
<template>
<aside><RouterView name="sidebar" /></aside>
<main><RouterView /></main>
<section><RouterView name="helper" /></section>
</template>2
3
4
5
6
7
8
9
10
11
<script setup lang="ts">
import { ref, watch } from 'vue'
const props = defineProps<{ id: string }>()
const detail = ref('')
watch(() => props.id, async (id, _old, onCleanup) => {
let active = true
onCleanup(() => { active = false })
console.log('detail request', id)
const value = await Promise.resolve(`订单详情 ${id}`)
if (active) detail.value = value
}, { immediate: true })
</script>
<template><p>{{ detail }}</p></template>2
3
4
5
6
7
8
9
10
11
12
13
<script setup lang="ts">
import { inject, type Ref } from 'vue'
defineProps<{ cacheKey: string }>()
const orders = inject<Readonly<Ref<readonly { id: string; label: string }[]>>>('orders')
if (!orders) throw new Error('缺少订单列表provider')
</script>
<template><ul><li v-for="order in orders" :key="order.id">{{ order.label }}</li></ul></template>2
3
4
5
6
7
OrderHelper.vue可写<script setup lang="ts">defineProps<{ compact: boolean }>()</script><template><p>选择订单查看详情</p></template>。将测试路由中的Detail/Sidebar/Helper换成三个SFC导入,把OrderWorkbench作为App根组件。示例详情的Promise是本地数据源,替换成真实请求后仍只有详情发该请求。浏览器在同层布局放<RouterView/>、<RouterView name="sidebar"/>、<RouterView name="helper"/>。详情接收id并发详情请求;sidebar接收cacheKey只读取第五章共享缓存,不再请求当前订单;helper只接收compact。删去helper时只该区域为空,因为三个出口读取同一record的不同属性,不会互相占位。
07|认证恢复与四类守卫结果
export function createAuthGuard(auth) {
let restoring = null
return async to => {
if (!to.meta.requiresAuth || to.name === 'login' || to.name === 'service-unavailable') return true
try {
if (auth.status === 'unknown') {
restoring ??= Promise.resolve().then(() => auth.restore()).finally(() => { restoring = null })
await restoring
}
} catch { return { name: 'service-unavailable' } }
if (!auth.isAuthenticated) return { name: 'login', query: { redirect: to.fullPath } }
return true
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
import assert from 'node:assert/strict'
import { createRouter, createMemoryHistory, isNavigationFailure, NavigationFailureType } from 'vue-router'
import { createAuthGuard } from './auth-guard.mjs'
const Empty = { render: () => null }
let restores = 0
const auth = { status: 'unknown', isAuthenticated: false,
async restore() { restores++; this.status = 'anonymous' } }
const router = createRouter({ history: createMemoryHistory(), routes: [
{ path: '/', component: Empty },
{ path: '/login', name: 'login', component: Empty },
{ path: '/unavailable', name: 'service-unavailable', component: Empty },
{ path: '/private', component: Empty, meta: { requiresAuth: true } },
{ path: '/blocked', component: Empty, beforeEnter: () => false },
{ path: '/crash', component: Empty, beforeEnter: () => { throw new Error('boom') } },
] })
router.beforeEach(createAuthGuard(auth))
router.onError(() => {})
assert.equal(await router.push('/'), undefined)
assert.equal(restores, 0)
assert.equal(await router.push('/private'), undefined)
assert.equal(router.currentRoute.value.name, 'login')
assert.equal(router.currentRoute.value.query.redirect, '/private')
assert.equal(restores, 1)
const failure = await router.push('/blocked')
assert.ok(isNavigationFailure(failure, NavigationFailureType.aborted))
assert.equal(router.currentRoute.value.name, 'login')
await assert.rejects(router.push('/crash'), /boom/)
auth.status = 'unknown'
auth.restore = async () => { throw new Error('offline') }
await router.push('/private')
assert.equal(router.currentRoute.value.name, 'service-unavailable')
let resolveSession
let sharedRestores = 0
const session = { status: 'unknown', isAuthenticated: false,
restore() {
sharedRestores++
return new Promise(resolve => { resolveSession = resolve })
}
}
const guard = createAuthGuard(session)
const first = guard({ meta: { requiresAuth: true }, fullPath: '/first' })
const second = guard({ meta: { requiresAuth: true }, fullPath: '/second' })
await Promise.resolve()
assert.equal(sharedRestores, 1)
assert.equal(await guard({ meta: {}, fullPath: '/public' }), true)
session.status = 'authenticated'
session.isAuthenticated = true
resolveSession(true)
assert.deepEqual(await Promise.all([first, second]), [true, true])2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
编辑页另在setup同步注册onBeforeRouteLeave(() => !dirty.value || window.confirm('仍要离开吗?'))。dirty时取消,草稿与URL保持;确认时离开。memory测试用record守卫演示false的导航结果,真正的组件leave注册还要在浏览器挂载编辑页验证。restore返回的失败被转换为service-unavailable,因此该分支本身是成功重定向;/crash才验证未被处理的异常reject。
08|迁移后的完整配置骨架
import { createRouter, createWebHistory } from 'vue-router'
import { useAuthStore } from './stores/auth'
import { pinia } from './pinia' // 客户端创建并安装的单一容器;SSR改为每请求传入
export const router = createRouter({
history: createWebHistory('/admin/'),
routes: [
{ path: '/', name: 'home', component: () => import('./Home.vue') },
{ path: '/login', name: 'login', component: () => import('./Login.vue') },
{ path: '/private', component: () => import('./Private.vue'), meta: { requiresAuth: true } },
{ path: '/:pathMatch(.*)*', name: 'not-found', component: () => import('./NotFound.vue') },
],
scrollBehavior(_to, _from, savedPosition) { return savedPosition ?? { left: 0, top: 0 } },
})
router.beforeEach(to => {
const auth = useAuthStore(pinia)
if (to.meta.requiresAuth && !auth.isAuthenticated) return { name: 'login' }
})2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import { createApp } from 'vue'
import App from './App.vue'
import { router } from './router'
import { pinia } from './pinia'
const app = createApp(App)
app.use(pinia).use(router)
router.isReady().then(() => app.mount('#app'))2
3
4
5
6
7
App用第02题的RouterView slot包KeepAlive。对应迁移断言:
| 旧合同 | 新入口 | 会发现回归的检查 |
|---|---|---|
/admin/部署 | history base | resolve href必须带/admin/;浏览器直开private不返回服务器404 |
onReady后挂载 | isReady Promise | 延迟初始guard,完成前不得挂载 |
*兜底 | catch-all参数 | /missing/a命中not-found且pathMatch为数组 |
| next拒绝/重定向 | return false/location | 匿名去login;取消时push返回aborted |
{x,y}恢复 | {left,top} | 传savedPosition原样返回;浏览器后退实际恢复 |
| 外层KeepAlive | slot内动态组件 | 切换再返回保留草稿;设置max限制缓存 |
服务器仍要把页面/admin/**回退到/admin/index.html,同时排除API和真实资源。三个依赖SFC可先用一行p标识页面,但正式回归必须包括认证、进度条、分析插件的初始化与清理;没有旧项目运行结果就不能声称行为完全等价。
09|父级登录与子级角色政策
import assert from 'node:assert/strict'
export function checkPolicy(to, session) {
if (to.matched.some(record => record.meta.requiresAuth) && !session) {
return { name: 'login', query: { redirect: to.fullPath } }
}
const roles = to.meta.roles ?? []
if (roles.length && !roles.some(role => session?.roles.includes(role))) return { name: 'forbidden' }
return true
}
const to = { fullPath: '/reports/42?tab=a',
matched: [{ meta: { requiresAuth: true } }, { meta: { roles: ['auditor'] } }],
meta: { title: '报告详情', requiresAuth: true, roles: ['auditor'] } }
assert.deepEqual(checkPolicy(to, null), { name: 'login', query: { redirect: '/reports/42?tab=a' } })
assert.deepEqual(checkPolicy(to, { roles: ['reader'] }), { name: 'forbidden' })
assert.equal(checkPolicy(to, { roles: ['auditor'] }), true)2
3
4
5
6
7
8
9
10
11
12
13
14
15
路由记录如下(ReportsLayout、ReportList、ReportDetail分别是父布局含RouterView、列表页面和详情页面):
import type { RouteRecordRaw } from 'vue-router'
const routes: RouteRecordRaw[] = [{
path: '/reports', component: ReportsLayout,
meta: { title: '报告', requiresAuth: true },
children: [
{ path: '', component: ReportList, meta: { title: '报告列表' } },
{ path: ':id', component: ReportDetail, meta: { title: '报告详情', roles: ['auditor'] } },
],
},
{ path: '/login', name: 'login', component: Login, meta: { title: '登录' } },
{ path: '/forbidden', name: 'forbidden', component: Forbidden, meta: { title: '无权限' } }]2
3
4
5
6
7
8
9
10
11
TypeScript路由文件沿用正文RouteMeta扩展,配置/reports父记录meta为{title:'报告',requiresAuth:true},child :id为{title:'报告详情',roles:['auditor']}。guard调用checkPolicy(to, session);客户端afterEach((to,_from,failure)=>{ if(!failure) document.title=to.meta.title })只在成功后改标题。
matched.some保留父级登录约束;roles使用合并后子级政策。这个例子采用“任意一个列出的角色即可”而非全部角色,业务变化要同步改判断和测试。页面可见仍不能证明用户有权读取具体报告,服务端还须检查资源归属。
10|有状态、最多两次的加载
export async function loadRouteWithOneRetry(loader, state, online) {
state.error = null
for (let attempt = 0; attempt < 2; attempt++) {
if (!online()) {
state.phase = 'error'
state.error = 'offline'
return { ok: false, kind: 'offline' }
}
state.phase = attempt === 0 ? 'loading' : 'retrying'
try {
const value = await loader()
state.phase = 'ready'
return { ok: true, value }
} catch (error) {
if (attempt === 1) {
state.phase = 'error'
state.error = error
return { ok: false, kind: 'load-failed' }
}
}
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import assert from 'node:assert/strict'
import { loadRouteWithOneRetry } from './retry.mjs'
const phases = []
const state = new Proxy({ phase: 'idle', error: null }, {
set(target, key, value) { if (key === 'phase') phases.push(value); target[key] = value; return true },
})
let calls = 0
const result = await loadRouteWithOneRetry(async () => {
if (++calls === 1) throw new Error('first')
return 'page'
}, state, () => true)
assert.deepEqual(result, { ok: true, value: 'page' })
assert.deepEqual(phases, ['loading', 'retrying', 'ready'])
assert.equal(calls, 2)
calls = 0
assert.equal((await loadRouteWithOneRetry(async () => { calls++; throw new Error('no') }, state, () => true)).ok, false)
assert.equal(calls, 2)
assert.equal(state.phase, 'error')
calls = 0
assert.equal((await loadRouteWithOneRetry(async () => { calls++ }, state, () => false)).kind, 'offline')
assert.equal(calls, 0)2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
路由提示保留to.fullPath并记录事件{kind:'route-load',path,phase},不记录凭证;全局重载恢复另用正文createChunkRecovery。让两次创建共享同一Map实现的storage,第一次调用返回reloading,第二次返回error,navigate计数必须为1;存储抛错时应直接error且计数0。这证明跨启动标记,与loader的两次运行断言互补。
11|锚点存在与缺失分别处理
import assert from 'node:assert/strict'
export function chooseScroll(to, savedPosition, findElement) {
if (savedPosition) return savedPosition
if (to.hash) {
const element = findElement(to.hash)
if (element) return { el: element, top: 64 }
}
return { top: 0, left: 0 }
}
const comments = { id: 'comments' }
const find = hash => hash === '#comments' ? comments : null
assert.deepEqual(chooseScroll({}, null, find), { top: 0, left: 0 })
const saved = { left: 0, top: 420 }
assert.equal(chooseScroll({ hash: '#comments' }, saved, find), saved)
assert.deepEqual(chooseScroll({ hash: '#comments' }, null, find), { el: comments, top: 64 })
assert.deepEqual(chooseScroll({ hash: '#missing' }, null, find), { top: 0, left: 0 })2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
浏览器在router的scrollBehavior(to,_from,saved)中调用chooseScroll(to,saved,findHashElement),findHashElement使用正文的安全ID查找。优先saved保留浏览器返回位置;没有saved时才处理锚点;不存在则回顶部。
测试中的comments是可比较占位对象,浏览器必须返回真实Element。自动化核对返回值只证明策略;还要在长页面从列表进入文章、后退、打开comments与missing,检查实际位置和64px头部遮挡。异步内容需等待目标就绪且有超时,不能把缺失时回顶部误称为已处理异步锚点。