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:冒烟验证达标/超预算/懒加载分流。
This commit is contained in:
2026-08-05 05:26:09 +08:00
parent 2afea16005
commit 5da367dfd8
8 changed files with 1286 additions and 0 deletions
+397
View File
@@ -0,0 +1,397 @@
# -*- 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"首屏关键路径")