前言

最近排查了一个很典型的现象: 音乐页的 APlayer 播放器, 直接打开页面一切正常; 从首页点导航跳进来, 播放按钮变成一个巨大的裸图标 (样式丢了), 有时播放器干脆不初始化。刷新一下又好了。

这种”直开正常、跳转进来就坏”的毛病, 十有八九和 pjax 有关。这篇把背后的机制讲透 —— 不只是 APlayer, 任何”只在特定页面加载的第三方组件”在 pjax 站点里都会遇到同样的问题。

一、先弄清 pjax 到底替换了什么

pjax = pushState + ajax。点击站内链接时, 它做的是:

  1. 拦截这次点击, 用 fetch 拉取目标页的完整 HTML
  2. 从返回的 HTML 里只取出主容器 (Butterfly 主题里是 #content-inner 这一层), 替换当前页的对应容器
  3. pushState 修改地址栏, 手动更新 <title>
  4. 重新执行容器内新出现的 script

关键就在这里: head 不替换, 页面尾部那些常驻脚本也不会重新执行。

下面三个坑, 全部由此推出。

坑一: 只在某页注入的样式, pjax 进入后会缺失

很多组件的 CSS 是”页面级”注入的 —— 比如通过页面 front-matter 控制、或主题模板判断”当前是音乐页才输出这条 link”。

  • 直开音乐页: head 里有这条 CSS, 正常
  • pjax 跳进来: head 还是上一页的, 这条 link 根本不存在 → 播放按钮的样式类全部失效, 图标按原始大小渲染, 看起来就是”巨大裸图标”

验证方法很简单: pjax 进入页面后, 在 DevTools 的 Elements 里搜组件的 CSS 类名, 看 head 里到底有没有对应的 link。

解法两层:

  1. 首选: 把组件 CSS 提升为全站注入。 一条几 KB 的 CSS 对全站加载影响可以忽略, 换来的省心是实打实的
  2. 或者用 JS 兜底补链 (见第三节)

坑二: 相对路径按”来源页”解析

pjax 拿到目标页 HTML 后, 是把它塞进当前文档的。此时目标页里写的相对路径引用, 会按当前页 (来源页) 的地址去解析。

举例: 从 /posts/abc.html 点击导航跳到 /music/, 目标页里如果写了 js/aplayer.js 这样的相对路径, 浏览器实际请求的是:

1
/posts/js/aplayer.js   ← 拼到了来源页的目录下, 404

规则: 凡是 pjax 可达页面里的静态资源引用, 一律用根绝对路径 (/js/aplayer.js), 不要用相对路径。

一个容易困惑的现象: “以前引用走 CDN (跨域完整 URL) 一直没事, 后来改成同域相对路径反而 404 了”。原因就是跨域完整 URL 不存在解析歧义, 而相对路径会被 pjax 拿来源页地址去拼。

坑三: exclude 拦不住”跳进该页”

pjax 配置里通常有 exclude 列表, 本意是”这些页面不要被 pjax 接管”。但它有两个盲区:

  1. exclude 是对链接 href 的精确匹配。 像 viewer.html?file=a.pdf 这种带 query 的链接, 往往匹配不上, 照样被拦截
  2. exclude 只影响”从 A 页点出去”的判断。 站内搜索、脚本跳转等其他方式进入目标页时, 该拦还是拦

想稳定绕过 pjax, 最可靠的方式是给链接加 target="_blank":

1
<a href="/pdf.js/web/viewer.html?file=/files/a.pdf" target="_blank" rel="noopener">查看</a>

pjax 不接管带 target 的链接, 这是规范行为, 比 exclude 靠谱得多。

二、通用兜底脚本: 补链 + 补初始化 + 销毁

对于”必须留在当前页打开”的组件 (比如音乐页), 写一段兜底脚本, 三段逻辑:

1
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
<script>
(function () {
// 1. 补样式: pjax 进入时 head 里没有本页的 CSS, 发现缺失就补一条
if (!document.querySelector('link.aplayer-style-marker')) {
var css = document.createElement('link');
css.rel = 'stylesheet';
css.href = '/css/APlayer.min.css'; // 注意用根绝对路径
css.className = 'aplayer-style-marker';
document.head.appendChild(css);
}
// 2. 补初始化: pjax 换入的 script 执行时机不确定, 轮询等 APlayer 就绪后再初始化;
// 完整加载 (直开/刷新) 时容器已有内容, 这段是空操作, 不会重复初始化
var timer = setInterval(function () {
var el = document.querySelector('.aplayer-tag-marker'); // 组件容器, 按你的接入方式调整
if (!el) return;
if (el.childElementCount > 0) { clearInterval(timer); return; }
if (window.APlayer && window.options && window.options.element) {
clearInterval(timer);
var ap = new APlayer(window.options);
(window.aplayers || (window.aplayers = [])).push(ap);
// 3. 离开时销毁: pjax 换页不会自动清理, 不销毁的话音乐切页后还在播
if (window.btf && btf.addGlobalFn) {
btf.addGlobalFn('pjaxSend', function () { try { ap.destroy() } catch (e) {} }, 'destroyMusicAplayer');
}
}
}, 50);
// 4. 10 秒后强制退出轮询, 防止组件永远没就绪时空转
setTimeout(function () { clearInterval(timer); }, 10000);
})()
</script>

几点说明:

  • .aplayer-tag-marker 和 window.options 是 hexo-tag-aplayer 这类标签插件渲染出来的钩子。用别的接入方式时, 换成对应的容器选择器和配置来源, 模式不变: 补链 + 补初始化 + 离开时销毁
  • 轮询间隔 50ms 对初始化场景足够, 上限 10 秒兜底
  • btf.addGlobalFn('pjaxSend', ...) 是 Butterfly 提供的 pjax 钩子; 其他主题换用自己的 pjax 事件即可

三、怎么确认问题是 pjax 引起的

按这个顺序验证, 两分钟出结论:

1
2
3
4
5
6
7
8
# 1. 临时关掉 pjax (主题配置 pjax.enable: false), 重新构建
# 症状消失 → 就是 pjax

# 2. DevTools → Network → 筛 404:
# 看有没有被拼到来源页目录下的资源请求 (坑二的实锤)

# 3. 对比两种进入方式的 head:
# 直开页面 Elements 里 head 有组件 CSS, pjax 进入后没有 (坑一的实锤)

四、一劳永逸的检查清单

pjax 站点接入任何第三方组件前, 过一遍这张表:

检查项做法
资源引用路径pjax 可达页面里一律根绝对路径 (/js/xx.js)
页面级 CSS提升为全站注入, 或 JS 兜底补链
组件初始化轮询兜底, 兼容”直开已初始化”和”pjax 进入未初始化”两种情况
实例清理pjax 离开事件里 destroy, 防止音乐/视频切页后继续播
需要独立环境的页面链接加 target="_blank", 别指望 exclude

小结

pjax 的三个坑本质上是同一件事: **pjax 是”局部替换”, 而组件生态默认的是”整页加载”**。

  • head 不替换 → 样式会缺
  • 内容塞进当前文档 → 相对路径错乱
  • 拦截的是”点击” → exclude 管不住其他进入方式

理解了这一点, 排查思路就清晰了: 先分清资源是”没加载”还是”加载错了路径”, 再分清脚本是”没执行”还是”执行了但没初始化”。

相关阅读: Hexo 里用 pdf.js 预览 PDF 页面塌陷成一条线: viewer.html 必须独立渲染 —— 同样是 pjax 引发的连锁问题, 附带一个”量高度而不是数 DOM”的调试方法论。