​pjax​​​ 是 ​​ajax​​​ 和 ​​pushState​​​ 的结合,它是一个 ​​jQuery​​​ 插件。它通过 ​​ajax​​​ 从服务器端获取 HTML 文件,在页面中用获取到的HTML替换指定容器元素中的内容。然后使用 ​​pushState​​ 技术更新浏览器地址栏中的当前地址,并且保持了真实的地址、网页标题,浏览器的后退(前进)按钮也可以正常使用。

由于 ​​pjax​​ 是局部刷新,引用的外部资源(js/css/font等)也不需要重复的加载,能够提供了极速的浏览体验。

服务器端也能够进行 ​​pjax​​ 支持,只提供指定需要的部分内容,从而提高响应速度。

一、发起 pjax请求

点击链接发起pjax请求:

$(document).on('click', 'a[target!=_blank]', (event) => {
$.pjax.click(event, ".column-main", {
scrollTo: 0,
fragment: ".column-main",
timeout: 8000,
})
})

其中第一个参数 ​​event​​​ 是触发 ​​pjax​​ 的元素;

第二个参数是要被替换内容的元素选择器;

第三个参数是 ​​pjax​​ 的参数列表,参数说明。

参数

默认值

说明

timeout

650

ajax请求如果超时将触发强制刷新

push

TRUE

使用 [pushState][] 在浏览器中添加导航记录

replace

FALSE

是否使用replace方式改变URL

maxCacheLength

20

返回的HTML片段字符串最大缓存数

version

当前pjax版本

scrollTo

0

当页面导航切换时滚动到的位置. 如果想页面切换不做滚动重置处理,请传入false.

type

GET

使用ajax的模板请求方法,参考 $.ajax

dataType

html

模板请求时的type,参考 $.ajax

container

内容替换的CSS选择器

url

link.href

用于ajax请求的url,可以是字符串或者返回字符串的函数

target

link

eventually the relatedTarget value for pjax events

fragment

从服务端返回的HTML字符串中子内容所在的CSS选择器,用于当服务端返回了整个HTML文档,但要求pjax局部刷新时使用。

除了上面那种方式外,还可以通过一下这种方式通过点击发起 ​​pjax​​ 请求,效果和上面的相同。

$(document).pjax("a[target!=_blank]", ".column-main", {
scrollTo: 0,
fragment: ".column-main",
timeout: 8000,
});

除了监听点击事件外,也可以对表单进行 ​​pjax​​ 请求:

$(document).on('submit', 'form[data-pjax]', function (event) {
$.pjax.submit(event, ".column-main", {
scrollTo: 0,
fragment: ".column-main",
timeout: 8000,
})
})

还可以使用 ​​$.pjax.reload​​ 重新加载当前界面。

$.pjax.reload('.column-main', options)

还可以通过 ​​$.pjax​​​ 直接发起 ​​pjax​​ 请求。

$.pjax({url: url, container: '.column-main'})

二、pjax 生命周期

​pjax​​ 的生命周期方法采用事件的形式,每个事件都是异步的,即上一个事件还没有执行完,下一个事件就可以开始。

2.1 pjax请求的生命周期

一个正常的 ​​pjax​​​ 请求将按顺序执行如下事件:​​pjax:click​​​、​​pjax:beforeSend​​​、​​pjax:start​​​、​​pjax:send​​​、​​pjax:clicked​​​、​​pjax:beforeReplace​​​、​​pjax:success​​​、​​pjax:complete​​​、​​pjax:end​​。

如果一个 ​​pjax​​​ 请求生命周期还未执行完,又发起一个新的 ​​pjax​​​ 请求,原先的 ​​pjax​​​ 请求将被取消,并执行 ​​pjax:error​​。

下图是发起 ​​pjax​​ 请求时的生命周期:

网站访问速度优化之pjax_前端

生命周期事件及其参数如下表:

事件

参数

说明

pjax:click

options

链接被激活的时候触发;取消的时候阻止pjax

pjax:beforeSend

xhr, options

可以设置XHR头

pjax:start

xhr, options

pjax:send

xhr, options

pjax:clicked

options

pjax通过链接点击已经开始之后触发

pjax:beforeReplace

contents, options

从服务器端加载的HTML内容完成之后,替换当前内容之前

pjax:success

data, status, xhr, options

从服务器端加载的HTML内容替换当前内容之后

pjax:timeout

xhr, options

在 options.timeout 之后触发;除非被取消,否则会强制刷新页面

pjax:error

xhr, textStatus, error, options

ajax请求出错;除非被取消,否则会强制刷新页面

pjax:complete

xhr, options

2.2 浏览器前进后退生命周期

浏览器前进后退时不会进行 ​​pjax​​​ 请求,一个成功的 前进后退操作会按顺序执行如下事件:​​pjax:popstate​​​、​​pjax:start​​​、​​pjax:beforeReplace​​​、​​pjax:end​​。

网站访问速度优化之pjax_加载_02

生命周期事件及其参数如下表:

事件

参数

说明

pjax:popstate

direction 事件的属性: “back”/“forward”

pjax:start

xhr(空),options

内容替换之前

pjax:beforeReplace

contents, options

在用缓存中的内容替换HTML之前

pjax:end

xhr(空),options

替换内容之后

pjax:callback

xhr(空),options

页面脚本加载完成后

2.3 监听生命周期事件

监听 ​​pjax:start​​ 事件示例,其他的事件监听方法都是一样的

$(document).on("pjax:start", function (event, xhr, options) {
console.log(`pjax:start`)
});

三、pjax适配方法

3.1 服务端适配

pjax 请求时将会添加请求头和请求参数信息:

  • ​x-pjax​​​ 请求头,值为​​true​​​ 表示当前是​​pjax​​ 请求
  • ​x-pjax-container​​​ 请求头,指定了当前​​pjax​​ 请求的容器
  • ​_pjax​​​ url参数,指定了当前​​pjax​​ 请求的目标内容

服务端可根据这些参数判断是否是 ​​pjax​​ 请求,以做相应的内容裁剪。

3.2 避免资源重复加载

如果将资源的引用放在 ​​pjax​​ 目标元素内部,那么重新加载界面时必定将导致资源文件的重新加载。

所以应该将文件放在目标元素的外部,然后在生命周期方法获取这些资源文件的引用,通过 ​​JavaScript​​ 判断文件是否加载,没加载的文件手动进行这些文件加载。

具体实现方法:

首先在页面加载时初始化当前已经加载到的资源文件路径信息:

const cssLoadCompletes = new Set($('link[href*=".css"]').map((i, item) => $(item).attr('href')).get())
const jsLoadCompletes = new Set($('script[src*=".js"]').map((i, item) => $(item).attr('src')).get())

经过尝试,只有在 ​​pjax:success​​​ 事件中可以通过 ​​data​​​ 参数拿到 ​​pjax​​​ 请求获取到的全部的 ​​html​​ 信息,所以资源文件的加载只能在这个事件里进行。

以下是加载 ​​css​​​ 文件的方法,通过 ​​cssLoadCompletes.has(href)​​ 判断资源文件是否已经加载过了。


​data-pjax​​​ 参数是服务端的实现,服务端给特定界面才有的资源文件添加了 ​​pjax​​​ 参数,用于帮助使用 ​​link[data-pjax]​​ 过滤。

一些无论哪个界面都会加载的资源文件,如 ​​jquery.js​​​ 他们就不可能会需要在 ​​pjax​​​ 时被加载,也就没有加 ​​data-pjax​​ 标识。


// 将string格式的html文本初始化为元素
const $currentTarget = $($.parseHTML(data, document, true));
const $head = $("head");
$currentTarget.filter('link[data-pjax]').each(function () {
let href = $(this).attr('href')
if (!cssLoadCompletes.has(href)) {
$head.append($(this))
console.log('加载css ' + $(this).attr('href'))
this.onload = function () {
cssLoadCompletes.add(href)
console.log('加载css完成 ' + $(this).attr('href'))
}
}
})

​js​​​ 的加载相比较于 ​​css​​​ 就更加的麻烦,因为我们 ​​pjax​​ 可能有一些初始化脚本的逻辑,必须等待脚本加载完成之后才可以执行初始化(我把这种加载方式称为同步加载)。但是有一些脚本又不需要初始化,可以不用等待它加载完成就执行初始化逻辑(我把这种加载方式称为异步加载)。

这里我借用了 ​​defer​​​ 和 ​​async​​​ 两个参数,如果 ​​script​​ 添加了这两个参数中的任意一个则将异步加载这个脚本文件,如果未添加则进行同步加载。

同样,服务端也给特定界面才有的脚本文件添加了 ​​data-pjax​​ 参数用于辅助过滤。


如果加载失败则不会被加入到 ​​jsLoadCompletes​​​ 集合,下次 ​​pjax​​ 请求时还可以重新加载。

​Utils.cachedScript​​​ 是自己写个,支持读取缓存的加载方法,因为 ​​$.getScript​​ 默认会自动添加一个时间戳参数,不能读取缓存。


let $scripts = $currentTarget.filter('script[data-pjax]');
let scriptSize = $scripts.length;
if (scriptSize > 0) {
await new Promise((resolve) => {
$scripts.each(function () {
let src = $(this).attr('src');
if (jsLoadCompletes.has(src)) {
if (--scriptSize === 0) resolve()
return;
}
if (this.defer || this.async) {
console.log('异步加载js ' + src)
Utils.cachedScript(src)
.done(function () {
console.log('异步加载js完成 ' + src)
jsLoadCompletes.add(src);
})
.fail(function () {
console.log('异步加载js失败 ' + src)
})
if (--scriptSize === 0) resolve()
} else {
console.log('同步加载js ' + src)
Utils.cachedScript(src)
.done(function () {
console.log('同步加载js完成 ' + src)
jsLoadCompletes.add(src);
if (--scriptSize === 0) resolve()
})
.fail(function () {
console.log('同步加载js失败 ' + src)
if (--scriptSize === 0) resolve()
})
}
})
})
}
console.log('全部处理完成')
// 执行初始化逻辑

3.3 避免初始化逻辑重复执行

假如,一个 ​​pjax​​​ 请求执行到了 ​​pjax:success​​​ 事件,但是因为同步加载脚本速度太慢而堵塞了初始化逻辑。这时候你重新发起了一个新的 ​​pjax​​​ 请求,然后过了一会旧请求执行了初始化逻辑,然后再过了一会,新的 ​​pjax​​ 也执行了初始化逻辑,界面就被初始化了两次。

这个问题可以通过给 ​​pjax​​​ 请求添加序列号的方式进行判断,过滤旧 ​​pjax​​ 请求的执行。

pjax 请求添加序列号:

下面的代码给 ​​pjax​​​ 请求添加了序列号,​​window.pjaxSerialNumber​​​ 表示全局的序列号,永远是最新一次 ​​pjax​​ 请求的序列号,可以全局获取。

然后本次请求将序列号加入到了请求的 ​​options​​​ 参数中,作为 ​​serialNumber​​​ 字段,在事件中可通过 ​​options.serialNumber​​ 方式获取。

const createSerialNumber = () => {
const serialNumber = new Date().getTime();
window.pjaxSerialNumber = serialNumber;
console.log(`sn = ${serialNumber}`)
return serialNumber;
}
$(document).on('click', 'a[target!=_blank]', (event) => {
$.pjax.click(event, ".column-main", {
scrollTo: 0,
fragment: ".column-main",
serialNumber: createSerialNumber(),
timeout: 8000,
})
})

判断当前请求是否是最新的一次请求

$(document).on("pjax:success", async function (event, data, status, xhr, options) {
const serialNumber = options.serialNumber;
console.log(`pjax:success sn = ${serialNumber}`)
// 与window.pjaxSerialNumber比较,判断是否是最新的序列号,如果不是则直接退出
if (pjaxSerialNumber !== serialNumber) return;

// 这里是一堆耗时的操作

console.log('全部处理完成')
// 再次判断当前是不是最新的序列号
if (pjaxSerialNumber !== serialNumber) return;
// 进行初始化操作
});


除了在事件中判断,还应该在脚本中也进行判断,这样才能达到更细的判断粒度。


在第一次初始化界面时也应该判断 ​​window.pjaxSerialNumber​​​ 是否为空,如果不为空则表示已经发起了 ​​pjax​​​ 请求,一些 ​​pjax​​ 会重新进行的初始化操作,也不再需要在初始化时执行了,也应该停止了。

3.4 浏览器前进后退适配

以上的适配都是基于脚本加载的适配,当浏览器前进和后退时,以上的脚本必定不会被执行到,这也就表示前进和后退需要执行的那部分初始化逻辑不能放在 ​​pjax:success​​ 事件中。

还有,要注意的就是,浏览器前进和后退是对缓存的内容的恢复,并不是对上次 ​​pjax​​​ 请求得到的内容的恢复。举个例子,上次 ​​pjax​​ 请求之后,你对部分元素进行的增删,然后进行了界面跳转。

这时,进行界面后退时就不需要再进行这些增删的操作,因为缓存的是最终的界面内容,这些修改还都是在的,但是容器外部的修改就需要重新初始化了(如导航栏页签的选中效果),因为只有容器内部的那部分内容会被恢复。

前进和后退时主要操作 ​​pjax:beforeReplace​​​、​​pjax:end​​​ 两个事件,因为 ​​pjax:beforeReplace​​​ 是在页面内容替换前触发的时间,​​pjax:end​​ 是在页面内容替换后触发的。

​pjax:beforeReplace​​ 用于进行一些容器内容无关的操作,例如导航栏页签的选中效果。

​pjax:end​​ 用于进行容器内容相关的初始化操作,例如根据容器内容初始化文章目录。


需要注意的是,浏览器前进和后退和 ​​pjax​​​ 请求的兼容性,因为浏览器前进后退时资源文件都是初始化好的,而 ​​pjax​​ 请求时需要等待资源文件加载完成才能进行初始化。

所以,有些初始化操作应该在 ​​pjax​​​ 请求时在 ​​pjax:success​​​ 事件里执行,前进和后退时在 ​​pjax:end​​ 里执行。

浏览器前进后退时 ​​xhr​​​ 参数为空,可通过判断 ​​xhr​​ 是否为空来判断是否是浏览器前进后退。


四、示例代码

以上的适配方法都是基于开发中遇到的问题进行适配的,本文最后附上最终写好的 ​​pjax​​ 实现代码。

const cssLoadCompletes = new Set($('link[href*=".css"]').map((i, item) => $(item).attr('href')).get())
const jsLoadCompletes = new Set($('script[src*=".js"]').map((i, item) => $(item).attr('src')).get())

// 为pjax请求创建一个序列号
const createSerialNumber = () => {
const serialNumber = new Date().getTime();
window.pjaxSerialNumber = serialNumber;
console.log(`sn = ${serialNumber}`)
return serialNumber;
}

/**
* 第二个参数是容器,即将被替换的内容
* fragment:是加载的文本中被选中的目标内容
*/
$(document).on('click', 'a[target!=_blank][href]:not(data-not-pjax)', (event) => {
$.pjax.click(event, ".column-main", {
scrollTo: 0,
fragment: ".column-main",
serialNumber: createSerialNumber(),
timeout: 8000,
})
})


$(document).on('submit', 'form[data-pjax]', function (event) {
$.pjax.submit(event, ".column-main", {
scrollTo: 0,
fragment: ".column-main",
serialNumber: createSerialNumber(),
timeout: 8000,
})
})

$(document).on("pjax:click", function (event, options) {
console.log("------------------------")
console.log(`pjax:click sn = ${options.serialNumber}`)
});

$(document).on("pjax:beforeSend", function (event, xhr, options) {
console.log(`pjax:beforeSend sn = ${options.serialNumber}`)
});

$(document).on("pjax:start", function (event, xhr, options) {
console.log(`pjax:start sn = ${options.serialNumber}`)
});

$(document).on("pjax:send", function (event, xhr, options) {
console.log(`pjax:send sn = ${options.serialNumber}`)
// $("html, body").animate(
// {
// scrollTop: $("body").position().top - 60,
// },
// 500
// );
});

$(document).on("pjax:clicked", function (event, options) {
console.log(`pjax:clicked sn = ${options.serialNumber}`)
});

/**
* pjax加载和浏览器前进后退都会触发的事件
* 在此处需要进行一些未进行pjax也需要执行的程序
*/
$(document).on("pjax:beforeReplace", function (event, contents, options) {
console.log(`pjax:beforeReplace sn = ${options.serialNumber}`)
/* 重新初始化导航条高亮 */
$(".navbar-nav .current,.panel-side-menu .current").removeClass("current");
commonContext.initNavbar();
/* 移动端关闭抽屉弹窗 */
$('html.disable-scroll').length > 0 && $('.navbar-mask').trigger("click");
});

/**
* pjax 替换内容成功之后
* 浏览器前进后退时不会执行
*/
$(document).on("pjax:success", async function (event, data, status, xhr, options) {
const serialNumber = options.serialNumber;
console.log(`pjax:success sn = ${serialNumber}`)
if (pjaxSerialNumber !== serialNumber) return;
/* 重新激活图片预览功能 */
commonContext.initGallery()
/* 重新加载目录和公告 */
commonContext.initTocAndNotice()

const $currentTarget = $($.parseHTML(data, document, true));
const $head = $("head");
$currentTarget.filter('link[data-pjax]').each(function () {
let href = $(this).attr('href')
if (!cssLoadCompletes.has(href)) {
$head.append($(this))
console.log('加载css ' + $(this).attr('href'))
this.onload = function () {
cssLoadCompletes.add(href)
console.log('加载css完成 ' + $(this).attr('href'))
}
}
})
let $scripts = $currentTarget.filter('script[data-pjax]');
let scriptSize = $scripts.length;
if (scriptSize > 0) {
await new Promise((resolve) => {
$scripts.each(function () {
let src = $(this).attr('src');
if (jsLoadCompletes.has(src)) {
if (--scriptSize === 0) resolve()
return;
}
if (this.defer || this.async) {
console.log('异步加载js ' + src)
Utils.cachedScript(src)
.done(function () {
console.log('异步加载js完成 ' + src)
jsLoadCompletes.add(src);
})
.fail(function () {
console.log('异步加载js失败 ' + src)
})
if (--scriptSize === 0) resolve()
} else {
console.log('同步加载js ' + src)
Utils.cachedScript(src)
.done(function () {
console.log('同步加载js完成 ' + src)
jsLoadCompletes.add(src);
if (--scriptSize === 0) resolve()
})
.fail(function () {
console.log('同步加载js失败 ' + src)
if (--scriptSize === 0) resolve()
})
}
})
})
}
console.log('全部处理完成')
if (pjaxSerialNumber !== serialNumber) return;
/* 初始化日志界面 */
window.journalPjax && window.journalPjax(serialNumber);
/* 初始化文章界面 */
window.postPjax && window.postPjax(serialNumber);
/* 加载主动推送或统计脚本 */
commonContext.loadMaintain();
});

$(document).on("pjax:timeout", function (event, xhr, options) {
console.log(`pjax:timeout sn = ${options.serialNumber}`)
});

$(document).on("pjax:error", function (event, xhr, textStatus, error, options) {
console.log(`pjax:error sn = ${options.serialNumber} error ${error}`)
});

// pjax结束
$(document).on("pjax:complete", function (event, xhr, textStatus, options) {
console.log(`pjax:complete sn = ${options.serialNumber}`)
});

/**
* pjax结束,无论是pjax加载还是浏览器前进后退都会被调用
* 浏览器前进后退时,唯一一个在渲染后被调用的方法
*/
$(document).on("pjax:end", function (event, xhr, options) {
console.log(`pjax:end sn = ${options.serialNumber}`)
// 浏览器前进后退
if (xhr == null) {
/* 重新加载目录和公告 */
commonContext.initTocAndNotice()
} else if (pjaxSerialNumber !== options.serialNumber) {
return;
}
});

$(document).on("pjax:popstate", function () {
console.log("pjax:popstate")
});