# -*- coding: utf-8 -*- """性能预算与校验引擎(Issue #54 / PRD 5.5「⑤ 配置化驾驶舱」)。 PRD 5.5 验收口径:**首屏渲染 ≤ 2s**(弱网/中端设备)。本模块把这条口径落为 **可校验的性能预算模型**——给定资源清单(HTML/CSS/JS/字体/图片/接口)与 各资源的实测耗时,判定首屏是否达标、哪些资源超预算、给出可解释的优化建议。 设计要点 -------- 1. **性能预算即规格**:首屏预算(默认 2000ms)拆解到各资源类型(JS/CSS/ 字体/图片/接口/其它),每类给一条时间预算(``ResourceBudget``)。预算与 行业模板绑定,换行业只改预算,不改前端代码。 2. **资源测量**(``ResourceMeasurement``):一个资源 = 类型 + 名称 + 传输字节 + 解析/执行耗时 + 是否阻塞渲染。耗时来源 RUM(真实用户监控)/合成监控。 3. **校验器**(``BudgetVerifier``):对一组测量值计算首屏总耗时(关键路径 串行耗时),与预算比对,产出 :class:`BudgetReport`(达标/超预算状态 + 超预算明细 + 优化建议,每条建议带 ``reason`` 可解释)。 4. **关键路径**:阻塞渲染的资源(``block_render=True``)串行累加;非阻塞 资源并行,仅取其最大值。该口径与浏览器首屏渲染时间(FCP/LCP)对齐。 5. **纯标准库**:无 numpy/pyyaml 依赖,与 iAOP 零运行时依赖原则一致。 用法:: budget = PerformanceBudget.default_cockpit() # 首屏 ≤ 2s 默认预算 measurements = [...] # 来自 RUM 的资源耗时 report = BudgetVerifier(budget).verify(measurements) if not report.passed: for item in report.overrun: print(item.resource.name, item.advice) """ from __future__ import annotations import os from dataclasses import dataclass, field from enum import Enum from typing import Dict, List, Optional, Sequence, Tuple #: 默认首屏预算(毫秒)—— PRD 5.5:首屏 ≤ 2s。 DEFAULT_FIRST_PAINT_BUDGET_MS = 2000 class BudgetError(ValueError): """性能预算声明/校验错误(预算负数、资源类型未知、测量值非法等)。""" class ResourceType(str, Enum): """资源类型(决定其时间预算归属与首屏关键路径权重)。 对齐浏览器 Performance Resource Timing 的 ``initiatorType`` 子集, 覆盖驾驶舱首屏主要资源。 """ HTML = "html" # 文档主体(必阻塞) CSS = "css" # 样式表(默认阻塞渲染) JS = "js" # 脚本(默认阻塞解析,可声明 async/defer) FONT = "font" # 字体(可阻塞文本渲染) IMAGE = "image" # 图片/图标 API = "api" # 首屏 XHR/fetch 接口 OTHER = "other" # 其它(追踪/埋点等) @property def label(self) -> str: return { ResourceType.HTML: "HTML 文档", ResourceType.CSS: "样式表", ResourceType.JS: "脚本", ResourceType.FONT: "字体", ResourceType.IMAGE: "图片", ResourceType.API: "接口", ResourceType.OTHER: "其它", }[self] # 类型注册表:字符串名 → ResourceType(模板配置用字符串,引擎内用枚举)。 TYPE_REGISTRY: Dict[str, ResourceType] = {t.value: t for t in ResourceType} @dataclass class ResourceBudget: """单类资源的时间预算(毫秒)。 Attributes: resource_type: 资源类型。 time_ms: 该类资源在首屏关键路径上的时间预算(ms)。 note: 预算说明(工艺/架构可解释,如 "首屏 JS 解析预算")。 """ resource_type: ResourceType time_ms: float note: str = "" def __post_init__(self) -> None: if self.time_ms < 0: raise BudgetError( f"ResourceBudget({self.resource_type.value}).time_ms 不能为负," f"实际 {self.time_ms}") @dataclass class PerformanceBudget: """性能预算:首屏总预算 + 分资源预算。 首屏总预算(``first_paint_ms``)= 关键路径渲染时间上限;分资源预算 (``by_type``)是按类型的归口预算,用于定位超预算的资源类别。两者共同 校验:任一类超预算或首屏总耗时超 ``first_paint_ms`` 即判 FAIL。 Attributes: name: 预算名(如 "cockpit-default")。 first_paint_ms: 首屏渲染总预算(ms),默认 2000。 by_type: 类型 → :class:`ResourceBudget`。 description: 预算描述(引 PRD 口径)。 """ name: str first_paint_ms: float = DEFAULT_FIRST_PAINT_BUDGET_MS by_type: Dict[ResourceType, ResourceBudget] = field(default_factory=dict) description: str = "" def __post_init__(self) -> None: if self.first_paint_ms <= 0: raise BudgetError( f"first_paint_ms 必须 > 0,实际 {self.first_paint_ms}") for rb in self.by_type.values(): if not isinstance(rb, ResourceBudget): raise BudgetError(f"by_type 值必须是 ResourceBudget,实际 {type(rb)}") # -- 构造便捷方法 ---------------------------------------------------- @classmethod def default_cockpit(cls) -> "PerformanceBudget": """驾驶舱默认预算(首屏 ≤ 2s,PRD 5.5)。 分资源预算(合计 1900ms,留 100ms 渲染裕量): - HTML 文档 200ms(首字节 + 解析) - CSS 300ms(关键样式阻塞渲染) - JS 800ms(脚本解析执行,最大头) - 字体 200ms(首屏字体加载) - 接口 300ms(首屏关键接口) - 图片 100ms(占位/骨架) """ return cls( name="cockpit-default", first_paint_ms=DEFAULT_FIRST_PAINT_BUDGET_MS, description="驾驶舱默认性能预算(PRD 5.5:首屏 ≤ 2s)", by_type={ ResourceType.HTML: ResourceBudget( ResourceType.HTML, 200, "首字节 + HTML 解析"), ResourceType.CSS: ResourceBudget( ResourceType.CSS, 300, "关键样式阻塞渲染"), ResourceType.JS: ResourceBudget( ResourceType.JS, 800, "脚本解析执行(首屏最大头)"), ResourceType.FONT: ResourceBudget( ResourceType.FONT, 200, "首屏字体加载"), ResourceType.API: ResourceBudget( ResourceType.API, 300, "首屏关键接口(趋势/KPI)"), ResourceType.IMAGE: ResourceBudget( ResourceType.IMAGE, 100, "占位/骨架图"), }, ) # -- 查询 ------------------------------------------------------------ def budget_for(self, resource_type: ResourceType) -> float: """取某类资源的时间预算(ms);未声明该类 → 返回 0(无归口预算)。""" rb = self.by_type.get(resource_type) return rb.time_ms if rb else 0.0 def covered_types(self) -> List[ResourceType]: """已声明预算的资源类型(有序)。""" return [rt for rt in ResourceType if rt in self.by_type] @dataclass class ResourceMeasurement: """单个资源的实测耗时(来自 RUM / 合成监控)。 Attributes: name: 资源名/URL(可读,用于报告定位)。 resource_type: 资源类型。 duration_ms: 单资源耗时(传输 + 解析 + 执行,ms)。 size_bytes: 传输字节(可选,用于体积建议)。 block_render: 是否阻塞渲染(关键路径串行累加;async/defer/非首屏=false)。 """ name: str resource_type: ResourceType duration_ms: float size_bytes: int = 0 block_render: bool = False def __post_init__(self) -> None: if not self.name: raise BudgetError("ResourceMeasurement.name 不能为空") if self.duration_ms < 0: raise BudgetError( f"资源 {self.name!r} duration_ms 不能为负,实际 {self.duration_ms}") if self.size_bytes < 0: raise BudgetError( f"资源 {self.name!r} size_bytes 不能为负,实际 {self.size_bytes}") class BudgetStatus(str, Enum): """预算校验状态。""" PASS = "pass" # 达标 FAIL = "fail" # 超预算 EMPTY = "empty" # 无测量值(无可校验内容) @dataclass class BudgetOverrun: """单条超预算记录(可解释:附 reason + advice)。""" resource_name: str resource_type: ResourceType actual_ms: float budget_ms: float reason: str # 为何超预算(事实陈述,如 "JS 耗时 950ms 超预算 800ms") advice: str # 优化建议(如 "代码分割 / 懒加载非首屏图表") @property def overrun_ms(self) -> float: return max(0.0, self.actual_ms - self.budget_ms) @property def overrun_ratio(self) -> float: """超幅比例(>1.0 表示超预算;实际/预算)。""" return (self.actual_ms / self.budget_ms) if self.budget_ms > 0 else float("inf") @dataclass class BudgetReport: """性能预算校验报告(可解释:状态 + 超预算明细 + 建议 + 总耗时)。""" status: BudgetStatus first_paint_ms: float # 测得的首屏关键路径总耗时 budget_ms: float # 首屏预算 by_type_actual: Dict[ResourceType, float] = field(default_factory=dict) overrun: List[BudgetOverrun] = field(default_factory=list) suggestions: List[str] = field(default_factory=list) reason: str = "" # 整体达标/不达标的可解释结论 @property def passed(self) -> bool: """是否达标(status == PASS)。""" return self.status == BudgetStatus.PASS def to_dict(self) -> dict: return { "status": self.status.value, "passed": self.passed, "first_paint_ms": round(self.first_paint_ms, 2), "budget_ms": self.budget_ms, "overrun_count": len(self.overrun), "by_type_actual": {rt.value: round(v, 2) for rt, v in self.by_type_actual.items()}, "suggestions": list(self.suggestions), "reason": self.reason, } class BudgetVerifier: """性能预算校验器:测量值 → 报告。 关键路径耗时口径: - 阻塞渲染资源(``block_render=True``)的耗时**串行累加**; - 非阻塞资源耗时并行,仅取其最大值,与串行耗时相加为首屏总耗时。 - 该口径对齐浏览器渲染:阻塞资源决定首次渲染时刻,并行资源不延后。 Args: budget: 性能预算。 """ def __init__(self, budget: PerformanceBudget) -> None: self.budget = budget # ------------------------------------------------------------------ def verify(self, measurements: Sequence[ResourceMeasurement]) -> BudgetReport: """对一组资源测量值校验预算。""" if not measurements: return BudgetReport( status=BudgetStatus.EMPTY, first_paint_ms=0.0, budget_ms=self.budget.first_paint_ms, reason="无测量值,无法校验性能预算", ) # 1) 按类型聚合实际耗时(关键路径:阻塞串行 + 非阻塞取最大) by_type_actual: Dict[ResourceType, float] = {} blocking_total = 0.0 non_blocking_max: Dict[ResourceType, float] = {} for m in measurements: by_type_actual.setdefault(m.resource_type, 0.0) if m.block_render: blocking_total += m.duration_ms # 该类阻塞耗时叠加到 by_type_actual(阻塞总和) by_type_actual[m.resource_type] += m.duration_ms else: # 非阻塞:该类型取所有非阻塞资源里的最大值 prev = non_blocking_max.get(m.resource_type, 0.0) non_blocking_max[m.resource_type] = max(prev, m.duration_ms) # 非阻塞类型累加其最大值(与阻塞串行相加为首屏总耗时) # 注意:某类型若同时有阻塞/非阻塞资源,非阻塞最大值与阻塞总和相加 non_blocking_total = sum(non_blocking_max.values()) first_paint_ms = blocking_total + non_blocking_total # by_type_actual:阻塞已叠加;非阻塞类型补齐其最大值 for rt, mx in non_blocking_max.items(): if rt not in by_type_actual or by_type_actual[rt] == 0.0: by_type_actual[rt] = mx else: by_type_actual[rt] += mx # 2) 逐类型比对预算,产出超预算明细 + 建议 overrun: List[BudgetOverrun] = [] suggestions: List[str] = [] for rt in ResourceType: actual = by_type_actual.get(rt, 0.0) budget = self.budget.budget_for(rt) if budget <= 0: continue # 未声明该类预算(如 OTHER)跳过 if actual > budget: advice = self._advice_for(rt, actual, budget) overrun.append(BudgetOverrun( resource_name=f"<{rt.value}>", resource_type=rt, actual_ms=actual, budget_ms=budget, reason=f"{rt.label} 耗时 {actual:.0f}ms 超预算 " f"{budget:.0f}ms(超 {actual - budget:.0f}ms)", advice=advice, )) suggestions.append(advice) # 3) 首屏总预算校验 over_total = first_paint_ms > self.budget.first_paint_ms if not overrun and not over_total: margin = self.budget.first_paint_ms - first_paint_ms return BudgetReport( status=BudgetStatus.PASS, first_paint_ms=first_paint_ms, budget_ms=self.budget.first_paint_ms, by_type_actual=dict(by_type_actual), overrun=[], suggestions=[], reason=f"首屏 {first_paint_ms:.0f}ms ≤ 预算 " f"{self.budget.first_paint_ms:.0f}ms(裕量 {margin:.0f}ms)", ) # 不达标:总耗时或某类超预算 reasons: List[str] = [] if over_total: reasons.append( f"首屏 {first_paint_ms:.0f}ms 超总预算 " f"{self.budget.first_paint_ms:.0f}ms") if overrun: reasons.append(f"{len(overrun)} 类资源超归口预算") if not suggestions: suggestions.append( f"首屏总耗时 {first_paint_ms:.0f}ms 超预算,需整体压缩关键路径" f"(代码分割 / 懒加载 / 预渲染)") return BudgetReport( status=BudgetStatus.FAIL, first_paint_ms=first_paint_ms, budget_ms=self.budget.first_paint_ms, by_type_actual=dict(by_type_actual), overrun=overrun, suggestions=suggestions, reason=";".join(reasons), ) # ------------------------------------------------------------------ @staticmethod def _advice_for(rt: ResourceType, actual: float, budget: float) -> str: """针对资源类型给出可解释优化建议(reason 体现为什么)。""" if rt is ResourceType.JS: return (f"{rt.label} 超预算 {actual - budget:.0f}ms:代码分割" f"(按路由拆 chunk)、首屏非关键脚本 defer/async、" f"Tree-shaking 移除死代码") if rt is ResourceType.CSS: return (f"{rt.label} 超预算 {actual - budget:.0f}ms:抽取关键" f"内联 CSS(Critical CSS)、首屏外样式异步加载") if rt is ResourceType.FONT: return (f"{rt.label} 超预算 {actual - budget:.0f}ms:font-display:" f"swap、preload 首屏字体、子集化中文字体") if rt is ResourceType.API: return (f"{rt.label} 超预算 {actual - budget:.0f}ms:首屏接口" f"合并(BFF)、SSR 预取、CDN 边缘缓存") if rt is ResourceType.IMAGE: return (f"{rt.label} 超预算 {actual - budget:.0f}ms:懒加载" f"viewport 外图片、WebP/AVIF、响应式 srcset") if rt is ResourceType.HTML: return (f"{rt.label} 超预算 {actual - budget:.0f}ms:启用 HTTP/2" f"推送、SSR 预渲染、CDN 边缘缓存文档") return (f"{rt.label} 超预算 {actual - budget:.0f}ms:优化或移出" f"首屏关键路径")