Halo 主题适配 PJAX 的问题
最近在给 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
事件监听, 于是监听 DOMContentLoaded
的 Listener
都会变成监听 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>
或者某一个确定的位置
我的方案是如果 head
和 footer
(通常这两个位置最容易有新内容) 中有新内容, 则手动构建 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 就会比较轻松
- 挂载事件的脚本都放在
<head>
或者<footer>
- 只提供接口不调用初始化方法的脚本放在
<head>
- 内联的脚本都放到
<head>
和<footer>
- 不要将脚本放到
<body>
标签下 (除<footer>
以外)
但问题就在于没有规范, 像协议和接口这些东西都是建立在有规范的前提下实现的, 这种没有规范的完全没有办法真正完美适配
并且每个脚本还有不一样的执行方式, 需要一种统一可预测的行为
比如 HighlightJS 插件生成复制按扭的方法, 没有判断其是否已存在复制按扭
CommentWidget 插件会把脚本插到 <body>
, 我们上面的代码没有处理这种注入到替换区的脚本
耗费大量经历去适配不可控的东西是不值得的
Comments