perf(traffic): single-slice snapshots with worker-side aging

The traffic pipeline re-rendered twice per second (worker snapshot
plus a 1 s now-tick), cloned the 10-minute and 60-minute arrays
through every snapshot, and re-filtered them on the main thread.
Snapshots now carry only the range slice the graph renders, a 1 s
worker-side heartbeat re-broadcasts only when points age out of the
window, and snapshot ingestion pauses while the window is hidden.
Perf harness A/B: hidden-phase webview CPU 2.45% -> 0.77%, main
0.90% -> 0.37%, webview RSS about -3 MB; visible phases unchanged.
This commit is contained in:
Tunglies
2026-09-11 13:19:50 +08:00
parent 2a975d546e
commit 38c54c5b3b
5 changed files with 70 additions and 81 deletions
@@ -20,6 +20,7 @@ import {
formatTrafficHourMinute, formatTrafficHourMinute,
formatTrafficMinuteSecond, formatTrafficMinuteSecond,
formatTrafficName, formatTrafficName,
isSameTrafficData,
} from '@/utils/traffic-sampler' } from '@/utils/traffic-sampler'
interface ITrafficItem { interface ITrafficItem {
@@ -84,29 +85,6 @@ interface EnhancedCanvasTrafficGraphProps {
ref?: Ref<EnhancedCanvasTrafficGraphRef> ref?: Ref<EnhancedCanvasTrafficGraphRef>
} }
const isSameTrafficData = (
current: ITrafficDataPoint[],
next: ITrafficDataPoint[],
) => {
if (current === next) return true
if (current.length !== next.length) return false
for (let i = 0; i < current.length; i++) {
const currentPoint = current[i]
const nextPoint = next[i]
if (
currentPoint.timestamp !== nextPoint.timestamp ||
currentPoint.up !== nextPoint.up ||
currentPoint.down !== nextPoint.down
) {
return false
}
}
return true
}
const displayDataReducer = ( const displayDataReducer = (
current: ITrafficDataPoint[], current: ITrafficDataPoint[],
payload: ITrafficDataPoint[], payload: ITrafficDataPoint[],
+32 -49
View File
@@ -1,16 +1,9 @@
import { import { useCallback, useEffect, useReducer, useRef, useState } from 'react'
useCallback,
useEffect,
useMemo,
useReducer,
useRef,
useState,
} from 'react'
import { Traffic } from 'tauri-plugin-mihomo-api' import { Traffic } from 'tauri-plugin-mihomo-api'
import { useVisibility } from '@/hooks/use-visibility' import { useVisibility } from '@/hooks/use-visibility'
import { debugLog } from '@/utils/debug' import { debugLog } from '@/utils/debug'
import { TrafficDataSampler, formatTrafficName } from '@/utils/traffic-sampler' import { TrafficDataSampler, isSameTrafficData } from '@/utils/traffic-sampler'
// 引用计数管理器 // 引用计数管理器
class ReferenceCounter { class ReferenceCounter {
@@ -59,8 +52,10 @@ class InlineTrafficMonitor {
private config = { ...WORKER_CONFIG } private config = { ...WORKER_CONFIG }
private sampler = new TrafficDataSampler(this.config) private sampler = new TrafficDataSampler(this.config)
private throttleTimer: ReturnType<typeof setTimeout> | null = null private throttleTimer: ReturnType<typeof setTimeout> | null = null
private agingTimer: ReturnType<typeof setInterval> | null = null
private currentRange = this.config.defaultRangeMinutes private currentRange = this.config.defaultRangeMinutes
private lastTimestamp: number | undefined private lastTimestamp: number | undefined
private lastBroadcast: ITrafficDataPoint[] = []
constructor( constructor(
private emit: (snapshot: ITrafficWorkerSnapshotMessage) => void, private emit: (snapshot: ITrafficWorkerSnapshotMessage) => void,
@@ -82,6 +77,10 @@ class InlineTrafficMonitor {
clearTimeout(this.throttleTimer) clearTimeout(this.throttleTimer)
this.throttleTimer = null this.throttleTimer = null
} }
if (this.agingTimer !== null) {
clearInterval(this.agingTimer)
this.agingTimer = null
}
this.sampler.clear() this.sampler.clear()
this.lastTimestamp = undefined this.lastTimestamp = undefined
} }
@@ -92,6 +91,14 @@ class InlineTrafficMonitor {
this.config = { ...message.config } this.config = { ...message.config }
this.sampler = new TrafficDataSampler(this.config) this.sampler = new TrafficDataSampler(this.config)
this.currentRange = message.config.defaultRangeMinutes this.currentRange = message.config.defaultRangeMinutes
// Re-broadcast when points age out of the range, not on every tick
this.agingTimer = setInterval(() => {
if (this.throttleTimer !== null) return
const slice = this.sampler.getDataForTimeRange(this.currentRange)
if (!isSameTrafficData(this.lastBroadcast, slice)) {
this.emitSnapshot('interval')
}
}, 1000)
this.emitSnapshot('init') this.emitSnapshot('init')
break break
} }
@@ -101,7 +108,6 @@ class InlineTrafficMonitor {
up: message.payload.up || 0, up: message.payload.up || 0,
down: message.payload.down || 0, down: message.payload.down || 0,
timestamp, timestamp,
name: formatTrafficName(timestamp),
} }
this.lastTimestamp = timestamp this.lastTimestamp = timestamp
@@ -133,19 +139,16 @@ class InlineTrafficMonitor {
private emitSnapshot(reason: ITrafficWorkerSnapshotMessage['reason']) { private emitSnapshot(reason: ITrafficWorkerSnapshotMessage['reason']) {
const dataPoints = this.sampler.getDataForTimeRange(this.currentRange) const dataPoints = this.sampler.getDataForTimeRange(this.currentRange)
const availableDataPoints = this.sampler.getDataForTimeRange(
this.config.compressedDataMinutes,
)
this.emit({ this.emit({
type: 'snapshot', type: 'snapshot',
dataPoints, dataPoints,
availableDataPoints,
samplerStats: this.sampler.getStats(), samplerStats: this.sampler.getStats(),
rangeMinutes: this.currentRange, rangeMinutes: this.currentRange,
lastTimestamp: this.lastTimestamp, lastTimestamp: this.lastTimestamp,
reason, reason,
}) })
this.lastBroadcast = dataPoints
} }
private scheduleSnapshot(reason: ITrafficWorkerSnapshotMessage['reason']) { private scheduleSnapshot(reason: ITrafficWorkerSnapshotMessage['reason']) {
@@ -207,9 +210,9 @@ class TrafficWorkerClient {
message.lastTimestamp ?? 0, message.lastTimestamp ?? 0,
) )
document.documentElement.dataset.perfPoints = String( document.documentElement.dataset.perfPoints = String(
message.availableDataPoints.length, message.dataPoints.length,
) )
const last = message.availableDataPoints.at(-1) const last = message.dataPoints.at(-1)
document.documentElement.dataset.perfLastUp = String( document.documentElement.dataset.perfLastUp = String(
last?.up ?? 0, last?.up ?? 0,
) )
@@ -360,6 +363,8 @@ const EMPTY_STATS: ISamplerStats = {
totalMemoryPoints: 0, totalMemoryPoints: 0,
} }
const EMPTY_DATA: ITrafficDataPoint[] = []
/** /**
* 增强的流量监控Hook - Web Worker驱动的数据采样与压缩 * 增强的流量监控Hook - Web Worker驱动的数据采样与压缩
*/ */
@@ -371,22 +376,18 @@ export const useTrafficMonitorEnhanced = (options?: {
const enabled = options?.enabled ?? true const enabled = options?.enabled ?? true
const isVisible = useVisibility() const isVisible = useVisibility()
const [latestSnapshot, setLatestSnapshot] = useState<{ const [latestSnapshot, setLatestSnapshot] = useState<{
availableDataPoints: ITrafficDataPoint[] dataPoints: ITrafficDataPoint[]
samplerStats: ISamplerStats samplerStats: ISamplerStats
lastTimestamp?: number
}>({ }>({
availableDataPoints: [], dataPoints: [],
samplerStats: EMPTY_STATS, samplerStats: EMPTY_STATS,
lastTimestamp: undefined,
}) })
const [rangeMinutes, setRangeMinutes] = useState(
WORKER_CONFIG.defaultRangeMinutes,
)
const [now, setNow] = useState(() => Date.now())
const [, forceRefCountRender] = useReducer((value) => value + 1, 0) const [, forceRefCountRender] = useReducer((value) => value + 1, 0)
const clientRef = useRef<TrafficWorkerClient | null>(getWorkerClient()) const clientRef = useRef<TrafficWorkerClient | null>(getWorkerClient())
const currentRangeRef = useRef<number>(WORKER_CONFIG.defaultRangeMinutes) const currentRangeRef = useRef<number>(WORKER_CONFIG.defaultRangeMinutes)
const isVisibleRef = useRef(isVisible)
isVisibleRef.current = isVisible
// 注册引用计数与Worker生命周期 // 注册引用计数与Worker生命周期
useEffect(() => { useEffect(() => {
@@ -404,15 +405,12 @@ export const useTrafficMonitorEnhanced = (options?: {
let unsubscribe: (() => void) | undefined let unsubscribe: (() => void) | undefined
if (subscribeToSnapshots) { if (subscribeToSnapshots) {
unsubscribe = client.onSnapshot((message) => { unsubscribe = client.onSnapshot((message) => {
if (!isVisibleRef.current) return
setLatestSnapshot({ setLatestSnapshot({
availableDataPoints: dataPoints: message.dataPoints,
message.availableDataPoints ?? message.dataPoints,
samplerStats: message.samplerStats, samplerStats: message.samplerStats,
lastTimestamp: message.lastTimestamp,
}) })
}) })
client.requestSnapshot()
} }
return () => { return () => {
@@ -425,15 +423,11 @@ export const useTrafficMonitorEnhanced = (options?: {
} }
}, [enabled, subscribeToSnapshots]) }, [enabled, subscribeToSnapshots])
// Periodically refresh "now" so idle streams age out of the selected window when subscribed // Re-prime snapshot ingestion after every visible transition
useEffect(() => { useEffect(() => {
if (!enabled || !subscribeToSnapshots || !isVisible) return if (enabled && subscribeToSnapshots && isVisible) {
clientRef.current?.requestSnapshot()
const timer = window.setInterval(() => { }
setNow(Date.now())
}, 1000)
return () => window.clearInterval(timer)
}, [enabled, subscribeToSnapshots, isVisible]) }, [enabled, subscribeToSnapshots, isVisible])
// 添加流量数据 // 添加流量数据
@@ -450,7 +444,6 @@ export const useTrafficMonitorEnhanced = (options?: {
(minutes: number) => { (minutes: number) => {
if (!enabled) return if (!enabled) return
currentRangeRef.current = minutes currentRangeRef.current = minutes
setRangeMinutes(minutes)
clientRef.current?.setRange(minutes) clientRef.current?.setRange(minutes)
}, },
[enabled], [enabled],
@@ -462,19 +455,9 @@ export const useTrafficMonitorEnhanced = (options?: {
clientRef.current?.clearData() clientRef.current?.clearData()
}, [enabled]) }, [enabled])
const filteredDataPoints = useMemo(() => {
if (!enabled) return []
const sourceData = latestSnapshot.availableDataPoints
if (sourceData.length === 0) return []
const cutoff = now - rangeMinutes * 60 * 1000
return sourceData.filter((point) => point.timestamp > cutoff)
}, [enabled, latestSnapshot.availableDataPoints, rangeMinutes, now])
return { return {
graphData: { graphData: {
dataPoints: filteredDataPoints, dataPoints: enabled ? latestSnapshot.dataPoints : EMPTY_DATA,
currentRangeMinutes: rangeMinutes,
requestRange, requestRange,
appendData, appendData,
clearData, clearData,
+14 -6
View File
@@ -1,4 +1,4 @@
import { TrafficDataSampler, formatTrafficName } from '../utils/traffic-sampler' import { TrafficDataSampler, isSameTrafficData } from '../utils/traffic-sampler'
const DEFAULT_CONFIG: ISamplingConfig & { const DEFAULT_CONFIG: ISamplingConfig & {
snapshotIntervalMs: number snapshotIntervalMs: number
@@ -16,6 +16,8 @@ interface WorkerScope {
onmessage: ((event: MessageEvent<TrafficWorkerRequestMessage>) => void) | null onmessage: ((event: MessageEvent<TrafficWorkerRequestMessage>) => void) | null
setTimeout: typeof setTimeout setTimeout: typeof setTimeout
clearTimeout: typeof clearTimeout clearTimeout: typeof clearTimeout
setInterval: typeof setInterval
clearInterval: typeof clearInterval
} }
const ctx: WorkerScope = self as unknown as WorkerScope const ctx: WorkerScope = self as unknown as WorkerScope
@@ -25,18 +27,15 @@ let sampler = new TrafficDataSampler(config)
let currentRangeMinutes = config.defaultRangeMinutes let currentRangeMinutes = config.defaultRangeMinutes
let throttleTimer: ReturnType<typeof setTimeout> | null = null let throttleTimer: ReturnType<typeof setTimeout> | null = null
let lastTimestamp: number | undefined let lastTimestamp: number | undefined
let lastBroadcast: ITrafficDataPoint[] = []
const broadcastSnapshot = (reason: ITrafficWorkerSnapshotMessage['reason']) => { const broadcastSnapshot = (reason: ITrafficWorkerSnapshotMessage['reason']) => {
const dataPoints = sampler.getDataForTimeRange(currentRangeMinutes) const dataPoints = sampler.getDataForTimeRange(currentRangeMinutes)
const availableDataPoints = sampler.getDataForTimeRange(
config.compressedDataMinutes,
)
const samplerStats = sampler.getStats() const samplerStats = sampler.getStats()
const message: ITrafficWorkerSnapshotMessage = { const message: ITrafficWorkerSnapshotMessage = {
type: 'snapshot', type: 'snapshot',
dataPoints, dataPoints,
availableDataPoints,
samplerStats, samplerStats,
rangeMinutes: currentRangeMinutes, rangeMinutes: currentRangeMinutes,
lastTimestamp, lastTimestamp,
@@ -44,6 +43,7 @@ const broadcastSnapshot = (reason: ITrafficWorkerSnapshotMessage['reason']) => {
} }
ctx.postMessage(message) ctx.postMessage(message)
lastBroadcast = dataPoints
} }
const scheduleSnapshot = (reason: ITrafficWorkerSnapshotMessage['reason']) => { const scheduleSnapshot = (reason: ITrafficWorkerSnapshotMessage['reason']) => {
@@ -62,6 +62,15 @@ ctx.onmessage = (event: MessageEvent<TrafficWorkerRequestMessage>) => {
config = { ...message.config } config = { ...message.config }
sampler = new TrafficDataSampler(config) sampler = new TrafficDataSampler(config)
currentRangeMinutes = message.config.defaultRangeMinutes currentRangeMinutes = message.config.defaultRangeMinutes
// Re-broadcast when points age out of the range, not on every tick;
// no handle needed, worker teardown (terminate) drops pending timers
ctx.setInterval(() => {
if (throttleTimer !== null) return
const slice = sampler.getDataForTimeRange(currentRangeMinutes)
if (!isSameTrafficData(lastBroadcast, slice)) {
broadcastSnapshot('interval')
}
}, 1000)
broadcastSnapshot('init') broadcastSnapshot('init')
break break
} }
@@ -71,7 +80,6 @@ ctx.onmessage = (event: MessageEvent<TrafficWorkerRequestMessage>) => {
up: message.payload.up || 0, up: message.payload.up || 0,
down: message.payload.down || 0, down: message.payload.down || 0,
timestamp, timestamp,
name: formatTrafficName(timestamp),
} }
lastTimestamp = timestamp lastTimestamp = timestamp
-2
View File
@@ -973,7 +973,6 @@ interface ITrafficDataPoint {
up: number up: number
down: number down: number
timestamp: number timestamp: number
name: string
} }
interface ISamplingConfig { interface ISamplingConfig {
@@ -1029,7 +1028,6 @@ type TrafficWorkerRequestMessage =
interface ITrafficWorkerSnapshotMessage { interface ITrafficWorkerSnapshotMessage {
type: 'snapshot' type: 'snapshot'
dataPoints: ITrafficDataPoint[] dataPoints: ITrafficDataPoint[]
availableDataPoints: ITrafficDataPoint[]
samplerStats: ISamplerStats samplerStats: ISamplerStats
rangeMinutes: number rangeMinutes: number
lastTimestamp?: number lastTimestamp?: number
+23 -1
View File
@@ -24,6 +24,29 @@ export const formatTrafficName = (timestamp: number) => {
)}` )}`
} }
export const isSameTrafficData = (
current: ITrafficDataPoint[],
next: ITrafficDataPoint[],
) => {
if (current === next) return true
if (current.length !== next.length) return false
for (let i = 0; i < current.length; i++) {
const currentPoint = current[i]
const nextPoint = next[i]
if (
currentPoint.timestamp !== nextPoint.timestamp ||
currentPoint.up !== nextPoint.up ||
currentPoint.down !== nextPoint.down
) {
return false
}
}
return true
}
export class TrafficDataSampler { export class TrafficDataSampler {
private rawBuffer: ITrafficDataPoint[] = [] private rawBuffer: ITrafficDataPoint[] = []
private rawHead = 0 private rawHead = 0
@@ -125,7 +148,6 @@ export class TrafficDataSampler {
up: p.up, up: p.up,
down: p.down, down: p.down,
timestamp: p.timestamp, timestamp: p.timestamp,
name: formatTrafficName(p.timestamp),
})) }))
return [...compressedData, ...rawData].sort( return [...compressedData, ...rawData].sort(