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

1062 lines
27 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 设计文档
## 概述
本设计文档针对 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:complete``pjax: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()`**
```javascript
/**
* 清理 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()` 优化版**
```javascript
/**
* 初始化图片懒加载
* 优化:检查现有 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 事件处理优化
**优化后的事件处理器:**
```javascript
$(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. 滚动位置管理优化
**优化后的滚动位置处理:**
```javascript
// 在 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
}
});
```
## 数据模型
### 全局状态变量
```javascript
// 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;
```
### 配置对象
```javascript
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. **降级方案**:不支持的功能提供降级实现
### 错误处理示例
```javascript
// 资源清理错误处理
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模拟和监视函数调用
**测试示例:**
```javascript
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 页面切换过程,关键初始化函数(`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
**测试配置:**
```javascript
// 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
}
}
};
```
**属性测试示例:**
```javascript
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浏览器自动化
- 真实浏览器环境测试
**集成测试示例:**
```javascript
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 初始化时间
- 内存使用情况
- 事件监听器数量
**性能测试示例:**
```javascript
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**
```yaml
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
```