§ 背景
Shoka 主题内置了基于 MoOx/pjax 的 Pjax 无刷新导航。Pjax 让页面切换更流畅,但也引入了一个根本性问题:传统页面"加载→初始化→用户离开→销毁"的单向生命周期,被撕裂为"首次加载"和"每次导航"两条交织的执行路径,且二者的执行时序截然不同。
Kimi 之前给出一份 Pjax 适配分析(以下简称"Kimi 分析"),从"主题内/主题外"两个视角梳理了通用写法。本文在此基础上更进一步——不再停留在"监听 pjax:success 重新初始化"这个表层答案,而是深入 Pjax 适配的本质矛盾和架构模式,覆盖实际踩过的坑和经过验证的解决方案。
§ Pjax 打破了什么?
传统页面的生命周期非常简单:
浏览器导航 → HTML 解析 → DOMContentLoaded → 初始化脚本 → 页面就绪
↓
用户离开 → 页面销毁(所有状态丢失)
Pjax 在此基础上叠加了第二条路径:
首次加载:浏览器导航 → HTML 解析 → DOMContentLoaded → siteRefresh(1) → 页面就绪
↓
Pjax 导航:点击链接 → pjax:send → 清理 → 拉取 HTML → 替换 .pjax DOM
↓
pjax:success → siteRefresh() → 页面就绪
注意这两条路径的关键差异:
| 维度 | 首次加载 | Pjax 导航 |
|---|---|---|
window.xxx 第三方库 | undefined → 需异步加载 | 已存在 → 同步使用 |
| callback 执行时机 | setTimeout(callback, 0) — 异步 | callback() — 同步 |
.pjax 外 DOM | 首次创建 | 保持不变 |
data-pjax 脚本 | siteRefresh(1) 跳过 | siteRefresh() 执行 |
| 事件监听器 | 全新绑定 | 需防重复绑定 |
| 定时器/Observer | 全新创建 | 需先清理旧实例 |
核心矛盾:同一份代码在两条路径上跑了两次,但执行环境和时序完全不同。如果代码假设"只会跑一次"或"DOM 此时一定处于某种状态",就会在 Pjax 导航时出错。
§ 三层适配架构
将 Pjax 适配问题拆分为三个正交的层次,每层有独立的关注点和模式。
§ Layer 1:生命周期事件层
这一层负责在正确的时机触发正确的逻辑。Shoka 使用两个事件:
// pjax.js:298-300 | |
window.addEventListener("pjax:send", pjaxReload); // 导航前:清理 | |
window.addEventListener("pjax:success", siteRefresh); // 导航后:初始化 |
pjaxReload 的职责是清理副作用:移除定时器、断开 Observer、销毁第三方实例、保存滚动位置。siteRefresh 的职责是重建页面状态:加载资源、初始化组件、绑定事件。
通用模式:如果只在主题外写代码,监听这两个事件即可。但真正的复杂性在下面两层。
§ Layer 2:资源加载层
Shoka 通过 vendorJs / vendorCss 实现条件加载——只有页面需要且资源尚未加载时才发起网络请求:
// utils.js:48-53 | |
const vendorJs = function (type, callback, condition) { | |
if (LOCAL[type]) { | |
// 页面配置声明了需要此资源 | |
getScript( | |
assetUrl("js", type), | |
callback || | |
function () { | |
window[type] = true; // 加载后标记全局变量 | |
}, | |
condition || window[type], | |
); // 已存在则跳过加载 | |
} | |
}; |
三个参数的含义:
type:资源标识(如'katex'),对应LOCAL[type]的布尔开关和CONFIG.js[type]的 CDN URLcallback:资源加载完成后的初始化逻辑condition:若为 truthy,跳过网络加载,直接同步执行 callback
关键设计:condition || window[type] —— 首次加载时 window.katex === undefined,触发异步加载;Pjax 导航时 window.katex 已存在,直接同步执行 callback。这就是下面要讨论的陷阱来源。
§ Layer 3:DOM 操作层
这一层处理具体 DOM 元素的初始化、更新和清理。核心原则有三条:
- 选择器限定在
.pjax内——容器外的 DOM 不会被替换,重复操作浪费性能甚至出错 - 防止重复初始化——用
data-inited、类名、或实例引用做守卫 - 清理先于创建——在
pjax:send中断开 Observer、销毁实例、移除事件监听
三层架构的总览:
pjax:send ──→ Layer 1: pjaxReload() ──→ 销毁 Swiper、断开 Observer、清空 #main
↓
Pjax 替换 .pjax DOM
↓
pjax:success ──→ Layer 1: siteRefresh()
├─→ Layer 2: vendorJs/vendorCss(条件加载资源)
├─→ Layer 2: data-pjax 脚本执行
└─→ Layer 3: postBeauty、TablePaginationManager、FigureLabelManager...
§ 陷阱一:getScript 的同步/异步不对称
这是本主题 Pjax 适配中最隐蔽的坑,直接引自 CLAUDE.md 中的记录。
§ 问题
utils.js:getScript() 的核心逻辑:
const getScript = function(url, callback, condition) { | |
if (condition) { | |
callback(); // Pjax:第三方库已存在 → 同步执行 | |
} else { | |
// 首次加载:异步加载脚本 → setTimeout (callback, 0) | |
script.onload = ... setTimeout(callback, 0); | |
} | |
}; |
| 场景 | window.xxx | callback 执行方式 | 相对于 siteRefresh() 的时序 |
|---|---|---|---|
| 首次加载 | undefined | setTimeout(callback, 0) — 异步 | siteRefresh() 完全结束后才执行 |
| Pjax 导航 | 已定义 | callback() — 同步 | siteRefresh() 执行到一半时触发 |
§ 影响
Pjax 时 callback 在 script[data-pjax] 处理、postBeauty()、Loader.hide() 等 DOM 操作之前执行。如果 callback 依赖这些步骤完成后的 DOM 状态(例如操作 .fancybox 包装后的图片),首次加载正常,Pjax 后静默失败。
§ 解法
- callback 内部做防御性检查:不假设 DOM 已处于最终状态,对关键元素做存在性判断
- 将依赖 DOM 就绪的逻辑从 callback 移到
siteRefresh末尾:利用siteRefresh是同步执行到底的特性,把需要在 DOM 完全就绪后运行的代码放在Loader.hide()之后 - 必要时在 callback 内使用
requestAnimationFrame或setTimeout延迟到下一个微任务:让siteRefresh的剩余同步代码先跑完
// 示例:Valine 初始化在 vendorJs callback 中 | |
vendorJs( | |
"valine", | |
function () { | |
// 此时 Pjax 同步执行,siteRefresh 尚未完成 postBeauty () 等步骤 | |
new MiniValine(options); | |
// 延迟到 siteRefresh 完全结束后再操作 DOM | |
setTimeout(function () { | |
positionInit(1); | |
postFancybox(".v"); | |
}, 1000); | |
}, | |
window.MiniValine, | |
); |
§ 陷阱二:JavaScript 静态快照陷阱
§ 问题
themes/shoka/source/js/_app/ 目录下的模块如果在定义时通过 ...LOCAL.xxx 展开全局变量,该值会成为一次性快照。Pjax 导航会更新 LOCAL(通过重新执行 <script data-config>),但已初始化的模块对象不会自动刷新。
// typesetting.js 加载时 | |
const TablePaginationManager = { | |
config: { | |
pageSize: 5, | |
...LOCAL.typesetting?.table, // 此时展开为快照 | |
}, | |
}; |
Pjax 后 LOCAL.typesetting.table 已更新为中文页面的配置(如 pageInfo: '第 ${current} 页,共 ${total} 页'),但 TablePaginationManager.config 仍是英文页面的旧快照(pageInfo: 'Page ${current} of ${total}')。
§ 解法
// 解法:每次调用时动态读取,而非依赖静态初始化 | |
const TablePaginationManager = { | |
get config() { | |
// 每次访问都从最新的 LOCAL 读取 | |
return Object.assign( | |
{ | |
pageSize: 5, | |
paginationClass: "pagination", | |
}, | |
LOCAL.typesetting?.table, | |
); | |
}, | |
}; |
或者更简单地在 initAllTables 中重新合并:
initAllTables: function() { | |
Object.assign(this.config, LOCAL.typesetting?.table); // 每次初始化前刷新 | |
// ... | |
} |
原则:需要响应 Pjax 变化的配置应在每次调用时从 LOCAL 动态读取,而非依赖模块加载时的静态初始化。
§ 选择器作用域策略
Pjax 只替换 selectors 中声明的元素:
// pjax.js:273-278 | |
pjax = new Pjax({ | |
selectors: [ | |
"head title", | |
".languages", | |
".pjax", // 主内容容器 | |
"script[data-config]", // 页面配置脚本 | |
], | |
}); |
这意味着:
.pjax内部的 DOM:每次导航都被全新替换,事件监听器丢失,需要重新绑定.pjax外部的 DOM(header、sidebar、tool button 等):整个会话期间持续存在,事件监听器只需绑定一次
§ 策略一:外部元素在 domInit 中一次性绑定
// pjax.js:1-43 — domInit 只在 DOMContentLoaded → siteInit 中执行一次 | |
const domInit = function () { | |
menuToggle.addEventListener("click", sideBarToggleHandle); | |
quickBtn.child(".down").addEventListener("click", goToBottomHandle); | |
// ... | |
}; |
§ 策略二:内部元素使用事件委托
事件委托将监听器挂在不会被替换的祖先元素上,无论 .pjax 内容如何变化都不需要重新绑定:
// 委托到 body(永不替换),过滤 .pjax 内的目标 | |
document.addEventListener("click", (e) => { | |
if (e.target.closest(".pjax .my-btn")) { | |
// 处理点击 | |
} | |
}); |
§ 策略三:必须在内部元素上直接绑定时,在 siteRefresh 中重建
// page.js:206-213 — copyBtn 在 postBeauty () 中重新绑定 | |
// postBeauty () 在每次 siteRefresh () 时都会执行 | |
copyBtn.addEventListener("click", function (event) { | |
clipBoard(code, function (result) { | |
/* ... */ | |
}); | |
}); |
§ 防重复初始化模式
Pjax 导航后重新初始化时,需要防止对同一个元素重复初始化。本主题实践了四种守卫模式:
§ 模式一:data 属性标记
// 在元素上留下标记,下次跳过 | |
document.querySelectorAll(".pjax .my-widget").forEach((el) => { | |
if (el.dataset.inited) return; // 守卫 | |
el.dataset.inited = "1"; | |
new SomeWidget(el); | |
}); |
§ 模式二:实例存在性检查
// Swiper 在重建前先销毁旧实例 | |
function initSwiper() { | |
if (window.swiper && window.swiper.destroy) { | |
window.swiper.destroy(true, true); | |
window.swiper = null; | |
} | |
var el = document.querySelector(".blog-slider"); | |
if (!el) return; | |
window.swiper = new Swiper(".blog-slider", { | |
/* ... */ | |
}); | |
} |
§ 模式三:DOM 存在性检查
// tabFormat 检查 data-ready 属性 | |
$.each("div.tab", function (element, index) { | |
if (element.attr("data-ready")) return; // 已初始化,跳过 | |
// ... 初始化逻辑 | |
element.attr("data-ready", true); | |
}); |
§ 模式四:MutationObserver 的断开/重连
// pjax.js:178-195 — Twikoo 评论标签注入 | |
if (twikooObserver) twikooObserver.disconnect(); // 先断开旧的 | |
twikooObserver = new MutationObserver(function (mutations) { | |
// ... 处理新增评论 | |
}); | |
twikooObserver.observe(document.body, { childList: true, subtree: true }); |
§ 第三方库适配模板
§ Swiper 轮播图(完整实例)
Swiper 的 Pjax 适配是本主题最完整的第三方库适配案例,位于 source/assets/js/swiper_init.js。它示范了首次加载 + Pjax 双路径统一处理的标准模式:
(function () { | |
"use strict"; | |
function initSwiper() { | |
// 1. 先销毁旧实例(Pjax 回航时已存在) | |
if (window.swiper && window.swiper.destroy) { | |
window.swiper.destroy(true, true); | |
window.swiper = null; | |
} | |
// 2. 检查 DOM 是否存在(当前页面是否有轮播容器) | |
var el = document.querySelector(".blog-slider"); | |
if (!el) return; | |
// 3. 创建新实例 | |
window.swiper = new Swiper(".blog-slider", { | |
/* options */ | |
}); | |
} | |
function pjaxMount() { | |
// 4. Pjax 路径匹配(有些页面不需要轮播) | |
var cfg = window.__SWIPER_CONFIG__; | |
if (!cfg) return; | |
var cpage = location.pathname; | |
if (cfg.epage !== "all" && cfg.epage !== cpage) return; | |
// 5. 重新注入 HTML(Pjax 替换了 .pjax,轮播容器可能已被移除) | |
var parent = cfg.get_layout(); | |
if (parent && !parent.querySelector(".blog-slider")) { | |
parent.insertAdjacentHTML(cfg.insertposition, cfg.html); | |
} | |
// 6. 初始化 Swiper | |
initSwiper(); | |
} | |
// 首次加载 | |
initSwiper(); | |
// Pjax 生命周期 | |
document.addEventListener("pjax:send", function () { | |
if (window.swiper && window.swiper.destroy) { | |
window.swiper.destroy(true, true); | |
window.swiper = null; | |
} | |
}); | |
document.addEventListener("pjax:success", pjaxMount); | |
})(); |
关键设计要点:
pjax:send中销毁而非pjax:success中:因为 DOM 即将被替换,在send阶段销毁可以释放内存,且避免操作即将消失的 DOM- HTML 重新注入:Swiper 的 HTML 容器是通过 JS 动态注入的(不在 Markdown 中),Pjax 替换
.pjax后容器消失,需要在pjax:success中重新注入 window.__SWIPER_CONFIG__桥接:首次加载的 inline 脚本暴露配置到全局,Pjax 脚本通过全局变量获取配置,避免重复执行 inline 脚本
§ Twikoo 评论(MutationObserver 模式)
Twikoo 的适配展示了如何应对异步渲染的 DOM:
// pjax.js:156-222 | |
if (CONFIG.twikoo) { | |
vendorJs( | |
"twikoo", | |
function () { | |
// 1. init 创建评论框(同步) | |
window.twikoo.init(options); | |
// 2. 用 MutationObserver 监听评论渲染(异步) | |
if (twikooObserver) twikooObserver.disconnect(); // 先断开旧的! | |
twikooObserver = new MutationObserver(function (mutations) { | |
mutations.forEach(function (mutation) { | |
mutation.addedNodes.forEach(function (node) { | |
if (node.nodeType === 1 && node.querySelectorAll) { | |
var comments = node.querySelectorAll(".tk-comment"); | |
if (comments.length > 0) { | |
applyTagColors(container, options); | |
addTwikooTags(container, options); | |
} | |
} | |
}); | |
}); | |
}); | |
twikooObserver.observe(document.body, { childList: true, subtree: true }); | |
// 3. setTimeout 兜底(observer 可能漏掉) | |
setTimeout(function () { | |
var container = document.querySelector("#twikoo"); | |
if (container) { | |
applyTagColors(container, options); | |
addTwikooTags(container, options); | |
} | |
}, 1000); | |
}, | |
window.twikoo, | |
); | |
} |
关键设计要点:
twikoo.init()必须在twikoo.getRecentComments()之前调用(顺序敏感)- MutationObserver 在每次
siteRefresh中重新创建前,必须disconnect()旧实例 setTimeout兜底是因为 Observer 的childList监听 body 可能漏掉某些渲染时机
§ 代码高亮与运行器
代码块的操作按钮(复制、运行、下载、全屏、折叠)在 postBeauty() 中每次重新创建。由于 postBeauty() 在 siteRefresh() 末尾同步执行,DOM 此时已完全就绪,不需要额外处理时序问题。
但有一个细节:代码运行器 window.__runCode 通过外部 data-pjax 脚本注入。Pjax 导航时,这段脚本由 siteRefresh 中的 $.each('script[data-pjax]', pjaxScript) 重新执行,因此 __runCode 始终可用。
§ data-pjax 脚本的执行机制
pjaxScript 函数(utils.js:71-97)的工作原理:
const pjaxScript = function (element) { | |
var code = element.text || element.textContent || element.innerHTML || ""; | |
var parent = element.parentNode; | |
parent.removeChild(element); // 1. 从 DOM 中移除旧脚本 | |
var script = document.createElement("script"); // 2. 创建新脚本元素 | |
// ... 复制 id、className、type、src ... | |
if (code !== "") { | |
script.appendChild(document.createTextNode(code)); // 3. 设置代码内容 | |
} | |
parent.appendChild(script); // 4. 插入 DOM → 浏览器执行 | |
}; |
核心技巧:移除旧 <script> 元素后创建新元素并 append。浏览器对新 append 的 <script> 元素会立即同步执行(inline)或按 async=false 顺序加载(external)。
有几种不同的 data-pjax 使用场景:
| 场景 | 写法 | 执行时机 |
|---|---|---|
| inline 脚本 | <script data-pjax>...</script> | Pjax 时 pjaxScript 同步执行 |
| external 脚本 | <script data-pjax src="..."></script> | async=false,按插入顺序加载执行 |
| 首次加载 | siteRefresh(1) | if(!reload) 跳过,避免与 DOMContentLoaded 重复 |
注意:
data-pjax脚本必须放在.pjax容器内或selectors声明的其他替换区域中。放在容器外的<script data-pjax>不会被 Pjax 替换,pjaxScript也不会重新执行它。
§ 调试技巧
§ 判断代码是否在 Pjax 后执行
在可疑函数开头加一条带时间戳和导航类型标记的日志:
function myInit() { | |
console.log("[myInit]", window._pjax_nav ? "Pjax" : "首次", new Date().toISOString()); | |
// ... | |
} | |
document.addEventListener("DOMContentLoaded", () => { | |
window._pjax_nav = false; | |
myInit(); | |
}); | |
window.addEventListener("pjax:success", () => { | |
window._pjax_nav = true; | |
myInit(); | |
}); |
§ 常见症状与诊断
| 症状 | 可能原因 | 排查方向 |
|---|---|---|
| 首次加载正常,Pjax 后功能失效 | callback 同步/异步时序问题 | 检查是否在 vendorJs callback 中操作了尚未就绪的 DOM |
| Pjax 后功能正常,再次 Pjax 后异常 | 重复初始化(双重绑定) | 检查是否缺少 pjax:send 清理或 init 守卫 |
| Pjax 后出现多个重复元素 | HTML 重复注入 | 检查注入前是否做了存在性检查(querySelector 守卫) |
| Pjax 后中文/英文配置混乱 | 静态快照陷阱 | 检查模块配置是否在加载时展开了 LOCAL |
| 评论区 Pjax 后不显示 | init 顺序问题 | 检查 twikoo.init 和 getRecentComments 的调用顺序 |
| 轮播图 Pjax 后消失 | HTML 容器未重新注入 | 检查 pjax:success 中是否重新注入了动态创建的 HTML |
§ 系统性检测方法
在浏览器控制台运行以下代码,观察 Pjax 前后的 DOM 状态:
// 记录当前页面状态 | |
console.log({ | |
Swiper实例: !!window.swiper, | |
轮播容器: !!document.querySelector(".blog-slider"), | |
评论容器: !!document.querySelector("#twikoo"), | |
代码块数量: document.querySelectorAll("figure.highlight").length, | |
表格数量: document.querySelectorAll(".table-container > table").length, | |
图片数量: document.querySelectorAll(".image-info").length, | |
}); |
点击链接导航到另一页面后再运行一次,对比两次数值是否符合预期。
§ 总结:Pjax 适配检查清单
为任意功能做 Pjax 适配时,按以下清单逐项检查:
- [ ] 生命周期:功能逻辑是否同时覆盖了首次加载(
DOMContentLoaded/siteRefresh(1))和 Pjax 导航(pjax:success/siteRefresh())? - [ ] 资源加载:依赖的第三方库是否使用了条件加载(
vendorJs/vendorCss模式),避免每页重复请求? - [ ] 时序敏感:是否依赖了
vendorJscallback 的同步/异步行为?callback 内是否对 DOM 就绪状态做了防御? - [ ] 静态快照:模块配置是否从
LOCAL动态读取,而非在模块加载时展开为一次性快照? - [ ] 选择器作用域:DOM 操作是否限定在
.pjax容器内?外部元素是否只在domInit中一次性处理? - [ ] 防重复:是否有守卫(
data-inited、实例存在性检查、DOM 存在性检查)防止重复初始化? - [ ] 清理:定时器、Observer、事件监听器、第三方实例是否在
pjax:send中正确清理? - [ ] 动态 HTML:JS 动态创建的 DOM 元素是否在 Pjax 后重新注入?
- [ ] inline 脚本:放在
.pjax内的<script>是否加了data-pjax?放在外面的脚本是否改用事件监听方式? - [ ] 调试标记:是否有日志或状态标记能区分"首次加载"和"Pjax 导航"两种执行路径?
Pjax 适配的本质不是"在事件回调里重新跑一遍初始化",而是理解两条执行路径的时序差异,并让代码在两种时序下都能正确工作。三层架构(生命周期事件 → 资源加载 → DOM 操作)提供了一个思考框架;四种防重复模式提供了一个工具箱;而 Swiper 和 Twikoo 的适配案例则展示了这些原则在复杂场景中的应用。