# 设计文档 ## 概述 本设计文档针对 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 = ''; 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 = ''; 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: ['/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 = ''; 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 = ''; 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 ```