27 KiB
设计文档
概述
本设计文档针对 Argon 主题的 PJAX 和 LazyLoad 功能进行优化。通过深入分析 argontheme.js(3700+ 行)的实际实现,我们识别出了重复初始化、资源泄漏、性能问题等关键缺陷。
优化目标:
- 消除 PJAX 事件处理中的重复初始化调用
- 完善 LazyLoad Observer 的生命周期管理
- 优化图片加载效果,使用 requestAnimationFrame 替代 setTimeout
- 改进 GT4 验证码、Zoomify、Tippy 等第三方库的清理逻辑
- 优化滚动位置管理和移动端目录初始化
设计原则:
- 最小化修改:优先重构现有代码,避免大规模重写
- 向后兼容:保持现有 API 和配置选项不变
- 性能优先:减少不必要的 DOM 操作和重复计算
- 渐进增强:确保在旧浏览器上有合理的降级方案
架构
当前架构问题
PJAX 事件流(当前):
pjax:click
↓
pjax:send (NProgress.set(0.618))
↓
pjax:beforeReplace
├─ 清理 lazyloadObserver
├─ 清理 zoomifyInstances
└─ 清理 Tippy 实例
↓
pjax:complete
├─ waterflowInit() ← 重复调用
├─ lazyloadInit() ← 重复调用
├─ zoomifyInit()
├─ highlightJsRender()
├─ ... (10+ 个初始化函数)
└─ 恢复滚动位置
↓
pjax:end
├─ setTimeout 100ms
│ ├─ waterflowInit() ← 重复调用
│ └─ lazyloadInit() ← 重复调用
└─ GT4 验证码重置
问题分析:
waterflowInit()和lazyloadInit()在pjax:complete和pjax:end中都被调用pjax:end使用 100ms 延迟,可能导致竞态条件- 资源清理在
beforeReplace中进行,但某些资源可能在complete后才创建
优化后架构
PJAX 事件流(优化后):
pjax:click
↓
pjax:send (NProgress.set(0.618))
↓
pjax:beforeReplace
├─ cleanupResources()
│ ├─ 清理 lazyloadObserver
│ ├─ 清理 zoomifyInstances
│ ├─ 清理 Tippy 实例
│ └─ 清理事件监听器
└─ 更新 UI 状态
↓
pjax:complete
├─ initializePageResources()
│ ├─ waterflowInit()
│ ├─ lazyloadInit()
│ ├─ zoomifyInit()
│ ├─ highlightJsRender()
│ └─ ... (其他初始化)
└─ 恢复滚动位置
↓
pjax:end
└─ finalizePageLoad()
├─ resetMobileCatalog()
└─ resetGT4Captcha()
优化要点:
- 将资源清理逻辑封装到
cleanupResources()函数 - 将初始化逻辑封装到
initializePageResources()函数 - 移除
pjax:end中的重复初始化调用 - 使用函数封装提高代码可维护性
组件和接口
1. 资源清理模块
函数:cleanupPjaxResources()
/**
* 清理 PJAX 页面切换前的所有资源
* 在 pjax:beforeReplace 事件中调用
*/
function cleanupPjaxResources() {
// 清理 LazyLoad Observer
if (lazyloadObserver) {
lazyloadObserver.disconnect();
lazyloadObserver = null;
}
// 清理 Zoomify 实例
if (zoomifyInstances && zoomifyInstances.length > 0) {
zoomifyInstances.forEach(function(instance) {
try {
if (instance && typeof instance.destroy === 'function') {
instance.destroy();
}
} catch(e) {
console.warn('Failed to destroy Zoomify instance:', e);
}
});
zoomifyInstances = [];
}
$('img.zoomify-initialized').removeClass('zoomify-initialized');
// 清理 Tippy 实例
if (typeof tippy !== 'undefined') {
document.querySelectorAll('[data-tippy-root]').forEach(function(el) {
try {
if (el._tippy && typeof el._tippy.destroy === 'function') {
el._tippy.destroy();
}
} catch(e) {
console.warn('Failed to destroy Tippy instance:', e);
}
});
$('.tippy-initialized').removeClass('tippy-initialized');
}
}
2. LazyLoad 优化模块
函数:lazyloadInit() 优化版
/**
* 初始化图片懒加载
* 优化:检查现有 Observer,使用 requestAnimationFrame 优化加载效果
*/
function lazyloadInit() {
// 清理旧的 Observer(防御性编程)
if (lazyloadObserver) {
lazyloadObserver.disconnect();
lazyloadObserver = null;
}
// 检查是否启用懒加载
if (argonConfig.lazyload === false || argonConfig.lazyload === 'false') {
loadAllImagesImmediately();
return;
}
let images = document.querySelectorAll('img.lazyload[data-src]');
if (images.length === 0) {
return;
}
let effect = argonConfig.lazyload_effect || 'fadeIn';
let threshold = parseInt(argonConfig.lazyload_threshold) || 800;
// 使用 IntersectionObserver 实现懒加载
if ('IntersectionObserver' in window) {
lazyloadObserver = new IntersectionObserver(function(entries) {
entries.forEach(function(entry) {
if (entry.isIntersecting) {
let img = entry.target;
loadImageOptimized(img, effect);
lazyloadObserver.unobserve(img);
}
});
}, {
rootMargin: threshold + 'px 0px'
});
images.forEach(function(img) {
// 重置图片状态
img.style.opacity = '';
img.style.transform = '';
img.style.transition = '';
lazyloadObserver.observe(img);
});
} else {
// 降级方案:使用滚动监听
lazyloadFallback(images, effect, threshold);
}
}
/**
* 优化的图片加载函数
* 使用 requestAnimationFrame 替代 setTimeout
*/
function loadImageOptimized(img, effect) {
let src = img.getAttribute('data-src');
if (!src) return;
// 预加载图片
let tempImg = new Image();
tempImg.onload = function() {
img.src = src;
img.removeAttribute('data-src');
img.classList.remove('lazyload');
// 移除所有 lazyload-style-* 类
img.className = img.className.replace(/\blazyload-style-\d+\b/g, '').trim();
// 使用 requestAnimationFrame 应用加载效果
applyLoadEffectOptimized(img, effect);
};
tempImg.onerror = function() {
// 加载失败时使用原始 src
img.src = src;
img.removeAttribute('data-src');
img.classList.remove('lazyload');
};
tempImg.src = src;
}
/**
* 使用 requestAnimationFrame 应用加载效果
*/
function applyLoadEffectOptimized(img, effect) {
if (effect === 'fadeIn') {
img.style.opacity = '0';
img.style.transition = 'opacity 0.3s ease';
requestAnimationFrame(function() {
requestAnimationFrame(function() {
img.style.opacity = '1';
});
});
// 清理 transition 样式
setTimeout(function() {
img.style.transition = '';
}, 310);
} else if (effect === 'slideDown') {
img.style.opacity = '0';
img.style.transform = 'translateY(-20px)';
img.style.transition = 'opacity 0.3s ease, transform 0.3s ease';
requestAnimationFrame(function() {
requestAnimationFrame(function() {
img.style.opacity = '1';
img.style.transform = 'translateY(0)';
});
});
// 清理样式
setTimeout(function() {
img.style.transition = '';
img.style.transform = '';
}, 310);
}
}
/**
* 降级方案:使用滚动监听实现懒加载
*/
function lazyloadFallback(images, effect, threshold) {
let loadedImages = new Set();
function checkImagesInView() {
let viewportHeight = window.innerHeight;
let scrollTop = document.documentElement.scrollTop || document.body.scrollTop;
images.forEach(function(img) {
if (loadedImages.has(img)) return;
let rect = img.getBoundingClientRect();
if (rect.top < viewportHeight + threshold && rect.bottom > -threshold) {
loadImageOptimized(img, effect);
loadedImages.add(img);
}
});
}
// 使用节流函数优化性能
let throttleTimer = null;
function throttledCheck() {
if (throttleTimer) return;
throttleTimer = setTimeout(function() {
checkImagesInView();
throttleTimer = null;
}, 100);
}
// 绑定事件监听器
window.addEventListener('scroll', throttledCheck, {passive: true});
window.addEventListener('resize', throttledCheck, {passive: true});
// 立即检查一次
checkImagesInView();
}
/**
* 立即加载所有图片(懒加载禁用时)
*/
function loadAllImagesImmediately() {
let images = document.querySelectorAll('img.lazyload[data-src]');
images.forEach(function(img) {
let src = img.getAttribute('data-src');
if (src) {
img.src = src;
img.removeAttribute('data-src');
img.classList.remove('lazyload');
img.className = img.className.replace(/\blazyload-style-\d+\b/g, '').trim();
}
});
}
3. PJAX 事件处理优化
优化后的事件处理器:
$(document).on('pjax:beforeReplace', function(e, dom) {
// 清理旧页面的资源
cleanupPjaxResources();
// 更新 UI 状态
if ($('#post_comment', dom[0]).length > 0) {
$('#fabtn_go_to_comment').removeClass('d-none');
} else {
$('#fabtn_go_to_comment').addClass('d-none');
}
// 处理滚动位置
if ($('html').hasClass('banner-as-cover')) {
if (!$('#main').hasClass('article-list-home')) {
pjaxScrollTop = 0;
}
}
});
$(document).on('pjax:complete', function() {
pjaxLoading = false;
NProgress.inc();
// MathJax 渲染
try {
if (typeof MathJax !== 'undefined') {
if (MathJax.Hub !== undefined) {
MathJax.Hub.Typeset();
} else {
MathJax.typeset();
}
}
} catch (err) {
console.warn('MathJax rendering failed:', err);
}
// KaTeX 渲染
try {
if (typeof renderMathInElement !== 'undefined') {
renderMathInElement(document.body, {
delimiters: [
{left: '$', right: '$', display: true},
{left: '$', right: '$', display: false},
{left: '\\(', right: '\\)', display: false}
]
});
}
} catch (err) {
console.warn('KaTeX rendering failed:', err);
}
// 初始化页面资源(只调用一次)
waterflowInit();
lazyloadInit();
zoomifyInit();
highlightJsRender();
panguInit();
clampInit();
tippyInit();
getGithubInfoCardContent();
showPostOutdateToast();
calcHumanTimesOnPage();
foldLongComments();
foldLongShuoshuo();
$('html').trigger('resize');
// 恢复滚动位置
if (pjaxScrollTop > 0) {
$('body,html').scrollTop(pjaxScrollTop);
pjaxScrollTop = 0;
}
// 调用用户自定义回调
if (typeof window.pjaxLoaded === 'function') {
try {
window.pjaxLoaded();
} catch (err) {
console.error('pjaxLoaded callback failed:', err);
}
}
NProgress.done();
});
$(document).on('pjax:end', function() {
// 只处理特殊的后置任务,不重复初始化
// 重置移动端目录状态
if (typeof window.resetMobileCatalog === 'function') {
try {
window.resetMobileCatalog();
} catch (err) {
console.warn('resetMobileCatalog failed:', err);
}
}
// GT4: PJAX 后确保评论页验证码已初始化
resetGT4Captcha();
});
/**
* 重置 GT4 验证码
*/
function resetGT4Captcha() {
try {
if ($('#geetest-captcha').length > 0) {
// 重置前端状态,避免重复提交阻塞
window.geetestVerified = false;
window.geetestAutoSubmitting = false;
// 清空隐藏字段,防止残留导致 pass_token 复用
$('#geetest_lot_number').val('');
$('#geetest_captcha_output').val('');
$('#geetest_pass_token').val('');
$('#geetest_gen_time').val('');
// 清空容器,防止重复 appendTo 导致多个实例
$('#geetest-captcha').empty();
// 若页面脚本已提供初始化方法,则调用以加载验证码
if (typeof initGeetestCaptcha === 'function') {
initGeetestCaptcha();
} else if (typeof loadGeetestScript === 'function' && typeof initGeetestCaptchaCore === 'function') {
loadGeetestScript(function() {
initGeetestCaptchaCore();
});
}
}
} catch (e) {
console.warn('Geetest init on PJAX failed:', e);
}
}
4. 滚动位置管理优化
优化后的滚动位置处理:
// 在 pjax:click 事件中设置滚动位置
$(document).on('pjax:click', function(e, options, g) {
pjaxLoading = true;
pjaxScrollTop = 0;
// 根据链接类型决定滚动位置
if ($('html').hasClass('banner-as-cover')) {
if (g.is('.page-link')) {
// 分页链接:滚动到内容区域
pjaxScrollTop = $('#content').offset().top - 80;
}
// 其他链接:滚动到顶部(pjaxScrollTop = 0)
}
});
数据模型
全局状态变量
// LazyLoad 相关
var lazyloadObserver = null; // IntersectionObserver 实例
// Zoomify 相关
var zoomifyInstances = []; // Zoomify 实例数组
// PJAX 相关
var pjaxLoading = false; // PJAX 加载状态
var pjaxScrollTop = 0; // PJAX 后的滚动位置
// GT4 验证码相关
window.geetestVerified = false;
window.geetestAutoSubmitting = false;
配置对象
argonConfig = {
lazyload: true, // 是否启用懒加载
lazyload_effect: 'fadeIn', // 加载效果:fadeIn | slideDown
lazyload_threshold: 800, // 提前加载阈值(px)
headroom: 'true', // Headroom 模式
toolbar_blur: false, // 顶栏模糊效果
waterflow_columns: '2and3', // 瀑布流列数
// ... 其他配置
};
错误处理
错误处理策略
- 资源清理错误:使用 try-catch 包裹,记录警告但继续执行
- 初始化错误:捕获异常,输出错误信息,不阻塞其他功能
- 第三方库错误:检查库是否存在,使用可选链操作符
- 降级方案:不支持的功能提供降级实现
错误处理示例
// 资源清理错误处理
function cleanupPjaxResources() {
try {
if (lazyloadObserver) {
lazyloadObserver.disconnect();
lazyloadObserver = null;
}
} catch (e) {
console.warn('Failed to cleanup LazyLoad Observer:', e);
}
try {
// 清理 Zoomify 实例
if (zoomifyInstances && zoomifyInstances.length > 0) {
zoomifyInstances.forEach(function(instance) {
if (instance && typeof instance.destroy === 'function') {
instance.destroy();
}
});
zoomifyInstances = [];
}
} catch (e) {
console.warn('Failed to cleanup Zoomify instances:', e);
}
}
// 初始化错误处理
function initializePageResources() {
try {
waterflowInit();
} catch (e) {
console.error('waterflowInit failed:', e);
}
try {
lazyloadInit();
} catch (e) {
console.error('lazyloadInit failed:', e);
}
// ... 其他初始化
}
测试策略
单元测试
测试范围:
- 资源清理函数(
cleanupPjaxResources) - LazyLoad 初始化函数(
lazyloadInit) - 图片加载函数(
loadImageOptimized) - 滚动位置计算逻辑
- GT4 验证码重置逻辑
测试工具:
- Jest 或 Mocha(JavaScript 单元测试框架)
- jsdom(模拟 DOM 环境)
- Sinon.js(模拟和监视函数调用)
测试示例:
describe('cleanupPjaxResources', function() {
it('should disconnect lazyloadObserver if it exists', function() {
// 创建模拟 Observer
lazyloadObserver = {
disconnect: jest.fn()
};
cleanupPjaxResources();
expect(lazyloadObserver.disconnect).toHaveBeenCalled();
expect(lazyloadObserver).toBeNull();
});
it('should destroy all Zoomify instances', function() {
// 创建模拟实例
const mockInstance = {
destroy: jest.fn()
};
zoomifyInstances = [mockInstance];
cleanupPjaxResources();
expect(mockInstance.destroy).toHaveBeenCalled();
expect(zoomifyInstances).toHaveLength(0);
});
});
describe('lazyloadInit', function() {
it('should create new Observer if IntersectionObserver is supported', function() {
// 模拟 IntersectionObserver
global.IntersectionObserver = jest.fn(function(callback, options) {
this.observe = jest.fn();
this.disconnect = jest.fn();
});
document.body.innerHTML = '<img class="lazyload" data-src="test.jpg">';
lazyloadInit();
expect(lazyloadObserver).not.toBeNull();
expect(lazyloadObserver.observe).toHaveBeenCalled();
});
it('should use fallback if IntersectionObserver is not supported', function() {
global.IntersectionObserver = undefined;
document.body.innerHTML = '<img class="lazyload" data-src="test.jpg">';
lazyloadInit();
// 验证降级方案被调用
// ...
});
});
属性测试(Property-Based Testing)
属性测试用于验证通用的正确性属性,确保在各种输入下系统行为符合预期。
测试库:
- fast-check(JavaScript 属性测试库)
测试配置:
- 每个属性测试运行 100 次迭代
- 使用随机生成的输入数据
- 每个测试标注对应的设计属性编号
正确性属性
属性是一个特征或行为,应该在系统的所有有效执行中保持为真——本质上是关于系统应该做什么的形式化陈述。属性作为人类可读规范和机器可验证正确性保证之间的桥梁。
属性 1: PJAX 初始化函数单次调用
对于任何 PJAX 页面切换过程,关键初始化函数(waterflowInit、lazyloadInit、zoomifyInit)在整个生命周期中应该只被调用一次。
验证:需求 1.1, 1.2, 1.3
属性 2: LazyLoad Observer 清理完整性
对于任何 lazyloadInit() 调用,如果全局变量 lazyloadObserver 已存在,则必须先调用其 disconnect() 方法并清空引用,然后再创建新实例。
验证:需求 2.1, 2.2
属性 3: PJAX beforeReplace 资源清理
对于任何 PJAX beforeReplace 事件触发,系统应该清理 lazyloadObserver、zoomifyInstances 和 Tippy 实例,并将相关全局变量重置为初始状态。
验证:需求 2.3, 6.1-6.5, 7.1-7.5
属性 4: IntersectionObserver 降级方案
对于任何 不支持 IntersectionObserver 的浏览器环境,lazyloadInit() 应该使用滚动监听降级方案,而不是抛出错误或不加载图片。
验证:需求 2.4
属性 5: 图片加载效果使用 requestAnimationFrame
对于任何 图片加载完成事件,应用加载效果(fadeIn 或 slideDown)时应该使用 requestAnimationFrame 而非 setTimeout 来同步浏览器渲染。
验证:需求 3.1
属性 6: 图片加载效果 CSS 属性正确性
对于任何 图片加载效果,应该正确设置 CSS 属性:fadeIn 效果设置 opacity 和 transition,slideDown 效果设置 opacity、transform 和 transition。
验证:需求 3.2, 3.3
属性 7: 图片加载失败处理
对于任何 图片加载失败事件,系统应该设置原始 src 并停止重试,不应该进入无限重试循环。
验证:需求 3.4, 10.4
属性 8: LazyLoad 类名清理
对于任何 图片加载完成或失败,系统应该移除 lazyload 类和所有 lazyload-style-* 类名。
验证:需求 3.5
属性 9: GT4 验证码状态重置完整性
对于任何 PJAX end 事件且页面包含 #geetest-captcha 元素,系统应该重置所有验证码状态变量、清空隐藏字段、清空容器 DOM,并调用初始化函数。
验证:需求 4.1, 4.2, 4.3, 4.4
属性 10: GT4 验证码初始化错误处理
对于任何 GT4 验证码初始化失败,系统应该记录警告但不抛出异常,不影响页面其他功能的正常运行。
验证:需求 4.5
属性 11: 滚动位置恢复正确性
对于任何 PJAX 页面切换,如果 pjaxScrollTop 大于 0,则在 pjax:complete 事件中应该恢复到该滚动位置,并在恢复后将 pjaxScrollTop 重置为 0。
验证:需求 5.3, 5.4
属性 12: 分页链接滚动位置计算
对于任何 分页链接点击且 Banner 设置为封面模式,系统应该将 pjaxScrollTop 设置为内容区域顶部位置减去 80px。
验证:需求 5.2, 5.5
属性 13: 移动端目录重置调用
对于任何 PJAX end 事件,如果 window.resetMobileCatalog 函数存在,系统应该调用该函数(不验证函数内部逻辑)。
验证:需求 8.1, 8.2
属性 14: 事件监听器清理
对于任何 PJAX 资源清理过程,系统应该移除所有动态绑定的事件监听器,防止内存泄漏和重复绑定。
验证:需求 9.2, 9.5
属性 15: 事件监听器防重复绑定
对于任何 功能重新初始化,系统应该检查事件监听器是否已存在,避免重复绑定相同的监听器。
验证:需求 9.3
测试策略(续)
属性测试实现
测试框架: Jest + fast-check
测试配置:
// jest.config.js
module.exports = {
testEnvironment: 'jsdom',
setupFilesAfterEnv: ['<rootDir>/test/setup.js'],
collectCoverageFrom: [
'argontheme.js',
'!**/node_modules/**',
'!**/vendor/**'
],
coverageThreshold: {
global: {
branches: 70,
functions: 80,
lines: 80,
statements: 80
}
}
};
属性测试示例:
const fc = require('fast-check');
describe('Property Tests - PJAX LazyLoad Optimization', function() {
/**
* Feature: pjax-lazyload-optimization, Property 1: PJAX 初始化函数单次调用
*/
it('should call initialization functions only once during PJAX lifecycle', function() {
// 创建 spy
const waterflowSpy = jest.spyOn(window, 'waterflowInit');
const lazyloadSpy = jest.spyOn(window, 'lazyloadInit');
const zoomifySpy = jest.spyOn(window, 'zoomifyInit');
// 模拟 PJAX 完整生命周期
$(document).trigger('pjax:beforeReplace', [[]]);
$(document).trigger('pjax:complete');
$(document).trigger('pjax:end');
// 验证每个函数只被调用一次
expect(waterflowSpy).toHaveBeenCalledTimes(1);
expect(lazyloadSpy).toHaveBeenCalledTimes(1);
expect(zoomifySpy).toHaveBeenCalledTimes(1);
// 清理
waterflowSpy.mockRestore();
lazyloadSpy.mockRestore();
zoomifySpy.mockRestore();
});
/**
* Feature: pjax-lazyload-optimization, Property 2: LazyLoad Observer 清理完整性
*/
it('should cleanup existing Observer before creating new one', function() {
// 创建模拟 Observer
const mockObserver = {
disconnect: jest.fn(),
observe: jest.fn()
};
window.lazyloadObserver = mockObserver;
// 调用初始化
lazyloadInit();
// 验证旧 Observer 被清理
expect(mockObserver.disconnect).toHaveBeenCalled();
expect(window.lazyloadObserver).not.toBe(mockObserver);
});
/**
* Feature: pjax-lazyload-optimization, Property 3: PJAX beforeReplace 资源清理
*/
it('should cleanup all resources on pjax:beforeReplace', function() {
// 设置资源
window.lazyloadObserver = {
disconnect: jest.fn()
};
window.zoomifyInstances = [{
destroy: jest.fn()
}];
// 触发事件
$(document).trigger('pjax:beforeReplace', [[]]);
// 验证清理
expect(window.lazyloadObserver).toBeNull();
expect(window.zoomifyInstances).toHaveLength(0);
});
/**
* Feature: pjax-lazyload-optimization, Property 5: 图片加载效果使用 requestAnimationFrame
*/
it('should use requestAnimationFrame for load effects', function() {
const rafSpy = jest.spyOn(window, 'requestAnimationFrame');
// 创建测试图片
document.body.innerHTML = '<img class="lazyload" data-src="test.jpg">';
const img = document.querySelector('img');
// 调用加载函数
loadImageOptimized(img, 'fadeIn');
// 模拟图片加载完成
const tempImg = new Image();
tempImg.onload();
// 验证 requestAnimationFrame 被调用
expect(rafSpy).toHaveBeenCalled();
rafSpy.mockRestore();
});
/**
* Feature: pjax-lazyload-optimization, Property 7: 图片加载失败处理
*/
it('should handle image load failure without retry', function() {
document.body.innerHTML = '<img class="lazyload" data-src="invalid.jpg">';
const img = document.querySelector('img');
// 调用加载函数
loadImageOptimized(img, 'fadeIn');
// 模拟加载失败
const tempImg = new Image();
tempImg.onerror();
// 验证图片 src 被设置且不重试
expect(img.src).toContain('invalid.jpg');
expect(img.hasAttribute('data-src')).toBe(false);
expect(img.classList.contains('lazyload')).toBe(false);
});
/**
* Feature: pjax-lazyload-optimization, Property 11: 滚动位置恢复正确性
*/
it('should restore scroll position and reset pjaxScrollTop', function() {
// 设置滚动位置
window.pjaxScrollTop = 500;
// 模拟 jQuery scrollTop 方法
const scrollTopSpy = jest.spyOn($.fn, 'scrollTop');
// 触发 pjax:complete
$(document).trigger('pjax:complete');
// 验证滚动位置恢复
expect(scrollTopSpy).toHaveBeenCalledWith(500);
expect(window.pjaxScrollTop).toBe(0);
scrollTopSpy.mockRestore();
});
});
集成测试
测试场景:
- 完整的 PJAX 页面切换流程
- 多次连续 PJAX 切换
- 快速点击多个链接
- 浏览器前进/后退按钮
- 移动端和桌面端切换
测试工具:
- Puppeteer 或 Playwright(浏览器自动化)
- 真实浏览器环境测试
集成测试示例:
describe('Integration Tests - PJAX LazyLoad', function() {
let page;
beforeAll(async function() {
page = await browser.newPage();
await page.goto('http://localhost:8080');
});
it('should handle multiple PJAX navigations without memory leak', async function() {
// 记录初始内存
const initialMemory = await page.metrics();
// 执行 10 次 PJAX 导航
for (let i = 0; i < 10; i++) {
await page.click('.pjax-link');
await page.waitForSelector('#content');
await page.goBack();
await page.waitForSelector('#content');
}
// 检查内存增长
const finalMemory = await page.metrics();
const memoryGrowth = finalMemory.JSHeapUsedSize - initialMemory.JSHeapUsedSize;
// 内存增长应该在合理范围内(< 10MB)
expect(memoryGrowth).toBeLessThan(10 * 1024 * 1024);
});
it('should load images lazily after PJAX navigation', async function() {
await page.click('.pjax-link');
await page.waitForSelector('#content');
// 检查懒加载图片
const lazyImages = await page.$$('img.lazyload[data-src]');
expect(lazyImages.length).toBeGreaterThan(0);
// 滚动到图片位置
await page.evaluate(function() {
window.scrollTo(0, document.body.scrollHeight);
});
// 等待图片加载
await page.waitForTimeout(1000);
// 验证图片已加载
const loadedImages = await page.$$('img:not(.lazyload)');
expect(loadedImages.length).toBeGreaterThan(0);
});
});
性能测试
测试指标:
- PJAX 页面切换时间
- LazyLoad 初始化时间
- 内存使用情况
- 事件监听器数量
性能测试示例:
describe('Performance Tests', function() {
it('should complete PJAX navigation within 500ms', async function() {
const startTime = performance.now();
await page.click('.pjax-link');
await page.waitForSelector('#content');
const endTime = performance.now();
const duration = endTime - startTime;
expect(duration).toBeLessThan(500);
});
it('should initialize LazyLoad within 100ms', function() {
const startTime = performance.now();
lazyloadInit();
const endTime = performance.now();
const duration = endTime - startTime;
expect(duration).toBeLessThan(100);
});
});
测试覆盖率目标
- 行覆盖率:≥ 80%
- 分支覆盖率:≥ 70%
- 函数覆盖率:≥ 80%
- 语句覆盖率:≥ 80%
持续集成
CI 配置(GitHub Actions):
name: Test PJAX LazyLoad Optimization
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Setup Node.js
uses: actions/setup-node@v2
with:
node-version: '16'
- name: Install dependencies
run: npm install
- name: Run unit tests
run: npm test
- name: Run property tests
run: npm run test:property
- name: Run integration tests
run: npm run test:integration
- name: Upload coverage
uses: codecov/codecov-action@v2
with:
files: ./coverage/lcov.info