AI 正在绞尽脑汁想思路 ING···
AI 摘要
DeepSeek & Kimi

§ 背景

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 URL
  • callback:资源加载完成后的初始化逻辑
  • condition:若为 truthy,跳过网络加载,直接同步执行 callback

关键设计condition || window[type] —— 首次加载时 window.katex === undefined,触发异步加载;Pjax 导航时 window.katex 已存在,直接同步执行 callback。这就是下面要讨论的陷阱来源。

§ Layer 3:DOM 操作层

这一层处理具体 DOM 元素的初始化、更新和清理。核心原则有三条:

  1. 选择器限定在 .pjax——容器外的 DOM 不会被替换,重复操作浪费性能甚至出错
  2. 防止重复初始化——用 data-inited、类名、或实例引用做守卫
  3. 清理先于创建——在 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.xxxcallback 执行方式相对于 siteRefresh() 的时序
首次加载undefinedsetTimeout(callback, 0) — 异步siteRefresh() 完全结束后才执行
Pjax 导航已定义callback() — 同步siteRefresh() 执行到一半时触发

§ 影响

Pjax 时 callback 在 script[data-pjax] 处理、postBeauty()Loader.hide() 等 DOM 操作之前执行。如果 callback 依赖这些步骤完成后的 DOM 状态(例如操作 .fancybox 包装后的图片),首次加载正常,Pjax 后静默失败。

§ 解法

  1. callback 内部做防御性检查:不假设 DOM 已处于最终状态,对关键元素做存在性判断
  2. 将依赖 DOM 就绪的逻辑从 callback 移到 siteRefresh 末尾:利用 siteRefresh 是同步执行到底的特性,把需要在 DOM 完全就绪后运行的代码放在 Loader.hide() 之后
  3. 必要时在 callback 内使用 requestAnimationFramesetTimeout 延迟到下一个微任务:让 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.initgetRecentComments 的调用顺序
轮播图 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 模式),避免每页重复请求?
  • [ ] 时序敏感:是否依赖了 vendorJs callback 的同步/异步行为?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 的适配案例则展示了这些原则在复杂场景中的应用。


在提问之前,你应该学会如何提问