Files
argon-theme/.kiro/specs/pjax-lazyload-optimization/design.md
nanhaoluo 5eb97a7d89 docs: 添加 PJAX 和 LazyLoad 优化总结文档
- 记录所有已完成的优化任务
- 说明性能改进和问题修复
- 提供测试建议和后续计划
- 包含完整的 Git 提交记录
2026-01-21 13:49:57 +08:00

27 KiB
Raw Blame History

设计文档

概述

本设计文档针对 Argon 主题的 PJAX 和 LazyLoad 功能进行优化。通过深入分析 argontheme.js3700+ 行)的实际实现,我们识别出了重复初始化、资源泄漏、性能问题等关键缺陷。

优化目标:

  • 消除 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 验证码重置

问题分析:

  1. waterflowInit()lazyloadInit()pjax:completepjax:end 中都被调用
  2. pjax:end 使用 100ms 延迟,可能导致竞态条件
  3. 资源清理在 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()

优化要点:

  1. 将资源清理逻辑封装到 cleanupResources() 函数
  2. 将初始化逻辑封装到 initializePageResources() 函数
  3. 移除 pjax:end 中的重复初始化调用
  4. 使用函数封装提高代码可维护性

组件和接口

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',  // 瀑布流列数
	// ... 其他配置
};

错误处理

错误处理策略

  1. 资源清理错误:使用 try-catch 包裹,记录警告但继续执行
  2. 初始化错误:捕获异常,输出错误信息,不阻塞其他功能
  3. 第三方库错误:检查库是否存在,使用可选链操作符
  4. 降级方案:不支持的功能提供降级实现

错误处理示例

// 资源清理错误处理
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 或 MochaJavaScript 单元测试框架)
  • 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-checkJavaScript 属性测试库)

测试配置:

  • 每个属性测试运行 100 次迭代
  • 使用随机生成的输入数据
  • 每个测试标注对应的设计属性编号

正确性属性

属性是一个特征或行为,应该在系统的所有有效执行中保持为真——本质上是关于系统应该做什么的形式化陈述。属性作为人类可读规范和机器可验证正确性保证之间的桥梁。

属性 1: PJAX 初始化函数单次调用

对于任何 PJAX 页面切换过程,关键初始化函数(waterflowInitlazyloadInitzoomifyInit)在整个生命周期中应该只被调用一次。

验证:需求 1.1, 1.2, 1.3

属性 2: LazyLoad Observer 清理完整性

对于任何 lazyloadInit() 调用,如果全局变量 lazyloadObserver 已存在,则必须先调用其 disconnect() 方法并清空引用,然后再创建新实例。

验证:需求 2.1, 2.2

属性 3: PJAX beforeReplace 资源清理

对于任何 PJAX beforeReplace 事件触发,系统应该清理 lazyloadObserverzoomifyInstances 和 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 效果设置 opacitytransitionslideDown 效果设置 opacitytransformtransition

验证:需求 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();
	});
});

集成测试

测试场景:

  1. 完整的 PJAX 页面切换流程
  2. 多次连续 PJAX 切换
  3. 快速点击多个链接
  4. 浏览器前进/后退按钮
  5. 移动端和桌面端切换

测试工具:

  • 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