Halo 博客中 Pjax 实践的通用方案

2025 年 01 月 04 日
50 次浏览
8361 字数

一、问题分析

先分析下,全页面加载(Full Page Load)和 pjax(PushState + Ajax)请求方式中有哪些 js 引入和方法调用的行为,然后针对差异给出解决方案。

1.1 全页面加载下 js 的引入和方法调用

全页面加载情况下,我们通常会有以下的 js 引入行为:

  1. 各个页面公共的外部 js 引入。

    一般位于 <head> 中。

  2. 特定页面额外的外部 js 按需引入。

    一般也位于 <head> 中。

  3. 内联形式的 <script> 标签。

    包含两种情况:第一,位于 <head> 中的事件挂载脚本。第二,位于 <body> 中的方法调用脚本。

同时,也会有以下的 js 方法调用的行为:

  1. 引入外部 js 时,脚本内初始化并在脚本内调用方法。

    规范开发情况下,这部分脚本应该需要拆分,引入的外部 js 部分一般只做资源加载和初始化,相关的方法调用在内联形式的 <script> 中完成,可以是放在 <head> 中的事件挂载方法,也可以放在 <body> 里按需调用。

  2. 内联形式的 <script> 标签调用方法。

  3. 自己的 main.js 定义的一些事件方法。

1.2 Pjax 请求下 js 的引入和方法调用

在 pjax 下,由于是局部刷新,在局部刷新元素内的 script 标签会被执行。但是 ,<head> 一般是未刷新部分,所以,各页面 <head> 中新增的 <script> 脚本既不会在当前页面中引入也不会执行。

对应 “全页面加载” 中的各项引入和方法调用行为,那么 Pjax 请求下,在 js 引入 的问题有:

  1. <head> 中,特定页面额外的外部 js 并未引入。
  2. <head> 中,特地页面新增的内联形式 script 标签未引入。

同样地,也存在以下 js 方法调用的问题:

  1. 引入外部 js 时,脚本内初始化调用方法在新页面未调用。

    这类脚本需要拆分,不额外赘述了。

  2. 已经存在的和新增的内联形式的 script 标签调用方法未调用。

1.3 Halo 插件引入 js 的机制

在 halo 的插件引入机制里,例如 hljs、katex、lightGallery 等插件,它们对 css 和 js 的引入是按需引入,即在文章页引入,而不是全局引入。按需引入确实是符合网页优化的原则的,我们普通设计的网页也应该是按需引入。
halo 插件引入脚本的执行方法也很简单粗暴,就是在对应插件引入脚本下添加内联形式的 script 标签,直接添加一个 DOMContentLoaded 事件。

二、解决方案

首先说一下 pjax 解决这个问题时,需要遵循的几个原则:

  1. 若想重用 DOMContentLoaded 事件,则不能重复添加同一方法到 DOMContentLoaded 事件
    因为我看到过有些方案是想重新触发 DOMContentLoaded 事件,来更简便的实现在非 PJax 下的 js 复用。例如:事件代理的方式Halo 主题 PJAX 开发实践|Takagi,或者可以直接手动触发该事件。
    不过,这种情况下不能重复添加 DOMContentLoaded 事件,这个很好理解,首先该事件是无法取消的,其次把同一个方法重复的添加到该事件里,导致事件触发时重复执行同一方法也是不优雅的。
  2. 正确看待 Pjax 的适配
    Pjax 只是锦上添花,不应该为了适配 Pjax 而影响全页面加载下的设计逻辑,它是额外添加的东西,是独立的,不能和原有的全页面加载逻辑杂糅在一起,也不要因此构建一个很复杂的逻辑。但同时,我们在设计全页面的时候,也应该遵循一定规范,否则会给 Pjax 的适配带来额外的成本。

三、结论

本来我也没想好最终的结论,只是想写个文章记录下分析过程,边分析思路边实现。原以为要写一大篇,但是写到上面,尤其是分析到解决方案的两个原则和 Halo 插件的引入机制,我基本能得出一个结论——想完美适配 Pjax ,我们更应该关注全页面加载下的开发规范,而不是想着怎么左粘右糊地让 Pjax 去适配不规范的 Js 引入行为。如果所有资源的引入都由我们自己控制,当然不存在规范问题。但是 Halo 的插件机制不在我们的控制范围内,它在哪个页面引入?方法调用是直接调用还是事件挂载?Pjax 下是否会有重复引入?等等各种问题,暂且不考虑具体的实现难度和可行性,总之在兼容插件情况下维护 PJax 成本极高
考虑到之后博客的扩展和维护,我不考虑放弃插件而自己引入资源,同时还是坚持 Pjax 只是锦上添花,不能本末倒置的原则,所以我不再追求完美的 Pjax 适配,决定采用一种相对妥协的通用方案。

四、权衡下的最终妥协方案

我最终给出的方案就一个原则:模拟全页面加载中 js 的引入流程——在 pjax 请求页面时,将新网页中 <head> 里新增的 <script> 标签,插入当前页面的 <head> 中并执行,根据各类型 <script> 的加载机制,在相应的同步、异步加载时机后,手动触发 DOMContentLoaded 事件。

4.1 待解决问题

相应地,这样的设计需要解决的问题有:

  1. 手动触发 DOMContentLoaded 事件的局限性。

    大多数 js 脚本的调用方法,都是挂载在该事件上,这样可以解决大部分脚本的方法调用问题。但如果有一些例外情况,可以手动添加到 pjax:complete 中。

  2. 检测 head 中新增的 script 标签的机制。

    如何判断新增的外部 js、内联 script,描述简单,具体实现还得考虑下。

  3. 模拟原网页中 js 加载的顺序机制与 DOMContentLoaded 事件触发时机。

    在插入并执行 script 标签时,最好保持新加载网页中各个 script 同步、异步加载的顺序,因为他们可能会有相互的依赖关系,且要在所有脚本加载完成后再触发 DOMContentLoaded 事件。总的原则,就是模拟原有网页正常加载、执行、触发事件的流程。

从设计哲学来讲,模拟正常请求过程新增 Js 的引入、执行和 DOMContentLoaded 事件的触发,这可能是最通用的 Pjax 解决方案,如果出现问题,那一定是其他引入脚本的开发规范问题。

4.2 Pjax 下全页面加载的脚本引入规范

为了避免 Pjax 适配带来的额外成本,我专门声明一下全页面加载的脚本引入规范。

  1. 外部脚本的引入与其方法调用需要拆分。

    外部脚本的引入,只为了初始化一些资源,暴露出特定方法接口,供内联形式的 <script> 标签直接调用或者事件挂载。

  2. 用于事件挂载的、内联形式的 <script> 标签应放到 <head> 中。

    为什么要额外强调这一点,在全页面加载中,这种事件挂载你放 <body> 中或者放任意位置都不影响页面的正常展示和逻辑。但在 Pjax 请求中,这样的事件挂载只需要一次,必须放在 <head> 这种 pjax 的未刷新区域。否则,Pjax 加载下将导致重复的挂载统一方法到某个事件中,使得该事件触发时重复执行同一方法是不优雅和资源浪费的。

  3. 用于直接调用的、内联形式的 <script> 标签应放到 <body> 中,或者 pjax 的刷新区域中。

    像 Halo 中的评论插件的初始化脚本等,Pjax 下就无需额外适配,会自动执行。

4.3 不符合脚本引入规范的情况处理

这里所述的不符合脚本引入规范的情况肯定是不受我们控制的脚本引入行为,也就是特指 Halo 插件的 js 引入行为。碰到不符合规范的 js 引入行为,就像我们前面说的,不是去左粘一下右糊一下我们的 Pjax 实现去适配它们,而应该推动它们更改以符合规范,如果不能更改,那就舍弃。一个不可控的东西,当使其为我们服务要付出的成本高于其产出效益时,就应该舍弃。

目前 Halo 中不符合我们声明规范的地方暂时发现两处:

  1. halo-tracker.js 中,将固定参数的请求方法绑定到 readystatechange 事件
  2. katex 插件中,在 <body> 中引入外部 js 以及在 <body> 中使用内联形式的 <script> 标签挂载事件。

五、创新点

相较于传统的 pjax 实现方案,我们这种实现方案或者说我们规定的这套 js 引入规范,它既不用简单粗糙的移除再创建 <script> 以实现全部重载已有 js,也无需额外花成本细粒度的去区分哪些是需要重载的 js,哪些需要重载的方法调用,哪些是事件挂载,如何避免同一方法的多次事件挂载等等问题。

六、最终实现

在谈最终实现之前,我们需要了解无类型、deferasync 等各个类型的 script 标签的执行与元素解析以及 DOMContentLoaded 事件触发的流程和顺序关系。

6.1 各类型 Script 标签加载执行顺序机制

不展开讲了,可以看这篇总结:HTML 解析过程中 Script 标签、DomContentLoaded 事件的加载顺序

6.2 实现思路

  1. 利用 Pjax 中 handleResponse 这个方法,获取新网页中的元素信息。
  2. 遍历所有新网页中的 script 标签,按顺序遍历,判断当前旧网页中是否已存在该资源。存在则跳过,不存在则执行以下逻辑。
  3. 对于普通类型和 defer 类型的 script 标签按顺序同步加载执行,并在加载完成后重新触发 DOMContentLoaded 事件。对于 async 类型标签则无需保证其与 DOMContentLoaded 时序关系,直接异步加载就可以。

    因为原有的 script 标签可能会有顺序依赖关系,所以需要按顺序同步执行。同时,在 pjax 中执行到 handleResponse 之前,就已经更新了相关元素或者说更新了页面,所以无需额外处理 defer 的执行顺序,也就是说我们所有的普通类型、defer 类型的 script 标签的执行时机都是在 pjax 替换元素之后,手动触发 DOMContentLoaded 事件之前,以模拟普通页面请求的方式和过程。

6.3 实现代码

let _reponseText = '';
var pjax = new Pjax({
    ...
    });

pjax._handleResponse = pjax.handleResponse;
pjax.handleResponse = function (responseText, request, href, options) {
    _responseText = responseText;
    pjax._handleResponse(responseText, request, href, options);
};

function insertResourcesToHead(responseText: string) {
  return new Promise((resolve) => {
    // 获取当前网页中已有 script css 标签
      ...
  })
}
                   
document.addEventListener("pjax:complete", () => {
    console.log("pjax complete");
    ...
    insertResourcesToHead(_responseText).then(() => {
      console.log('手动触发 DomContentLoaded 事件');
      document.dispatchEvent(new Event("DOMContentLoaded"));
    });
  });

参考文献

[1] Script 标签加载顺序与机制: javascript - 谈谈 script 标签以及其加载顺序问题,包含 defer & async

[2] Halo 主题 PJAX 开发实践:Halo 主题 PJAX 开发实践|Takagi

[3] Moox/Pjax: MoOx/pjax: Easily enable fast Ajax navigation on any website (using pushState + xhr)