最近给 Typecho 主题 Daydream 加上了 PJAX 无刷新。本以为加一段代码就好,结果遇到了一大堆问题,无法提交评论、插件无效、数学公式没法渲染……特此记录一下。

什么是 PJAX

pjax = pushState + ajax

对于传统的 Typecho 主题,实际上就是服务端渲染(SSR)的代表,用户每请求一个页面,服务器(php)就渲染好这个页面的内容(包括文章、评论之类的动态内容),渲染好一个静态的 HTML 然后传给浏览器。这样虽然方便,但是不难发现传输数据量其实不小。在加载不同的页面的时候,有不少部分是重复渲染的,比如页面的页眉、页脚,每个页面都是一样的,但是加载每个页面都要重新加载一次。这就会降低访问体验。

而以调用 Restful 接口等方式进行客户端渲染(CSR)的单页应用程序(SPA)则是与之对立的另一个典型。用户第一次访问只加载一个页面框架,其中的 js 代码通过接口向服务器请求数据,然后在客户端进行渲染。进入新的页面链接时只刷新需要更新的部分(一般是通过 DOM 操作)。这样每次只需要修改页面中很少的信息量,可以加快加载的速度。这称为无刷新技术。然而 Typecho 并不是以这种 CSR 的思路构建的。
(我之前开过的一个坑 Vuecho 其实就是这种尝试,这是一个 demo:alpha.skywt.cn

那么 PJAX 实际上就是以上两者的「折中方案」。通过这段神奇的 js 代码,它可以自己判断哪些部分刷新了,然后在刷新(或者进入新的页面)时通过 DOM 操作更新要更新的部分。虽然后端的实现完全是服务器端渲染,但是前端看起来就好像是客户端渲染一样,实现全站无刷新,可以获得非常迅捷的体验。

给 Typecho 主题加上 PJAX

⚠️ 本文使用的是 jquery-pjax

这一步其实非常简单(麻烦的在后面),
首先把博客的动态内容(即从一个页面进入另一个页面要更新的内容)用一个 <div> 之类的标签包裹起来,并将 id 设置为 pjax-container。这个容器就叫做 PJAX 容器。
在这个容器外部,是刷新页面不需要更新的部分,如每个页面都一样的页眉、导航栏、页脚。
在这个容器内部,是刷新页面需要重新加载的部分,比如从文章列表进入一篇文章,容器中的内容由「文章列表」改变为「文章内容」。

一般主题的结构包含 header.phpfooter.php,只要在 header.php 的末尾加上容器的开放 tag,在 footer.php 的开头加上容器的封闭 tag 即可。最后渲染出来的页面应该像是这样:

<html>
    <head>
        <!-- ... -->
    </head>
    <body>
        <header><!-- 页眉,标题什么的 --></header>
        <nav><!-- 导航栏什么的 --></nav>
        <main id="pjax-container">
            <!-- 网站主体内容,文章列表 / 文章内容 / 评论什么的 -->
        </main>
        <script>
            // 下面要加上的代码
        </script>
        <footer><!-- 页脚什么的 --></footer>
    </body>
</html>

在 footer.php 或者其他地方(PJAX 容器的后面,就是上面这段代码的 <script> 部分)加上这段代码:

$(document).pjax('a[href^="<?php $this->options->siteUrl()?>"]:not(a[target="_blank"])', {
    container: '#pjax-container',
    fragment: '#pjax-container'
});
$(document).on('pjax:send',function() {
    // alert('开始加载');
    // 开始加载时要运行的代码(如显示加载动画)
});
$(document).on('pjax:complete', function() {   
    // alert('加载完成');
    // 加载完成后要运行的代码(如去除加载动画)
});

传入 pjax 函数的第一个参数是 selector,它告诉 PJAX,只对于目标为本站内的且没有设置在新页面打开(target="_blank")的链接进行无刷新加载,其他链接(如站外链接)则会正常加载。
第二个参数是 container,指定 id 为 pjax-container 的元素作为 PJAX 容器,当然也可以任意修改。可以查阅文档修改更多参数。

oncomplete 都是 PJAX 事件,文档里也列举了更多事件。可以用这个测试一下 PJAX 开启成功了没有。

经过以上步骤(应该)可以发现页面之间的切换明显变快了。

解决评论问题

启用 PJAX 后,如果从一个页面进入另一个页面(比如从首页进入某个文章页面),提交评论会发现页面刷新了一下(或者 Safari 干脆显示无法加载),没法正常发表评论。必须手动刷新这个页面才能发评论。

这是因为 Typecho 提交评论的 js 脚本放在 <head> 里(F12 可以看到),而当我们刷新页面,<head> 中的脚本并不会更新(因为其在 PJAX 容器之外)。所以只有第一次进入页面可以正常提交评论,进入其他页面后就不行。

解决方法也很简单。在评论区加上这段从 <head> 里 copy 出来的代码:

(function() {
    window.TypechoComment = {
        dom: function(id) {
            return document.getElementById(id);
        },
        create: function(tag, attr) {
            var el = document.createElement(tag);
            for (var key in attr) {
                el.setAttribute(key, attr[key]);
            }
            return el;
        },
        reply: function(cid, coid) {
            var comment = this.dom(cid),
                parent = comment.parentNode,
                response = this.dom('<?php echo $this->respondId; ?>'),
                input = this.dom('comment-parent'),
                form = 'form' == response.tagName ? response : response.getElementsByTagName('form')[0],
                textarea = response.getElementsByTagName('textarea')[0];
            if (null == input) {
                input = this.create('input', {
                    'type': 'hidden',
                    'name': 'parent',
                    'id': 'comment-parent'
                });
                form.appendChild(input);
            }
            input.setAttribute('value', coid);
            if (null == this.dom('comment-form-place-holder')) {
                var holder = this.create('div', {
                    'id': 'comment-form-place-holder'
                });
                response.parentNode.insertBefore(holder, response);
            }
            comment.appendChild(response);
            this.dom('cancel-comment-reply-link').style.display = '';
            if (null != textarea && 'text' == textarea.name) {
                textarea.focus();
            }
            return false;
        },
        cancelReply: function() {
            var response = this.dom('<?php echo $this->respondId; ?>'),
                holder = this.dom('comment-form-place-holder'),
                input = this.dom('comment-parent');
            if (null != input) {
                input.parentNode.removeChild(input);
            }
            if (null == holder) {
                return true;
            }
            this.dom('cancel-comment-reply-link').style.display = 'none';
            holder.parentNode.insertBefore(response, holder);
            return false;
        }
    };
})();

注意其中的 <?php echo $this->respondId; ?>,这就是前文评论失败的原因——每个页面的 respondId 不一样,所以这个是需要刷新的。

除此之外,需要关闭 Typecho 后台的「设置」-「评论」-「评论提交」中取消勾选「开启反垃圾保护」。这个选项实际上是开启反 CSRF 攻击的防护,即每次加载页面服务器会传一个每次不同的随机字符串(在页面里表单最后面一个名为 _ 的 hidden input 元素),提交评论时表单里会带上它,而用 PJAX 的方式去拿这个字符串非常麻烦,索性关闭了。最好加上一些反垃圾评论的插件,因为没有了反 CSRF 防护,可以用脚本轻易多次提交评论。

经过以上的修改,应该可以正常提交评论了。

解决插件问题

KaTeX 数学公式没法渲染(第一次加载能渲染,进入新页面没法渲染),也是和评论类似的问题。渲染 KaTeX 公式的这段 js 代码:

renderMathInElement(
    document.body,
    {
        delimiters: [
            {left: "$$", right: "$$", display: true},
            {left: "$", right: "$", display: false},
        ]
    }
);

放在 PJAX 容器之外,所以只会在最初加载页面的时候执行一次。它涉及到 DOM 操作,我们希望它每次都能执行。解决方法是放在 PJAX 容器内部,或者在 complete 事件中也执行一次。

除此之外,包括 aplayer 插件、fancybox 图片灯箱、代码高亮等等都是类似的问题,只要把 DOM 操作的代码放到 PJAX 容器内部即可。

经过以上的操作,基本上网站的 PJAX 就没问题啦。享受极速的响应吧~