静态博客前端:PJAX 与持续音乐
2026年9月13日 · 前端技术 · 约 9 分钟阅读
更新于 2026年9月17日
从长驻 audio 和可清理的每页工具入手,用有界请求协调示例解释 PJAX 竞态、失败回退与播放意图。
音乐在文章之间继续播放,关键不是换页后重新寻找上次的秒数,而是让浏览器始终保留同一个 audio 节点。PJAX 也是同一个思路:只替换页面的一部分,让外壳活下来。
但外壳长驻会带来新问题:旧页面的计时器、观察器和事件监听要清理;快速换页时旧请求可能晚到;前进后退和锚点不能只换内容不管 History。这篇把这几件事拆开讲,给出每页工具、请求协调和播放意图的有限示例。
它们不是一份完整的 PJAX 导航器。链接拦截、文档应用、历史记录和滚动恢复需要由站点导航层实现。普通链接仍然是基础,脚本失败时也应该能整页访问。
长驻外壳与每页工具使用不同的生命周期
先划分外壳和正文 下面的结构只保留播放器,导航层替换 main。这里还没有局部导航,普通链接仍然会整页加载:
<header > <a href ="/" > 首页</a > </header > <main id ="page" tabindex ="-1" > <article > <h1 > 当前文章</h1 > <p > 文章正文</p > </article > </main > <aside id ="player" aria-label ="音乐播放器" > <audio id ="site-audio" preload ="none" > </audio > <button id ="play-toggle" type ="button" > 播放</button > <span id ="play-status" role ="status" > 尚未加载音频</span > </aside >
没有 src 时不主动下载音频。播放器放在 main 外还不够,导航层也不能每次换页都重建整个 body、重新初始化播放器,或者反复调用 load()。
外站、下载、新标签页、组合键点击和同页锚点应该保留原生行为。完整刷新、外站跳转或整页回退仍然会重建 audio,不属于持续播放的保证范围。
每页工具要有清理函数 播放器只初始化一次;目录观察器、正文按钮和图片增强属于当前 main。每页增强返回一个 cleanup,换页前调用。
下面是一个最小的折叠说明工具,配合正文里的 data-note-toggle 按钮使用:
<button type ="button" data-note-toggle aria-controls ="page-note" aria-expanded ="true" > 补充说明</button > <p id ="page-note" > 这是当前文章的补充内容。</p >
function mountPageTools (main ) { const lifetime = new AbortController (); for (const button of main.querySelectorAll ('[data-note-toggle]' )) { const id = button.getAttribute ('aria-controls' ); const panel = id ? main.querySelector (`#${CSS.escape (id)} ` ) : null ; if (!panel) continue ; button.setAttribute ('aria-expanded' , String (!panel.hidden )); button.addEventListener ('click' , () => { panel.hidden = !panel.hidden ; button.setAttribute ('aria-expanded' , String (!panel.hidden )); }, { signal : lifetime.signal }); } return () => lifetime.abort (); } let cleanupPage = ( ) => {};function mountCurrentPage (main ) { cleanupPage (); cleanupPage = mountPageTools (main); } mountCurrentPage (document .querySelector ('#page' ));
真正替换 main 前,先调用 cleanupPage();新 main 插入后,再调用 mountCurrentPage(newMain)。复杂工具的 cleanup 还要断开 observer、取消计时器,并关闭关联旧节点的浮层。
可选插件单独捕获加载失败,不阻塞正文和导航。图片保留普通原图链接,即使灯箱没启动也能访问;图解本身不应该依赖灯箱才能阅读。
请求协调:最后一次导航获胜 请求协调需要两道保护:AbortController 尽量取消旧请求,序号让已经返回的旧结果失去提交资格。失败分支也要检查序号,否则较慢的旧请求失败后,仍可能把用户从新文章带走。
下面的工厂只负责请求与提交顺序 ,不监听链接,也不操作 History。它要求调用方提供同步的 applyDocument(next, finalURL);没有这个实现,就不能直接获得完整导航功能。
function createPageLoader (applyDocument ) { if (typeof applyDocument !== 'function' ) throw new TypeError ('applyDocument is required' ); let controller = null ; let sequence = 0 ; return async function loadPage (href ) { const target = new URL (href, location.href ); if (target.origin !== location.origin || !/^https?:$/ .test (target.protocol )) { throw new TypeError ('Only same-origin HTTP pages are supported' ); } const ticket = ++sequence; controller?.abort (); const current = new AbortController (); controller = current; const timeout = setTimeout (() => current.abort (), 8000 ); try { const response = await fetch (target.href , { signal : current.signal , credentials : 'same-origin' , cache : 'no-cache' }); if (ticket !== sequence) return ; const type = response.headers .get ('content-type' ) || '' ; const finalURL = new URL (response.url ); if (!response.ok || !/^text\/html(?:;|$)/i .test (type) || finalURL.origin !== location.origin ) { throw new Error ('Unexpected page response' ); } finalURL.hash = target.hash ; const html = await response.text (); if (ticket !== sequence || current.signal .aborted ) { if (ticket === sequence) throw new Error ('Navigation timed out' ); return ; } const next = new DOMParser ().parseFromString (html, 'text/html' ); if (next.querySelectorAll ('main#page' ).length !== 1 ) { throw new Error ('Expected one page container' ); } applyDocument (next, finalURL); } catch (error) { if (ticket !== sequence) return ; location.assign (target.href ); } finally { clearTimeout (timeout); if (ticket === sequence) controller = null ; } }; }
applyDocument 是站点集成接口,不是省略几行就能通用的替换函数。实现它时至少要明确这些规则:
提交前校验 :目标 main、文章结构和构建版本符合主题约定;外壳版本不同就整页导航。
同步提交 :清理旧页工具,替换 main,更新 title、canonical、description 和站点需要的其他元信息;不直接执行响应 HTML 里的脚本。
恢复交互 :挂载新页工具;前进、后退、普通点击使用不同的 History 策略,不能一律 pushState。
滚动与焦点 :带 hash 时定位真实目标;普通文章跳转把焦点移到新 main;历史返回按约定恢复位置。不能只换内容,却让读屏器仍停在旧位置。
文档解析不是 HTML 消毒。这里只接收本站受信任的 HTML,不把第三方响应插入页面;即使来源相同,也要校验站点约定的结构和版本。链接过滤、popstate、滚动保存和焦点恢复不在这里的协调片段内,省略这些后不应该称为完整导航器。
播放意图和实际状态分开 audio.play() 返回 Promise。用户按下播放后可能立刻暂停,旧 Promise 不应该把界面又改回“正在播放”。把用户意图和实际事件分开,才能避免一个图标同时代表加载、播放、等待和失败。
下面只演示单曲的播放与暂停,配合前面的播放器 HTML 使用。音频路径换成自己有权公开、浏览器可访问的同源文件:
const audio = document .querySelector ('#site-audio' );const playButton = document .querySelector ('#play-toggle' );const playStatus = document .querySelector ('#play-status' );let wantsPlayback = false ;let playSequence = 0 ;function renderButton ( ) { playButton.textContent = wantsPlayback ? '暂停' : '播放' ; } playButton.addEventListener ('click' , async () => { const ticket = ++playSequence; wantsPlayback = !wantsPlayback; renderButton (); if (!wantsPlayback) { audio.pause (); playStatus.textContent = '已暂停' ; return ; } if (!audio.getAttribute ('src' )) audio.src = '/media/example.mp3' ; playStatus.textContent = '正在请求播放' ; try { await audio.play (); if (ticket !== playSequence || !wantsPlayback) { if (!wantsPlayback) audio.pause (); return ; } } catch { if (ticket !== playSequence) return ; wantsPlayback = false ; renderButton (); playStatus.textContent = '播放未能开始,请检查音频或再次点击' ; } }); audio.addEventListener ('playing' , () => { if (!wantsPlayback) { audio.pause (); return ; } playStatus.textContent = '正在播放' ; }); audio.addEventListener ('waiting' , () => { if (wantsPlayback) playStatus.textContent = '正在缓冲' ; }); audio.addEventListener ('pause' , () => { if (audio.paused ) stopIntent (audio.ended ? '播放结束' : '已暂停' ); }); function stopIntent (message ) { ++playSequence; wantsPlayback = false ; renderButton (); playStatus.textContent = message; } audio.addEventListener ('ended' , () => stopIntent ('播放结束' )); audio.addEventListener ('error' , () => stopIntent ('音频加载失败' ));
这个例子不实现播放列表、seek、跨会话位置保存或自动续播。需要唱片动画时,根据实际的 playing 开始旋转,waiting、pause、ended 和 error 停止;减少动态效果偏好下关闭动画。不要在按下按钮的一刻就宣称音频已经播放。
换曲和续播要单独建状态 播放列表比单曲多几项状态:当前音源代号、当前播放请求、是否曾由用户启动,以及用户最新的播放意图。切换曲目时增加音源代号,旧音源的 Promise 或事件不能修改新曲状态。
暂停时点“下一首”,可以只选曲、不自动播放;正在播放时,才按当前意图尝试新曲。自然结束后的续播也要确认事件属于当前曲目、用户没有暂停,而且曾经实际开始过播放。错误不等于 ended,不能用加载失败触发无限跳过和请求。
浏览器仍然可能拒绝续播。失败时显示可以再次操作的状态,不要通过自动点击或绕过自动播放策略来解决。
用节点身份和失败路径验收 集成时可以先保存 audio 引用,局部导航后检查:
savedAudio === document .querySelector ('#site-audio' )
预期节点身份不变,也没有因为换页再次附加 src 或调用 load()。这个检查只能证明节点被保留,不能证明所有网络条件下音频都不会缓冲。
再分别检查:慢请求晚返回不会覆盖新页面;旧请求失败不触发整页回退;当前请求超时会回退;可选插件失败不阻塞正文;重复挂载后按钮仍然只响应一次。音乐部分检查播放后立即暂停、播放被拒绝和音频加载错误,确认界面和最新意图一致。
完整导航层还要在浏览器里验证后退、前进、锚点、焦点及无脚本访问。协调片段和状态测试不能代替这些导航行为,也不能代替真实媒体播放测试。
技术参考