PHP 日记 - pjax
顺便也把 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:complete
和pjax: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 . ' » RSS 2.0" href="' . $allows['rss2'] . '" />' . "\n";
}
if (!empty($allows['rss1']) && $allowFeed) {
$header .= '<link rel="alternate" type="application/rdf+xml" title="' . $title . ' » RSS 1.0" href="' . $allows['rss1'] . '" />' . "\n";
}
if (!empty($allows['atom']) && $allowFeed) {
$header .= '<link rel="alternate" type="application/atom+xml" title="' . $title . ' » 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 实际上经历了两个回路:
- POST 表单到路径
./comment
; - 收到重定向后请求定向资源。
所以真正的问题就在第一点上,按照 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;
返回播放状态的过程会稍许不顺畅,但至少,只想到这样的方法曲线救国了。
如有问题,欢迎留言或邮件咨询