最近在给 Halo 的主题上 PJAX: Terminal, 遇到了非常多的问题

PJAX 是一种 PushState + Ajax 来实现的一种局部刷新技术, 原理是拦截 a 标签, 用 fetch 去访问页面, 将获取到的页面替换进旧的页面

Halo 作为一个传统 CMS, 采用的是服务端渲染的模式, 每一次请求都会加载全部的 JS 和 CSS, 每次访问不同页面总会有很多相同的内容, 于是就可以用 PJAX 来优化

有两个实现 PJAX 的库

  • https://github.com/defunkt/jquery-pjax
  • https://github.com/MoOx/pjax

第一个库是依赖 jquery 实现的, 第二个库是原生 JS 实现无需任何依赖, 更容易集成且轻量, 所以我使用的是第二个库

PJAX 的问题, 实际都是 script 执行的问题, 这要涉及到浏览器的机制

  • 普通的 <script> , 在构建 DOM 的时候, 遇到就会立即执行并阻塞 DOM 的构建
  • defer 属性的 <script defer> 会在 DOM 构建完成后才执行, 主流浏览器的行为是按 defer 脚本的顺序执行
  • async 属性的 <script async> 会在脚本加载完后立即执行, 但不会阻塞 DOM 的构建

不管是那种 script 都跟 DOM 的构建有关, 都是在 DOM 构建时才执行的

PJAX 的核心问题也就是: PJAX 并不会触发 DOM 构建

替换新页面时就要考虑几种情况:

  • 没有 DOMContentLoaded 事件, 依赖监听的脚本无法生效
  • 新内容依赖已经存在的脚本, 需要重新执行
  • 新内容中新增了脚本, 需要重新执行

不触发 DOMContentLoaded 事件

在 PJAX 请求的新页面中有有依赖 DOMContentLoaded 事件的 script 均会失效

解决方案是 JS 强大的原型链, 通过原型链去修改 addEventListener

EventTarget.prototype.addEventListener = function (type, listener, options) {
    if (type === "DOMContentLoaded") {
        if (listener) {
            window.addEventListener(
                "pjax:success",
                () => {
                    retry(() => listener(), 10, 100);
                },
                {
                    once: true,
                }
            );
            return;
        }
    }
    originalAddEventListener.call(this, type, listener, options);
};

上面这段代码就将添加 DOMContentLoaded 事件监听修改为了添加 pjax:success 事件监听, 于是监听 DOMContentLoadedListener 都会变成监听 pjax:success

脚本方法需要重新执行

这种情况移除旧的 <script> 重新添加就会重新执行

替换区内的脚本

在这种情况下, 统一将替换区内的脚本设定为主题可控的脚本, 但有一些不规范的插件会将脚本注入到这个区域, 这里不做处理

因为在替换区内, 所以新旧页面的 DOM 结构相对于要重新执行的 <script> 来说父节点都是一致的, 可以直接在父元素移除子元素再添加

约定在替换区内的有 data-pjax 属性的脚本都会被重新执行, 因为替换区里的东西相对来说是不变的, 不会有变化

let pjaxDoms = document.querySelectorAll(".content script[data-pjax]:not([no-pjax])");
pjaxDoms.forEach((element) => {
    let code = element.text || element.textContent || element.innerHTML || "";
    let parent = element.parentNode;
    if (parent === null) {
        return;
    }
    parent.removeChild(element);
    let script = renewElement(element);
    if (code !== "") {
        script.appendChild(document.createTextNode(code));
    }
    parent.appendChild(script);
});

替换区外已存在的脚本

这部分脚本通常是主题中可控的脚本, 以及插件全局注入进来的脚本

比如文章的目录生成, 正常情况下点进文章执行脚本根据 h 标签生成 TOC

但如果是 PJAX, 除非手动刷新或者出错, 根本不会有 DOM 树构建, 于是生成 TOC 的脚本无法执行

这种情况的解决方案是统一管理, 发生 PJAX 时手动刷新

比如像这样有一个接口:

export class Terminal {
    constructor() {
        this._refreshFunctions = [];
        this._initFunctions = [];
        this.initialized = false;
    }

    registerRefresh(func) {
        this._refreshFunctions.push(func);
    }

    registerInitFunc(func) {
        this._initFunctions.push(func);
    }

    refresh() {
        if (!this.initialized) {
            this._initFunctions.forEach(func => func());
            this.initialized = true;
        }
        this._refreshFunctions.forEach(func => func());
    }
}

export var terminal = new Terminal();
window.terminal = terminal;

每一个需要刷新的方法都通过 registerRefesh 来注册进去

terminal.registerRefresh(generateToc);

然后在 pjax:complete 时手动执行:

document.addEventListener("pjax:complete", function () {  
	....
	window.terminal.refresh();
}

替换区外的新脚本

通常有一些 script 会根据页面动态注入进来, 也有一些没有公开加载方式的脚本, 这部分脚本我们无法通过上一种方式提前注册

比如 Halo 的 highlightjs 插件就是根据页面将 <script> 注入到 <head> 标签中

通常 <head> 下除了 <title> 和一些 <meta> 都是不该替换的, 于是这部分脚本根本就无法被 PJAX 替换进来

需要区分内联和非内联的情况, 并且因为两边的内容不一样, 不一定有相同的父元素, 所以要么只重新执行比如 <head> 下这种确定父元素的脚本, 要么就将脚本不管父元素全部插入到 <head> 或者某一个确定的位置

我的方案是如果 headfooter (通常这两个位置最容易有新内容) 中有新内容, 则手动构建 DOM 的方式重新执行, 而没有对整个 HTML 进行脚本替换. 这样难免会有一些不守规范的漏网之鱼, 但这是一个取舍的问题

非内联

对于非内联的, 只需要通过 src 来当唯一的 ID 去检查原本是否存在, 不存在就重新 append

比如下面的代码:

function insertScript() {
    const parser = new DOMParser();
    const dom = parser.parseFromString(responseText, 'text/html');
    const existingScripts = new Set();

    // 首先加载原本就存在的 script
    document.querySelectorAll('head script[src]:not([no-pjax])').forEach(script => {
        existingScripts.add(script.src);
    });

    dom.querySelectorAll('head script[src]:not([no-pjax])').forEach(script => {
        const src = script.src;
        // 如果原本就存在, 则不需要插入
        if (src && !existingScripts.has(src)) {
            // 这里的 renew 就是重新 create 了一个 script 并将旧的属性全部复制过去
            const newScript = renewElement(script);
            document.head.appendChild(newScript);
        }
    });
}

为了实现目的还必须 hook PJAX 的处理响应报文的方法:

// 这里把 HTML 页面保存下来, 用于后面做插入
let _responseText = '';

pjax._handleResponse = pjax.handleResponse;

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

内联

内联的脚本无法通过 document.querySelectorAll 去检查是否已存在

如果不检测是否存在的话, head 标签会被插入随页面访问递增数量的相同脚本

这里就是权衡速度还是内存了, 如果不做去重, 那么一直膨胀, 内存占用会变大; 如果去重, 势必要有方法来判断两个内容是否相同, 从而牺牲速度

我这里选择牺牲速度, 将 innerHTML 算一个 hash 值作为 <script> 的属性, 这样就可以通过 ``document.querySelector去尝试能不能选到带有某个key , 如果有则先 remove` 再插入

function djb2(str) {
    let hash = 5381;
    for (let i = 0; i < str.length; i++) {
        hash = ((hash << 5) + hash) + str.charCodeAt(i);
    }
    return (hash >>> 0)
}

function insertInlineScript(dom) {
    dom.querySelectorAll('script:not([src]):not([no-pjax])').forEach(script => {
        if (script.innerHTML.trim()) {
            let hash = djb2(script.innerHTML);
            // 先用 hash 检查有没有
            let old = document.querySelector(`script[data-hash="${hash}"]`);
            if (old) {
                document.head.removeChild(old);
            } else {
                // 如果没有再计算已经存在的内联 script 标签的 innerHTML 的 hash, 删除等于新内容 hash 的 script
                let scripts = document.head.querySelectorAll('script:not([src]):not([no-pjax])');
                scripts.forEach((s) => {
                    if (djb2(s.innerHTML) === hash) {
                        document.head.removeChild(s);
                    }
                });
            }
            const newScript = renewElement(script);
            newScript.innerHTML = script.innerHTML;
            // 设置一个属性, 防止重复
            newScript.setAttribute("data-hash", hash);
            document.head.appendChild(newScript);
        }
    });

}

最终的实现

document.addEventListener("pjax:complete", function () {
    // 处理头和尾有新增的情况
    process(_responseText);
    // 处理替换的内容中存在脚本的情况
    let pjaxDoms = document.querySelectorAll(".content script[data-pjax]:not([no-pjax]");
    pjaxDoms.forEach((element) => {
        let code = element.text || element.textContent || element.innerHTML || "";
        let parent = element.parentNode;
        if (parent === null) {
            return;
        }
        parent.removeChild(element);
        let script = renewElement(element);
        if (code !== "") {
            script.appendChild(document.createTextNode(code));
        }
        parent.appendChild(script);
    });
    // 刷新注册的函数
    window.terminal.refresh();
});

有没有必要?

对于我这个主题而言完全没有必要, 足够轻量, 并且浏览器会缓存, 也许意义就在于折腾...

如果网页非常重且是服务器渲染的多页面网页, 那局部刷新还是非常有必要的, 尤其是针对首屏加载速度

并且要完美实现 PJAX 需要主题专门为 PJAX 编写, 不过这样肯定是不合理的, 所以必须权衡取舍

如果每个主题之外的脚本都遵守一定规范, 那 PJAX 就会比较轻松

  1. 挂载事件的脚本都放在 <head> 或者 <footer>
  2. 只提供接口不调用初始化方法的脚本放在 <head>
  3. 内联的脚本都放到 <head><footer>
  4. 不要将脚本放到 <body> 标签下 (除 <footer> 以外)

但问题就在于没有规范, 像协议和接口这些东西都是建立在有规范的前提下实现的, 这种没有规范的完全没有办法真正完美适配

并且每个脚本还有不一样的执行方式, 需要一种统一可预测的行为

比如 HighlightJS 插件生成复制按扭的方法, 没有判断其是否已存在复制按扭

CommentWidget 插件会把脚本插到 <body>, 我们上面的代码没有处理这种注入到替换区的脚本

耗费大量经历去适配不可控的东西是不值得的

Reference