1062 lines
27 KiB
Markdown
1062 lines
27 KiB
Markdown
# 设计文档
|
||
|
||
## 概述
|
||
|
||
本设计文档针对 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 验证码重置
|
||
```
|
||
|
||
**问题分析:**
|
||
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 或 Mocha(JavaScript 单元测试框架)
|
||
- 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-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
|
||
|
||
**测试配置:**
|
||
```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
|
||
```
|