在 Web 开发过程中,如何实现局部页面加载一直是一个热门话题。以单页面应用(SPA)为例,它通过路由机制实现局部页面加载,是一种常见的前端解决方案。而对于内容管理系统(CMS),其通常采用传统的服务端渲染(SSR)模式,如果不使用额外的技术优化,CMS 往往是多页面应用(MPA),即每次页面请求都会加载完整的 JS 和 CSS 资源。

PJAX(PushState + AJAX)可以看作是多页面应用与单页面应用之间的过渡技术。在保留原有服务端渲染和页面结构的基础上,它通过局部更新页面内容,提升用户体验。此外,由于页面结构得以保留,PJAX 也不会带来单页面应用常见的 SEO 问题。

PJAX 适用于某些特定场景,比如网站拥有全局媒体播放器时,PJAX 能在页面切换时保持播放不中断;又如需要异步加载页面内容、提升用户体验时,它也能发挥作用。然而,PJAX 同样存在一些缺点,如增加开发和维护的复杂性,尤其是在全局状态管理和路由处理方面。因此,尽管本篇文章重点介绍 PJAX 技术,但如果项目中没有强烈需求,也并不推荐使用 PJAX,需综合权衡它所带来的便利与额外成本。

需要补充的是,用于服务端渲染的多页面应用的局部加载技术有很多,PJAX 只是其中一种。而随着前端技术的快速迭代,PJAX 已经逐渐不再具有竞争力。本文的结尾会介绍其他的相关技术用于参考。

接下来,我将基于实际的 Halo 主题开发,介绍 PJAX 的具体实践。

PJAX 相关库

在实际开发中,我们通常会借助现有的库来提高开发效率。PJAX 的相关库通常封装了浏览器的 PushState 和 AJAX 请求逻辑,能够大幅减少手动实现的工作量。

这是一个基于 JQuery 的插件,通过 PushState 和 AJAX 实现 PJAX。虽然功能齐全,但它依赖于 JQuery,且最后一个版本发布于 2017 年,已经长期未维护。

  • pjax: https://github.com/MoOx/pjax

该库完全使用原生 JavaScript 实现,通过 PushState 和 XHR 完成 PJAX 的功能。最后一次发布版本是在 2019 年,同样处于未维护状态。

从这些库可以看出,PJAX 作为一种过渡技术,其相关库已经颇具年代感,然而,这并不妨碍它在特定场景中的使用。个人推荐使用 MoOx/pjax,因为它脱离了对 JQuery 的依赖,能够与各种框架集成。

Halo 主题开发融合 PJAX

本文将以我开发的 Halo 主题 Sakura 为例,详细讲解 PJAX 在 Halo 主题开发中的应用。我将分享我如何在主题中实现 PJAX、为什么选择使用它、遇到的问题及相应的解决方案。

为什么选择使用 PJAX

在我的主题中,有全局的媒体播放器功能,并集成了诸如 Plugin-Live2d 这样的插件。我希望在用户浏览不同页面时,媒体播放和看板娘等全局元素不会被刷新。同时,我希望能够通过异步懒加载来优化资源的加载——在首页仅加载部分必要资源,而剩余资源将在用户点击其他页面时动态加载,从而提高首屏的加载速度。基于这些需求,我最终选择在主题中引入 PJAX 进行优化。

主题中如何使用 PJAX

在主题中集成 PJAX 相对简单,只需要通过 <script> 标签将 PJAX 加载到网页中即可。如果使用构建工具,可以将打包后的文件(如 pjax.min.js)链接到页面,具体步骤可参考 MoOx/pjax 的安装说明

然而,PJAX 的默认配置往往无法满足实际开发需求。以我的主题为例,以下是我在主题中使用 PJAX 的基本配置:

const pjax = new Pjax({
  elements: "a[data-pjax]", // 触发 PJAX 的条件,当点击具有 data-pjax 属性的 <a> 标签时触发 PJAX。
  selectors: ["head title", ".wrapper", ".pjax"], // 定义需要替换的页面区域。
  switches: {
    ".wrapper": Pjax.switches.innerHTML,
  }, // 定义旧元素如何替换为新元素。
  analytics: false, // 关闭自动调用页面分析,建议通过其他方式处理。
  cacheBust: false, // 关闭跳过浏览器缓存
  debug: import.meta.env.MODE === "development" ? true : false, // 在开发模式下启用调试信息。
});

在这个配置中,最关键的两个参数是 elementsselectors

  • elements 指定了哪些元素会触发 PJAX,例如带有 data-pjax 属性的 <a> 标签;

  • selectors 定义了页面中哪些区域会在 PJAX 加载时被替换。

switches 参数则用于定义页面元素如何进行替换,比如 Pjax.switches.innerHTML 使用`innerHTML 及原有元素上的 class 。

按此配置,理论上网站已经可以正常使用 PJAX。然而在实际开发中,仍然会遇到很多问题,主要集中在脚本的执行上。PJAX 不会自动重新加载非替换区域的脚本,而对于替换区域的脚本,它仅会简单的执行一次。因此这就带来了一系列问题,包括但不限于:

  1. 脚本支持问题:新替换的内容可能依赖已经加载的脚本,因此需要重新加载相关脚本;

  2. 脚本方法需要重新执行:新内容可能依赖已加载脚本的某些方法,但无需重新加载整个脚本,只需重新执行特定方法;

  3. DOMContentLoaded 事件问题:某些脚本依赖于浏览器的 DOMContentLoaded 事件,这在 PJAX 环境下不会再次触发;

  4. 第三方脚本问题:需要重新加载的脚本可能位于非替换区,或加载方式不公开(例如第三方脚本);

  5. PJAX 无法包含新内容:一些未通过 PJAX 加载的内容,加载完成后无法被 PJAX 管理。

问题解决方案

以下是我在 Sakura 主题中应对上述问题的解决方案。虽然这些方案并不一定完美,但希望能为有类似需求的开发者提供参考。

问题 1:新替换的内容需要重新加载已加载的脚本

这是最常见的问题之一,尤其当某些脚本需要在每个页面执行,而不依赖于特定页面数据时,例如页面分析代码。

解决方案通常是在 PJAX 替换内容成功后,重新执行已加载的脚本。常见的处理方式是移除旧的 <script> 标签并将其重新添加到 DOM 中。代码示例如下:

window.addEventListener("pjax:success", () => {
  // 对具有 data-pjax 属性的脚本进行重新加载
  let pjaxDoms = document.querySelectorAll("script[data-pjax]") as NodeListOf<HTMLScriptElement>;
  pjaxDoms.forEach((element) => {
    let code: string = element.text || element.textContent || element.innerHTML || "";
    let parent: ParentNode | null = element.parentNode;
    if (parent === null) {
      return;
    }
    // 移除旧的 script 标签
    parent.removeChild(element);
    // 创建新的 script 标签并重新添加到 DOM 中
    let script: HTMLElementTagNameMap["script"] = document.createElement("script");
    if (element.id) {
      script.id = element.id;
    }
    if (element.className) {
      script.className = element.className;
    }
    if (element.type) {
      script.type = element.type;
    }
    if (element.src) {
      script.src = element.src;
      script.async = false;
    }
    if (element.dataset.pjax !== undefined) {
      script.dataset.pjax = "";
    }
    if (code !== "") {
      // 插入原始的脚本内容
      script.appendChild(document.createTextNode(code));
    }
    // 将新 script 插入到文档中
    parent.appendChild(script);
  });
});

通过上述方式,确保了在 PJAX 加载新页面后,相关脚本能够重新执行。建议为所有需要支持 PJAX 的脚本都添加 data-pjax 属性,以便统一管理和重新加载。

问题 2:新替换的内容需要已加载脚本的部分方法支持,但不重新加载脚本,仅重新执行方法

这种情况同样常见,例如 Halo 旧版本的评论组件会在首页进行加载,但评论功能实际用于各个内容页面,且依赖新页面的数据。为了处理此类需求,本主题实现了一个全局状态管理机制,专门处理此类场景。

首先,当页面通过 PJAX 加载完成后,触发状态刷新事件:

window.addEventListener("pjax:success", () => {
  // 调用 sakura 的 refresh 方法,重新执行需要的方法或功能
  sakura.refresh();
});

refresh 方法用于重新调用目标脚本中的功能,而无需重新加载整个脚本。比如,以下代码重新执行评论组件的初始化:

const registerCommentWidget = (id: string, group: string, kind: string, name: string): Promise<string> => {
  return new Promise((resolve, reject) => {
    if (!CommentWidget) {
      return reject("Failed to fetch data");
    }
    // 初始化评论组件
    CommentWidget.init(
      `#${id}`,
      "/plugins/PluginCommentWidget/assets/static/style.css",
      {
        group: group,
        kind: kind,
        name: name,
        colorScheme: 'light'
      }
    );
    resolve("success");
  });
};

在这个实现中,registerCommentWidget 方法不会重新加载 CommentWidget,而是直接调用它的初始化方法来支持新页面的数据。这样可以避免不必要的脚本重复加载,提升页面性能。

注:上述代码为旧版代码,仅用于说明问题解决方案。

问题 3:替换区域的脚本依赖于浏览器的 DOMContentLoaded 事件

如果脚本依赖于 DOMContentLoaded 事件来执行,当页面通过 PJAX 刷新时,这些脚本将无法正常运行,因为 PJAX 并不会重新触发浏览器的 DOMContentLoaded 事件。即使我们通过移除并重新添加脚本的方式来处理,这类脚本也无法执行,因为它们始终依赖浏览器的 DOMContentLoaded,而该事件只会在页面初次完全加载时触发一次。

为了解决这个问题,本主题使用了事件代理的方式,将所有监听 DOMContentLoaded 事件的脚本转化为监听 pjax:success 事件,从而保证相关方法在 PJAX 完成后能够正确执行。实现如下:

const originalAddEventListener = EventTarget.prototype.addEventListener;

EventTarget.prototype.addEventListener = function (type, listener, options) {
  // 检查是否是 'DOMContentLoaded' 事件类型
  if (type === "DOMContentLoaded") {
    if (listener) {
      window.addEventListener(
        "pjax:success",
        () => {
          // 使用自定义的重试机制执行 DOMContentLoaded 的逻辑
          Util.retry(() => listener(), 10, 100);
        },
        {
          once: true,  // 确保只执行一次
        }
      );
      return;
    }
  }
  // 对于其他事件类型,调用原始的 addEventListener 方法
  originalAddEventListener.call(this, type, listener, options);
};

这段代码通过代理 addEventListener 方法,拦截所有对 DOMContentLoaded 的事件绑定操作,将其转换为对 pjax:success 事件的监听。具体做法是,每当有脚本试图绑定 DOMContentLoaded 事件时,我会将其改为监听 pjax:success 事件,并使用自定义的 retry 方法确保回调函数能够执行。这种方法可以保证即使在 PJAX 环境下,依赖 DOMContentLoaded 事件的脚本仍能被触发。

通过这种方式,脚本不再依赖于页面初次加载时的 DOMContentLoaded 事件,而是在 PJAX 页面切换时依然能正常执行其逻辑。

问题 4:需要重新加载的脚本位于非加载区,或其加载方法不公开(通常为第三方脚本)

这类问题是 PJAX 应用中最棘手的问题,尤其在涉及第三方脚本时。以 Halo 为例,文章页面会通过 script 增加访问量统计,但首页并不包含该脚本。这意味着如果用户从首页访问文章页,统计脚本不会加载,导致访问量无法统计。如果用户在文章页内切换其他文章,统计脚本也不会为新文章记录访问量,因为该脚本依赖于特定页面的数据,处理起来更加复杂。

解决这类问题的方案有限,以下是常见的几种处理方式:

  • 服务端渲染动态添加脚本

    • 由脚本提供方确保在每个页面都包含相关脚本,或将脚本移动到 PJAX 的加载区域。这样脚本将能够在页面切换时始终保持可用状态。

  • 公开脚本方法

    • 如果第三方脚本能够抛出公开的方法,PJAX 可以通过调用此方法来初始化或重新加载该脚本。例如,脚本提供方可以提供一个函数接口,允许开发者在 PJAX 切换页面时主动调用该方法。

  • 监听 PJAX 事件

    • 如果第三方脚本能够监听 pjax:successDOMContentLoaded 事件,可以在这些事件触发时重新初始化脚本。这种方式能够确保脚本在 PJAX 页面切换后依然能够正确运行。

  • 主题中特殊处理

    • 在主题中进行定制处理,例如为 selectors 添加对该脚本的特殊识别,确保在 PJAX 替换时能够识别并处理该脚本。然后通过 switches 对脚本进行替换,保证新页面中该脚本能够重新加载和执行。

问题 5:由非 PJAX 加载的内容,加载完成后无法享受到 PJAX

这种问题通常出现在分页加载的场景中。当新加载分页内容的请求没有使用 PJAX 时,新内容自然也就无法享受 PJAX 的功能。为了解决这一问题,我们需要在动态加载内容后重新注册 PJAX 功能,确保新加载的内容能够继续参与 PJAX 的交互。

  public registerPostListPaginationEvent() {
    const paginationElement = document.getElementById("pagination");
    if (!paginationElement) {
      return;
    }
    const listPaginationLinkElement = paginationElement.querySelector("a");
    if (!listPaginationLinkElement) {
      return;
    }
    listPaginationLinkElement.addEventListener("click", (event) => {
      event.preventDefault();
      const postListElement = document.getElementById("main");
      if (!postListElement) {
        return;
      }
      const targetElement = event.target as HTMLLinkElement;
      const url = targetElement.href;
      targetElement.classList.add("loading");
      targetElement.textContent = "";
      fetch(url, {
        method: "GET",
      })
        .then((response) => response.text())
        .then((html) => {
          const parser = new DOMParser();
          const doc = parser.parseFromString(html, "text/html");
          const postListNewElements = doc.querySelectorAll("#main .post");

          // 将新文章追加到文章列表
          if (postListNewElements && postListNewElements.length > 0) {
            postListNewElements.forEach((element) => {
              postListElement.appendChild(element);
            });
          }

          // 绑定新的 DOM 元素至 PJAX 上,确保新内容享受到 PJAX
          if (sakura.$pjax) {
            sakura.$pjax.refresh(postListElement);
          }
          
          // 更新分页链接
          const nextPaginationElement = doc.querySelector("#pagination a") as HTMLLinkElement;
          if (nextPaginationElement) {
            targetElement.href = nextPaginationElement.href;
          } else {
            paginationElement.innerHTML = `<span>${sakura.translate("page.theend", "没有更多文章了")}</span>`;
          }
        })
        .catch((error) => {
          console.error(error);
        })
        .finally(() => {
          targetElement.classList.remove("loading");
          targetElement.textContent = sakura.translate("page.next", "下一页");
        });
      return false;
    });
  }

在分页数据加载完成后,调用 pjax.refresh(postListElement) 重新注册 PJAX 功能,确保新加载的文章列表能够享受 PJAX 的页面切换功能。

Halo 中的特殊问题

在 Halo 中,PJAX 请求还会引发一些特定问题。例如,在发送 PJAX 请求时,默认的请求头只包含 accept: * 这导致 Halo 无法拦截请求以获取 Security Context

为了避免这种问题,我们需要手动设置正确的 Accept 头信息,使得 Halo 可以正确处理这些请求。在 PJAX 请求中,可以将 Accept 头设置为 text/html, application/json, text/plain, */* ,用于处理此问题。此解决方案的详细代码可以参考 PR#476

PJAX 实践经验

在开发过程中,使用 PJAX 来优化主题的局部页面加载,确实能够提升用户体验,但与此同时,它也带来了不小的开发和维护成本,甚至可能会遇到难以解决的技术障碍。基于我的个人实践,以下是一些使用 PJAX 的经验总结:

  1. 优先选择替代方案
    如果仅仅是为了提升用户体验,不要轻易使用 PJAX。在许多情况下,其他优化手段(如延迟加载、按需加载等)可能更为高效,且更容易实现,避免了 PJAX 带来的复杂性。

  2. 避免在 head 标签中添加非全局脚本
    PJAX 通常不会替换整个 head 区域,而只替换特定的内容。因此,如果在 head 标签中添加了某些仅在部分页面使用的脚本,PJAX 可能无法正常加载这些脚本。尽量将页面中可选的或动态的脚本放置在可替换的区域内,而不是 head 标签中。

  3. 明确配置 elements 属性
    一定要明确配置 PJAX 中的 elements 选项。默认情况下,PJAX 会将所有的 <a> 标签都设为可触发 PJAX 请求,但这样可能会导致某些意料之外的问题,例如错误的链接被 PJAX 处理。通过精确指定触发 PJAX 的链接(如 [a[data-pjax]]),可以避免这种情况,提高系统的稳定性。

  4. 遵循约定大于配置
    在 Halo 中,部分脚本可能已支持 PJAX 刷新,但通常会使用约定俗成的方法。如果某个脚本需要在页面局部刷新后重新执行,建议为该脚本添加如 class="pjax" 的标记。这样可以确保这些脚本在 PJAX 刷新后得到正确处理。因此,建议在主题 PJAX的 elements 属性中增加 .pjax 选择器,以便正确处理需要局部刷新的脚本。

相关技术

实际上,实现多页面应用局部加载的技术有很多,其中大多数比 PJAX 更加现代化和通用。因此,如果有这类需求,建议在进行充分调研后选择适合的技术方案。下面是几种常见的、与 PJAX 相近的技术,它们在不同场景下具有各自的优势和特点。

  1. Turbo
    Turbo 是 GitHub 使用的一种局部页面加载技术,与 PJAX 相比,它更加重量级,但也封装了更多功能,能够简化客户端的开发。作为 Hotwire 前端框架的一部分,Turbo 尤其适合基于 Rails 架构的应用程序。它在处理表单、响应性页面更新等方面表现出色,并且支持无刷新加载和缓存,提升了开发效率。

  2. htmx
    htmx 和 PJAX 一样能够实现页面的局部更新,但两者的实现方式和关注点有所不同。htmx 强调通过 HTML 属性控制动态表单、实时数据更新、WebSocket 集成等功能,使其在构建交互式和响应式的网页时更为灵活,是一个更广泛适用的选择。

  3. barba.js
    barba.js 以创建平滑的页面过渡效果为核心,能够在页面切换时提供更佳的用户体验。如果你的应用对动画效果和视觉体验有较高要求,barba.js 是一个很好的选择,尤其是在构建具有复杂转场动画的网站时非常有用。

  4. Unpoly
    Unpoly 是一个比 PJAX 更现代化的解决方案,不仅能够实现局部页面更新,还增加了转场动画、缓存和模态框等功能支持。它的 API 更加通用和友好,能够在不引入太多复杂性的情况下,提供全面的局部加载功能,是比 PJAX 更值得考虑的选择之一。

高木同学赛高!