启动耗时诊断器显示冷启动从 1.04 秒降到 812 ms,数字看起来很好。但两次采集不一定从同一时刻开始:一次以进程创建为零点,另一次以应用框架初始化为零点,瀑布图上的条形更短,却无法判断优化到底发生在哪个阶段。
零点差多少,结论就能假多少。以框架初始化为零点的采集,天然比以进程创建为零点少一段,两次条形摆在一起,短的那次未必真的快。所以对齐不是图表美化,是结论成立的前提。
我先给一次启动分配稳定的 traceId,再把所有阶段转换成相对同一个根事件的时间。图表渲染只消费对齐后的片段。
启动阶段需要父子关系
export interface StartupSpan {
id: string
traceId: string
parentId?: string
name: string
startedAt: number
endedAt: number
thread: 'main' | 'worker' | 'io'
}
export interface AlignedSpan extends StartupSpan {
offsetMs: number
durationMs: number
}
traceId 区分不同启动批次,parentId 用于确认资源初始化是否属于同一条启动链。没有这些字段,多个进程或上一次残留回调很容易混进当前瀑布图。
traceId 在进程内自增,冷启动记一次、热启动另记一次,两次之间不共用。残留回调靠它挡在门外:晚到的 span 只要 traceId 对不上,alignTrace 直接过滤掉,图表上看不见,也不会把 offset 算歪。
let traceSeq = 0
export function nextTraceId(kind: 'cold' | 'hot'): string {
traceSeq += 1
return `${kind}-${traceSeq}`
}
一个进程里冷启动通常只发生一次,热启动可能连着来七八回。自增序列让每次采集都有唯一编号,导出报告时按编号就能把同一批片段找齐,不用靠时间戳猜。
有了父子关系,下一步就是把这些片段对齐到同一个零点。
export function alignTrace(spans: StartupSpan[], traceId: string): AlignedSpan[] {
const current = spans.filter((span: StartupSpan) => span.traceId === traceId)
if (current.length === 0) return []
const root = current.find((span: StartupSpan) => span.parentId === undefined) ??
current.slice().sort((a: StartupSpan, b: StartupSpan) => a.startedAt - b.startedAt)[0]
return current.map((span: StartupSpan) => ({
...span,
offsetMs: Math.max(0, span.startedAt - root.startedAt),
durationMs: Math.max(0, span.endedAt - span.startedAt)
})).sort((a: AlignedSpan, b: AlignedSpan) =>
a.offsetMs - b.offsetMs || a.durationMs - b.durationMs)
}
如果采集器没有显式根事件,就用最早开始的片段兜底,但页面应标记为“近似对齐”。真实诊断优先要求采集层上报进程创建或启动请求的根 span。
兜底方案在本地演示里够用:所有片段同源,最早开始的那个离真正的进程创建通常只差几毫秒。可它一旦出现在对比报告里,118 ms 的“优化”就可能整个是零点差造成的,所以近似对齐的图表只在调研时看,不进结论。

对比前先按名称与父链配对
“资源初始化”可能有两个同名任务,单纯按名字比对会把 IO 线程和主线程混在一起。配对键由父链、名称和线程组成。
function lineage(span: StartupSpan, all: StartupSpan[]): string {
const result: string[] = [span.name]
let parentId = span.parentId
while (parentId) {
const parent = all.find((item: StartupSpan) => item.id === parentId)
if (!parent) break
result.unshift(parent.name)
parentId = parent.parentId
}
return `${span.thread}:${result.join('/')}`
}
export interface SpanDiff {
key: string
beforeMs: number
afterMs: number
deltaMs: number
}
export function compare(before: StartupSpan[], after: StartupSpan[]): SpanDiff[] {
const afterMap = new Map(after.map((span: StartupSpan) => [lineage(span, after), span]))
return before.map((span: StartupSpan) => {
const key = lineage(span, before)
const next = afterMap.get(key)
const beforeMs = Math.max(0, span.endedAt - span.startedAt)
const afterMs = next ? Math.max(0, next.endedAt - next.startedAt) : 0
return { key, beforeMs, afterMs, deltaMs: afterMs - beforeMs }
})
}
这样诊断器能明确说明“资源初始化减少 118 ms”,而不是只给出一个总时长差。
对比结果按阶段键逐行给出,长这样:
| 阶段键 | 优化前 | 优化后 | 变化 |
|---|---|---|---|
| main:启动/资源初始化 | 236 ms | 118 ms | -118 ms |
| io:启动/资源初始化 | 96 ms | 94 ms | -2 ms |
| main:启动/首帧布局 | 154 ms | 143 ms | -11 ms |
| worker:启动/预加载数据 | 88 ms | 未采集 | 不作对比 |
同一份报告里,主线程的资源初始化砍掉一半,IO 线程只少了 2 ms,着力点一眼能看出来。最后一行是没采到的部分,画成横杠,不画成 0。
首帧和可交互,两个终点分开报
页面显示首帧时间 812 ms、可交互时间 1.16 秒。首帧意味着内容已绘制,可交互则还要等输入处理、关键数据和动画门槛满足。它们应该分别由不同事件结束。
这两个数字差着 348 ms,差的正是输入通道打通的那一段。只报首帧,用户点第一下没反应的尴尬就被藏起来了;只报可交互,渲染侧的优化又看不见。两个终点并排摆,谁的问题谁领走。

export interface StartupMilestone {
traceId: string
kind: 'firstFrame' | 'interactive'
reachedAt: number
}
export function milestoneOffset(rootAt: number, milestone?: StartupMilestone): number | undefined {
return milestone ? Math.max(0, milestone.reachedAt - rootAt) : undefined
}
报告页不能把首帧缩短直接写成“用户已经能操作”。两个终点并排展示,优化资源初始化后若输入仍被阻塞,问题仍然会留在可交互时间上。
少采了一个阶段,图上怎么说话
某次启动没有采到某个 span,不能把它画成 0 ms。对比结果增加 missing 状态,报告区用“未采集”替代“已优化”。
export interface SafeSpanDiff extends SpanDiff {
missingBefore: boolean
missingAfter: boolean
}
export function toSafeDiff(
diff: SpanDiff,
beforeHas: boolean,
afterHas: boolean
): SafeSpanDiff {
return {
...diff,
missingBefore: !beforeHas,
missingAfter: !afterHas
}
}
export function describeDiff(diff: SafeSpanDiff): string {
if (diff.missingBefore && diff.missingAfter) return '两次都未采集'
if (diff.missingBefore) return '优化前未采集,不作对比'
if (diff.missingAfter) return '优化后未采集,疑似丢失'
const sign = diff.deltaMs > 0 ? '+' : ''
return `${diff.key} ${sign}${diff.deltaMs} ms`
}
“疑似丢失”这四个字是刻意留的:优化后反而采不到,通常是采集点被优化掉了,也可能是链路真的断了,两种都得人工看一眼才能下结论。
missingBefore 和 missingAfter 分开记也是有原因的:只记一个 missing,就没法区分“以前没采”和“现在没采”,而这两种情况后续动作完全不同——前者放弃对比,后者去查采集链路。
异常场景清单
对齐这条链上翻过的车,按出现频率排了四条,处理方式都收在上面的代码里:
- 采集器没给根事件。 兜底用最早开始的片段,页面标记“近似对齐”。这种模式下的报告只信趋势,不信绝对值,118 ms 的“优化”可能整个是零点差。
- 时间回退。 设备改时间或时钟同步让 endedAt 小于 startedAt,offsetMs 和 durationMs 都有 Math.max(0, …) 兜底,负值画不出反向条形。
- 同名 span 跨线程。 主线程和 IO 线程各有一个“资源初始化”,lineage 键带线程前缀,配对时不混。去掉前缀,两次对比会把 IO 的 96 ms 记到主线程账上。
- 里程碑缺省。 某次采集没有 firstFrame 事件,milestoneOffset 返回 undefined,报告区显示“—”。0 是量出来的,没有才是没采到,两个含义不能用一个数字表达。
当前数据用于本地诊断演示。后续接入真实性能追踪时,只要保证同一启动链拥有相同 traceId,瀑布图、阶段对比和首帧指标就能围绕统一零点解释,不会因为采集起点不同制造一场虚假的优化。
转载自 CSDN-专业IT技术社区
原文链接:https://blog.csdn.net/Dovis5884/article/details/169139310




