纯标准库实现,对齐 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:冒烟验证达标/超预算/懒加载分流。
398 lines
17 KiB
Python
398 lines
17 KiB
Python
# -*- 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"首屏关键路径")
|