948 lines
24 KiB
Markdown
948 lines
24 KiB
Markdown
|
|
# 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<boolean>} 初始化是否成功
|
|||
|
|
*/
|
|||
|
|
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<void>}
|
|||
|
|
*/
|
|||
|
|
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
|
|||
|
|
```
|
|||
|
|
|