Files
iAOP/templates/ti-cl4/perf-budget/perf_budget.py
T
bot_dev1 5da367dfd8 feat(#54): 性能预算与懒加载策略引擎(首屏≤2s,图表懒加载)
纯标准库实现,对齐 PRD 5.5「⑤ 配置化驾驶舱」性能口径:

- perf_budget.py:PerformanceBudget(首屏 2000ms + 6 类资源预算)、
  ResourceMeasurement(block_render 区分关键路径)、BudgetVerifier
  (阻塞串行+非阻塞取最大口径,逐类型比对预算,PASS/FAIL/EMPTY +
  类型化优化建议,reason 可解释)。
- lazy_load.py:LazyLoadStrategy(immediate/visible/idle/never)、
  LazyLoadPolicy(视口行数+预加载+分页阈值+虚拟滚动)、LazyLoadPlanner
  (widget 坐标+滚动位置 → 决策+分页计划+虚拟滚动窗口)。
- tests:32 用例覆盖预算构造/查询、关键路径口径、达标/超预算场景、
  always_load 强制加载、视口边界、分页/虚拟滚动、输入校验。
- _sanity_check.py:冒烟验证达标/超预算/懒加载分流。
2026-08-05 05:26:09 +08:00

398 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- 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"首屏关键路径")