NexT 适配 PJAX 的方案

2019 年 8 月,Hexo 的 NexT 主题正式加入 PJAX 功能。最初的 PR 一共包含 35 个 commit,约 600 行代码改动。不过这个数字有些夸张,其中约有 200 行是在适配 PJAX 过程中,发现一些插件对于 PJAX 不友好,顺手修改了。此后根据收到的用户反馈,又用了不下十个 PR,修复了 PJAX 中全部已知问题。

由于在同一时期,NexT 已有弃用 jQuery 的计划,因此没有采用广泛使用的 jquery-pjax,而是用了一个较为小众的库:moOx/pjax,并且根据需求修改了一些功能。
适配 PJAX 的要点总结来说有以下几条。

将 PJAX 刷新区域分离

对于一个网站中的全部网页,往往具有一些在每个页面中相同的部分,以及在每个页面中不同的部分。相同部分可能包括 <head> 中加载的 <script>,以及导航栏、页脚等组件。不同部分则是页面的正文部分等。PJAX 的刷新区域应当只包含这些不同的部分。因此,将页面中的内容进行分类非常重要。对于使用各种模版引擎生成的网站,这一操作往往并不复杂,并且还有些额外的好处,例如可以对相同的内容进行缓存,加快网站的生成时间。

选择器

一般 PJAX 会根据设定的选择器来确定刷新页面中的哪些区域。这时需要确保选择器的唯一性。例如,如果文章内容由 Markdown 渲染生成,那么可能会在各个 Heading 处产生带有 id 的元素。需要避免它们与 PJAX 刷新区域的 id 冲突,那么可以考虑使用 class 进行选择。

一致性

另一个问题是 PJAX 刷新过程中,页面状态的一致性。NexT 主题在适配 PJAX 时就遇到了这个问题。在侧边栏中有两个区域:文章目录和站点概览。与之对应的是两个 <button>,点击一个 <button> 便会显示其对应的区域,隐藏另一个;这一过程由 className 控制。但有一些页面,例如首页,是没有文章目录的。如果从一个文章页面通过 PJAX 刷新回到首页,就可能导致侧边栏中的两个区域都被隐藏,看上去没有内容,并且 <button> 的状态与侧栏中两个区域的状态不符合。解决方案有两种:

  • 在每次 PJAX 刷新后,根据情况「点击」其中一个按钮,确保侧边栏显示正确;
  • 将控制显示的 className 移动到刷新区域外,例如设置为 <body>className

第二种方案可以减少 DOM reflow,保证 PJAX 刷新时渲染一步到位。

重新加载脚本

一些常见的 PJAX 插件都自带了重新加载脚本的功能,但为了精确地控制,这一部分可以考虑自行实现。

页面中的脚本大致可以分为三类:

  1. 在每个页面中都存在,但只需要加载一次,重复加载反而有可能导致问题(例如音乐播放器,看板娘,背景动画等)
  2. 在每个页面中都存在,并且 PJAX 刷新时需要重新加载(例如访问量统计,FancyBox 等)
  3. 仅在部分页面中存在,不使用时没有必要加载(例如 MathJax,网站评论区等)

第一类脚本

对于第一类脚本,将其放在 PJAX 刷新区域之外即可,不需要进行任何其它的处理。这里额外提一下 addEventListener 的问题。一般的 PJAX 插件会给出 PJAX 刷新完成时的事件,需要正确的使用。而 DOMContentLoadedload 事件都只会触发一次。
此外,应当避免重复注册相同的 addEventListener。如果每个新页面都使用匿名的回调函数注册一个相同的事件,最后一个事件将触发所有的回调函数,场面一定非常壮观。要避免这个问题,可以:

  • 不使用匿名函数作为回调函数,而是将其封装,通过 function 进行声明,然后将其作为 addEventListener 的参数,这可以保证其只触发一次;
  • 或者在必要时使用 removeEventListener

第二类脚本

对于第二类脚本,需要在 PJAX 刷新完成后,重新执行。最简单的方法是复制这个脚本的属性和内容,然后将其移除,再通过 replaceChild 方法将复制插入到原来的位置。
为了使 PJAX 区分第一类和第二类脚本,避免错误地加载,NexT 使用的方法是为全部第二类脚本加上 data-pjax 属性。这样,重新加载脚本的代码如下所示

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
const elements = document.querySelectorAll('script[data-pjax]');
elements.forEach(function(element) {
const code = element.text || element.textContent || element.innerHTML || '';
const 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;
// Force synchronous loading of peripheral JS.
script.async = false;
}
if (element.dataset.pjax !== undefined) {
script.dataset.pjax = '';
}
if (code !== '') {
script.appendChild(document.createTextNode(code));
}
element.parentNode.replaceChild(script, element);
});

复制 type 是为了避免执行非 JS 脚本,例如 MathJax 的配置。复制 dataset.pjax 则是为了确保在多次的 PJAX 刷新中,script[data-pjax] 选择器始终有效。

第三类脚本

比较麻烦的是第三类脚本。对于那些可以重复进行初始化的插件而言,其「本体」只需要加载一次,此后的 PJAX 刷新过程只需要再次进行初始化。典型的例子是 Valine 和 MathJax。对于 Valine 而言,每个新页面中,重新执行 new Valine() 即可。而对于 MathJax,初始化也是类似的。无论如何,最佳的实现应当保证:

  • 如果用户通过 PJAX 浏览的页面中,都不包含数学公式,那么无需加载 MathJax,减少网络请求;
  • 如果用户浏览到了第一个包含数学公式的页面,那么需要加载 MathJax;
  • 在此后用户浏览的所有页面中,如果包含数学公式,那么只需要调用以下方法,重新进行渲染
    1
    2
    3
    4
    MathJax.startup.document.state(0);
    MathJax.typesetClear();
    MathJax.texReset();
    MathJax.typesetPromise();

当然,你也可以通过其它方式实现同样的效果。如果要省事的话,可以将第三类脚本按照第二类的方式进行处理。唯一的不同是需要将其放置在 PJAX 刷新区域之内,否则可能造成额外的网络请求和资源消耗。

总结

总而言之,PJAX 适配过程中最大的难点是重新加载脚本的问题。想要做到完美而没有任何疏忽和遗漏无疑是困难的。这依赖于大量的测试来检验系统的鲁棒性。