顺便也把 pjax 做完了,虽然没有很大的现实意义,但是要填的坑却一个也不少。一定程度上,启用 ajax 能让浏览感觉更完整一些,而且 pjax 配置十分简单,反而修改结构要花更多的时间。

简介

简单来说,pjax = pushState + ajax,主要的配置和使用方法请浏览jquery-pjax

除配置以外,使用 pjax 要面临很多问题,困扰我的主要有:

  • 原博客框架问题,需要小幅调整并测试选择器与容器,同时伴随着少量的 css 修改;
  • 原博客主页 js 事件修改,部分无法绑定的事件需要考虑在内容输出时重载;
  • 由于 ajax 会令部分事件丢失,导致插件大规模失效,需要逐一查看 js 源码找出每一个初始函数;
  • Typecho 与 ajax 的部分不和,需要修改源码适配。

JS 代码重载

启用 ajax 后,眼看 js 挂掉一半,其实绝大部分都是事件绑定方式不兼容。排除一些事件后,仍有部分代码需要在 ajax 内容输出时重载。其实所谓的重载,只需要把它们套进一个函数,在每次输出内容时调用即可。如:

var pjaxInit = function(){
    ......
}

插件初始函数

列举部分 Typecho 插件,如有相同可直接参考。

  • highSlide 相册:hs.updateAnchors();
  • highSlide Caption 设置:hs.captionEval = "this.thumb.alt";
  • Prism 代码高亮:Prism.highlightAll();
  • NProgress 加载进度条:NProgress.start();NProgress.done();

popstate 初始化

建立 JS 重载代码后,我就直接应用到了pjax:completepjax:popstate事件上。后来发现,触发 popstate 后实际上该 JS 代码仍未「重载」,调试一番终于发现原来并非初始函数未执行,而是 popstate 事件发生后就立即执行了初始函数,这时候 DOM 仍未改变。解决的方法是设置延时函数,待 DOM 完全替换后再执行初始函数。

$(document).on('pjax:popstate', function() {
    setTimeout(function(){
        pjaxInit();
    },500);
});

Typecho 评论修复

定位到:var/Widget/Archive.php #1707。

使用 Typecho 原生评论框,在启用 ajax 时回复评论会出错。原因在于评论回复的 js 函数在页面完整加载时已写死了变量并输出在代码头部,ajax 在内容输出与替换时均不刷新页面,所以导致页面内容变更后部分参数与原页面参数不对应。修复的方法有很多,直接从源码入手会更高效一些。

问题出于原代码三处responseID被写死为$this->respondId{$this->respondId}由 php 直接输出的参数。我的解法是把三个相关的参数改写为形如以下直接输出表达式,从而交由 js 解析这个参数(模板不一样关键字可能不一样);

responseID = typeof($('.respond').eq(0).attr('id')) == 'undefined' ? '".$this->respondID."' : $('.respond').eq(0).attr('id');

除此之外,由于 ajax 异步加载的原因,需要保证在任何路径进入页面都必须加载这两段脚本(除非不需要原生评论和反垃圾评论)。实测在本人的设置环境下从首页进入刷新页面不会输出这两段脚本。这里偷个懒直接把所有判断都取消了,因为在 ajax 环境下无论某个路径页面是否开启评论,都必须载入脚本,否则在其他路径将会缺失函数。完整修改后的代码段如下。

    public function header($rule = NULL)
    {
        $rules = array();
        $allows = array(
            'description'   =>  htmlspecialchars($this->_description),
            'keywords'      =>  htmlspecialchars($this->_keywords),
            'generator'     =>  $this->options->generator,
            'template'      =>  $this->options->theme,
            'pingback'      =>  $this->options->xmlRpcUrl,
            'xmlrpc'        =>  $this->options->xmlRpcUrl . '?rsd',
            'wlw'           =>  $this->options->xmlRpcUrl . '?wlw',
            'rss2'          =>  $this->_feedUrl,
            'rss1'          =>  $this->_feedRssUrl,
            'commentReply'  =>  1,
            'antiSpam'      =>  1,
            'atom'          =>  $this->_feedAtomUrl
        );

        /** 头部是否输出聚合 */
        $allowFeed = !$this->is('single') || $this->allow('feed') || $this->_makeSinglePageAsFrontPage;

        if (!empty($rule)) {
            parse_str($rule, $rules);
            $allows = array_merge($allows, $rules);
        }

        $allows = $this->pluginHandle()->headerOptions($allows, $this);
        $title = (empty($this->_archiveTitle) ? '' : $this->_archiveTitle . ' » ') . $this->options->title;

        $header = '';
        if (!empty($allows['description'])) {
            $header .= '<meta name="description" content="' . $allows['description'] . '" />' . "\n";
        }

        if (!empty($allows['keywords'])) {
            $header .= '<meta name="keywords" content="' . $allows['keywords'] . '" />' . "\n";
        }

        if (!empty($allows['generator'])) {
            $header .= '<meta name="generator" content="' . $allows['generator'] . '" />' . "\n";
        }

        if (!empty($allows['template'])) {
            $header .= '<meta name="template" content="' . $allows['template'] . '" />' . "\n";
        }

        if (!empty($allows['pingback']) && 2 == $this->options->allowXmlRpc) {
            $header .= '<link rel="pingback" href="' . $allows['pingback'] . '" />' . "\n";
        }

        if (!empty($allows['xmlrpc']) && 0 < $this->options->allowXmlRpc) {
            $header .= '<link rel="EditURI" type="application/rsd+xml" title="RSD" href="' . $allows['xmlrpc'] . '" />' . "\n";
        }

        if (!empty($allows['wlw']) && 0 < $this->options->allowXmlRpc) {
            $header .= '<link rel="wlwmanifest" type="application/wlwmanifest+xml" href="' . $allows['wlw'] . '" />' . "\n";
        }

        if (!empty($allows['rss2']) && $allowFeed) {
            $header .= '<link rel="alternate" type="application/rss+xml" title="' . $title . ' &raquo; RSS 2.0" href="' . $allows['rss2'] . '" />' . "\n";
        }

        if (!empty($allows['rss1']) && $allowFeed) {
            $header .= '<link rel="alternate" type="application/rdf+xml" title="' . $title . ' &raquo; RSS 1.0" href="' . $allows['rss1'] . '" />' . "\n";
        }

        if (!empty($allows['atom']) && $allowFeed) {
            $header .= '<link rel="alternate" type="application/atom+xml" title="' . $title . ' &raquo; ATOM 1.0" href="' . $allows['atom'] . '" />' . "\n";
        }
        
/*
        if ($this->options->commentsThreaded && $this->is('single')) {
            if ('' != $allows['commentReply']) {
                if (1 == $allows['commentReply']) {
*/
                    $header .= "<script type=\"text/javascript\">
(function () {
    window.TypechoComment = {
        rid : function () {
            return typeof($('.respond').eq(0).attr('id')) == 'undefined' ? '".$this->respondID."' : $('.respond').eq(0).attr('id');
        },
    
        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(this.rid()), 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(this.rid()),
            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;
        }
    };
})();
</script>
";
/*
                } else {
                    $header .= '<script src="' . $allows['commentReply'] . '" type="text/javascript"></script>';
                }
            }
        }
*/

        /** 反垃圾设置 */
        if ($this->options->commentsAntiSpam && $this->is('single')) {
            if ('' != $allows['antiSpam']) {
                if (1 == $allows['antiSpam']) {
                    $header .= "<script type=\"text/javascript\">
(function () {
    var event = document.addEventListener ? {
        add: 'addEventListener',
        triggers: ['scroll', 'mousemove', 'keyup', 'touchstart'],
        load: 'DOMContentLoaded'
    } : {
        add: 'attachEvent',
        triggers: ['onfocus', 'onmousemove', 'onkeyup', 'ontouchstart'],
        load: 'onload'
    }, added = false;

    document[event.add](event.load, function () {
        var rid = typeof($('.respond').eq(0).attr('id')) == 'undefined' ? '".$this->respondID."' : $('.respond').eq(0).attr('id');
        var r = document.getElementById(rid),
            input = document.createElement('input');
        input.type = 'hidden';
        input.name = '_';
        input.value = " . Typecho_Common::shuffleScriptVar(
            $this->security->getToken($this->request->getRequestUrl())) . "

        if (null != r) {
            var forms = r.getElementsByTagName('form');
            if (forms.length > 0) {
                function append() {
                    if (!added) {
                        forms[0].appendChild(input);
                        added = true;
                    }
                }
            
                for (var i = 0; i < event.triggers.length; i ++) {
                    var trigger = event.triggers[i];
                    document[event.add](trigger, append);
                    window[event.add](trigger, append);
                }
            }
        }
    });
})();
</script>";
                } else {
                    $header .= '<script src="' . $allows['antiSpam'] . '" type="text/javascript"></script>';
                }
            }
        }

        /** 输出header */
        echo $header;

        /** 插件支持 */
        $this->pluginHandle()->header($header, $this);
    }

特别提醒,使用 pjax 请勿开启「评论反垃圾」选项。

Typecho 评论表单

考虑过直接使用 ajax 提交表单,也就是说提交后把评论插入到现有的 DOM 中。但有一个问题,这样人为插入评论并不能保证评论该有的样式,首先层级就难以保证。碍于没有这样的能力也没有如此的精力,所以直接交由 pjax 提供的方法$.pjax.submit处理,反正也是基于 ajax 提交表单,重新获取一次资源也无妨。而实际处理却并不那么简单,首先是通过如下的方式绑定事件:

$(document).on('submit', pjaxCommentForm, function(event) {
    $.pjax.submit(event, pjaxContainer, {
        fragment: pjaxContainer,
        timeout: pjaxTimeout,
        scrollTop: commentMainFrame
    });
});

而问题出于 Typecho 在接收评论表单后在原回路加上锚点后直接返回 302,亦即对于 pjax 实际上经历了两个回路:

  1. POST 表单到路径./comment
  2. 收到重定向后请求定向资源。

所以真正的问题就在第一点上,按照 pjax 的逻辑,在头部没有使用X-PJAX-URL指定 URL 的情况下,浏览器保存初始请求地址,亦即在 Form 中可以看到的 action 属性./comment。但这不是一个可请求的资源,仅为评论提交表单时请求的地址,所以真正该保存的地址应该不包含此路径的原回路。

起初以为直接在处理重定向的位置加上头部X-PJAX-URL指定 URL 就可以解决,然而发现想要影响浏览器地址的 URL,就要在最后的响应上加上该头部,也就是难以判断其来路的重定向后的 GET 请求。终于,在蠢哭了很长一段时间后想到了一个很 Tricky 的方法,就是在function.php中加入该头部,如下。至于为什么可以这么判断,分析一下各个页面下 pjax GET 请求与 302 后 GET 请求的区别就知道了。

function is_pjax(){   
    return array_key_exists('HTTP_X_PJAX', $_SERVER) && $_SERVER['HTTP_X_PJAX'];   
}

/* 加入到 function.php 最开始的位置 */
if(empty($_SERVER['QUERY_STRING']) && is_pjax())
    header('X-PJAX-URL: '.str_replace($_SERVER['QUERY_STRING'], '', 'https://'.$_SERVER['HTTP_HOST'].$_SERVER['REQUEST_URI']), true);

至于那个评论错误页,我想就很好解决了。

APlayer 重载

APlayer是一个非常美观的 HTML5 音频播放器,特别喜欢。pjax 下有一好处,就是音频在切换页面时不会切断播放,但这也带来一个问题:内容被刷走后怎么控制播放?

很明显这是 ajax 的共同问题,当然也可以放在无刷的 DOM 上如侧栏,不过我不太喜欢这么做。方法一样同上,放入初始函数。这里我使用了APlayer-Typecho的插件,但其Meting.min.js脚本的函数不容易初始化,故对其做了少许修改,具体内容就不在这里展开了。

然而 DOM 被替换,即使播放器解析出来了,也失去了原来正在播放音频的控制。针对这个问题,我可谓曲线救国了。利用 APlayer 和音频原生的 API,把原来的播放状态返回到新的播放器上。其中包括:播放曲目、最后播放时间、音量和播放状态。最终大概就是下面的方法:

function aplayerInit() {

    if(window.location.pathname == '/relax') {

        /* get former status */
        var init = typeof(aplayers[0]) == 'undefined' ? true : false;

        if(!init && !mobile) {
            var index = aplayers[0].playIndex,
                time = aplayers[0].audio.currentTime,
                paused = aplayers[0].audio.paused,
                volume = aplayers[0].audio.volume;

            try {aplayers[0].destroy();} catch(e){}
            initMeting(function(){
                aplayers[0].volume(0);
                aplayers[0].setMusic(index);
                setTimeout(function(){
                    aplayers[0].play(time+0.4);
                    if(paused) {
                        setTimeout(function(){
                            aplayers[0].pause();
                            aplayers[0].volume(volume);
                        }, 200);
                        return true;
                    }
                    aplayers[0].volume(volume);
                }, 500);
            });
        }
        else if(!init && mobile) {
            var index = aplayers[0].playIndex;

            try {aplayers[0].destroy();} catch(e){}
            initMeting(function(){
                aplayers[0].setMusic(index);
            });
        }
        else if(init) {
            initMeting();
        }

    }
}

以上的方法可能只适用于与我一样播放器只在一个页面的情况,如果是有很多文章都有播放器的情况,那么就要考虑如何判断什么时候初始化了。上面区分是否移动端,是因为移动端不能自播放,所以进入播放器的页面时只好把音频摧毁了。用到的mobile方法可以参考:

var mobile=false;
if((navigator.userAgent.match(/(phone|pad|pod|iPhone|iPod|ios|iPad|Android|Mobile|BlackBerry|IEMobile|MQQBrowser|JUC|Fennec|wOSBrowser|BrowserNG|WebOS|Symbian|Windows Phone)/i)))
    mobile=true;

返回播放状态的过程会稍许不顺畅,但至少,只想到这样的方法曲线救国了。


如有问题,欢迎留言或邮件咨询

  • « 上一篇:PHP 日记 - 源码修改