{pageTitle}
这里是页面主体内容。
启用 Astro 的客户端路由后,页面主体会在同一个文档里完成交换。视觉过渡结束前,键盘用户却可能停留在已经被替换的节点上:焦点指示器消失,屏幕阅读器也不一定马上知道新的内容从哪里开始。处理这类问题时,可以把“新页面应该从哪里继续阅读”和“旧页面的同一个控件是否仍然存在”分成两条路径。
Astro 官方入口:https://astro.build/
官方文档:https://docs.astro.build/en/guides/view-transitions/
普通页面导航适合在astro:after-swap中把焦点交给新页面的稳定入口,例如带tabindex="-1"的主标题;如果要保留同一个带transition:persist的控件焦点,则应在自定义before-swap中使用swapFunctions.saveFocus()。
astro:after-swap 发生在新页面内容已经替换旧页面之后、视图过渡结束之前。body。Astro 的 ClientRouter 生命周期大致包含 preparation、swap 和 page-load 阶段。astro:after-swap 在 DOM 已交换、历史记录和滚动位置已经更新后触发;astro:page-load 则更接近导航完成,适合重新初始化页面脚本。焦点恢复需要尽早把阅读起点交给新页面,因此通常放在 after-swap,而不是等待所有新脚本执行完。
这并不意味着 after-swap 适合所有场景。若用户点击的是表单控件、展开按钮或页内锚点,导航完成后仍然有明确的上下文,就不应把焦点强行移到标题。焦点管理的目标是恢复可理解的阅读位置,而不是让每一次页面交换都产生一次明显跳转。
最小方案是让每个页面的主内容容器或主标题拥有稳定选择器,并使用 tabindex="-1" 允许脚本聚焦。它不会把标题加入普通 Tab 顺序,但脚本可以在路由交换后把阅读起点设置到这里:
--- // 主标题只作为脚本恢复焦点的入口,不加入普通 Tab 顺序。 const pageTitle = '订单概览'; ---{pageTitle}
这里是页面主体内容。
如果站点有“跳过导航”链接,可以让它指向同一个 main-content。这样,键盘用户既能在页面加载后快速跳过公共导航,也能在客户端导航后从新页面主体继续阅读。

下面的监听器只展示“把焦点交给新页面主体”这一条路径。实际项目可以根据站点自己的导航状态补充判断,例如跳过初次加载、保留页内锚点,或在返回历史记录时尊重浏览器已经恢复的滚动位置。
const focusAfterSwap = () => {
// 页内锚点通常已有明确目标,不要用标题焦点覆盖它。
if (window.location.hash) return;
const main = document.querySelector('#main-content');
if (!(main instanceof HTMLElement)) return;
// 让屏幕阅读器和键盘用户从新页面主体继续,而不是停在旧节点上。
main.focus({ preventScroll: true });
};
// after-swap 发生在新页面 DOM 已经替换完成之后。
document.addEventListener('astro:after-swap', focusAfterSwap);
preventScroll: true 的作用是避免焦点动作顺带改变 Astro 已经处理好的滚动位置。若你的设计要求焦点进入视口,也可以去掉该选项,但要先确认它不会覆盖返回导航或深链接的滚动语义。
页面导航来源不同,焦点策略也应不同。可以把常见情况整理成下面的判断:
| 场景 | 更合适的处理 |
|---|---|
| 进入新的顶层页面 | after-swap 聚焦新页面的 main 或 h1 |
| 链接带有 hash | 保留锚点目标,避免标题覆盖 |
| 用户正在输入表单 | 不要用全局监听器打断当前输入上下文 |
| 后退或前进 | 优先尊重历史记录的滚动与焦点恢复,再做缺失时的降级 |
| 页面没有稳定目标 | 保留当前焦点,或在页面结构中补充可聚焦的主内容入口 |
这里的“判断”不需要把所有业务状态塞进一个事件处理器。更好的做法是让链接、表单和页面布局先表达自己的语义,after-swap 只负责在焦点确实丢失、且新页面需要新的阅读起点时介入。
另一种问题是:用户正在操作一个带 transition:persist 的全局搜索框、媒体控件或导航组件,页面交换后这个控件仍然存在,但你希望它继续保持焦点。此时重点不是把焦点移到新页面标题,而是保存当前焦点,并在自定义交换完成后恢复它。
import { swapFunctions } from 'astro:transitions/client';
function customSwap(newDocument) {
// 保存当前页面的焦点,后面只会在匹配的持久元素仍存在时恢复。
const restoreFocus = swapFunctions.saveFocus();
// 这里只保留示例所需的交换步骤,真实项目还要同步 head 和脚本状态。
swapFunctions.swapRootAttributes(newDocument);
swapFunctions.swapHeadElements(newDocument);
swapFunctions.swapBodyElement(newDocument.body, document.body);
// 恢复同一个持久控件的焦点,而不是把焦点移到页面标题。
restoreFocus();
}
document.addEventListener('astro:before-swap', (event) => {
// 接管 swap 后,Astro 才能按这条自定义路径执行焦点保存与恢复。
event.swap = () => customSwap(event.newDocument);
});

这条路径的代价是要承担更多交换实现责任。官方 API 参考列出了同步根节点属性、head、body 和脚本状态等需要关注的工具;如果项目没有明确的持久控件需求,优先使用普通 after-swap 聚焦新页面入口,维护成本更低。
main 使用了合适的语义。tabindex="-1" 没有被 CSS 或脚本意外覆盖。prefers-reduced-motion 的用户仍能获得清楚的页面焦点和阅读起点。可以,但前提是标题有可聚焦设置,例如 tabindex="-1",并且当前导航确实需要把阅读起点移到新页面。若标题不适合获得焦点,优先聚焦带稳定 ID 的 main。
页面交换刚完成、需要尽早设置阅读起点时用 after-swap;需要等待新脚本和阻塞样式加载完再初始化交互时用 page-load。两者不是互相替代的全局钩子。
saveFocus() 返回的是一个在交换后调用的恢复函数,官方示例把它放在自定义 swap 中,并通过 astro:before-swap 接管交换。普通的新页面焦点重置不需要引入这套自定义交换逻辑。
先确认焦点调用是否带有 preventScroll: true,再检查自己的 after-swap 逻辑有没有覆盖历史导航或 hash 目标。滚动位置和焦点是两个相关但不同的状态,不要用一个固定的 scrollTo 解决所有情况。
Astro endpoint 怎样导出带缓存头的二进制响应