perf(web): page-data cache + progressive rendering (item 22, frontend)

Page switches no longer refetch the base datasets every time:
servers/customers/nodes/protocols are reused across pages for 30s,
and every successful mutation (all write paths funnel through api())
invalidates the cache. Rendering is progressive — the page paints
immediately with what is already in state and re-renders as each
dataset lands, instead of waiting for a Promise.all of 2-5 requests.
Overview/system/jobs still fetch fresh on every load.
This commit is contained in:
aaaamoooo
2026-10-21 10:50:56 +00:00
parent 05366d9177
commit 6d97d4e172
+48 -15
View File
@@ -128,6 +128,9 @@ async function api(path, options = {}) {
if (response.status === 401 && path !== '/api/auth/login') showLogin(); if (response.status === 401 && path !== '/api/auth/login') showLogin();
throw new Error((data && data.message) || `请求失败 (${response.status})`); throw new Error((data && data.message) || `请求失败 (${response.status})`);
} }
// Any successful mutation invalidates the page-data cache (item 22):
// every write path in this file funnels through api().
if (!['GET', 'HEAD'].includes(options.method || 'GET')) invalidateDataCache();
return data; return data;
} }
@@ -240,29 +243,59 @@ function pagerHTML(key, total) {
} }
/* ---------- 数据加载与路由 ---------- */ /* ---------- 数据加载与路由 ---------- */
/* 基础数据集页间缓存(第 22 条):servers/customers/nodes/protocols 在
30 秒内切页直接复用、不重复请求;任何写操作经 api() 统一失效。
渲染不再等全部请求落地:每份数据到达即更新 state 并重渲染当前页。 */
const DATA_CACHE_TTL_MS = 30000;
const dataCacheAt = { servers: 0, customers: 0, nodes: 0, protocols: 0 };
function invalidateDataCache() { for (const key of Object.keys(dataCacheAt)) dataCacheAt[key] = 0; }
function cacheFresh(key) { return dataCacheAt[key] && Date.now() - dataCacheAt[key] < DATA_CACHE_TTL_MS; }
async function load(page = state.page) { async function load(page = state.page) {
const isCurrent = () => state.page === page && !modalOpen();
const apply = (fn) => { fn(); if (isCurrent()) render(); };
const jobs = [];
const errors = [];
// Cached base dataset: fetch only when stale. A partial failure keeps
// whatever data is already in state (the page renders progressively);
// only a total failure surfaces a toast, as the old all-or-nothing
// load did.
const base = (key, path, fn) => {
if (cacheFresh(key)) return;
jobs.push(api(path).then((data) => { dataCacheAt[key] = Date.now(); apply(() => fn(data)); }).catch((error) => errors.push(error)));
};
const fresh = (path, fn) => { jobs.push(api(path).then((data) => apply(() => fn(data))).catch((error) => errors.push(error))); };
const setServers = (d) => { state.servers = d.servers; };
const setCustomers = (d) => { state.customers = d.customers; state.customerSummary = d.summary || null; };
const setNodes = (d) => { state.nodes = d.nodes; state.nodeClients = d.nodeClients; };
const setOverview = (d) => { state.overview = d; state.version = d.version || state.version; $('#version-label').textContent = `v${state.version}`; };
try { try {
if (page === 'overview') { if (page === 'overview') {
const [overview, system, servers, customers, routes] = await Promise.all([api('/api/overview'), api('/api/system'), api('/api/servers'), api('/api/customers'), api('/api/nodes')]); fresh('/api/overview', setOverview);
state.overview = overview; state.system = system; state.version = overview.version || state.version; fresh('/api/system', (d) => { state.system = d; });
state.servers = servers.servers; state.customers = customers.customers; state.customerSummary = customers.summary || null; state.nodes = routes.nodes; state.nodeClients = routes.nodeClients; base('servers', '/api/servers', setServers);
$('#version-label').textContent = `v${state.version}`; base('customers', '/api/customers', setCustomers);
base('nodes', '/api/nodes', setNodes);
} else if (page === 'servers') { } else if (page === 'servers') {
const [servers, routes] = await Promise.all([api('/api/servers'), api('/api/nodes')]); base('servers', '/api/servers', setServers);
state.servers = servers.servers; state.nodes = routes.nodes; state.nodeClients = routes.nodeClients; base('nodes', '/api/nodes', setNodes);
} else if (page === 'customers') { } else if (page === 'customers') {
const [customers, routes] = await Promise.all([api('/api/customers'), api('/api/nodes')]); base('customers', '/api/customers', setCustomers);
state.customers = customers.customers; state.customerSummary = customers.summary || null; state.nodes = routes.nodes; state.nodeClients = routes.nodeClients; base('nodes', '/api/nodes', setNodes);
} else if (page === 'chains') { } else if (page === 'chains') {
const [servers, customers, routes, protocols] = await Promise.all([api('/api/servers'), api('/api/customers'), api('/api/nodes'), api('/api/protocols')]); base('servers', '/api/servers', setServers);
state.servers = servers.servers; state.customers = customers.customers; state.customerSummary = customers.summary || null; state.nodes = routes.nodes; state.nodeClients = routes.nodeClients; base('customers', '/api/customers', setCustomers);
state.protocols = protocols.profiles; state.realityPresets = protocols.realityPresets || []; base('nodes', '/api/nodes', setNodes);
base('protocols', '/api/protocols', (d) => { state.protocols = d.profiles; state.realityPresets = d.realityPresets || []; });
} else if (page === 'operations') { } else if (page === 'operations') {
const [jobs, overview] = await Promise.all([api('/api/jobs'), api('/api/overview')]); fresh('/api/jobs', (d) => { state.jobs = d.jobs; });
state.jobs = jobs.jobs; state.overview = overview; state.version = overview.version || state.version; fresh('/api/overview', setOverview);
$('#version-label').textContent = `v${state.version}`;
} }
render(); // Render immediately with whatever is already in state (cached or
// previously loaded), then re-render as the rest lands.
if (isCurrent()) render();
await Promise.allSettled(jobs);
if (errors.length && errors.length === jobs.length) throw errors[0];
} catch (error) { toast(error.message, true); } } catch (error) { toast(error.message, true); }
} }