← 返回文章

静态博客前端:PJAX 与持续音乐

更新于

从长驻 audio 和可清理的每页工具入手,用有界请求协调示例解释 PJAX 竞态、失败回退与播放意图。

两页叠放的外壳只替换正文,右下角唱片和波形持续在外壳之外播放

音乐在文章之间继续播放,关键不是换页后重新寻找上次的秒数,而是让浏览器始终保留同一个 audio 节点。PJAX 也是同一个思路:只替换页面的一部分,让外壳活下来。

但外壳长驻会带来新问题:旧页面的计时器、观察器和事件监听要清理;快速换页时旧请求可能晚到;前进后退和锚点不能只换内容不管 History。这篇把这几件事拆开讲,给出每页工具、请求协调和播放意图的有限示例。

它们不是一份完整的 PJAX 导航器。链接拦截、文档应用、历史记录和滚动恢复需要由站点导航层实现。普通链接仍然是基础,脚本失败时也应该能整页访问。

顶栏和播放器保留在外壳中;局部导航先清理旧文章工具,再替换 main 并挂载新工具。
长驻外壳与每页工具使用不同的生命周期



先划分外壳和正文

下面的结构只保留播放器,导航层替换 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;
// 当前请求超时也要回退,不能把所有 AbortError 都当作新导航取消。
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');
}
// 不 await:文档应用接口应同步提交,避免异步提交再次引入竞态。
applyDocument(next, finalURL);
} catch (error) {
if (ticket !== sequence) return;
location.assign(target.href);
} finally {
clearTimeout(timeout);
if (ticket === sequence) controller = null;
}
};
}

applyDocument 是站点集成接口,不是省略几行就能通用的替换函数。实现它时至少要明确这些规则:

  1. 提交前校验:目标 main、文章结构和构建版本符合主题约定;外壳版本不同就整页导航。
  2. 同步提交:清理旧页工具,替换 main,更新 title、canonical、description 和站点需要的其他元信息;不直接执行响应 HTML 里的脚本。
  3. 恢复交互:挂载新页工具;前进、后退、普通点击使用不同的 History 策略,不能一律 pushState。
  4. 滚动与焦点:带 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;
}
// 实际播放状态由 playing / waiting 等事件呈现。
} 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', () => {
// 也响应浏览器或系统发起的暂停;忽略已经恢复播放的旧 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()。这个检查只能证明节点被保留,不能证明所有网络条件下音频都不会缓冲。

再分别检查:慢请求晚返回不会覆盖新页面;旧请求失败不触发整页回退;当前请求超时会回退;可选插件失败不阻塞正文;重复挂载后按钮仍然只响应一次。音乐部分检查播放后立即暂停、播放被拒绝和音频加载错误,确认界面和最新意图一致。

完整导航层还要在浏览器里验证后退、前进、锚点、焦点及无脚本访问。协调片段和状态测试不能代替这些导航行为,也不能代替真实媒体播放测试。

技术参考