mirror of
https://github.com/router-for-me/Cli-Proxy-API-Management-Center.git
synced 2026-02-19 03:00:49 +08:00
feat(config): add section-based caching and tunable status interval
This commit is contained in:
102
app.js
102
app.js
@@ -24,7 +24,8 @@ import {
|
|||||||
MIN_AUTH_FILES_PAGE_SIZE,
|
MIN_AUTH_FILES_PAGE_SIZE,
|
||||||
MAX_AUTH_FILES_PAGE_SIZE,
|
MAX_AUTH_FILES_PAGE_SIZE,
|
||||||
OAUTH_CARD_IDS,
|
OAUTH_CARD_IDS,
|
||||||
STORAGE_KEY_AUTH_FILES_PAGE_SIZE
|
STORAGE_KEY_AUTH_FILES_PAGE_SIZE,
|
||||||
|
STATUS_UPDATE_INTERVAL_MS
|
||||||
} from './src/utils/constants.js';
|
} from './src/utils/constants.js';
|
||||||
|
|
||||||
// 核心服务导入
|
// 核心服务导入
|
||||||
@@ -41,9 +42,9 @@ class CLIProxyManager {
|
|||||||
this.isConnected = false;
|
this.isConnected = false;
|
||||||
this.isLoggedIn = false;
|
this.isLoggedIn = false;
|
||||||
|
|
||||||
// 配置缓存
|
// 配置缓存 - 改为分段缓存
|
||||||
this.configCache = null;
|
this.configCache = {}; // 改为对象,按配置段缓存
|
||||||
this.cacheTimestamp = null;
|
this.cacheTimestamps = {}; // 每个配置段的时间戳
|
||||||
this.cacheExpiry = CACHE_EXPIRY_MS;
|
this.cacheExpiry = CACHE_EXPIRY_MS;
|
||||||
|
|
||||||
// 状态更新定时器
|
// 状态更新定时器
|
||||||
@@ -708,37 +709,91 @@ class CLIProxyManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 检查缓存是否有效
|
// 检查缓存是否有效
|
||||||
isCacheValid() {
|
isCacheValid(section = null) {
|
||||||
if (!this.configCache || !this.cacheTimestamp) {
|
if (section) {
|
||||||
|
// 检查特定配置段的缓存
|
||||||
|
// 注意:配置值可能是 false、0、'' 等 falsy 值,不能用 ! 判断
|
||||||
|
if (!(section in this.configCache) || !(section in this.cacheTimestamps)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return (Date.now() - this.cacheTimestamps[section]) < this.cacheExpiry;
|
||||||
|
}
|
||||||
|
// 检查全局缓存(兼容旧代码)
|
||||||
|
if (!this.configCache['__full__'] || !this.cacheTimestamps['__full__']) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return (Date.now() - this.cacheTimestamp) < this.cacheExpiry;
|
return (Date.now() - this.cacheTimestamps['__full__']) < this.cacheExpiry;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取配置(优先使用缓存)
|
// 获取配置(优先使用缓存,支持按段获取)
|
||||||
async getConfig(forceRefresh = false) {
|
async getConfig(section = null, forceRefresh = false) {
|
||||||
if (!forceRefresh && this.isCacheValid()) {
|
const now = Date.now();
|
||||||
this.updateConnectionStatus(); // 更新状态显示
|
|
||||||
return this.configCache;
|
// 如果请求特定配置段且该段缓存有效
|
||||||
|
if (section && !forceRefresh && this.isCacheValid(section)) {
|
||||||
|
this.updateConnectionStatus();
|
||||||
|
return this.configCache[section];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果请求全部配置且全局缓存有效
|
||||||
|
if (!section && !forceRefresh && this.isCacheValid()) {
|
||||||
|
this.updateConnectionStatus();
|
||||||
|
return this.configCache['__full__'];
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const config = await this.makeRequest('/config');
|
const config = await this.makeRequest('/config');
|
||||||
this.configCache = config;
|
|
||||||
this.cacheTimestamp = Date.now();
|
if (section) {
|
||||||
this.updateConnectionStatus(); // 更新状态显示
|
// 缓存特定配置段
|
||||||
return config;
|
this.configCache[section] = config[section];
|
||||||
|
this.cacheTimestamps[section] = now;
|
||||||
|
// 同时更新全局缓存中的这一段
|
||||||
|
if (this.configCache['__full__']) {
|
||||||
|
this.configCache['__full__'][section] = config[section];
|
||||||
|
} else {
|
||||||
|
// 如果全局缓存不存在,也创建它
|
||||||
|
this.configCache['__full__'] = config;
|
||||||
|
this.cacheTimestamps['__full__'] = now;
|
||||||
|
}
|
||||||
|
this.updateConnectionStatus();
|
||||||
|
return config[section];
|
||||||
|
} else {
|
||||||
|
// 缓存全部配置
|
||||||
|
this.configCache['__full__'] = config;
|
||||||
|
this.cacheTimestamps['__full__'] = now;
|
||||||
|
|
||||||
|
// 同时缓存各个配置段
|
||||||
|
Object.keys(config).forEach(key => {
|
||||||
|
this.configCache[key] = config[key];
|
||||||
|
this.cacheTimestamps[key] = now;
|
||||||
|
});
|
||||||
|
|
||||||
|
this.updateConnectionStatus();
|
||||||
|
return config;
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('获取配置失败:', error);
|
console.error('获取配置失败:', error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 清除缓存
|
// 清除缓存(支持清除特定配置段)
|
||||||
clearCache() {
|
clearCache(section = null) {
|
||||||
this.configCache = null;
|
if (section) {
|
||||||
this.cacheTimestamp = null;
|
// 清除特定配置段的缓存
|
||||||
this.configYamlCache = '';
|
delete this.configCache[section];
|
||||||
|
delete this.cacheTimestamps[section];
|
||||||
|
// 同时清除全局缓存中的这一段
|
||||||
|
if (this.configCache['__full__']) {
|
||||||
|
delete this.configCache['__full__'][section];
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 清除所有缓存
|
||||||
|
this.configCache = {};
|
||||||
|
this.cacheTimestamps = {};
|
||||||
|
this.configYamlCache = '';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 启动状态更新定时器
|
// 启动状态更新定时器
|
||||||
@@ -750,7 +805,7 @@ class CLIProxyManager {
|
|||||||
if (this.isConnected) {
|
if (this.isConnected) {
|
||||||
this.updateConnectionStatus();
|
this.updateConnectionStatus();
|
||||||
}
|
}
|
||||||
}, 1000); // 每秒更新一次
|
}, STATUS_UPDATE_INTERVAL_MS);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 停止状态更新定时器
|
// 停止状态更新定时器
|
||||||
@@ -766,7 +821,8 @@ class CLIProxyManager {
|
|||||||
try {
|
try {
|
||||||
console.log(i18n.t('system_info.real_time_data'));
|
console.log(i18n.t('system_info.real_time_data'));
|
||||||
// 使用新的 /config 端点一次性获取所有配置
|
// 使用新的 /config 端点一次性获取所有配置
|
||||||
const config = await this.getConfig(forceRefresh);
|
// 注意:getConfig(section, forceRefresh),不传 section 表示获取全部
|
||||||
|
const config = await this.getConfig(null, forceRefresh);
|
||||||
|
|
||||||
// 获取一次usage统计数据,供渲染函数和loadUsageStats复用
|
// 获取一次usage统计数据,供渲染函数和loadUsageStats复用
|
||||||
let usageData = null;
|
let usageData = null;
|
||||||
|
|||||||
@@ -1,81 +1,106 @@
|
|||||||
// 设置与开关相关方法模块
|
// 设置与开关相关方法模块
|
||||||
// 注意:这些函数依赖于在 CLIProxyManager 实例上提供的 makeRequest/clearCache/showNotification 等基础能力
|
// 注意:这些函数依赖于在 CLIProxyManager 实例上提供的 makeRequest/clearCache/showNotification/errorHandler 等基础能力
|
||||||
|
|
||||||
export async function updateDebug(enabled) {
|
export async function updateDebug(enabled) {
|
||||||
|
const previousValue = !enabled;
|
||||||
try {
|
try {
|
||||||
await this.makeRequest('/debug', {
|
await this.makeRequest('/debug', {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: JSON.stringify({ value: enabled })
|
body: JSON.stringify({ value: enabled })
|
||||||
});
|
});
|
||||||
this.clearCache(); // 清除缓存
|
this.clearCache('debug'); // 仅清除 debug 配置段的缓存
|
||||||
this.showNotification(i18n.t('notification.debug_updated'), 'success');
|
this.showNotification(i18n.t('notification.debug_updated'), 'success');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.showNotification(`${i18n.t('notification.update_failed')}: ${error.message}`, 'error');
|
this.errorHandler.handleUpdateError(
|
||||||
// 恢复原状态
|
error,
|
||||||
document.getElementById('debug-toggle').checked = !enabled;
|
i18n.t('settings.debug_mode') || '调试模式',
|
||||||
|
() => document.getElementById('debug-toggle').checked = previousValue
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateProxyUrl() {
|
export async function updateProxyUrl() {
|
||||||
const proxyUrl = document.getElementById('proxy-url').value.trim();
|
const proxyUrl = document.getElementById('proxy-url').value.trim();
|
||||||
|
const previousValue = document.getElementById('proxy-url').getAttribute('data-previous-value') || '';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.makeRequest('/proxy-url', {
|
await this.makeRequest('/proxy-url', {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: JSON.stringify({ value: proxyUrl })
|
body: JSON.stringify({ value: proxyUrl })
|
||||||
});
|
});
|
||||||
this.clearCache(); // 清除缓存
|
this.clearCache('proxy-url'); // 仅清除 proxy-url 配置段的缓存
|
||||||
|
document.getElementById('proxy-url').setAttribute('data-previous-value', proxyUrl);
|
||||||
this.showNotification(i18n.t('notification.proxy_updated'), 'success');
|
this.showNotification(i18n.t('notification.proxy_updated'), 'success');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.showNotification(`${i18n.t('notification.update_failed')}: ${error.message}`, 'error');
|
this.errorHandler.handleUpdateError(
|
||||||
|
error,
|
||||||
|
i18n.t('settings.proxy_url') || '代理设置',
|
||||||
|
() => document.getElementById('proxy-url').value = previousValue
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function clearProxyUrl() {
|
export async function clearProxyUrl() {
|
||||||
|
const previousValue = document.getElementById('proxy-url').value;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.makeRequest('/proxy-url', { method: 'DELETE' });
|
await this.makeRequest('/proxy-url', { method: 'DELETE' });
|
||||||
document.getElementById('proxy-url').value = '';
|
document.getElementById('proxy-url').value = '';
|
||||||
this.clearCache(); // 清除缓存
|
document.getElementById('proxy-url').setAttribute('data-previous-value', '');
|
||||||
|
this.clearCache('proxy-url'); // 仅清除 proxy-url 配置段的缓存
|
||||||
this.showNotification(i18n.t('notification.proxy_cleared'), 'success');
|
this.showNotification(i18n.t('notification.proxy_cleared'), 'success');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.showNotification(`${i18n.t('notification.update_failed')}: ${error.message}`, 'error');
|
this.errorHandler.handleUpdateError(
|
||||||
|
error,
|
||||||
|
i18n.t('settings.proxy_url') || '代理设置',
|
||||||
|
() => document.getElementById('proxy-url').value = previousValue
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateRequestRetry() {
|
export async function updateRequestRetry() {
|
||||||
const retryCount = parseInt(document.getElementById('request-retry').value);
|
const retryInput = document.getElementById('request-retry');
|
||||||
|
const retryCount = parseInt(retryInput.value);
|
||||||
|
const previousValue = retryInput.getAttribute('data-previous-value') || '0';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.makeRequest('/request-retry', {
|
await this.makeRequest('/request-retry', {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: JSON.stringify({ value: retryCount })
|
body: JSON.stringify({ value: retryCount })
|
||||||
});
|
});
|
||||||
this.clearCache(); // 清除缓存
|
this.clearCache('request-retry'); // 仅清除 request-retry 配置段的缓存
|
||||||
|
retryInput.setAttribute('data-previous-value', retryCount.toString());
|
||||||
this.showNotification(i18n.t('notification.retry_updated'), 'success');
|
this.showNotification(i18n.t('notification.retry_updated'), 'success');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.showNotification(`${i18n.t('notification.update_failed')}: ${error.message}`, 'error');
|
this.errorHandler.handleUpdateError(
|
||||||
|
error,
|
||||||
|
i18n.t('settings.request_retry') || '重试设置',
|
||||||
|
() => retryInput.value = previousValue
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loadDebugSettings() {
|
export async function loadDebugSettings() {
|
||||||
try {
|
try {
|
||||||
const config = await this.getConfig();
|
const debugValue = await this.getConfig('debug'); // 仅获取 debug 配置段
|
||||||
if (config.debug !== undefined) {
|
if (debugValue !== undefined) {
|
||||||
document.getElementById('debug-toggle').checked = config.debug;
|
document.getElementById('debug-toggle').checked = debugValue;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('加载调试设置失败:', error);
|
this.errorHandler.handleLoadError(error, i18n.t('settings.debug_mode') || '调试设置');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loadProxySettings() {
|
export async function loadProxySettings() {
|
||||||
try {
|
try {
|
||||||
const config = await this.getConfig();
|
const proxyUrl = await this.getConfig('proxy-url'); // 仅获取 proxy-url 配置段
|
||||||
if (config['proxy-url'] !== undefined) {
|
const proxyInput = document.getElementById('proxy-url');
|
||||||
document.getElementById('proxy-url').value = config['proxy-url'] || '';
|
if (proxyUrl !== undefined) {
|
||||||
|
proxyInput.value = proxyUrl || '';
|
||||||
|
proxyInput.setAttribute('data-previous-value', proxyUrl || '');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('加载代理设置失败:', error);
|
this.errorHandler.handleLoadError(error, i18n.t('settings.proxy_settings') || '代理设置');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
279
src/utils/dom.js
Normal file
279
src/utils/dom.js
Normal file
@@ -0,0 +1,279 @@
|
|||||||
|
/**
|
||||||
|
* DOM 操作工具函数模块
|
||||||
|
* 提供高性能的 DOM 操作方法
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量渲染列表项,使用 DocumentFragment 减少重绘
|
||||||
|
* @param {HTMLElement} container - 容器元素
|
||||||
|
* @param {Array} items - 数据项数组
|
||||||
|
* @param {Function} renderItemFn - 渲染单个项目的函数,返回 HTML 字符串或 Element
|
||||||
|
* @param {boolean} append - 是否追加模式(默认 false,清空后渲染)
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* renderList(container, files, (file) => `
|
||||||
|
* <div class="file-item">${file.name}</div>
|
||||||
|
* `);
|
||||||
|
*/
|
||||||
|
export function renderList(container, items, renderItemFn, append = false) {
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const fragment = document.createDocumentFragment();
|
||||||
|
|
||||||
|
items.forEach((item, index) => {
|
||||||
|
const rendered = renderItemFn(item, index);
|
||||||
|
|
||||||
|
if (typeof rendered === 'string') {
|
||||||
|
// HTML 字符串,创建临时容器
|
||||||
|
const temp = document.createElement('div');
|
||||||
|
temp.innerHTML = rendered;
|
||||||
|
// 将所有子元素添加到 fragment
|
||||||
|
while (temp.firstChild) {
|
||||||
|
fragment.appendChild(temp.firstChild);
|
||||||
|
}
|
||||||
|
} else if (rendered instanceof HTMLElement) {
|
||||||
|
// DOM 元素,直接添加
|
||||||
|
fragment.appendChild(rendered);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!append) {
|
||||||
|
container.innerHTML = '';
|
||||||
|
}
|
||||||
|
container.appendChild(fragment);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建 DOM 元素的快捷方法
|
||||||
|
* @param {string} tag - 标签名
|
||||||
|
* @param {Object} attrs - 属性对象
|
||||||
|
* @param {string|Array<HTMLElement>} content - 内容(文本或子元素数组)
|
||||||
|
* @returns {HTMLElement}
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const div = createElement('div', { class: 'item', 'data-id': '123' }, 'Hello');
|
||||||
|
* const ul = createElement('ul', {}, [
|
||||||
|
* createElement('li', {}, 'Item 1'),
|
||||||
|
* createElement('li', {}, 'Item 2')
|
||||||
|
* ]);
|
||||||
|
*/
|
||||||
|
export function createElement(tag, attrs = {}, content = null) {
|
||||||
|
const element = document.createElement(tag);
|
||||||
|
|
||||||
|
// 设置属性
|
||||||
|
Object.keys(attrs).forEach(key => {
|
||||||
|
if (key === 'class') {
|
||||||
|
element.className = attrs[key];
|
||||||
|
} else if (key === 'style' && typeof attrs[key] === 'object') {
|
||||||
|
Object.assign(element.style, attrs[key]);
|
||||||
|
} else if (key.startsWith('on') && typeof attrs[key] === 'function') {
|
||||||
|
const eventName = key.substring(2).toLowerCase();
|
||||||
|
element.addEventListener(eventName, attrs[key]);
|
||||||
|
} else {
|
||||||
|
element.setAttribute(key, attrs[key]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 设置内容
|
||||||
|
if (content !== null && content !== undefined) {
|
||||||
|
if (typeof content === 'string') {
|
||||||
|
element.textContent = content;
|
||||||
|
} else if (Array.isArray(content)) {
|
||||||
|
content.forEach(child => {
|
||||||
|
if (child instanceof HTMLElement) {
|
||||||
|
element.appendChild(child);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else if (content instanceof HTMLElement) {
|
||||||
|
element.appendChild(content);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return element;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量更新元素属性,减少重绘
|
||||||
|
* @param {HTMLElement} element - 目标元素
|
||||||
|
* @param {Object} updates - 更新对象
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* batchUpdate(element, {
|
||||||
|
* className: 'active',
|
||||||
|
* style: { color: 'red', fontSize: '16px' },
|
||||||
|
* textContent: 'Updated'
|
||||||
|
* });
|
||||||
|
*/
|
||||||
|
export function batchUpdate(element, updates) {
|
||||||
|
if (!element) return;
|
||||||
|
|
||||||
|
// 使用 requestAnimationFrame 批量更新
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
Object.keys(updates).forEach(key => {
|
||||||
|
if (key === 'style' && typeof updates[key] === 'object') {
|
||||||
|
Object.assign(element.style, updates[key]);
|
||||||
|
} else {
|
||||||
|
element[key] = updates[key];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 延迟渲染大列表,避免阻塞 UI
|
||||||
|
* @param {HTMLElement} container - 容器元素
|
||||||
|
* @param {Array} items - 数据项数组
|
||||||
|
* @param {Function} renderItemFn - 渲染函数
|
||||||
|
* @param {number} batchSize - 每批渲染数量
|
||||||
|
* @returns {Promise} 完成渲染的 Promise
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* await renderListAsync(container, largeArray, renderItem, 50);
|
||||||
|
*/
|
||||||
|
export function renderListAsync(container, items, renderItemFn, batchSize = 50) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
if (!container || !items.length) {
|
||||||
|
resolve();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
container.innerHTML = '';
|
||||||
|
let index = 0;
|
||||||
|
|
||||||
|
function renderBatch() {
|
||||||
|
const fragment = document.createDocumentFragment();
|
||||||
|
const end = Math.min(index + batchSize, items.length);
|
||||||
|
|
||||||
|
for (let i = index; i < end; i++) {
|
||||||
|
const rendered = renderItemFn(items[i], i);
|
||||||
|
if (typeof rendered === 'string') {
|
||||||
|
const temp = document.createElement('div');
|
||||||
|
temp.innerHTML = rendered;
|
||||||
|
while (temp.firstChild) {
|
||||||
|
fragment.appendChild(temp.firstChild);
|
||||||
|
}
|
||||||
|
} else if (rendered instanceof HTMLElement) {
|
||||||
|
fragment.appendChild(rendered);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
container.appendChild(fragment);
|
||||||
|
index = end;
|
||||||
|
|
||||||
|
if (index < items.length) {
|
||||||
|
requestAnimationFrame(renderBatch);
|
||||||
|
} else {
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
requestAnimationFrame(renderBatch);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 虚拟滚动渲染(仅渲染可见区域)
|
||||||
|
* @param {Object} config - 配置对象
|
||||||
|
* @param {HTMLElement} config.container - 容器元素
|
||||||
|
* @param {Array} config.items - 数据项数组
|
||||||
|
* @param {Function} config.renderItemFn - 渲染函数
|
||||||
|
* @param {number} config.itemHeight - 每项高度(像素)
|
||||||
|
* @param {number} [config.overscan=5] - 额外渲染的项数(上下各)
|
||||||
|
* @returns {Object} 包含 update 和 destroy 方法的对象
|
||||||
|
*/
|
||||||
|
export function createVirtualScroll({ container, items, renderItemFn, itemHeight, overscan = 5 }) {
|
||||||
|
if (!container) return { update: () => {}, destroy: () => {} };
|
||||||
|
|
||||||
|
const totalHeight = items.length * itemHeight;
|
||||||
|
const viewportHeight = container.clientHeight;
|
||||||
|
|
||||||
|
// 创建占位容器
|
||||||
|
const placeholder = document.createElement('div');
|
||||||
|
placeholder.style.height = `${totalHeight}px`;
|
||||||
|
placeholder.style.position = 'relative';
|
||||||
|
|
||||||
|
const content = document.createElement('div');
|
||||||
|
content.style.position = 'absolute';
|
||||||
|
content.style.top = '0';
|
||||||
|
content.style.width = '100%';
|
||||||
|
|
||||||
|
placeholder.appendChild(content);
|
||||||
|
container.innerHTML = '';
|
||||||
|
container.appendChild(placeholder);
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
const scrollTop = container.scrollTop;
|
||||||
|
const startIndex = Math.max(0, Math.floor(scrollTop / itemHeight) - overscan);
|
||||||
|
const endIndex = Math.min(
|
||||||
|
items.length,
|
||||||
|
Math.ceil((scrollTop + viewportHeight) / itemHeight) + overscan
|
||||||
|
);
|
||||||
|
|
||||||
|
const fragment = document.createDocumentFragment();
|
||||||
|
|
||||||
|
for (let i = startIndex; i < endIndex; i++) {
|
||||||
|
const element = renderItemFn(items[i], i);
|
||||||
|
if (typeof element === 'string') {
|
||||||
|
const temp = document.createElement('div');
|
||||||
|
temp.innerHTML = element;
|
||||||
|
while (temp.firstChild) {
|
||||||
|
fragment.appendChild(temp.firstChild);
|
||||||
|
}
|
||||||
|
} else if (element instanceof HTMLElement) {
|
||||||
|
fragment.appendChild(element);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
content.style.top = `${startIndex * itemHeight}px`;
|
||||||
|
content.innerHTML = '';
|
||||||
|
content.appendChild(fragment);
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleScroll = () => requestAnimationFrame(render);
|
||||||
|
container.addEventListener('scroll', handleScroll);
|
||||||
|
|
||||||
|
// 初始渲染
|
||||||
|
render();
|
||||||
|
|
||||||
|
return {
|
||||||
|
update: (newItems) => {
|
||||||
|
items = newItems;
|
||||||
|
placeholder.style.height = `${newItems.length * itemHeight}px`;
|
||||||
|
render();
|
||||||
|
},
|
||||||
|
destroy: () => {
|
||||||
|
container.removeEventListener('scroll', handleScroll);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 防抖包装器(用于搜索、输入等)
|
||||||
|
* @param {Function} fn - 要防抖的函数
|
||||||
|
* @param {number} delay - 延迟时间(毫秒)
|
||||||
|
* @returns {Function} 防抖后的函数
|
||||||
|
*/
|
||||||
|
export function debounce(fn, delay = 300) {
|
||||||
|
let timer;
|
||||||
|
return function (...args) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
timer = setTimeout(() => fn.apply(this, args), delay);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 节流包装器(用于滚动、resize 等)
|
||||||
|
* @param {Function} fn - 要节流的函数
|
||||||
|
* @param {number} limit - 限制时间(毫秒)
|
||||||
|
* @returns {Function} 节流后的函数
|
||||||
|
*/
|
||||||
|
export function throttle(fn, limit = 100) {
|
||||||
|
let inThrottle;
|
||||||
|
return function (...args) {
|
||||||
|
if (!inThrottle) {
|
||||||
|
fn.apply(this, args);
|
||||||
|
inThrottle = true;
|
||||||
|
setTimeout(() => inThrottle = false, limit);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user