# Design Document: PJAX 和 Lazyload 功能修复 ## Overview 本设计旨在修复 Argon WordPress 主题中 PJAX 页面无刷新跳转和 Lazyload 图片懒加载功能的问题。当前实现存在以下核心问题: 1. **资源泄漏**:页面切换时 Observer 和第三方库实例未正确清理 2. **脚本执行失败**:新页面的内联脚本未执行 3. **样式错误**:动态样式未清理导致样式冲突 4. **Mermaid 初始化时序问题**:清除缓存后首次加载报语法错误 设计方案采用完整的生命周期管理模式,确保资源在正确的时机创建和销毁,同时提供降级方案保证兼容性。 ## Architecture ### 核心架构原则 1. **生命周期驱动**:所有资源管理基于 PJAX 生命周期事件 2. **集中清理**:统一的资源清理函数,避免遗漏 3. **错误隔离**:每个模块独立的错误处理,互不影响 4. **降级支持**:为不支持的特性提供降级方案 ### 架构图 ``` PJAX Lifecycle ├── pjax:click (开始) │ └── 设置加载状态 ├── pjax:beforeReplace (清理阶段) │ ├── cleanupPjaxResources() │ │ ├── 清理 Lazyload Observer │ │ ├── 销毁 Zoomify 实例 │ │ ├── 销毁 Tippy 实例 │ │ ├── 清理 Mermaid 实例 │ │ └── 移除动态 style/script │ └── 更新 UI 状态 ├── pjax:complete (初始化阶段) │ ├── 执行内联脚本 │ ├── 初始化功能模块 │ │ ├── waterflowInit() │ │ ├── lazyloadInit() │ │ ├── zoomifyInit() │ │ ├── highlightJsRender() │ │ └── ... (其他模块) │ └── 恢复滚动位置 └── pjax:end (收尾阶段) ├── resetMobileCatalog() └── resetGT4Captcha() ``` ## Components and Interfaces ### 1. Resource Cleanup Manager (资源清理管理器) **职责**:统一管理所有资源的清理 **接口**: ```javascript /** * 清理 PJAX 页面切换前的所有资源 * @returns {void} */ function cleanupPjaxResources() { cleanupLazyloadObserver(); cleanupZoomifyInstances(); cleanupTippyInstances(); cleanupMermaidInstances(); cleanupDynamicStyles(); cleanupDynamicScripts(); cleanupEventListeners(); } /** * 清理 Lazyload Observer * @returns {void} */ function cleanupLazyloadObserver() { if (lazyloadObserver) { lazyloadObserver.disconnect(); lazyloadObserver = null; } } /** * 清理 Zoomify 实例 * @returns {void} */ function cleanupZoomifyInstances() { if (zoomifyInstances && zoomifyInstances.length > 0) { zoomifyInstances.forEach(instance => { try { if (instance && typeof instance.destroy === 'function') { instance.destroy(); } } catch(e) { ArgonDebug.warn('Failed to destroy Zoomify instance:', e); } }); zoomifyInstances = []; } $('img.zoomify-initialized').removeClass('zoomify-initialized'); } ``` ### 2. Script Executor (脚本执行器) **职责**:提取并执行新页面中的内联脚本 **接口**: ```javascript /** * 执行新页面中的内联脚本 * @param {HTMLElement} container - 新页面的容器元素 * @returns {void} */ function executeInlineScripts(container) { const scripts = container.querySelectorAll('script'); scripts.forEach(script => { if (!script.src) { // 只执行内联脚本 try { executeScript(script); } catch(e) { ArgonDebug.error('Script execution failed:', e); } } }); } /** * 执行单个脚本 * @param {HTMLScriptElement} script - 脚本元素 * @returns {void} */ function executeScript(script) { const newScript = document.createElement('script'); newScript.textContent = script.textContent; // 复制属性 Array.from(script.attributes).forEach(attr => { newScript.setAttribute(attr.name, attr.value); }); document.head.appendChild(newScript); document.head.removeChild(newScript); } ``` ### 3. Lazyload Manager (懒加载管理器) **职责**:管理图片懒加载的完整生命周期 **接口**: ```javascript /** * 初始化懒加载 * @returns {void} */ function lazyloadInit() { // 清理旧的 Observer cleanupLazyloadObserver(); // 检查是否启用 if (argonConfig.lazyload === false) { loadAllImagesImmediately(); return; } const images = document.querySelectorAll('img.lazyload[data-src]'); if (images.length === 0) return; // 使用 IntersectionObserver 或降级方案 if ('IntersectionObserver' in window) { initWithObserver(images); } else { initWithScrollListener(images); } } /** * 使用 IntersectionObserver 初始化 * @param {NodeList} images - 图片元素列表 * @returns {void} */ function initWithObserver(images) { const threshold = parseInt(argonConfig.lazyload_threshold) || 800; lazyloadObserver = new IntersectionObserver(entries => { entries.forEach(entry => { if (entry.isIntersecting) { loadImage(entry.target); lazyloadObserver.unobserve(entry.target); } }); }, { rootMargin: `${threshold}px 0px` }); images.forEach(img => lazyloadObserver.observe(img)); } /** * 加载单张图片 * @param {HTMLImageElement} img - 图片元素 * @returns {void} */ function loadImage(img) { const src = img.getAttribute('data-src'); if (!src) return; const tempImg = new Image(); tempImg.onload = () => { img.src = src; img.removeAttribute('data-src'); img.classList.remove('lazyload'); applyLoadEffect(img); }; tempImg.onerror = () => { // 降级:直接设置 src img.src = src; img.removeAttribute('data-src'); img.classList.remove('lazyload'); }; tempImg.src = src; } ``` ### 4. Mermaid Renderer (Mermaid 渲染器) **职责**:管理 Mermaid 图表的渲染和生命周期 **接口**: ```javascript /** * 初始化 Mermaid * @returns {Promise} 初始化是否成功 */ async function initMermaid() { // 等待 Mermaid 库加载 if (typeof window.mermaid === 'undefined') { await waitForMermaid(); } // 检查 API 可用性 if (!checkMermaidAPI()) { return false; } // 配置 Mermaid const theme = getMermaidTheme(); window.mermaid.initialize({ startOnLoad: false, theme: theme, securityLevel: 'loose' }); return true; } /** * 等待 Mermaid 库加载 * @param {number} timeout - 超时时间(毫秒) * @returns {Promise} */ function waitForMermaid(timeout = 5000) { return new Promise((resolve, reject) => { const startTime = Date.now(); const checkInterval = setInterval(() => { if (typeof window.mermaid !== 'undefined' && typeof window.mermaid.render === 'function') { clearInterval(checkInterval); resolve(); } else if (Date.now() - startTime > timeout) { clearInterval(checkInterval); reject(new Error('Mermaid load timeout')); } }, 100); }); } /** * 渲染所有 Mermaid 图表 * @returns {void} */ function renderAllMermaidCharts() { const blocks = detectMermaidBlocks(); blocks.forEach((block, index) => { renderMermaidChart(block, index); }); } /** * 渲染单个图表 * @param {HTMLElement} element - 代码块元素 * @param {number} index - 图表索引 * @returns {void} */ async function renderMermaidChart(element, index) { const chartId = `mermaid-chart-${Date.now()}-${index}`; const code = extractMermaidCode(element); try { const result = await window.mermaid.render(`mermaid-svg-${chartId}`, code); const container = createMermaidContainer(chartId, result.svg, code); element.parentNode.replaceChild(container, element); } catch(error) { // 尝试降级方案 if (await tryLegacyMermaidAPI(element, code, chartId)) { return; } // 显示错误 showMermaidError(element, error, code); } } ``` ### 5. Style Manager (样式管理器) **职责**:管理动态样式的添加和清理 **接口**: ```javascript /** * 清理动态添加的样式 * @returns {void} */ function cleanupDynamicStyles() { // 只清理标记为动态的样式 document.querySelectorAll('style[data-dynamic="true"]').forEach(style => { style.remove(); }); } /** * 应用新页面的样式 * @param {HTMLElement} container - 新页面容器 * @returns {void} */ function applyNewPageStyles(container) { const styles = container.querySelectorAll('style'); styles.forEach(style => { const newStyle = style.cloneNode(true); newStyle.setAttribute('data-dynamic', 'true'); document.head.appendChild(newStyle); }); } ``` ### 6. Event Manager (事件管理器) **职责**:管理事件监听器的绑定和清理 **接口**: ```javascript /** * 事件监听器注册表 */ const eventRegistry = new Map(); /** * 注册事件监听器 * @param {HTMLElement} element - 目标元素 * @param {string} event - 事件名称 * @param {Function} handler - 处理函数 * @param {Object} options - 选项 * @returns {void} */ function registerEventListener(element, event, handler, options = {}) { element.addEventListener(event, handler, options); // 记录到注册表 if (!eventRegistry.has(element)) { eventRegistry.set(element, []); } eventRegistry.get(element).push({ event, handler, options }); } /** * 清理所有注册的事件监听器 * @returns {void} */ function cleanupEventListeners() { eventRegistry.forEach((listeners, element) => { listeners.forEach(({ event, handler, options }) => { element.removeEventListener(event, handler, options); }); }); eventRegistry.clear(); } /** * 使用事件委托绑定 * @param {string} selector - 选择器 * @param {string} event - 事件名称 * @param {Function} handler - 处理函数 * @returns {void} */ function delegateEvent(selector, event, handler) { $(document).on(event, selector, handler); } ``` ## Data Models ### ResourceState (资源状态) ```javascript /** * 资源状态枚举 */ const ResourceState = { UNINITIALIZED: 'uninitialized', // 未初始化 INITIALIZING: 'initializing', // 初始化中 READY: 'ready', // 就绪 CLEANING: 'cleaning', // 清理中 CLEANED: 'cleaned' // 已清理 }; /** * 资源管理器状态 */ class ResourceManager { constructor() { this.state = ResourceState.UNINITIALIZED; this.resources = { lazyloadObserver: null, zoomifyInstances: [], tippyInstances: [], mermaidInstances: [], dynamicStyles: [], dynamicScripts: [], eventListeners: [] }; } /** * 清理所有资源 */ cleanup() { this.state = ResourceState.CLEANING; // 清理逻辑 this.state = ResourceState.CLEANED; } /** * 初始化资源 */ initialize() { this.state = ResourceState.INITIALIZING; // 初始化逻辑 this.state = ResourceState.READY; } } ``` ### LazyloadConfig (懒加载配置) ```javascript /** * 懒加载配置 */ class LazyloadConfig { constructor() { this.enabled = true; // 是否启用 this.effect = 'fadeIn'; // 加载效果 this.threshold = 800; // 提前加载阈值(像素) this.useObserver = true; // 是否使用 Observer } /** * 从全局配置加载 */ loadFromGlobal() { this.enabled = argonConfig.lazyload !== false; this.effect = argonConfig.lazyload_effect || 'fadeIn'; this.threshold = parseInt(argonConfig.lazyload_threshold) || 800; this.useObserver = 'IntersectionObserver' in window; } } ``` ### MermaidState (Mermaid 状态) ```javascript /** * Mermaid 渲染状态 */ class MermaidState { constructor() { this.initialized = false; // 是否已初始化 this.libraryLoaded = false; // 库是否加载 this.theme = 'default'; // 当前主题 this.renderedCharts = new Set(); // 已渲染的图表 } /** * 检查是否可以渲染 */ canRender() { return this.initialized && this.libraryLoaded; } /** * 标记图表已渲染 */ markRendered(chartId) { this.renderedCharts.add(chartId); } /** * 清理状态 */ cleanup() { this.renderedCharts.clear(); } } ``` ## Correctness Properties ### 什么是 Correctness Properties? 属性(Property)是一个关于系统行为的形式化陈述,它应该在所有有效执行中保持为真。属性是人类可读规范和机器可验证正确性保证之间的桥梁。通过属性测试,我们可以验证系统在各种输入下的行为是否符合预期。 ### Property Reflection(属性反思) 在编写属性之前,我们需要识别并消除冗余的属性: **识别的冗余项**: 1. 属性 8.3 (Mermaid 渲染失败显示错误) 与属性 3.5 重复 2. 属性 10.1 (IntersectionObserver 降级) 与属性 8.2 重复 3. 多个清理相关的属性可以合并为一个综合的资源清理属性 **合并策略**: - 将所有资源清理验证合并为一个综合属性 - 将所有降级方案验证合并为一个兼容性属性 - 保留独立的功能性属性(如脚本执行、图片加载等) ### Core Properties(核心属性) #### Property 1: 资源清理完整性 *For any* PJAX 页面切换,当触发 pjax:beforeReplace 事件时,所有旧页面资源(Observer、第三方库实例、动态标签)都应该被完全清理,且相关引用应该被置为 null 或清空。 **Validates: Requirements 1.1, 1.2, 1.3, 1.4, 2.3** #### Property 2: Observer 生命周期管理 *For any* Lazyload 初始化操作,如果存在旧的 Observer 实例,则必须先调用 disconnect() 并置空引用,然后才能创建新的 Observer 实例。 **Validates: Requirements 2.1, 2.2** #### Property 3: 图片加载后清理 *For any* 被 Lazyload 监听的图片,当图片加载完成(成功或失败)后,Observer 应该取消对该图片的监听,避免重复处理。 **Validates: Requirements 2.4, 2.5** #### Property 4: 功能模块错误隔离 *For any* 功能模块初始化过程,如果某个模块抛出错误,该错误应该被捕获并记录,且不应该阻止其他模块的初始化。 **Validates: Requirements 1.6, 8.1** #### Property 5: 脚本执行顺序 *For any* 新页面包含的内联脚本集合,这些脚本应该按照它们在 DOM 中出现的顺序依次执行。 **Validates: Requirements 4.3** #### Property 6: 脚本错误隔离 *For any* 内联脚本执行过程,如果某个脚本抛出错误,该错误应该被捕获,且不应该阻止后续脚本的执行。 **Validates: Requirements 4.4** #### Property 7: Mermaid 库加载等待 *For any* Mermaid 初始化操作,系统应该检查 mermaid.render 方法是否存在,如果不存在则等待库加载或使用降级方案。 **Validates: Requirements 3.1, 3.2** #### Property 8: Mermaid 渲染降级 *For any* Mermaid 图表渲染失败的情况,系统应该尝试使用旧版 API(mermaidAPI.render 或 mermaid.init),如果所有方案都失败,则显示友好的错误提示并保留原始代码。 **Validates: Requirements 3.3, 3.5** #### Property 9: 动态样式清理选择性 *For any* 页面切换操作,系统应该只清理标记为动态的 style 标签,保留主题核心样式。 **Validates: Requirements 5.1, 5.2** #### Property 10: 事件监听器清理 *For any* 注册到 eventRegistry 的事件监听器,在页面切换前都应该被正确移除。 **Validates: Requirements 6.1** #### Property 11: 节流函数限制频率 *For any* 使用节流函数包装的事件处理器,在指定时间窗口内,无论触发多少次事件,处理函数的实际执行次数都不应该超过 1 次。 **Validates: Requirements 7.1** #### Property 12: IntersectionObserver 降级 *For any* 浏览器环境,如果不支持 IntersectionObserver,Lazyload 应该自动使用滚动监听降级方案。 **Validates: Requirements 8.2, 10.1** #### Property 13: 第三方库缺失保护 *For any* 第三方库(Zoomify、Tippy、Mermaid),如果库未加载,系统应该提供空实现或跳过相关功能,不应该抛出未捕获的错误。 **Validates: Requirements 8.5** #### Property 14: PJAX 加载失败降级 *For any* PJAX 加载失败的情况,系统应该回退到传统的页面跳转方式。 **Validates: Requirements 8.4** #### Property 15: 懒加载禁用时立即加载 *For any* 图片元素,当懒加载功能被禁用时,所有图片应该立即加载,不应该创建 Observer 实例。 **Validates: Requirements 2.6** ## Error Handling ### 错误处理策略 1. **模块级错误隔离** - 每个功能模块的初始化都包裹在 try-catch 中 - 错误不应该传播到其他模块 - 记录详细的错误信息用于调试 2. **降级方案** - IntersectionObserver 不支持 → 滚动监听 - Mermaid.render 失败 → 旧版 API → init 方法 → 显示错误 - PJAX 失败 → 传统页面跳转 - 第三方库缺失 → 空实现或跳过功能 3. **用户友好的错误提示** - Mermaid 渲染失败显示可折叠的错误信息 - 保留原始代码供用户查看 - 提供错误类型分类(语法错误、渲染错误等) ### 错误处理实现 ```javascript /** * 安全执行函数(带错误处理) * @param {Function} fn - 要执行的函数 * @param {string} moduleName - 模块名称 * @returns {boolean} 是否执行成功 */ function safeExecute(fn, moduleName) { try { fn(); ArgonDebug.log(`${moduleName} initialized successfully`); return true; } catch(error) { ArgonDebug.error(`${moduleName} initialization failed:`, error); return false; } } /** * PJAX complete 事件处理(带错误隔离) */ $(document).on('pjax:complete', function() { pjaxLoading = false; NProgress.inc(); // 每个模块独立的错误处理 safeExecute(() => waterflowInit(), 'Waterflow'); safeExecute(() => lazyloadInit(), 'Lazyload'); safeExecute(() => zoomifyInit(), 'Zoomify'); safeExecute(() => highlightJsRender(), 'HighlightJS'); safeExecute(() => panguInit(), 'Pangu'); safeExecute(() => clampInit(), 'Clamp'); safeExecute(() => tippyInit(), 'Tippy'); safeExecute(() => renderAllMermaidCharts(), 'Mermaid'); // 恢复滚动位置 if (pjaxScrollTop > 0) { $('body,html').scrollTop(pjaxScrollTop); pjaxScrollTop = 0; } NProgress.done(); }); ``` ### 降级方案实现 ```javascript /** * Lazyload 降级方案 */ function initWithScrollListener(images) { const loadedImages = new Set(); function checkImagesInView() { const viewportHeight = window.innerHeight; const scrollTop = document.documentElement.scrollTop || document.body.scrollTop; images.forEach(img => { if (loadedImages.has(img)) return; const rect = img.getBoundingClientRect(); const threshold = parseInt(argonConfig.lazyload_threshold) || 800; if (rect.top < viewportHeight + threshold && rect.bottom > -threshold) { loadImage(img); loadedImages.add(img); } }); } // 使用节流优化性能 const throttledCheck = argonEventManager ? argonEventManager.throttle(checkImagesInView, 100) : checkImagesInView; window.addEventListener('scroll', throttledCheck, {passive: true}); window.addEventListener('resize', throttledCheck, {passive: true}); checkImagesInView(); } /** * Mermaid 降级方案 */ async function tryLegacyMermaidAPI(element, code, chartId) { // 尝试 Mermaid 8.x API if (typeof window.mermaidAPI !== 'undefined' && typeof window.mermaidAPI.render === 'function') { try { window.mermaidAPI.render(`mermaid-svg-${chartId}`, code, (svgCode) => { const container = createMermaidContainer(chartId, svgCode, code); element.parentNode.replaceChild(container, element); }); return true; } catch(e) { ArgonDebug.warn('Legacy API failed:', e); } } // 尝试 init 方法 if (typeof window.mermaid.init === 'function') { try { const container = document.createElement('div'); container.className = 'mermaid'; container.textContent = code; element.parentNode.replaceChild(container, element); window.mermaid.init(undefined, container); return true; } catch(e) { ArgonDebug.warn('Init method failed:', e); } } return false; } ``` ## Testing Strategy ### 测试方法 本项目采用**双重测试策略**:单元测试 + 属性测试 1. **单元测试**:验证具体示例、边缘情况和错误条件 2. **属性测试**:验证通用属性在各种输入下的正确性 两种测试方法互补,共同保证代码质量: - 单元测试捕获具体的 bug - 属性测试验证通用的正确性 ### 属性测试配置 **测试库选择**:使用 `fast-check` (JavaScript 的属性测试库) **配置要求**: - 每个属性测试至少运行 100 次迭代 - 每个测试必须引用对应的设计文档属性 - 标签格式:`Feature: pjax-lazyload-fix, Property N: [property_text]` ### 测试用例示例 #### 单元测试示例 ```javascript describe('Resource Cleanup', () => { it('should disconnect lazyload observer on cleanup', () => { // 创建 Observer lazyloadInit(); expect(lazyloadObserver).not.toBeNull(); // 清理 cleanupLazyloadObserver(); // 验证 expect(lazyloadObserver).toBeNull(); }); it('should handle missing Mermaid library gracefully', () => { // 移除 Mermaid const originalMermaid = window.mermaid; delete window.mermaid; // 尝试渲染 expect(() => renderAllMermaidCharts()).not.toThrow(); // 恢复 window.mermaid = originalMermaid; }); }); ``` #### 属性测试示例 ```javascript const fc = require('fast-check'); describe('Property Tests', () => { /** * Feature: pjax-lazyload-fix, Property 1: 资源清理完整性 * For any PJAX 页面切换,所有旧页面资源都应该被完全清理 */ it('Property 1: Resource cleanup completeness', () => { fc.assert( fc.property( fc.array(fc.string()), // 随机页面内容 (pageContent) => { // 创建资源 lazyloadInit(); createZoomifyInstances(); createTippyInstances(); // 记录资源状态 const hasObserver = lazyloadObserver !== null; const hasZoomify = zoomifyInstances.length > 0; const hasTippy = document.querySelectorAll('[data-tippy-root]').length > 0; // 清理 cleanupPjaxResources(); // 验证所有资源都被清理 expect(lazyloadObserver).toBeNull(); expect(zoomifyInstances).toHaveLength(0); expect(document.querySelectorAll('[data-tippy-root]')).toHaveLength(0); return true; } ), { numRuns: 100 } ); }); /** * Feature: pjax-lazyload-fix, Property 4: 功能模块错误隔离 * For any 功能模块初始化,某个模块错误不应该阻止其他模块 */ it('Property 4: Module error isolation', () => { fc.assert( fc.property( fc.integer(0, 10), // 随机选择失败的模块索引 (failIndex) => { const modules = [ { name: 'waterflow', fn: waterflowInit }, { name: 'lazyload', fn: lazyloadInit }, { name: 'zoomify', fn: zoomifyInit }, { name: 'highlight', fn: highlightJsRender } ]; // 让指定模块抛出错误 const originalFn = modules[failIndex % modules.length].fn; modules[failIndex % modules.length].fn = () => { throw new Error('Test error'); }; // 初始化所有模块 const results = modules.map(m => safeExecute(m.fn, m.name)); // 恢复原函数 modules[failIndex % modules.length].fn = originalFn; // 验证:失败的模块返回 false,其他模块不受影响 const failedCount = results.filter(r => !r).length; expect(failedCount).toBe(1); return true; } ), { numRuns: 100 } ); }); /** * Feature: pjax-lazyload-fix, Property 5: 脚本执行顺序 * For any 内联脚本集合,应该按 DOM 顺序执行 */ it('Property 5: Script execution order', () => { fc.assert( fc.property( fc.array(fc.string(), 1, 10), // 随机脚本内容 (scriptContents) => { // 创建测试容器 const container = document.createElement('div'); const executionOrder = []; // 创建脚本标签 scriptContents.forEach((content, index) => { const script = document.createElement('script'); script.textContent = `window.testOrder.push(${index});`; container.appendChild(script); }); // 执行脚本 window.testOrder = []; executeInlineScripts(container); // 验证执行顺序 const expectedOrder = scriptContents.map((_, i) => i); expect(window.testOrder).toEqual(expectedOrder); return true; } ), { numRuns: 100 } ); }); }); ``` ### 测试覆盖目标 - **单元测试覆盖率**:> 80% - **属性测试**:覆盖所有 15 个核心属性 - **集成测试**:覆盖完整的 PJAX 生命周期 - **边缘情况**:Mermaid 缓存清除、浏览器兼容性 ### 测试执行 ```bash # 运行所有测试 npm test # 运行单元测试 npm run test:unit # 运行属性测试 npm run test:property # 生成覆盖率报告 npm run test:coverage ```