让 YoduPlayer 切页不断歌:给 Typecho 博客定制 PJAX 的完整实战

让 YoduPlayer 切页不断歌:给 Typecho 博客定制 PJAX 的完整实战

admin
2026-08-18 / 0 评论 / 5 阅读

让 YoduPlayer 切页不断歌:给 Typecho 博客定制 PJAX 的完整实战

博客装了 YoduPlayer 这款背景音乐播放器后,一直有个很破坏体验的问题:音乐正放着,点进一篇文章,页面一刷新,歌就断了。访客每看一篇新文章就要重新点一次播放,背景音乐的"背景"两个字完全名存实亡。

这篇文章记录我把这个问题彻底解决的全过程:从原理分析,到方案选型,再到自己动手写一个约 200 行的轻量 PJAX,最后处理掉一串切页后才会暴露的隐藏 Bug。完整代码都在文中,可以直接抄走用。

一、先搞清楚:歌为什么会断

浏览器里的音频播放依赖一个 DOM 元素 <audio>(YoduPlayer 里就是全局的 yaudio 对象)。而传统页面跳转的本质是:

点击链接 → 浏览器销毁整个文档 → 请求新页面 → 重新解析 HTML/CSS/JS

文档销毁的瞬间,挂在文档里的 <audio> 元素跟着被销毁,播放自然中断。新页面里插件重新输出了一套播放器 HTML 和 JS,但那是一个全新的 yaudio,播放进度、当前曲目全部归零。

所以问题的根源不在插件,而在于整页刷新这种导航方式本身。想让音乐不断,就不能销毁承载播放器的那份文档。

二、方案选型:为什么是 PJAX

解决思路业界已经很成熟了——局部刷新:导航时只替换页面的内容区域,头部、底部、播放器所在的 DOM 保持不动。实现方式常见的有三种:

方案原理缺点
iframe把整站套进框架页URL 不变、SEO 灾难、移动端体验差
SPA 改造前端框架接管路由Typecho 主题基本要重写,成本过高
PJAXAJAX 拉取新页面 + History API 改地址只需主题小幅配合

PJAX(PushState + AJAX)的原理一句话就能说清:

拦截链接点击 → preventDefault 阻止跳转 → XHR 拉取新页面 HTML
→ 用 DOMParser 解析 → 只取内容区域替换进当前文档 → pushState 更新地址栏

整个过程文档从未销毁,<audio> 一直活着,音乐自然不断。同时 URL 会真实变化、浏览器前进后退可用、对搜索引擎完全透明——这是它碾压 iframe 的地方。

YoduPlayer 的 README 里也写了"需要主题支持 pjax 或 instantclick",说明作者早就预留了这条路,缺的只是主题侧的实现。我的主题是 Joe,直接引现成的 PJAX 库和主题代码有各种兼容性小毛病,所以干脆自己写了一个定制版。

三、动手实现

3.1 确定内容容器边界

第一步是划分"哪些 DOM 切页时要换,哪些不能动"。看 Joe 主题的 index.php 结构:

<div id="Joe">
  <!-- 文章列表 / 正文 / 侧边栏 / footer 都在这里面 -->
</div>
<?php $this->footer(); ?>  <!-- YoduPlayer 的播放器 HTML 输出在这里 -->
</body>

很清晰:#Joe 是内容容器,切页时替换它的 innerHTML;播放器、脚本初始化代码都在容器外,天生不受影响。如果你的主题结构不同,把这层边界划对是第一件事。

3.2 核心 PJAX 脚本

在主题里新建 assets/lib/pjax/pjax.js,核心不到 200 行:

(function () {
  'use strict';

  var CONTAINER = '#Joe';
  var NO_INSTANT = 'data-no-instant';
  var xhr;

  history.replaceState({ url: location.href }, '', location.href);

  // 1. 拦截站内链接点击
  document.addEventListener('click', function (e) {
    if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;

    var link = e.target.closest('a');
    if (!link) return;
    if (link.target === '_blank' || link.hasAttribute('download')) return;

    var href = link.getAttribute('href');
    if (!href || href.charAt(0) === '#' 
        || href.indexOf('javascript:') === 0) return;

    var url;
    try { url = new URL(link.href); } catch (err) { return; }
    if (url.origin !== location.origin) return;      // 外链放行
    if (url.pathname.indexOf('/admin/') === 0) return; // 后台放行
    if (url.href.split('#')[0] === location.href.split('#')[0]) return;

    // 沿 DOM 向上检查 data-no-instant,标记了就走原生跳转
    var el = link;
    while (el && el !== document.documentElement) {
      if (el.hasAttribute && el.hasAttribute(NO_INSTANT)) return;
      el = el.parentNode;
    }

    e.preventDefault();
    loadPage(url.href, true);
  });

  // 2. 前进 / 后退
  window.addEventListener('popstate', function (e) {
    loadPage((e.state && e.state.url) || location.href, false);
  });

  // 3. 拉取新页面
  function loadPage(url, push) {
    if (xhr) xhr.abort();
    xhr = new XMLHttpRequest();
    xhr.open('GET', url);
    xhr.timeout = 15000;
    xhr.onload = function () {
      var ct = xhr.getResponseHeader('Content-Type') || '';
      if (xhr.status >= 200 && xhr.status < 400 && ct.indexOf('text/html') >= 0) {
        applyPage(xhr.responseText, url, push);
      } else {
        location.href = url;  // 兜底:非 HTML 直接真跳转
      }
    };
    xhr.onerror = function () { location.href = url; };
    xhr.ontimeout = function () { location.href = url; };
    xhr.send();
  }

  // 4. 替换内容 + 重执行脚本
  function applyPage(html, url, push) {
    var newDoc = new DOMParser().parseFromString(html, 'text/html');
    document.title = newDoc.title;

    var oldC = document.querySelector(CONTAINER);
    var newC = newDoc.querySelector(CONTAINER);
    if (!oldC || !newC) { location.href = url; return; }

    oldC.innerHTML = newC.innerHTML;

    // 关键:innerHTML 插入的 <script> 不会执行,手动重建
    var scripts = oldC.querySelectorAll('script');
    for (var i = 0; i < scripts.length; i++) {
      var s = scripts[i];
      if (s.hasAttribute(NO_INSTANT)) continue; // 带标记的跳过

      var ns = document.createElement('script');
      for (var j = 0; j < s.attributes.length; j++) {
        ns.setAttribute(s.attributes[j].name, s.attributes[j].value);
      }
      ns.textContent = s.textContent;
      s.parentNode.replaceChild(ns, s);
    }

    if (push) history.pushState({ url: url }, '', url);
    window.scrollTo(0, 0);

    // 重新触发 DOMContentLoaded,让主题的初始化逻辑跑一遍
    try { document.dispatchEvent(new Event('DOMContentLoaded')); } catch (err) {}

    // 广播事件,其他脚本可监听
    document.dispatchEvent(new CustomEvent('pjax:complete', { detail: { url: url } }));
  }
})();

几个容易踩坑的点单独说一下:

  • innerHTML 里的脚本不执行。这是浏览器的安全设计,所以必须手动 createElement('script') 重建节点。而 data-no-instant 标记的脚本(比如插件的初始化配置)跳过不执行——它们已经在页面上活着了,再执行一遍等于重置播放器。
  • 一定要有兜底。请求失败、超时、返回的不是 HTML、新页面找不到容器,任何一种异常情况都直接 location.href 真跳转,宁可断歌也不能白屏。
  • popstate 处理前进后退pushState 时带上的 state.url 在这里取出来用,否则回退会没有反应。

最后在 public/include.php 里引入,注意自身要带 data-no-instant,防止被未来的自己重复执行:

<script src="<?php _getAssets('assets/lib/pjax/pjax.js'); ?>" data-no-instant></script>

3.3 与 YoduPlayer 的分工

做完上面这步,音乐其实已经不会断了。剩下的工作是让播放器的 UI 在切页后依然正常。YoduPlayer 的加载分两部分,正好对应两种处理方式:

// Plugin.php footer() 中的关键输出

// 音频引擎 + 核心函数:带 data-no-instant,切页不重执行
<script data-no-instant>
var yaudio = new Audio();
var musicArr = [{title:"xxx", artist:"xxx", mp3:"http:xxx", cover:"xxx"},];
var sj = musicArr[0];
yaudio.src = sj.mp3;
</script>
<script src=".../js/player.js" data-no-instant></script>

// UI 层:不带标记,切页后随容器重建
<script src=".../js/prpr.js"></script>

分工非常清晰:

  • player.js + 内联配置:持有 yaudiomusicArr 这些全局状态和 playbtu()next() 这些核心函数,标记为 data-no-instant,一次加载终身有效;
  • prpr.js:负责播放器界面,重新生成歌单列表 DOM、把播放/切歌按钮绑定到还活着的 yaudio、同步当前曲目和封面。它不带标记,每次 PJAX 切页后都会重新执行一遍,相当于给新 DOM"接上"旧引擎。

如果你在给其他播放器做类似改造,把"状态"和"UI"拆开、状态部分标记为不重执行,就是最核心的思路。

四、不断歌之后,才轮到真正的坑

音乐连续了只是第一步。整页刷新被干掉后,一堆原本"刷新后自然解决"的问题全部浮出水面:

4.1 主题初始化不再触发

Joe 主题把文章列表懒加载、轮播、各种事件绑定全放在一个 DOMContentLoaded 监听器里。PJAX 换完内容后这个事件不会自己触发,页面看着是换了,交互全是死的。解法就是上面代码里的那句:

try { document.dispatchEvent(new Event('DOMContentLoaded')); } catch (err) {}

手动补发一次,主题的初始化逻辑就会对新内容重跑一遍。

4.2 评论表单的安全令牌失效

这是最隐蔽的一个。上线后发现:PJAX 切页后发的第一条评论永远失败,报"评论发表失败"。

排查后发现 Typecho 有个防spam机制:页面 head 里有一段内联脚本,会在评论表单里动态注入一个 name="_" 的隐藏字段作为令牌,服务端校验它。这段脚本在 <head> 里,PJAX 只替换内容容器根本不会碰它,于是新页面里表单是新的、令牌是旧的,校验必然失败。

解法是在 applyPage 里补一段:解析新页面 head 中的内联脚本,把包含 name = '_'(防spam)和 TypechoComment(评论回复逻辑,它的 respondId 是每篇文章独有的)的挑出来手动执行:

var headInline = newDoc.head.querySelectorAll('script:not([src])');
for (var hi = 0; hi < headInline.length; hi++) {
  var hcode = headInline[hi].textContent;
  if (hcode.indexOf("name = '_'") !== -1 ||
      hcode.indexOf('TypechoComment') !== -1) {
    var hns = document.createElement('script');
    hns.textContent = hcode;
    document.head.appendChild(hns);
    document.head.removeChild(hns);
  }
}

4.3 评论重复提交

评论能发之后,又发现每条评论提交了两次。原因是主题的 joe.global.min.js 在补发的 DOMContentLoaded 里又绑定了一次 AJAX 评论处理器,和 PJAX 脚本里的处理器叠加了。解法是绑定评论事件时用 e.stopImmediatePropagation() 抢占,并在每次切页后 $form.off('submit') 清掉旧绑定再重绑,保证表单上永远只有一个处理器。

4.4 收起状态记忆失效

播放器有个收起/展开状态存在 localStorage 里。切页后新 DOM 是初始展开状态,需要在 PJAX 完成后重新读取并应用。YoduPlayer 的 prpr.js 开头有现成逻辑:

if (localStorage.getItem("yoduplayer_collapsed") === "1") {
  document.getElementById('bgmplayer').classList.remove("bgmon");
}

这也侧面验证了前面"UI 层每次重执行"设计的正确性——这类状态同步逻辑放在 UI 层,天然会在每次切页后自动跑一遍。

五、总结

回头看,整件事的技术含量不在 PJAX 本身(核心逻辑不到 200 行),而在于理解"整页刷新"一直在默默帮你处理什么,并在干掉它之后把这些事情一一接手:脚本重执行、主题重初始化、评论令牌重注入、事件重绑定。

总结几条可复用的经验:

  1. 容器边界划分是 PJAX 改造的第一步,播放器必须在容器外;
  2. 状态与 UI 分离,状态脚本标 data-no-instant,UI 脚本每次重建后重执行;
  3. 补发 DOMContentLoaded 让主题的初始化逻辑重跑;
  4. 异常兜底真跳转,任何失败路径都不能让用户白屏;
  5. 切页后评论、表单类功能务必手动测一遍,令牌和事件绑定最容易漏。

现在博客的背景音乐从进站到离开可以一路连续播放,切页只换内容不换"灵魂"。如果你也在用 Typecho + YoduPlayer,希望这篇能帮你少踩几个坑。

插件地址:YoduPlayer - GitHub,感谢作者 Jrotty 的开源贡献。
0

评论 (0)

取消
0:00