From 193ecbbd7fc9a69bfa32ba58398fffd98ee61606 Mon Sep 17 00:00:00 2001 From: hiyuki <674883329@qq.com> Date: Mon, 3 Aug 2026 19:41:24 +0800 Subject: [PATCH] refactor-mpx-perf --- .../mpx2rn/references/rn-script-reference.md | 46 +- docs-vitepress/guide/advance/perf.md | 476 ++++++------ packages/perf/AGENTS.md | 67 +- packages/perf/README.md | 234 ++++-- packages/perf/__tests__/bus.test.ts | 270 ++++--- packages/perf/__tests__/console.test.ts | 126 ++- packages/perf/__tests__/impl.test.ts | 279 +++++-- packages/perf/src/bus.ts | 147 +++- packages/perf/src/impl.ts | 138 +++- packages/perf/src/index.ts | 15 +- packages/perf/src/noop.ts | 26 +- packages/perf/src/reporters/console.ts | 307 +++++--- packages/perf/src/types.ts | 47 +- solutions/rn-image-onload-size-resolution.md | 563 ++++++++++++++ solutions/rn-runtime-perf-profile.md | 728 ++++++++++++++++++ 15 files changed, 2701 insertions(+), 768 deletions(-) create mode 100644 solutions/rn-image-onload-size-resolution.md create mode 100644 solutions/rn-runtime-perf-profile.md diff --git a/.agents/skills/mpx2rn/references/rn-script-reference.md b/.agents/skills/mpx2rn/references/rn-script-reference.md index 8324a60915..d43b95ae8e 100644 --- a/.agents/skills/mpx2rn/references/rn-script-reference.md +++ b/.agents/skills/mpx2rn/references/rn-script-reference.md @@ -687,36 +687,48 @@ createComponent({ ## 运行时性能探针 -`@mpxjs/perf` 为 Mpx2RN 提供编译期按需开启的耗时聚合和 mark 时间线。使用前在 `mpx.config.js` 的 `pluginOptions.mpx.plugin.perf` 中设置 `enable` 与 `probes: ['framework', 'user']`;关闭态会通过 DefinePlugin、tree-shaking 与 Terser 消除探针实现和名称字符串。 +`@mpxjs/perf` 为 Mpx2RN 提供编译期按需开启的三类性能统计。使用前在 `mpx.config.js` 的 `pluginOptions.mpx.plugin.perf` 中设置 `enable` 与 `probes: ['framework', 'user']`;关闭态会通过 DefinePlugin、tree-shaking 与 Terser 消除探针实现和名称字符串。 | API | RN 语义 | | --- | --- | -| `start()` / `end(reporter?)` | 打开/结束录制窗口,并自动生成名为 start/end 的时间线边界。空窗口也会触发 reporter。 | -| `scopeStart(name)` / `scopeEnd(id)` | 用数字句柄记录高频同步耗时;未录制时 start 返回 `-1`。 | -| `measureStart(name)` / `measureEnd(name)` | 用同一个 name 配对跨作用域耗时,并聚合到同名桶。 | -| `mark(name)` | 记录独立、有序的时间线里程碑,同名 mark 不合并。 | +| `aggrStart(name, useName?)` / `aggrEnd(idOrName)` | 区段聚类统计,输出 count/sum/avg/max。默认数字 id 模式适合高频、嵌套、同名并发;传 `true` 改用 name 配对。 | +| `traceStart(name, useName?)` / `traceEnd(idOrName, info?)` | 区段序列统计,保存每次开始位置和持续时长,可生成火焰图/瀑布图。默认数字 id 模式;传 `true` 改用 name 配对。 | +| `mark(name, info?)` | 点序列统计,记录独立、有序的时间线里程碑,同名 mark 不合并。 | +| `start(options?)` / `end(reporter?)` | 打开/结束录制窗口。`start` 可配置 `markLimit` / `traceLimit`,并自动生成 mark 的 start/end 边界。 | +| `scope*` / `measure*` | 旧聚类 API 兼容导出,内部复用 `aggr*`;新代码优先使用 `aggr*`。 | -Reporter 签名为 `(measures: Map, timeline?: MarkTimeline) => void`。`MarkTimeline.events` 中的 `at` 是相对当前 `start()` 的毫秒偏移;包含边界在内最多保留 256 条(start + 最多 254 个显式 mark + end),超出数量记录在 `dropped`,end 始终保留。 +Reporter 签名为 `(aggregates: Map, marks?: MarkTimeline, traces?: TraceTimeline) => void`。正常结束的窗口始终传入 marks 和 traces;后两个参数保持可选,兼容一、二参数 Reporter。 + +`MarkEvent` 与 `TraceEvent` 都使用 `start` 表示相对录制窗口的毫秒偏移、`timestamp` 表示原始时钟值;TraceEvent 另含 `duration`。两类事件都可保存可选 `info` 引用,调用侧应只传小型、可序列化的诊断字段。mark 和 trace 的默认容量均为 1024,分别用 `start({ markLimit, traceLimit })` 覆盖;trace 未完成区段计入 `incomplete`,容量溢出计入各自 `dropped`。 ```ts import { start, end, mark, - measureStart, measureEnd + aggrStart, aggrEnd, + traceStart, traceEnd } from '@mpxjs/perf' -if (__mpx_perf__) start() -if (__mpx_perf_user__) measureStart('goods:request') +if (__mpx_perf__) start({ traceLimit: 2048 }) -loadPageData().finally(() => { - if (__mpx_perf_user__) { - measureEnd('goods:request') - mark('goods:data-ready') - } - if (__mpx_perf__) end() -}) +let renderId = -1 +let moduleId = -1 +if (__mpx_perf_user__) { + renderId = aggrStart('goods:render') + moduleId = traceStart('goods:module') +} + +renderGoods() + +if (__mpx_perf_user__) { + aggrEnd(renderId) + traceEnd(moduleId, { module: 'goods' }) + mark('goods:data-ready', { source: 'cache' }) +} + +if (__mpx_perf__) end() ``` -所有调用必须直接置于 `if (__mpx_perf__)`、`if (__mpx_perf_framework__)` 或 `if (__mpx_perf_user__)` 字面量门禁中,确保关闭分组时零残留。`mark` 仅用于时间线,不是 measure 起点;旧 `mark/measure` 耗时写法需直接迁移为 `measureStart/measureEnd`,旧 `measure` 不再导出。 +所有调用必须直接置于 `if (__mpx_perf__)`、`if (__mpx_perf_framework__)` 或 `if (__mpx_perf_user__)` 字面量门禁中,确保关闭分组时零残留。聚类 id 模式沿用数组槽位和 free list,适合高频 render;trace/mark 会保留事件对象,只用于有限插桩点和诊断窗口。同名并发、递归或嵌套的 aggr/trace 必须使用默认 id 模式,name 模式后一次 start 会覆盖前一次映射。 --- diff --git a/docs-vitepress/guide/advance/perf.md b/docs-vitepress/guide/advance/perf.md index 1082d42f6b..6d4353f50b 100644 --- a/docs-vitepress/guide/advance/perf.md +++ b/docs-vitepress/guide/advance/perf.md @@ -1,31 +1,36 @@ # RN 运行时按需测速 {#rn-runtime-perf-probe} -Mpx 跨端输出 React Native 时,运行时核心组件(`mpx-view` / `mpx-text` / `mpx-simple-view` / `mpx-simple-text` 等)以及 `useTransformStyle` / `__getStyle` 等公共函数是高频热路径,在大列表、复杂样式、嵌套文本场景下经常成为性能瓶颈。Hermes Profiler / Flipper 只能看到 RN 原生层调用栈,难以定位到 `splitStyle`、`useTransformStyle`、`wrapChildren` 这些 mpx 自身逻辑。 +Mpx 跨端输出 React Native 时,运行时核心组件(`mpx-view` / `mpx-text` / `mpx-simple-view` / `mpx-simple-text` 等)以及 `useTransformStyle` / `__getStyle` 等公共函数是高频热路径。Hermes Profiler / Flipper 难以直接表达 Mpx 自身的逻辑分段,`@mpxjs/perf` 提供可按构建关闭、关闭态零残留的显式性能探针。 -`@mpxjs/perf` 是 Mpx 内置的运行时性能探针,提供「需要时打开、不需要时关闭、关闭态产物零字节残留」的能力。它同时提供实时聚合的 measure 数据和有序 mark 时间线,与 Hermes Profiler / Flipper / Perfetto 等系统级工具互补。 +## 三类统计能力 {#statistics} -## 设计原则 {#design-principles} +| 统计类型 | API | 输出 | 典型用途 | +| --- | --- | --- | --- | +| 区段聚类统计 | `aggrStart` / `aggrEnd` | `Map` | 高频 render、hook、函数的 count/sum/avg/max | +| 区段序列统计 | `traceStart` / `traceEnd` | `TraceTimeline` | 模块火焰图、嵌套调用 profile、异步区段瀑布图 | +| 点序列统计 | `mark` | `MarkTimeline` | 数据就绪、首次渲染、页面可交互等里程碑 | -`@mpxjs/perf` 采用「**编译期常量开关 + 运行时探针实现 + tree-shaking 兜底**」三层结构: +`start/end` 控制整个录制窗口,Reporter 在窗口结束时同步收到三类结果。聚类统计不保留逐次样本,适合高频热路径;trace 和 mark 会保留事件对象,只适合有限插桩点和诊断窗口。 + +## 设计原则 {#design-principles} -1. `MpxWebpackPlugin` 通过 `DefinePlugin` 注入一组 `__mpx_perf_*__` 字面量常量。 -2. 探针调用包在 `if (__mpx_perf_framework__) ...` / `if (__mpx_perf_user__) ...` 字面量条件里。 -3. Terser 把 `if (false) {...}` 整段消除,`@mpxjs/perf` 包内的 `impl` / `bus` / `reporters` 模块在 webpack tree-shaking 阶段被剔除。 +`@mpxjs/perf` 采用「编译期常量开关 + 运行时探针实现 + tree-shaking 兜底」三层结构: -最终:**关闭态下产物里既不存在探针代码,也不存在事件名字符串字面量,对 bundle size 与运行时性能均无任何影响**。 +1. `MpxWebpackPlugin` 通过 `DefinePlugin` 注入 `__mpx_perf__` 和分组常量。 +2. 探针调用直接包在 `if (__mpx_perf_framework__)` / `if (__mpx_perf_user__)` 字面量条件里。 +3. Terser 消除关闭分组的调用点,webpack tree-shaking 继续剔除失活的实现模块。 -measure 在录制窗口内实时聚合为 `Map`,不保留逐次耗时样本;push 阶段直接累加,end 时回填 `avg`。mark 则用于表达数据就绪、首次渲染、可交互等低频时间线里程碑,每次调用保留一条独立事件,同名 mark 不合并。时间线包含自动生成的 start/end 边界,总量固定最多 256 条。 +关闭态产物中不会保留探针实现、名称字符串或模块依赖。 ::: warning 该方案不支持线上动态开关 -线上开关意味着探针字节必须进入产物,与「关闭态零残留」目标冲突。线上诊断需重新打一个**开启探针的内测包**。 +线上开关意味着探针字节必须进入产物,与关闭态零残留目标冲突。线上诊断需重新构建一个开启探针的内测包。 ::: ## 配置入口 {#config} -在 `mpx.config.js` 的 `pluginOptions.mpx.plugin` 下新增 `perf` 字段(与 `MpxWebpackPlugin` 其他选项同级): +在 `mpx.config.js` 的 `pluginOptions.mpx.plugin` 下配置 `perf`: ```js -// mpx.config.js const { defineConfig } = require('@vue/cli-service') module.exports = defineConfig({ @@ -33,7 +38,7 @@ module.exports = defineConfig({ mpx: { plugin: { perf: { - enable: !!process.env.MPX_PERF, // 用环境变量控制内测构建 + enable: !!process.env.MPX_PERF, probes: ['framework', 'user'] } } @@ -46,383 +51,344 @@ module.exports = defineConfig({ | 字段 | 类型 | 说明 | | --- | --- | --- | -| `enable` | `boolean` | 总开关。`false` 或不传 `perf` → 整套探针关闭,产物零残留。 | -| `probes` | `string[]` | 要打开的分组列表。当前支持 `'framework'` / `'user'` 两个分组。`enable: true` 但 `probes: []` 等价于 `enable: false`。出现未知分组名(typo)时编译期直接抛错。 | +| `enable` | `boolean` | 总开关。`false` 或不传时整套探针关闭。 | +| `probes` | `string[]` | 当前支持 `'framework'` / `'user'`。空数组等价于关闭,未知分组会在编译期报错。 | -### 分组开关的语义 {#groups} - -| 分组 | 控制对象 | 典型用法 | +| 分组 | 控制对象 | 典型用途 | | --- | --- | --- | -| `framework` | 框架内部探针(`view:render:*` / `text:render:*` / `getStyle:*`) | 调试 mpx 框架自身渲染性能 | -| `user` | 业务侧自定义探针(业务前缀如 `myBiz:list:filter`) | 定位业务函数耗时 | - -两个分组的开关粒度独立、产物 DCE 独立,但**共用同一根聚合 Map、mark 时间线与 reporter**——业务侧 reporter 可同时读取所有耗时桶和里程碑。 +| `framework` | 框架内建探针,如 `view:render:*` / `getStyle:*` | 调试 Mpx 框架自身渲染性能 | +| `user` | 业务自定义探针 | 定位业务流程和函数耗时 | -| 配置 | 行为 | -| --- | --- | -| `{ enable: true, probes: ['framework'] }` | 只采集框架探针,业务噪声不干扰基线 | -| `{ enable: true, probes: ['user'] }` | 只采集业务探针,专注定位业务函数耗时 | -| `{ enable: true, probes: ['framework', 'user'] }` | 全量诊断,看完整调用链 | +两个分组独立 DCE,但共享同一个录制窗口和 Reporter。 -## 业务侧使用 {#usage} - -### 最小用法(默认 reporter) {#minimal} - -`@mpxjs/perf` 的默认 reporter 即 `consoleReporter`——业务方什么都不调,开启探针并 `start()` / `end()` 后 console 就有 measure 聚合和 mark 时间线输出,**零接入门槛**。`start()` / `end()` 本身会生成同名边界事件,因此空窗口也会触发 reporter。 +## 录制窗口 {#recording-window} ```ts import { start, end } from '@mpxjs/perf' -// 路由钩子:进入"商品详情"页 → 离开页面 router.beforeEnter('/goods/:id', () => { - if (__mpx_perf__) start() + if (__mpx_perf__) { + start({ + markLimit: 2048, + traceLimit: 4096 + }) + } }) router.beforeLeave('/goods/:id', () => { - if (__mpx_perf__) end() // end 内部同步触发 reporter,console 立即看到聚合表 + if (__mpx_perf__) end() }) ``` -输出样例(对齐字符串,跨 RN / 浏览器 / Node 显示一致): - -``` -[mpx perf] 4 measure buckets / 2 marks -measures -name count sum avg max ------------------- ----- -------- ------- -------- -view:render:total 120 480.32ms 4.00ms 18.21ms -view:render:style 120 92.15ms 0.77ms 3.42ms -getStyle:total 120 21.08ms 0.18ms 1.10ms -text:render:total 84 8.42ms 0.10ms 0.55ms - -timeline -index at name ------ ------ ----- - 0 0.00ms start - 1 1420.00ms end +```ts +interface PerfStartOptions { + markLimit?: number + traceLimit?: number +} ``` -> 默认 reporter 不使用 `console.table`——React Native 远程调试 / Hermes inspector 对它支持参差不齐(典型表现是把每行渲染成 `{…}` 不展开),改成对齐字符串 + 单条 `console.log`,在 RN console、Chrome DevTools、终端 Node 中都能直接读。 +- `markLimit` 是 `MarkTimeline` 的总事件上限,包含 start/end 边界,默认 1024,必须是不小于 2 的整数。 +- `traceLimit` 是 trace 区段上限,默认 1024,必须是非负整数;传 `0` 可关闭当前窗口的 trace 存储。 +- 无效值回退为 1024。 +- 录制中重复调用 `start(options)` 保持幂等,不清空数据,也不修改当前容量。 +- 新窗口重新读取配置,不继承上一窗口的自定义容量。 -### 指标含义 {#metrics} +`start()` 自动生成 `{ name: 'start', start: 0, timestamp: startedAt }`,`end()` 自动生成同结构的 end 边界。即使没有显式探针,完整窗口也会触发 Reporter。 -| 指标 | 含义 | 用途 | -| --- | --- | --- | -| `count` | 录制窗口内该事件触发次数 | 估算频率,例如 `view:render` 在窗口内 120 次说明列表在抖 | -| `sum` | 总耗时(ms) | 看占帧预算比例(一秒 = 16.67ms × 60 帧) | -| `avg` | 平均耗时(ms) | 单次成本基线 | -| `max` | 最大耗时(ms) | 长尾尖刺 | +## 区段聚类统计 {#aggregate} -`avg` 仅在 `end()` 时一次性回填,`push` 阶段不做除法。 +聚类 API 将同名样本实时合并为 `{ count, sum, avg, max }`,不保存逐次耗时。 -### 三种 start / end 调用模板 {#start-end-patterns} +### id 模式 {#aggregate-id} -#### 路由钩子 {#pattern-router} +默认 id 模式支持嵌套、乱序结束和同名并发,也是同步高频路径的首选: ```ts -router.beforeEnter('/goods/:id', () => { - if (__mpx_perf__) start() -}) -router.beforeLeave('/goods/:id', () => { - if (__mpx_perf__) end() -}) -``` - -#### 交互按钮 {#pattern-button} +import { aggrStart, aggrEnd } from '@mpxjs/perf' -```ts -const onSubmit = () => { - if (__mpx_perf__) start() - doSubmit() // 内部触发若干 mpx-view 重渲染、setState - if (__mpx_perf__) end() +function expensiveCompute (data) { + let id = -1 + if (__mpx_perf_user__) id = aggrStart('myBiz:list:filter') + const result = data.filter(/* ... */).sort(/* ... */) + if (__mpx_perf_user__) aggrEnd(id) + return result } ``` -#### React 组件挂载窗口 {#pattern-effect} +未录制时 `aggrStart(name)` 返回 `-1` 且不读取时钟。录制中使用数组槽位和 free list,稳态零对象、零闭包分配。 -```ts -useEffect(() => { - if (__mpx_perf__) start() - return () => { if (__mpx_perf__) end() } -}, []) -``` +### name 模式 {#aggregate-name} -### 自定义 reporter(可选) {#custom-reporter} - -如果想把数据接到自家 APM、写文件、发本地 socket,调 `setReporter` 替换默认 console。reporter 的第一个参数是已聚合的 `Map`,第二个参数是有界、有序的 mark 时间线: +开始和结束不方便传递 id 时,显式传入 `useName: true`: ```ts -// App.tsx -import { setReporter } from '@mpxjs/perf' -import type { AggResult, MarkTimeline } from '@mpxjs/perf' +if (__mpx_perf_user__) aggrStart('goods:request', true) -if (__mpx_perf__) { - setReporter((agg: Map, timeline?: MarkTimeline) => { - // agg 是 bus 内部 Map 的引用,不要直接修改;如需保留请自行复制成普通对象。 - const fw: Record = {} - const user: Record = {} - const FW_PREFIX = /^(view:|simple-view:|text:|simple-text:|getStyle:)/ - for (const [name, s] of agg) { - if (FW_PREFIX.test(name)) fw[name] = s - else user[name] = s - } - MyAPM.report('mpx_perf_fw', fw) - MyAPM.report('mpx_perf_user', user) - if (timeline) MyAPM.report('mpx_perf_timeline', timeline) - }) -} +loadPageData().finally(() => { + if (__mpx_perf_user__) aggrEnd('goods:request') +}) ``` -::: warning 注册时机 -`setReporter` 必须用 `if (__mpx_perf__)` 包住——总开关为 false 时整段被 DCE 删除,自定义 reporter 函数 + 闭包字节也一并消失。 - -**不要**写成 `setReporter(__mpx_perf__ ? myFn : undefined)`——`myFn` 引用没被字面量条件包裹,仍可能被 webpack 视作活引用。 -::: - -::: warning 引用语义 -`reporter` 收到的 Map、timeline 及 events 都是 bus 内部窗口数据的引用,**不要在 reporter 内修改它们**。下一次 `start()` 会重建窗口数据,旧引用可安全异步消费;但当次修改会污染同批次的局部 reporter。 -::: +name 模式允许在录制窗口外保存起点,只有 `aggrEnd(name)` 发生在录制中时样本才会进入结果。后一次同名 start 会覆盖前一次起点,同名并发必须改用 id 模式。 -切换 reporter 直接再调一次 `setReporter(otherReporter)`。想完全停止上报,调 `clearReporter()`——之后 `end()` 收集到的桶被静默丢弃。 +## 区段序列统计 {#trace} -如果只想在某一次录制窗口结束时追加一个局部 reporter,可以直接传给 `end(localReporter)`。局部 reporter 与全局 reporter **不互斥**:二者会收到同一份 Map 和 timeline,局部 reporter 只在这次 `end` 调用中额外触发一次,不会改变后续窗口的全局配置。 +trace 保存每次区段的开始位置和持续时长,可还原模块火焰图或异步瀑布图: ```ts -import { start, end } from '@mpxjs/perf' +import { traceStart, traceEnd } from '@mpxjs/perf' -const onSubmit = () => { - if (__mpx_perf__) start() - doSubmit() - if (__mpx_perf__) { - end((agg, timeline) => { - MyAPM.report('submit_perf', agg) - if (timeline) MyAPM.report('submit_timeline', timeline) - }) - } +let appId = -1 +let routerId = -1 +if (__mpx_perf_user__) { + appId = traceStart('module:app') + routerId = traceStart('module:router') +} + +if (__mpx_perf_user__) { + traceEnd(routerId, { moduleId: 42 }) + traceEnd(appId, { moduleId: 1 }) } ``` -### 自定义 console 输出 {#custom-console} +trace 在 start 时预留事件位置,在 end 时回填 `duration` / `info`,因此嵌套区段始终按 start 顺序输出。未完成区段在窗口结束时从 `events` 移除,并计入 `incomplete`。 -默认 console 不满足时,调 `createConsoleReporter` 工厂定制: +跨作用域且不方便传递 id 时可以使用 name 模式: ```ts -import { setReporter, createConsoleReporter } from '@mpxjs/perf' +if (__mpx_perf_user__) traceStart('request:goods', true) -if (__mpx_perf__) { - setReporter(createConsoleReporter({ - sortBy: 'max', // 只排序 measure:'sum'(默认) | 'avg' | 'max' | 'count' - filter: /^view:/, // 过滤 measure 与显式 mark;不会隐藏 start/end - header: true // 是否带 console.group 头 - })) -} +loadPageData().finally(() => { + if (__mpx_perf_user__) { + traceEnd('request:goods', { status: 'fulfilled' }) + } +}) ``` -::: tip 高阶统计的取舍 -measure 只做实时聚合,**不保留**逐次耗时样本,因此无法在窗口结束后再计算 p50 / p95 / 直方图等分位指标。mark 时间线保存的是不同里程碑的发生时刻,不是 measure 样本,也不用于聚类或分位数分析。 +trace 必须在录制窗口中 start。name 模式同名覆盖时,被覆盖的旧区段会计入 `incomplete`;同名并发、递归和严格嵌套必须使用 id 模式。上一窗口未消费的旧 id 不能结束下一窗口的新事件。 -高频渲染耗时继续使用 scope/measure 聚合;低频流程里程碑才使用 mark,并由 256 条硬上限控制最坏内存。 -::: +## 点序列统计 {#mark} -### Mark 时间线 {#mark-timeline} - -`mark(name)` 记录“某件事在何时发生”,不产生耗时聚合桶。每次调用都在 start/end 之间追加一条独立事件,数组顺序就是权威顺序: +`mark(name, info?)` 记录瞬时里程碑,不产生聚合桶: ```ts -import { start, mark, end } from '@mpxjs/perf' - -if (__mpx_perf__) start() - -loadPageData().then(() => { - if (__mpx_perf_user__) mark('goods:data-ready') -}) - -onFirstContentRendered(() => { - if (__mpx_perf_user__) mark('goods:first-render') -}) +import { mark } from '@mpxjs/perf' -onPageInteractive(() => { - if (__mpx_perf_user__) mark('goods:interactive') - if (__mpx_perf__) end() -}) +if (__mpx_perf_user__) { + mark('goods:data-ready', { + source: 'cache', + itemCount: 20 + }) +} ``` -Reporter 得到的 `MarkTimeline` 结构为: +同名 mark 仍是多条独立事件。`info` 按引用保存,不复制、不校验、不序列化;建议只传小型、可序列化的诊断字段,不要传组件实例、完整 props、响应体或大数组。 + +## 数据结构 {#data-types} ```ts +interface AggrResult { + count: number + sum: number + avg: number + max: number +} + interface MarkEvent { name: string - at: number // 相对当前 start() 的毫秒偏移 + start: number + timestamp: number + info?: unknown } interface MarkTimeline { events: MarkEvent[] dropped: number } + +interface TraceEvent { + name: string + start: number + timestamp: number + duration: number + info?: unknown +} + +interface TraceTimeline { + events: TraceEvent[] + dropped: number + incomplete: number +} ``` -内建 start 永远是首项,内建 end 永远是完整窗口的末项。总事件数固定最多 256:1 个 start、最多 254 个显式 mark、1 个 end。超过上限后保留前缀并增加 `dropped`,end 始终保留。业务 mark 应避免使用 `start` / `end` 名称。 +`start` 是相对当前录制窗口起点的毫秒偏移。`timestamp` 是 `performance.now()`、Hermes `nativePerformanceNow()` 或 `Date.now()` 返回的原始值,不保证是 Unix epoch;同一运行环境和窗口中的值可以直接比较。 -## 业务侧自定义探针 {#user-probes} +mark 和 trace 使用独立容量。达到上限后只增加对应 `dropped`,不保存事件、时间、name 或 info。mark 始终为 end 边界预留最后一个位置。 -业务侧自有 RN 代码(非 `.mpx` 的纯 RN 封装、列表项、外置 hook 等)想接入同一根 reporter 通道时,使用 `__mpx_perf_user__` 分组开关。同步高频路径优先使用 `scopeStart` / `scopeEnd` 句柄式 API: +## Reporter {#reporter} ```ts -import { scopeStart, scopeEnd } from '@mpxjs/perf' +type Reporter = ( + aggregates: Map, + marks?: MarkTimeline, + traces?: TraceTimeline +) => void +``` -function expensiveCompute (data) { - let id = -1 - if (__mpx_perf_user__) id = scopeStart('myBiz:list:filter') - const result = data.filter(/* ... */).sort(/* ... */) - if (__mpx_perf_user__) scopeEnd(id) - return result +通过正常 `start/end` 完成的窗口始终传入 marks 和 traces;后两个参数保持可选,兼容旧的一、二参数 Reporter 和外部手动调用。 + +```ts +import { setReporter } from '@mpxjs/perf' +import type { + AggrResult, + MarkTimeline, + TraceTimeline +} from '@mpxjs/perf' + +if (__mpx_perf__) { + setReporter(( + aggregates: Map, + marks?: MarkTimeline, + traces?: TraceTimeline + ) => { + MyAPM.report('mpx_perf_aggregates', aggregates) + if (marks) MyAPM.report('mpx_perf_marks', marks) + if (traces) MyAPM.report('mpx_perf_traces', traces) + }) } ``` -事件名建议加业务前缀(`myBiz:` / 模块名 / 业务线代号)以与框架的 `view:` / `text:` / `getStyle:` 区分。 +全局 Reporter 和 `end(localReporter)` 传入的局部 Reporter 会依次收到同一份 Map、marks 和 traces 引用。不要直接修改;需要改写时自行复制。 -### 为什么是 scopeStart / scopeEnd 而不是闭包 {#why-handle} +::: warning 注册时机 +`setReporter` 必须直接放在 `if (__mpx_perf__)` 中,确保自定义函数和闭包也能在关闭态被 DCE。 +::: -`scopeStart(name)` 返回的是一个 `number` 句柄而非闭包: +### Console Reporter {#console-reporter} -- 录制态:内部从 freeList 取一个 id,写入两个数组下标(name + start 时间),返回 id。**零对象 / 零闭包分配**。 -- 未录制:直接返回 `-1`,不调 `now()`、不写数组。`scopeEnd(-1)` 是安全 noop。 +默认 `consoleReporter` 分别输出 aggregates、traces 和 marks。`createConsoleReporter({ sortBy, filter, header })` 可定制: -这是为高频渲染场景特别设计的——一帧 1000+ 次 scope 调用下,旧的闭包模式会产生 1000+ 个闭包 Context 对象,显著放大 GC 压力,让测速本身变成被测项的瓶颈。 +- `sortBy` 只影响 aggregates。 +- `filter` 同时作用于 aggregate、trace 和显式 mark,内建 start/end 不会隐藏。 +- trace 和 mark 保持原始顺序。 +- 仅有事件包含 info 时显示 info 列。 +- mark dropped、trace dropped 和 trace incomplete 分别提示。 +- info 的 JSON 格式化失败不会中断业务或其他统计输出。 -### 跨作用域 measure {#named-measure} +### Chrome Trace 对接 {#chrome-trace} -当开始与结束不在同一同步作用域时,使用同名的 `measureStart` / `measureEnd`: +`TraceEvent` 可映射为 Chrome Trace / Perfetto Complete Event: ```ts -import { measureStart, measureEnd } from '@mpxjs/perf' - -if (__mpx_perf_user__) measureStart('goods:request') -loadPageData().finally(() => { - if (__mpx_perf_user__) measureEnd('goods:request') -}) +const chromeEvents = traces.events.map(event => ({ + name: event.name, + ph: 'X', + ts: event.timestamp * 1000, + dur: event.duration * 1000, + args: event.info +})) ``` -该名称同时作为配对 key 和最终聚合桶名。`measureEnd` 命中后消费起点,重复结束 noop;同名并发测量时后一次起点覆盖前一次。`measureStart/measureEnd` 不写入时间线,`mark` 也不会注册 measure 起点。 - -### 强约束 {#user-probe-constraints} - -1. **字面量条件**:所有探针调用必须直接包在 `if (__mpx_perf_framework__)`(框架探针)/ `if (__mpx_perf_user__)`(业务探针)字面量条件里——不能先把常量赋给变量再用。只有字面量条件才能被 DefinePlugin + Terser DCE 静态消除。 -2. **起止 API 必须配对**:scope 用同一个数字 id;跨作用域 measure 的 start/end 使用同一个 name。所有调用都分别放入对应的字面量门禁。 -3. **不要跨类混用**:业务代码里只用 `__mpx_perf_user__`,不要错用 `__mpx_perf_framework__`(反之亦然)。 +核心包不内置文件写入或格式转换器,业务 Reporter 可按需补充 `pid` / `tid` / `cat` 等字段。MarkEvent 可用同一 `timestamp * 1000` 规则映射为 Instant Event。 ## API 参考 {#api} | API | 说明 | | --- | --- | -| `scopeStart(name): number` | 起一段 scope,返回 id 句柄。未录制时返回 `-1`。**首选**。无闭包 / 对象分配。 | -| `scopeEnd(id): void` | 关闭 id 对应的 scope,累加进聚合。`id < 0` 或重复 end 安全 noop。 | -| `measureStart(name): void` | 注册跨作用域耗时的具名起点。 | -| `measureEnd(name): void` | 消费同名起点,将耗时聚合到同名桶;找不到起点时 noop。 | -| `mark(name): void` | 向当前窗口追加独立、有序的时间线事件,同名 mark 不合并。 | -| `start(): void` | 打开录制窗口,新建聚合 Map 和时间线,并生成 start 边界。重复 `start` 幂等。 | -| `end(reporter?): void` | 生成 end 边界,回填 measure 的 avg 后同步触发全局及可选局部 reporter。 | -| `setReporter(r)` | 替换默认 reporter。可选,默认即 `consoleReporter`。 | -| `clearReporter()` | 清空全局 reporter。 | -| `createConsoleReporter(opts?)` | 工厂函数,定制 console 输出。 | -| `consoleReporter` | 默认 reporter,等价于 `createConsoleReporter()` 默认参数。 | - -`Reporter` 签名:`(measures: Map, timeline?: MarkTimeline) => void`。通过正常 `start/end` 完成的窗口始终传入 timeline;第二参数保持可选,以兼容单参数 reporter 和手动调用。 -`AggResult`:`{ count, sum, avg, max }`,所有时长字段单位为 ms。 - -::: warning API 直接变更 -旧 `mark(name)` 的 measure 起点语义已直接替换为时间线语义;请迁移为 `measureStart(name)`。旧 `measure(resultName, startName)` 请迁移为使用同一名称的 `measureEnd(name)`,旧 `measure` 不再导出。本次不提供 alias 或过渡阶段。 -::: - -### 录制窗口语义 {#recording-window} +| `aggrStart(name, useName?)` | 默认返回数字 id;传 `true` 改用 name 配对。 | +| `aggrEnd(idOrName)` | 完成区段并实时聚合;无效或重复目标安全 noop。 | +| `traceStart(name, useName?)` | 默认返回数字 id;传 `true` 改用 name 配对。 | +| `traceEnd(idOrName, info?)` | 完成 trace 并保存可选 info。 | +| `mark(name, info?)` | 追加独立、有序的点事件。 | +| `start(options?)` | 创建录制窗口和三类容器;重复 start 幂等。 | +| `end(reporter?)` | 关闭窗口,回填 avg、压缩未完成 trace 并触发 Reporter。 | +| `setReporter(r)` / `clearReporter()` | 替换或清空全局 Reporter。 | +| `createConsoleReporter(opts?)` | 创建可配置的 Console Reporter。 | +| `consoleReporter` | 默认 Reporter。 | + +### 旧 API 兼容 {#legacy-api} + +| 旧 API | 内部映射 | 兼容语义 | +| --- | --- | --- | +| `scopeStart(name)` | `aggrStart(name)` | 返回 id;未录制返回 `-1`。 | +| `scopeEnd(id)` | `aggrEnd(id)` | 负 id 和重复结束安全 noop。 | +| `measureStart(name)` | `aggrStart(name, true)` | 后一次同名 start 覆盖前一次。 | +| `measureEnd(name)` | `aggrEnd(name)` | 命中后消费起点。 | -- **`start()` / `end()` 之间触发的 scope、mark 与完成的 measure 才会进入当前窗口**。未录制时 `scopeStart` 返回 `-1`,`mark` 不读取时钟也不分配事件。 -- **start/end 自动构成闭合时间线**:最小窗口也包含 `{ name: 'start', at: 0 }` 和名为 end 的末事件,因此没有显式 mark 和 measure 时仍会触发 reporter。 -- **`end()` 同步触发 reporter**:调用 `end()` 后立即在 console 看到结果;`end(localReporter)` 不会替换全局 reporter,只对当前窗口追加一次局部上报。 -- **不强制配对**:误调 `end()`(未先 start)是 noop;重复 `start()` 沿用已有窗口(幂等)。 -- **强制重开新窗口**:先 `end()` 再 `start()`,第二次 start 会新建 Map、timeline 和 events。 -- **跨窗口引用安全**:每次 start 都重建窗口数据,reporter 异步消费旧窗口不会被下一次窗口覆盖。 -- **时间线容量固定**:包含边界在内最多 256 条;显式 mark 超过 254 条后只累计 `dropped`。measure Map 仍应使用有限、稳定的桶名,避免动态名称集合增长。 +四个旧函数继续导出且没有移除版本,但新代码优先使用 `aggrStart/aggrEnd`。聚合结果类型已由 `AggResult` 更名为 `AggrResult`,不保留旧类型别名;`MarkEvent.at` 已更名为 `start`,并新增 `timestamp` / `info`。 ## 内置框架探针事件 schema {#schema} -首版接入了四个内建组件 + 一个 core mixin 方法。统一只测**同步 render 耗时**(不含 `useEffect`、不含 commit 后副作用),每个组件至少产出一个 `*:render:total`,加若干 `*:render:`——子阶段相加 ≈ total,差值代表函数自身骨架开销。 +现有框架探针继续通过兼容的 `scopeStart/scopeEnd` 采集同步 render 聚合耗时。 ### `mpx-view` {#schema-view} | 事件名 | 覆盖代码段 | | --- | --- | -| `view:render:total` | 整个 `forwardRef` 回调(最外层,含子阶段) | +| `view:render:total` | 整个 `forwardRef` 回调 | | `view:render:props` | `splitProps` + 解构 + `useHover` | -| `view:render:style` | `useTransformStyle` + `splitStyle` + `useTextPassThroughValue` + `useLayout` + `useAnimationHooks` | +| `view:render:style` | `useTransformStyle` + `splitStyle` + 布局和动画 Hook | | `view:render:innerProps` | `useInnerProps` | -| `view:render:createElement` | `wrapWithChildren` + `createElement(View / Animated.View / GestureDetector / Portal)` 收尾 | +| `view:render:createElement` | `wrapWithChildren` + `createElement` 收尾 | ### `mpx-simple-view` {#schema-simple-view} | 事件名 | 覆盖代码段 | | --- | --- | | `simple-view:render:total` | 整个函数 | -| `simple-view:render:style` | `splitProps` + `splitStyle`(含 `isBoxSizingAffectingStyle` 副检测)+ `useTextPassThroughValue` + `transformBoxSizing` | +| `simple-view:render:style` | `splitProps` + `splitStyle` + 样式变换 | | `simple-view:render:innerProps` | `useInnerProps` | -| `simple-view:render:createElement` | `wrapChildren` + `createElement(View, ...)` 收尾 | +| `simple-view:render:createElement` | `wrapChildren` + `createElement` 收尾 | ### `mpx-text` {#schema-text} | 事件名 | 覆盖代码段 | | --- | --- | | `text:render:total` | 整个 `forwardRef` 回调 | -| `text:render:props` | `useContext(TextPassThroughContext)` + `extendObject` 合并 inherited + 解构 | -| `text:render:style` | `useTransformStyle` + 合并 inherited textStyle + `splitStyle`(提取 childTextStyle)+ `useTextPassThroughValue` + `useNodesRef` | +| `text:render:props` | 文本上下文 + props 合并 | +| `text:render:style` | 样式转换、继承与拆分 | | `text:render:innerProps` | `useInnerProps` | -| `text:render:createElement` | `decode` + `wrapChildren` + `createElement(Text / Portal)` 收尾 | +| `text:render:createElement` | `decode` + `wrapChildren` + `createElement` 收尾 | ### `mpx-simple-text` {#schema-simple-text} | 事件名 | 覆盖代码段 | | --- | --- | | `simple-text:render:total` | 整个函数 | -| `simple-text:render:style` | `useContext(TextPassThroughContext)` + 合并 mergedStyle + `splitStyle` + `transformBoxSizing` + 合并 mergedProps + `useTextPassThroughValue` | -| `simple-text:render:innerProps` | `useInnerProps`(带 allowFontScaling / 最终 style) | -| `simple-text:render:createElement` | `wrapChildren` + `createElement(Text, ...)` 收尾 | +| `simple-text:render:style` | 文本上下文、样式和 props 合并 | +| `simple-text:render:innerProps` | `useInnerProps` | +| `simple-text:render:createElement` | `wrapChildren` + `createElement` 收尾 | ### `@mpxjs/core: __getStyle` {#schema-getstyle} -`__getStyle` 是每个 mpx 组件 render 时都会被调一次的样式聚合入口,是除内建组件外测速的核心入口: - | 事件名 | 覆盖代码段 | | --- | --- | | `getStyle:total` | 整个 `__getStyle` 函数 | -| `getStyle:class` | classString 解析 + 遍历 `__getClassStyle` / `__getAppClassStyle` / externalClasses 查找 | -| `getStyle:style` | `parseStyleText(staticStyle)` + `normalizeDynamicStyle(dynamicStyle)` + `transformStyleObj(styleObj)` | +| `getStyle:class` | class 解析与样式查找 | +| `getStyle:style` | 静态/动态 style 解析与转换 | ## 性能影响评估 {#perf-impact} -| 状态 | 关闭 | 打开 + 未录制(未 start) | 打开 + 录制中 | -| --- | --- | --- | --- | -| 单次 scope 额外耗时 | 0 | 一次 `isRecording()` 比较 → return | 状态判断 + freeList 取 id + 一次 `now()` + 两次数组下标写 + push 阶段 `Map.get` + 数值累加 | -| 单次 scope 堆分配 | 0 | 0 | 0(首次出现新桶时一次 `AggResult` 对象) | -| 单次 mark | 0 | 一次 `isRecording()` 比较,不读取时钟 | 一次 `now()`、相对时间计算和一次事件 push;达到容量后只增加 dropped | -| 内存 | 0 | 0 | 桶数 × `AggResult` + 最多 256 个 MarkEvent。measure 不保存逐次样本 | -| Hook 调用顺序 | 不变 | 不变(同一构建内常量恒定) | 不变 | -| reporter 触发开销 | 无 | 无 | 仅 `end()` 触发一次同步调用,不在热路径上重复跑 | - -::: tip 实时聚合 vs 事件流模型 -高频 scope/measure 只做实时聚合,不保存逐次样本;单次 scope 在录制态下零对象分配。mark 明确保留事件对象,因此只用于低频里程碑,并用 256 条硬上限约束最坏内存。 -::: +| 能力 | 未录制 | 录制中 | +| --- | --- | --- | +| aggr id 模式 | 状态判断后返回 `-1` | 数组槽位 + free list;稳态零对象、零闭包分配 | +| aggr name 模式 | 保存 name 起点 | Map set/get/delete;只保留聚合桶 | +| trace | 状态判断后 noop | 每个被接受区段一个事件对象和一条进行中映射,默认最多 1024 条 | +| mark | 状态判断后 noop | 每个显式 mark 一个事件对象,默认最多 1022 条,加边界后最多 1024 条 | -::: warning 观测者效应 -打开态测得的耗时本身仍含探针自身开销(一次 `now()` + 数组下标写 + Map 累加)。**对比应当在同一开关态下进行**——不要拿打开态数据 vs 关闭态线上数据做绝对对比。 +::: tip 聚合与事件序列的取舍 +高频 render 和函数耗时使用 aggr;需要火焰图或逐区段瀑布图时使用 trace;里程碑使用 mark。trace/mark 不能替代渲染循环中的聚类统计。 ::: -## 与现有方案的关系 {#vs-others} +## 与现有工具的关系 {#vs-others} -- **vs `__DEV__`**:`__DEV__` 区分开发 / 生产环境,无法支持「生产构建里临时开探针」;`__mpx_perf_*__` 是构建参数,可以打一个生产 + 开探针的内测包。 -- **vs Hermes Profiler**:Hermes 看 JS 函数级耗时,看不到 mpx 抽象(`useTransformStyle` 内部是若干小函数 + Hook,非单一函数)。本方案产生的 scope 时间戳与 Hermes 时间轴对齐(都用 `performance.now()` / `nativePerformanceNow`),可以一起分析。 -- **vs 既有 APM**:本方案不替代业务 APM,只提供数据源;业务可用自定义 reporter 把聚合结果接入既有上报通道。 +- **Hermes Profiler**:提供 JS 函数级采样;trace 提供 Mpx 业务语义明确的区段序列,两者可使用相同 performance 时钟对照分析。 +- **Perfetto / Chrome Trace**:Perf 的 trace 是稳定数据源,业务 Reporter 负责转换和补充进程、线程、分类字段。 +- **业务 APM**:Perf 不替代 APM,只提供聚合、区段和里程碑数据。 -## Terser / babel 兼容性约束 {#terser-babel} +## Terser / Babel 兼容性约束 {#terser-babel} - 最终构建依赖 `@mpxjs/perf` 的 `dist/index.js` 保留顶层三元、`sideEffects: false` 与使用方 Terser 完成 DCE。 -- 接入方需保留默认 Terser 配置(不要关闭 minimizer / 不要禁用 `dead_code` / `conditionals`)。 -- `babel-preset-env` 不要把三元条件 `__mpx_perf__ ? impl.x : noop.x` 变换平铺,否则 DCE 失效。 +- 探针调用必须直接置于 `if (__mpx_perf_framework__)` / `if (__mpx_perf_user__)` 字面量条件内。 +- 接入方需保留默认 Terser 的 `dead_code` / `conditionals` 优化。 +- Babel 不应提前破坏 `__mpx_perf__ ? impl.x : noop.x` 顶层三元结构。 diff --git a/packages/perf/AGENTS.md b/packages/perf/AGENTS.md index 54d7d9eb30..0ac3f9a0fe 100644 --- a/packages/perf/AGENTS.md +++ b/packages/perf/AGENTS.md @@ -1,38 +1,51 @@ # @mpxjs/perf -Mpx2RN 运行时按需性能探针,提供实时耗时聚合与有界 mark 时间线。采用「编译期常量开关 + 运行时探针实现 + tree-shaking 兜底」三层结构,关闭态下产物中**不含**任何探针代码、字符串字面量、模块依赖。设计与背景见 [solutions/rn-runtime-perf-probe.md](../../solutions/rn-runtime-perf-probe.md) 和 [solutions/rn-runtime-perf-mark-timeline.md](../../solutions/rn-runtime-perf-mark-timeline.md)。 +Mpx2RN 运行时按需性能探针,提供实时区段聚合、有界 trace 区段序列和有界 mark 点序列。采用「编译期常量开关 + 运行时探针实现 + tree-shaking 兜底」三层结构,关闭态产物不含探针实现、名称字符串或模块依赖。设计与背景见 [solutions/rn-runtime-perf-probe.md](../../solutions/rn-runtime-perf-probe.md)、[solutions/rn-runtime-perf-mark-timeline.md](../../solutions/rn-runtime-perf-mark-timeline.md) 和 [solutions/rn-runtime-perf-profile.md](../../solutions/rn-runtime-perf-profile.md)。 ## 入口文件 -- [src/index.ts](src/index.ts):顶层三元分流(`__mpx_perf__ ? impl.x : noop.x`),对外导出 `scopeStart` / `scopeEnd` / `measureStart` / `measureEnd` / `mark` / `start` / `end` / `setReporter` / `clearReporter` / `createConsoleReporter` / `consoleReporter`,及类型 `Reporter` / `AggResult` / `MarkEvent` / `MarkTimeline`。 -- [src/global.d.ts](src/global.d.ts):声明 `__mpx_perf__` 全局常量,由使用方 webpack 的 DefinePlugin 注入。 -- `package.json` 的 `main` 指向 `dist/index.js`;产物需保留顶层三元,再由最终构建链的 Terser 完成 DCE。 +- [src/index.ts](src/index.ts):为每个 API 保留独立顶层三元分流(`__mpx_perf__ ? impl.x : noop.x`)。对外提供: + - 聚类统计:`aggrStart` / `aggrEnd`。 + - 区段序列:`traceStart` / `traceEnd`。 + - 点序列:`mark`。 + - 兼容聚类 API:`scopeStart` / `scopeEnd` / `measureStart` / `measureEnd`。 + - 窗口与 Reporter:`start` / `end` / `setReporter` / `clearReporter` / `createConsoleReporter` / `consoleReporter`。 + - 类型:`AggrResult` / `MarkEvent` / `MarkTimeline` / `TraceEvent` / `TraceTimeline` / `PerfStartOptions` / `Reporter`。 +- [src/global.d.ts](src/global.d.ts):声明 `__mpx_perf__`,由使用方 webpack 的 DefinePlugin 注入。 +- `package.json` 的 `main` 指向 `dist/index.js`;产物必须保留顶层三元,再由最终构建链完成 DCE。 ## 核心模块 -- [src/impl.ts](src/impl.ts):录制态实现。 - - 内部 `now()` 优先 `performance.now`,回退 Hermes `globalThis.nativePerformanceNow`,再兜底 `Date.now`。 - - `scopeStart` / `scopeEnd` 用平行数组 `stackName` / `stackStart` + `freeList` 复用 id 槽位,**零对象 / 零闭包分配**,未录制态直接返回 `-1`。 - - `measureStart` / `measureEnd` 用 `Map` 暂存同名起点,命中后立即 `delete`;`mark` 只向 bus 追加时间线事件,两套状态互不复用。 -- [src/bus.ts](src/bus.ts):录制状态机、实时聚合容器与 mark 时间线。 - - 维护 `_recording`、窗口起点、`aggMap: Map`、`MarkTimeline` 与全局 `_reporter`(默认 `consoleReporter`)。 - - `start(startedAt)` 重建 Map 和 timeline,自动写入 start;重复 start 幂等。`end(endedAt)` 自动写入 end,再触发 reporter。 - - `pushMeasure` 只做 `Map.get` + 累加,`end()` 时回填 avg;`pushMark` 保留最多 254 个显式事件,为 start/end 预留边界,总量固定最多 256。 -- [src/noop.ts](src/noop.ts):关闭态空实现。`scopeStart` 恒返回 `-1`,其他 API 为空函数;通过顶层三元让 Terser 把 `impl` / `bus` / `reporters` 整支 DCE。 -- [src/types.ts](src/types.ts):对外类型 `AggResult`、`MarkEvent`、`MarkTimeline` 与 `Reporter = (measures, timeline?) => void`。 -- [src/reporters/console.ts](src/reporters/console.ts):内置 console reporter。 - - `createConsoleReporter(options?)` 工厂支持 `sortBy` / `filter` / `header`;分别输出 measure 与 timeline,timeline 保持原序,内建 start/end 不受 filter 影响,并显示 dropped 提示。 - - `consoleReporter` 是默认参数实例,bus 未被 `setReporter` 替换时使用它。 +- [src/impl.ts](src/impl.ts):录制态 API。 + - `now()` 优先 `performance.now`,回退 Hermes `globalThis.nativePerformanceNow`,最后使用 `Date.now`。 + - `aggrStart/aggrEnd` 的 id 模式使用 `aggrNames` / `aggrStarts` 平行数组和 free list,保持高频路径零对象、零闭包分配;name 模式用 `Map` 配对。 + - `traceStart/traceEnd` 使用单调数字 id 或 name 映射到 bus 预留的事件位置。窗口结束清空映射,旧 id 不得污染下一窗口。 + - 四个旧聚类函数只是 `aggr*` 的薄包装,不维护第二套状态。 +- [src/bus.ts](src/bus.ts):录制状态机和三类窗口数据。 + - 聚合容器为 `aggrMap: Map`,push 阶段累加,end 时回填 avg。 + - MarkTimeline 自动包含 start/end 边界;TraceTimeline 在 trace start 时占位、end 时回填,窗口结束原地压缩未完成事件并计算 `incomplete`。 + - mark 与 trace 容量独立,默认均为 1024,由 `start({ markLimit, traceLimit })` 覆盖。达到上限后只增加对应 `dropped`。 + - 全局与局部 Reporter 同步收到同一份 aggregates、marks、traces 引用。 +- [src/noop.ts](src/noop.ts):关闭态壳子。id 模式 start 返回 `-1`,其他 API noop;签名必须与 impl 对齐。 +- [src/types.ts](src/types.ts):所有公开类型。Reporter 签名为 `(aggregates, marks?, traces?) => void`。 +- [src/reporters/console.ts](src/reporters/console.ts):分别输出 aggregates、traces、marks 和 dropped/incomplete 提示。`sortBy` 只影响 aggregates;filter 同时作用于三类名称但保留 mark 边界;info 格式化失败不得影响业务。 ## 典型调用链 -1. **业务接入**:`mpx.config.js` 中开启 `pluginOptions.mpx.plugin.perf.enable` → webpack-plugin 通过 DefinePlugin 注入 `__mpx_perf__` 与分组开关(如 `__mpx_perf_framework__` / `__mpx_perf_user__`)→ 引用 `@mpxjs/perf` 的源码被使用方 Terser 静态折叠。 -2. **录制窗口**:业务 `start()` → [bus.ts](src/bus.ts) 新建 `aggMap` 和带 start 边界的 timeline → 调用方在字面量门禁下用 scope 或具名 measure 聚合耗时、用 mark 追加里程碑 → `end(reporter?)` 追加 end、回填 avg,并把同一份 Map 和 timeline 同步交给全局 + 局部 reporter。 -3. **关闭态零残留**:`__mpx_perf__: false` 时顶层 `false ? impl.x : noop.x` → `noop.x`;`impl.ts` 引用的 `bus.ts` / `reporters/*.ts` 级联失活,被 webpack `usedExports` 标记后整段 tree-shake,`noop.ts` 空函数本身也被调用点 inline 消除。 - -## 注意 - -- 源码语言是 TypeScript(仓库唯一),`tsc -p` 产物落 `dist/`;新增能力维持 `src/` 文件粒度小、`index.ts` 仅顶层三元分流的形态,便于 DCE。 -- 调用方约束:必须保留默认 Terser 配置(不关 minimizer、不禁用 `dead_code` / `conditionals`),`babel-preset-env` 不要把顶层三元变换成 `if/else` 平铺,否则 DCE 失效。 -- 业务自定义探针推荐 `let id = -1` + `scopeStart` / `scopeEnd` 句柄形式,配合 `if (__mpx_perf_user__)` 字面量门禁;不要把分组开关下沉到本包内部——`@mpxjs/perf` **不感知分组**。 -- Reporter 拿到的 Map、timeline 和 events 是 bus 内部引用,回调内不要直接修改;如需改写请自行复制。 +1. 业务配置 `pluginOptions.mpx.plugin.perf.enable/probes`,webpack-plugin 注入 `__mpx_perf__` 与分组字面量。 +2. `start(options?)` 创建新的聚合 Map、带边界的 MarkTimeline 和空 TraceTimeline。 +3. 调用方在字面量门禁下使用 aggr 聚合高频耗时、trace 保存完整区段、mark 保存里程碑。 +4. `end(reporter?)` 写入 mark end 边界、关闭录制、回填 avg、压缩未完成 trace,再触发全局和局部 Reporter。 +5. 总开关关闭时,顶层三元选择 noop,impl / bus / console reporter 级联失活并由最终构建 tree-shake。 + +## 性能与兼容约束 + +- 所有新 API 继续使用独立顶层三元,禁止在 [src/index.ts](src/index.ts) 新建包装闭包。 +- 聚类 id 热路径只能使用数组槽位和 free list;不要引入事件对象、闭包或通用计时器类。 +- trace 必须在 start 时预留事件位置,确保 Reporter 顺序与 traceStart 顺序一致;不得改成 end 时 push 后再排序。 +- mark 与 trace 超出容量后不得保存 name、时间或 info;不得使用 `shift` 或循环覆盖。 +- `info` 只保存引用,不复制、不校验、不序列化。默认 Console Reporter 必须安全处理循环引用和异常 stringify。 +- `scope*` / `measure*` 保持导出和原签名;新代码优先使用 `aggr*`。 +- `AggrResult` 不保留旧 `AggResult` 类型别名;MarkEvent 使用 `start` / `timestamp`,不再使用 `at`。 +- 调用方必须直接使用 `if (__mpx_perf_framework__)` / `if (__mpx_perf_user__)` 字面量门禁;本包不感知分组。 +- 运行时代码禁止 Object spread;对象合并使用 `Object.assign` 或仓库内部工具。 diff --git a/packages/perf/README.md b/packages/perf/README.md index 4d861c8548..f74d4acba2 100644 --- a/packages/perf/README.md +++ b/packages/perf/README.md @@ -1,22 +1,28 @@ # @mpxjs/perf -Mpx2RN 运行时按需性能探针,支持实时耗时聚合和有界 mark 时间线。 +Mpx2RN 运行时按需性能探针,提供区段聚类统计、区段序列统计和点序列统计。 ## 设计原则 -「编译期常量开关 + 运行时探针实现 + tree-shaking 兜底」三层结构,关闭态下产物里**不含**探针代码、名称字符串或模块依赖。 +`@mpxjs/perf` 使用「编译期常量开关 + 运行时探针实现 + tree-shaking 兜底」。关闭态下,探针实现、名称字符串和模块依赖都不会进入最终产物。 -- measure 在录制窗口内实时聚合为 `Map`,不保存逐次耗时样本。 -- mark 按调用顺序保留为时间线事件,同名事件不合并。 -- 时间线包含自动生成的 `start` / `end` 边界,固定最多 256 条:1 个 start、最多 254 个显式 mark、1 个 end。 +| 统计类型 | API | 输出 | 典型用途 | +| --- | --- | --- | --- | +| 区段聚类统计 | `aggrStart` / `aggrEnd` | `Map` | 高频 render、hook、函数的 count/sum/avg/max | +| 区段序列统计 | `traceStart` / `traceEnd` | `TraceTimeline` | 模块火焰图、嵌套调用 profile、异步瀑布图 | +| 点序列统计 | `mark` | `MarkTimeline` | 数据就绪、首次渲染、页面可交互等里程碑 | + +聚类统计不保留逐次样本,适合高频路径;trace 和 mark 会保留事件对象,只用于有限插桩点和诊断窗口。 ## API ```ts import { + aggrStart, aggrEnd, + traceStart, traceEnd, + mark, scopeStart, scopeEnd, measureStart, measureEnd, - mark, start, end, setReporter, clearReporter, createConsoleReporter, consoleReporter @@ -25,21 +31,18 @@ import { | API | 说明 | | --- | --- | -| `scopeStart(name): number` | 开始同步 scope,返回数字句柄。未录制时返回 `-1`。高频同步路径首选。 | -| `scopeEnd(id): void` | 结束 scope 并将耗时聚合到同名桶。`id < 0` 或重复结束安全 noop。 | -| `measureStart(name): void` | 注册跨作用域耗时的具名起点。 | -| `measureEnd(name): void` | 消费同名起点,并将耗时聚合到同名桶;找不到起点时 noop。 | -| `mark(name): void` | 向当前窗口追加一条独立、有序的时间线事件;未录制时 noop。 | -| `start(): void` | 打开录制窗口并生成 `{ name: 'start', at: 0 }`。重复 start 幂等。 | -| `end(reporter?): void` | 生成 `end` 边界并同步触发全局及可选局部 reporter。未 start 时 noop。 | -| `setReporter(r)` | 替换全局 reporter。 | -| `clearReporter()` | 清空全局 reporter。 | +| `aggrStart(name, useName?)` | 默认返回数字 id;传 `true` 时改用 name 配对且不返回 id。id 模式未录制时返回 `-1`。 | +| `aggrEnd(idOrName)` | 结束聚合区段并写入同名桶;无效目标和重复结束安全 noop。 | +| `traceStart(name, useName?)` | 默认返回数字 id;传 `true` 时改用 name 配对。必须在录制窗口中 start。 | +| `traceEnd(idOrName, info?)` | 结束 trace,保存持续时长与可选 info。 | +| `mark(name, info?)` | 保存独立、有序的点事件与可选 info。 | +| `start(options?)` | 打开录制窗口;可配置 `markLimit` / `traceLimit`。重复 start 幂等。 | +| `end(reporter?)` | 关闭窗口并同步触发全局及可选局部 reporter。 | +| `setReporter(r)` / `clearReporter()` | 替换或清空全局 reporter。 | | `createConsoleReporter(opts?)` | 创建可配置的 console reporter。 | | `consoleReporter` | 默认 reporter,等价于 `createConsoleReporter()`。 | -`AggResult` 包含 `count` / `sum` / `avg` / `max`,时长单位均为 ms。`MarkTimeline.events` 包含 `{ name, at }`,其中 `at` 是相对当前 `start()` 的毫秒偏移;`dropped` 表示超过时间线容量后丢弃的显式 mark 数。 - -这是一次直接 API 变更:旧 `mark(name)` 起点改为 `measureStart(name)`,旧 `measure(resultName, startName)` 改为同名的 `measureEnd(name)`,不再导出旧 `measure`。 +`scopeStart/scopeEnd` 与 `measureStart/measureEnd` 保持兼容导出,内部只调用 `aggrStart/aggrEnd`。新代码优先使用 `aggr*`。 ## 接入 @@ -62,98 +65,181 @@ module.exports = defineConfig({ }) ``` -### 录制窗口与时间线 +### 录制窗口 ```ts -import { start, mark, end } from '@mpxjs/perf' +import { start, end } from '@mpxjs/perf' -if (__mpx_perf__) start() +if (__mpx_perf__) { + start({ + markLimit: 2048, + traceLimit: 4096 + }) +} -loadPageData().then(() => { - if (__mpx_perf_user__) mark('goods:data-ready') -}) +// 执行业务流程 -onPageInteractive(() => { - if (__mpx_perf_user__) mark('goods:interactive') - if (__mpx_perf__) end() -}) +if (__mpx_perf__) end() ``` -即使没有 measure 和显式 mark,正常结束的窗口也会用 start/end 两条边界事件触发 reporter。 +两类序列默认容量均为 1024: -当窗口内同时包含一个 measure 桶和两条显式 mark 时,默认 `consoleReporter` 会先输出聚合结果,再按调用顺序输出时间线: +- `markLimit` 包含 start/end 边界,必须是不小于 2 的整数。 +- `traceLimit` 只计算 trace,必须是非负整数;传 `0` 可关闭当前窗口的 trace 存储。 +- 无效配置回退到 1024。 +- 录制中重复调用 `start(options)` 不清空数据,也不修改当前窗口容量。 -```text -[mpx perf] 1 measure bucket / 4 marks -measures -name count sum avg max -------------- ----- ------- ------- ------- -goods:request 1 86.20ms 86.20ms 86.20ms - -timeline -index at name ------ -------- ----------------- - 0 0.00ms start - 1 86.20ms goods:data-ready - 2 145.06ms goods:interactive - 3 145.11ms end -``` +### 区段聚类统计 -默认 `header: true` 时会通过 `console.group` 包裹输出,不同控制台可能展示为可折叠分组,文本内容与上述结构一致。 - -### 同步与跨作用域耗时 +同步高频路径使用默认 id 模式: ```ts -import { - scopeStart, scopeEnd, - measureStart, measureEnd -} from '@mpxjs/perf' +import { aggrStart, aggrEnd } from '@mpxjs/perf' function expensiveCompute (data) { let id = -1 - if (__mpx_perf_user__) id = scopeStart('myBiz:list:filter') + if (__mpx_perf_user__) id = aggrStart('myBiz:list:filter') const result = data.filter(/* ... */).sort(/* ... */) - if (__mpx_perf_user__) scopeEnd(id) + if (__mpx_perf_user__) aggrEnd(id) return result } +``` + +跨作用域时显式使用 name 模式: + +```ts +if (__mpx_perf_user__) aggrStart('goods:request', true) -if (__mpx_perf_user__) measureStart('goods:request') loadPageData().finally(() => { - if (__mpx_perf_user__) measureEnd('goods:request') + if (__mpx_perf_user__) aggrEnd('goods:request') }) ``` -`measureStart` / `measureEnd` 使用同一个名称作为配对 key 和聚合桶名。同名并发 measure 会由后一次起点覆盖前一次;第一次成功的 `measureEnd` 会消费起点。 +id 模式支持嵌套、乱序结束和同名并发。name 模式中,后一次同名 start 会覆盖前一次起点。 -### 自定义 reporter +### 区段序列统计 + +```ts +import { traceStart, traceEnd } from '@mpxjs/perf' + +let appId = -1 +let routerId = -1 +if (__mpx_perf_user__) { + appId = traceStart('module:app') + routerId = traceStart('module:router') +} + +if (__mpx_perf_user__) { + traceEnd(routerId, { moduleId: 42 }) + traceEnd(appId, { moduleId: 1 }) +} +``` + +trace 在 start 时预留位置,因此嵌套区段按 start 顺序输出。未完成区段不会进入 `events`,数量记录在 `incomplete`。name 模式适合不方便传递 id 的跨作用域区段;同名并发、递归和嵌套必须使用 id 模式。 + +### 点序列统计 + +```ts +import { mark } from '@mpxjs/perf' + +if (__mpx_perf_user__) { + mark('goods:data-ready', { + source: 'cache', + itemCount: 20 + }) +} +``` + +同名 mark 不合并。`info` 按引用保存,不复制、不校验、不序列化;建议只传小型、可序列化的诊断字段,不要传组件实例、完整 props、响应体或大数组。 + +## 数据与 Reporter + +```ts +interface AggrResult { + count: number + sum: number + avg: number + max: number +} + +interface MarkEvent { + name: string + start: number + timestamp: number + info?: unknown +} + +interface TraceEvent { + name: string + start: number + timestamp: number + duration: number + info?: unknown +} + +type Reporter = ( + aggregates: Map, + marks?: MarkTimeline, + traces?: TraceTimeline +) => void +``` + +`start` 是相对当前录制窗口起点的毫秒偏移,`timestamp` 是同一时钟源返回的原始值。它不保证是 Unix epoch,但同一运行环境和录制窗口内可以直接比较。 + +通过正常 `start/end` 结束的窗口始终传入 marks 和 traces。参数保持可选,以兼容外部手动调用及旧的一、二参数 Reporter。全局和局部 Reporter 收到同一份 Map、marks 和 traces 引用,不要直接修改。 ```ts import { setReporter } from '@mpxjs/perf' -import type { AggResult, MarkTimeline } from '@mpxjs/perf' +import type { + AggrResult, + MarkTimeline, + TraceTimeline +} from '@mpxjs/perf' if (__mpx_perf__) { - setReporter((measures: Map, timeline?: MarkTimeline) => { - MyAPM.reportMeasures(measures) - if (timeline) MyAPM.reportTimeline(timeline) + setReporter(( + aggregates: Map, + marks?: MarkTimeline, + traces?: TraceTimeline + ) => { + MyAPM.report({ aggregates, marks, traces }) }) } ``` -Reporter 签名为 `(measures, timeline?) => void`。通过 `start/end` 结束的窗口始终传入 timeline;第二参数保持可选,以兼容单参数 reporter 和手动调用。 +`TraceEvent` 可转换为 Chrome Trace Complete Event: + +```ts +const chromeEvents = traces.events.map(event => ({ + name: event.name, + ph: 'X', + ts: event.timestamp * 1000, + dur: event.duration * 1000, + args: event.info +})) +``` + +核心包不内置文件写入或 Chrome Trace 转换器,业务 Reporter 可自行补充 `pid` / `tid` / `cat` 等字段。 + +## Console Reporter -全局与 `end(localReporter)` 传入的局部 reporter 会依次收到同一份 Map 和 timeline 引用。不要修改它们;需要保留或改写时请自行复制。 +`createConsoleReporter({ sortBy, filter, header })` 分别输出 aggregates、traces 和 marks: -`createConsoleReporter({ sortBy, filter, header })` 会分别输出 measures 与 timeline。`sortBy` 只排序 measure;timeline 永远保持调用顺序。`filter` 会过滤 measure 和显式 mark,但不会隐藏内建 start/end;发生容量截断时会输出 dropped 提示。 +- `sortBy` 只影响 aggregates。 +- `filter` 同时作用于 aggregate、trace 和显式 mark,内建 start/end 不会被隐藏。 +- trace 与 mark 保持原始顺序。 +- 仅有事件包含 info 时才显示 info 列。 +- mark dropped、trace dropped 和 trace incomplete 会分别提示。 +- info 序列化失败不会中断业务或其他统计输出。 -## 性能特性 +## 性能与 DCE -- `scopeStart` 录制态使用数组槽位和数字句柄,不分配闭包或事件对象。 -- measure push 阶段仅执行 `Map.get` 与数值累加,每个新桶分配一个 `AggResult`。 -- `mark` 未录制时只做一次状态判断;录制时每条事件分配一个小对象,最多保留 254 条显式事件。 -- `start/end` 每个窗口固定生成两个边界事件;达到 256 条总上限后只累计 `dropped`,并始终保留 end。 +- `aggr` id 模式沿用数组槽位和 free list;录制稳态零对象、零闭包分配。 +- `aggr` name 模式使用 `Map` 保存起点。 +- trace 每个被接受区段分配一个内部事件对象和一条进行中映射。 +- mark 每个被接受事件分配一个小对象;mark 和 trace 容量相互独立。 +- 超出容量的数据只增加对应 `dropped`,不会保存 name、时间或 info。 -## Terser / Babel 约束 +最终构建依赖 `dist/index.js` 中的顶层 `__mpx_perf__ ? impl.x : noop.x` 三元、`sideEffects: false` 和使用方 Terser 完成 DCE。所有探针调用必须直接放在 `if (__mpx_perf_user__)` / `if (__mpx_perf_framework__)` 字面量条件内。 -- 最终构建依赖 `dist/index.js` 保留的顶层 `__mpx_perf__ ? impl.x : noop.x` 三元、`sideEffects: false` 和使用方 Terser 完成 DCE。 -- 探针调用必须直接放在 `if (__mpx_perf_user__)` / `if (__mpx_perf_framework__)` 字面量条件内。 -- 接入方需保留默认 Terser 的 `dead_code` / `conditionals` 优化;Babel 不应提前破坏顶层三元结构。 +`AggrResult` 是聚合结果的新类型名,不再导出旧 `AggResult`;显式类型导入需同步迁移。`MarkEvent.at` 已更名为 `start`,并增加 `timestamp` / `info`。 diff --git a/packages/perf/__tests__/bus.test.ts b/packages/perf/__tests__/bus.test.ts index f083050e6d..86eec2715c 100644 --- a/packages/perf/__tests__/bus.test.ts +++ b/packages/perf/__tests__/bus.test.ts @@ -1,170 +1,214 @@ import { bus } from '../src/bus' -import type { AggResult, MarkTimeline } from '../src/types' +import type { + AggrResult, + MarkTimeline, + TraceTimeline +} from '../src/types' interface Captured { - measures: Map - timeline: MarkTimeline + aggregates: Map + marks: MarkTimeline + traces: TraceTimeline } -describe('bus 状态机 + measure 聚合 + mark 时间线', () => { +const timestamp = (value: number) => () => value + +describe('bus 录制窗口', () => { let captured: Captured[] = [] beforeEach(() => { captured = [] - bus.setReporter((measures, timeline) => { - captured.push({ measures, timeline: timeline! }) + bus.setReporter((aggregates, marks, traces) => { + captured.push({ + aggregates, + marks: marks!, + traces: traces! + }) }) }) afterEach(() => { + if (bus.isRecording()) bus.end(0) bus.setReporter(undefined) }) - it('未 start 时 pushMeasure / pushMark 丢弃,end 是 noop', () => { - bus.pushMeasure('a', 1) - bus.pushMark('ready', 1) + it('未 start 时丢弃聚合、mark 和 trace,end 是 noop', () => { + bus.pushAggr('a', 1) + bus.pushMark('ready', timestamp(1)) + expect(bus.reserveTrace('task', timestamp(1))).toBe(-1) + bus.finishTrace(0, 2) bus.end(2) - expect(captured.length).toBe(0) + expect(captured).toHaveLength(0) }) - it('start / pushMeasure / end 完整流程聚合 count/sum/avg/max', () => { + it('实时聚合 count/sum/avg/max', () => { bus.start(10) - bus.pushMeasure('a', 1) - bus.pushMeasure('a', 3) - bus.pushMeasure('b', 5) + bus.pushAggr('a', 1) + bus.pushAggr('a', 3) + bus.pushAggr('b', 5) bus.end(20) - expect(captured.length).toBe(1) - const agg = captured[0].measures - expect(agg.size).toBe(2) - const a = agg.get('a')! - expect(a.count).toBe(2) - expect(a.sum).toBe(4) - expect(a.avg).toBe(2) - expect(a.max).toBe(3) - const b = agg.get('b')! - expect(b.count).toBe(1) - expect(b.sum).toBe(5) - expect(b.avg).toBe(5) - expect(b.max).toBe(5) + + const aggregates = captured[0].aggregates + expect(aggregates.get('a')).toEqual({ count: 2, sum: 4, avg: 2, max: 3 }) + expect(aggregates.get('b')).toEqual({ count: 1, sum: 5, avg: 5, max: 5 }) }) - it('start/end 自动生成时间线边界,显式 mark 保序且同名不合并', () => { + it('mark 保存相对时间、原始时间戳和 info 引用', () => { + const info = { source: 'cache' } bus.start(10) - bus.pushMark('ready', 12) - bus.pushMark('ready', 13) + bus.pushMark('ready', timestamp(12), info) + bus.pushMark('plain', timestamp(13)) + info.source = 'network' bus.end(20) - expect(captured[0].timeline).toEqual({ + + expect(captured[0].marks).toEqual({ events: [ - { name: 'start', at: 0 }, - { name: 'ready', at: 2 }, - { name: 'ready', at: 3 }, - { name: 'end', at: 10 } + { name: 'start', start: 0, timestamp: 10 }, + { name: 'ready', start: 2, timestamp: 12, info }, + { name: 'plain', start: 3, timestamp: 13 }, + { name: 'end', start: 10, timestamp: 20 } ], dropped: 0 }) + expect(Object.prototype.hasOwnProperty.call(captured[0].marks.events[2], 'info')).toBe(false) }) - it('重复 start 幂等,沿用已有窗口和时间线起点', () => { - bus.start(10) - bus.pushMeasure('a', 1) - bus.pushMark('first', 11) + it('trace 按 start 顺序输出并压缩未完成事件', () => { + const info = { moduleId: 42 } bus.start(100) - bus.pushMeasure('a', 2) - bus.pushMark('second', 12) - bus.end(20) - const result = captured[0] - expect(result.measures.get('a')).toMatchObject({ count: 2, sum: 3 }) - expect(result.timeline.events).toEqual([ - { name: 'start', at: 0 }, - { name: 'first', at: 1 }, - { name: 'second', at: 2 }, - { name: 'end', at: 10 } + const parent = bus.reserveTrace('parent', timestamp(101)) + const child = bus.reserveTrace('child', timestamp(102)) + const incomplete = bus.reserveTrace('incomplete', timestamp(103)) + bus.finishTrace(child, 105, info) + bus.finishTrace(child, 110) + bus.finishTrace(parent, 111) + bus.end(120) + + expect(incomplete).toBe(2) + expect(captured[0].traces).toEqual({ + events: [ + { name: 'parent', start: 1, timestamp: 101, duration: 10 }, + { name: 'child', start: 2, timestamp: 102, duration: 3, info } + ], + dropped: 0, + incomplete: 1 + }) + }) + + it('markLimit 与 traceLimit 相互独立并保留各自前缀', () => { + bus.start(0, { markLimit: 4, traceLimit: 2 }) + bus.pushMark('mark-0', timestamp(1)) + bus.pushMark('mark-1', timestamp(2)) + bus.pushMark('mark-dropped', timestamp(3)) + const first = bus.reserveTrace('trace-0', timestamp(1)) + const second = bus.reserveTrace('trace-1', timestamp(2)) + expect(bus.reserveTrace('trace-dropped', timestamp(3))).toBe(-1) + bus.finishTrace(first, 4) + bus.finishTrace(second, 5) + bus.end(6) + + expect(captured[0].marks.events.map(event => event.name)).toEqual([ + 'start', + 'mark-0', + 'mark-1', + 'end' ]) + expect(captured[0].marks.dropped).toBe(1) + expect(captured[0].traces.events.map(event => event.name)).toEqual([ + 'trace-0', + 'trace-1' + ]) + expect(captured[0].traces.dropped).toBe(1) }) - it('end → start 重新开窗口会清空 measure 和 timeline', () => { + it('默认容量均为 1024', () => { bus.start(0) - bus.pushMeasure('a', 1) - bus.pushMark('first', 1) - bus.end(2) - bus.start(10) - bus.pushMeasure('b', 2) - bus.pushMark('second', 11) - bus.end(12) - expect(captured.length).toBe(2) - expect(captured[0].measures.has('a')).toBe(true) - expect(captured[0].measures.has('b')).toBe(false) - expect(captured[1].measures.has('a')).toBe(false) - expect(captured[1].measures.has('b')).toBe(true) - expect(captured[0].timeline.events[1].name).toBe('first') - expect(captured[1].timeline.events[1].name).toBe('second') + Array.from({ length: 1030 }).forEach((_, index) => { + bus.pushMark(`mark-${index}`, timestamp(index + 1)) + const eventIndex = bus.reserveTrace(`trace-${index}`, timestamp(index + 1)) + if (eventIndex >= 0) bus.finishTrace(eventIndex, index + 2) + }) + bus.end(2000) + + expect(captured[0].marks.events).toHaveLength(1024) + expect(captured[0].marks.events[1022].name).toBe('mark-1021') + expect(captured[0].marks.events[1023].name).toBe('end') + expect(captured[0].marks.dropped).toBe(8) + expect(captured[0].traces.events).toHaveLength(1024) + expect(captured[0].traces.events[1023].name).toBe('trace-1023') + expect(captured[0].traces.dropped).toBe(6) }) - it('无显式 mark 和 measure 的窗口仍以 start/end 触发 reporter', () => { - bus.start(10) - bus.end(15) - expect(captured[0].measures.size).toBe(0) - expect(captured[0].timeline).toEqual({ - events: [{ name: 'start', at: 0 }, { name: 'end', at: 5 }], - dropped: 0 + it('traceLimit 为 0 时只累计 dropped', () => { + bus.start(0, { traceLimit: 0 }) + expect(bus.reserveTrace('a', timestamp(1))).toBe(-1) + expect(bus.reserveTrace('b', timestamp(2))).toBe(-1) + bus.end(3) + expect(captured[0].traces).toEqual({ + events: [], + dropped: 2, + incomplete: 0 }) }) - it('时间线总量固定为 256,保留前 254 个显式 mark 和末尾 end', () => { - bus.start(0) - Array.from({ length: 260 }).forEach((_, index) => { - bus.pushMark(`mark-${index}`, index + 1) + it('无效容量回退默认值', () => { + bus.start(0, { markLimit: 1, traceLimit: -1 }) + Array.from({ length: 1025 }).forEach((_, index) => { + bus.pushMark(`mark-${index}`, timestamp(index)) + bus.reserveTrace(`trace-${index}`, timestamp(index)) }) - bus.end(300) - const timeline = captured[0].timeline - expect(timeline.events.length).toBe(256) - expect(timeline.events[0]).toEqual({ name: 'start', at: 0 }) - expect(timeline.events[254]).toEqual({ name: 'mark-253', at: 254 }) - expect(timeline.events[255]).toEqual({ name: 'end', at: 300 }) - expect(timeline.dropped).toBe(6) - }) + bus.end(2000) - it('reporter 抛错被吞,不影响 end 返回', () => { - bus.setReporter(() => { throw new Error('boom') }) - bus.start(0) - bus.pushMeasure('a', 1) - expect(() => bus.end(1)).not.toThrow() + expect(captured[0].marks.events).toHaveLength(1024) + expect(captured[0].marks.dropped).toBe(3) + expect(captured[0].traces.dropped).toBe(1) + expect(captured[0].traces.incomplete).toBe(1024) }) - it('clearReporter 后 end 静默丢弃', () => { - bus.setReporter(undefined) - bus.start(0) - bus.pushMeasure('a', 1) - expect(() => bus.end(1)).not.toThrow() + it('重复 start 幂等且忽略新容量,新窗口重新读取配置', () => { + bus.start(10, { markLimit: 3, traceLimit: 1 }) + bus.start(100, { markLimit: 10, traceLimit: 10 }) + bus.pushMark('first', timestamp(11)) + bus.pushMark('dropped', timestamp(12)) + const firstTrace = bus.reserveTrace('first', timestamp(11)) + expect(bus.reserveTrace('dropped', timestamp(12))).toBe(-1) + bus.finishTrace(firstTrace, 13) + bus.end(20) + + expect(captured[0].marks.events.map(event => event.name)).toEqual(['start', 'first', 'end']) + expect(captured[0].traces.dropped).toBe(1) + + bus.start(30, { markLimit: 4, traceLimit: 2 }) + bus.pushMark('second', timestamp(31)) + bus.pushMark('third', timestamp(32)) + bus.end(33) + expect(captured[1].marks.events.map(event => event.name)).toEqual([ + 'start', + 'second', + 'third', + 'end' + ]) }) - it('全局与局部 reporter 同批触发同一份 Map 和 timeline', () => { + it('全局与局部 reporter 收到相同引用,reporter 异常不影响 end', () => { let local: Captured | undefined bus.start(0) - bus.pushMeasure('a', 1) - bus.end(1, (measures, timeline) => { - local = { measures, timeline: timeline! } + bus.pushAggr('a', 1) + bus.end(1, (aggregates, marks, traces) => { + local = { aggregates, marks: marks!, traces: traces! } }) - expect(captured.length).toBe(1) + expect(local).toBeDefined() - expect(captured[0].measures).toBe(local!.measures) - expect(captured[0].timeline).toBe(local!.timeline) - }) + expect(captured[0].aggregates).toBe(local!.aggregates) + expect(captured[0].marks).toBe(local!.marks) + expect(captured[0].traces).toBe(local!.traces) - it('全局 reporter 清空后 end 的局部 reporter 仍生效', () => { - let local: Captured | undefined - bus.setReporter(undefined) - bus.start(0) - bus.pushMeasure('a', 1) - bus.end(1, (measures, timeline) => { - local = { measures, timeline: timeline! } - }) - expect(captured.length).toBe(0) - expect(local!.measures.get('a')!.count).toBe(1) - expect(local!.timeline.events.map(event => event.name)).toEqual(['start', 'end']) + bus.setReporter(() => { throw new Error('boom') }) + bus.start(2) + expect(() => bus.end(3)).not.toThrow() }) - it('isRecording 反映当前窗口状态', () => { + it('isRecording 反映窗口状态', () => { expect(bus.isRecording()).toBe(false) bus.start(0) expect(bus.isRecording()).toBe(true) diff --git a/packages/perf/__tests__/console.test.ts b/packages/perf/__tests__/console.test.ts index 5c8dc84444..e7c1a30cd7 100644 --- a/packages/perf/__tests__/console.test.ts +++ b/packages/perf/__tests__/console.test.ts @@ -1,19 +1,23 @@ /* eslint-disable no-console */ import { createConsoleReporter } from '../src/reporters/console' -import type { AggResult, MarkTimeline } from '../src/types' +import type { + AggrResult, + MarkTimeline, + TraceTimeline +} from '../src/types' describe('console reporter', () => { afterEach(() => { jest.restoreAllMocks() }) - it('未传 timeline 时保持原有 measure 输出格式', () => { + it('只传聚合 Map 时保持旧式输出格式', () => { const log = jest.spyOn(console, 'log').mockImplementation(() => undefined) - const measures = new Map([ + const aggregates = new Map([ ['render', { count: 2, sum: 4, avg: 2, max: 3 }] ]) - createConsoleReporter({ header: false })(measures) + createConsoleReporter({ header: false })(aggregates) expect(log).toHaveBeenCalledTimes(1) expect(log.mock.calls[0][0]).toBe( @@ -24,51 +28,113 @@ describe('console reporter', () => { ) }) - it('timeline 按原始顺序输出,同名 mark 不合并且 filter 保留边界', () => { + it('分别输出 aggregates、traces、marks 并应用 filter', () => { const log = jest.spyOn(console, 'log').mockImplementation(() => undefined) - const measures = new Map([ + const aggregates = new Map([ ['keep:render', { count: 1, sum: 2, avg: 2, max: 2 }], ['hidden', { count: 1, sum: 1, avg: 1, max: 1 }] ]) - const timeline: MarkTimeline = { + const marks: MarkTimeline = { events: [ - { name: 'start', at: 0 }, - { name: 'hidden', at: 1 }, - { name: 'keep:ready', at: 2 }, - { name: 'keep:ready', at: 3 }, - { name: 'end', at: 4 } + { name: 'start', start: 0, timestamp: 100 }, + { name: 'hidden', start: 1, timestamp: 101 }, + { name: 'keep:ready', start: 2, timestamp: 102, info: { source: 'cache' } }, + { name: 'end', start: 4, timestamp: 104 } ], dropped: 2 } + const traces: TraceTimeline = { + events: [ + { name: 'hidden', start: 1, timestamp: 101, duration: 2 }, + { name: 'keep:task', start: 2, timestamp: 102, duration: 3, info: { id: 1 } } + ], + dropped: 3, + incomplete: 4 + } - createConsoleReporter({ header: false, filter: 'keep:' })(measures, timeline) + createConsoleReporter({ header: false, filter: 'keep:' })(aggregates, marks, traces) const output = log.mock.calls[0][0] as string - expect(output).toContain('[mpx perf] 1 measure bucket / 4 marks') - expect(output).toContain('measures\n') - expect(output).toContain('timeline\n') + expect(output).toContain('[mpx perf] 1 aggregate bucket / 1 traces / 3 marks') + expect(output).toContain('aggregates\n') + expect(output).toContain('traces\n') + expect(output).toContain('marks\n') expect(output).not.toContain(' hidden') - const timelineOutput = output.slice(output.indexOf('timeline\n')) - expect(timelineOutput.indexOf('start')).toBeLessThan(timelineOutput.indexOf('keep:ready')) - expect(timelineOutput.match(/keep:ready/g)).toHaveLength(2) - expect(timelineOutput.indexOf('keep:ready')).toBeLessThan(timelineOutput.indexOf('end')) - expect(output).toContain('mark timeline truncated: 2 events dropped after limit 256') + expect(output).toContain('102.00ms') + expect(output).toContain('3.00ms') + expect(output).toContain('{"id":1}') + expect(output).toContain('{"source":"cache"}') + const markOutput = output.slice( + output.lastIndexOf('marks\n'), + output.indexOf('[mpx perf] mark timeline') + ) + expect(markOutput.indexOf('start')).toBeLessThan(markOutput.indexOf('keep:ready')) + expect(markOutput.indexOf('keep:ready')).toBeLessThan(markOutput.indexOf('end')) + expect(output).toContain('mark timeline truncated: 2 events dropped') + expect(output).toContain('trace timeline truncated: 3 events dropped') + expect(output).toContain('trace timeline incomplete: 4 events unfinished') + }) + + it('trace 与 mark 保持原始顺序和原始 index', () => { + const log = jest.spyOn(console, 'log').mockImplementation(() => undefined) + const marks: MarkTimeline = { + events: [ + { name: 'start', start: 0, timestamp: 0 }, + { name: 'mark:b', start: 2, timestamp: 2 }, + { name: 'mark:a', start: 3, timestamp: 3 }, + { name: 'end', start: 4, timestamp: 4 } + ], + dropped: 0 + } + const traces: TraceTimeline = { + events: [ + { name: 'trace:b', start: 1, timestamp: 1, duration: 4 }, + { name: 'trace:a', start: 2, timestamp: 2, duration: 1 } + ], + dropped: 0, + incomplete: 0 + } + + createConsoleReporter({ header: false, sortBy: 'max' })(new Map(), marks, traces) + + const output = log.mock.calls[0][0] as string + expect(output.indexOf('trace:b')).toBeLessThan(output.indexOf('trace:a')) + expect(output.indexOf('mark:b')).toBeLessThan(output.indexOf('mark:a')) + }) + + it('循环引用和异常字符串化不会中断输出', () => { + const log = jest.spyOn(console, 'log').mockImplementation(() => undefined) + const info: { self?: unknown; toString: () => string } = { + toString: () => { throw new Error('boom') } + } + info.self = info + const marks: MarkTimeline = { + events: [ + { name: 'start', start: 0, timestamp: 0 }, + { name: 'ready', start: 1, timestamp: 1, info }, + { name: 'end', start: 2, timestamp: 2 } + ], + dropped: 0 + } + + expect(() => createConsoleReporter({ header: false })(new Map(), marks)).not.toThrow() + expect(log.mock.calls[0][0]).toContain('[unprintable]') }) - it('无 measure 时只输出 start/end 时间线', () => { + it('未传 trace 参数时仍正常输出 mark 时间线', () => { const log = jest.spyOn(console, 'log').mockImplementation(() => undefined) - const timeline: MarkTimeline = { - events: [{ name: 'start', at: 0 }, { name: 'end', at: 5 }], + const marks: MarkTimeline = { + events: [ + { name: 'start', start: 0, timestamp: 10 }, + { name: 'end', start: 5, timestamp: 15 } + ], dropped: 0 } - createConsoleReporter({ header: false })(new Map(), timeline) + createConsoleReporter({ header: false })(new Map(), marks) const output = log.mock.calls[0][0] as string - expect(output).toContain('[mpx perf] 0 measure buckets / 2 marks') - expect(output).not.toContain('measures\n') - expect(output).toContain('timeline\n') - expect(output).toContain('start') - expect(output).toContain('end') + expect(output).toContain('/ 0 traces / 2 marks') + expect(output).toContain('marks\n') }) }) diff --git a/packages/perf/__tests__/impl.test.ts b/packages/perf/__tests__/impl.test.ts index a062c25690..a17033b9ef 100644 --- a/packages/perf/__tests__/impl.test.ts +++ b/packages/perf/__tests__/impl.test.ts @@ -1,140 +1,255 @@ import { - scopeStart, - scopeEnd, + aggrEnd, + aggrStart, + clearReporter, + end, mark, - measureStart, measureEnd, - start, - end, + measureStart, + scopeEnd, + scopeStart, setReporter, - clearReporter + start, + traceEnd, + traceStart } from '../src/impl' -import type { AggResult, MarkTimeline } from '../src/types' +import type { + AggrResult, + MarkTimeline, + TraceTimeline +} from '../src/types' -describe('impl scope / measure / mark', () => { - let captured: Map | null = null - let timeline: MarkTimeline | null = null +describe('impl 三类统计 API', () => { + let aggregates: Map | null = null + let marks: MarkTimeline | null = null + let traces: TraceTimeline | null = null + let nowSpy: jest.SpyInstance beforeEach(() => { - captured = null - timeline = null - setReporter((measures, marks) => { - captured = measures - timeline = marks! + aggregates = null + marks = null + traces = null + nowSpy = jest.spyOn(performance, 'now') + setReporter((result, markTimeline, traceTimeline) => { + aggregates = result + marks = markTimeline! + traces = traceTimeline! }) }) afterEach(() => { + end() clearReporter() + nowSpy.mockRestore() }) - it('scopeStart/scopeEnd 起止生成一条 measure 样本', () => { + function mockNow (...values: number[]) { + let index = 0 + nowSpy.mockImplementation(() => values[Math.min(index++, values.length - 1)]) + } + + it('aggr id 模式支持同名并发、乱序结束和正确聚合', () => { + mockNow(0, 1, 2, 5, 7, 8) start() - const id = scopeStart('foo') - expect(id).toBeGreaterThanOrEqual(0) - scopeEnd(id) + const first = aggrStart('foo') + const second = aggrStart('foo') + aggrEnd(first) + aggrEnd(second) end() - expect(captured).not.toBeNull() - const foo = captured!.get('foo')! - expect(foo.count).toBe(1) - expect(foo.sum).toBeGreaterThanOrEqual(0) - expect(foo.max).toBe(foo.sum) - expect(foo.avg).toBe(foo.sum) + + expect(aggregates!.get('foo')).toEqual({ + count: 2, + sum: 9, + avg: 4.5, + max: 5 + }) }) - it('同名 scope 多次累加,count/sum/avg/max 正确', () => { + it('aggr name 模式由后一次 start 覆盖,并消费起点', () => { + mockNow(0, 1, 4, 9, 10) start() - const a = scopeStart('foo'); scopeEnd(a) - const b = scopeStart('foo'); scopeEnd(b) - const c = scopeStart('foo'); scopeEnd(c) + expect(aggrStart('request', true)).toBeUndefined() + aggrStart('request', true) + aggrEnd('request') + aggrEnd('request') end() - const foo = captured!.get('foo')! - expect(foo.count).toBe(3) - expect(foo.avg).toBeCloseTo(foo.sum / 3, 10) + + expect(aggregates!.get('request')).toEqual({ + count: 1, + sum: 5, + avg: 5, + max: 5 + }) }) - it('嵌套 scope 不串台,freeList 正确回收', () => { + it('aggr name 模式允许窗口外保存起点', () => { + mockNow(1, 2, 5, 6) + aggrStart('request', true) start() - const outer = scopeStart('outer') - const inner = scopeStart('inner') - scopeEnd(inner) - scopeEnd(outer) + aggrEnd('request') end() - expect(captured!.get('outer')!.count).toBe(1) - expect(captured!.get('inner')!.count).toBe(1) + expect(aggregates!.get('request')!.sum).toBe(4) }) - it('未 start 时 scopeStart 返回 -1,scopeEnd(-1) 安全 noop', () => { - const id = scopeStart('lost') - expect(id).toBe(-1) - expect(() => scopeEnd(id)).not.toThrow() + it('无效、缺失和重复的 aggrEnd 安全 noop', () => { + mockNow(0, 1, 2, 3) + start() + const id = aggrStart('foo') + aggrEnd(-1) + aggrEnd(999) + aggrEnd('missing') + aggrEnd(id) + aggrEnd(id) end() - expect(captured).toBeNull() + expect(aggregates!.get('foo')!.count).toBe(1) }) - it('scopeEnd 重复调用同一 id 不重复累加', () => { + it('旧 scope/measure API 复用聚合语义', () => { + mockNow(0, 1, 3, 4, 7, 8) start() - const id = scopeStart('foo') - scopeEnd(id) + const id = scopeStart('scope') scopeEnd(id) + measureStart('measure') + measureEnd('measure') end() - expect(captured!.get('foo')!.count).toBe(1) + expect(aggregates!.get('scope')!.sum).toBe(2) + expect(aggregates!.get('measure')!.sum).toBe(3) + }) + + it('未录制时 aggr/trace id 和 mark 不读取时钟', () => { + expect(aggrStart('aggr')).toBe(-1) + expect(traceStart('trace')).toBe(-1) + expect(traceStart('trace-name', true)).toBeUndefined() + mark('lost') + expect(nowSpy).not.toHaveBeenCalled() }) - it('measureStart/measureEnd 使用同名 key 配对并聚合', () => { + it('mark 和 trace 超出容量时不读取时钟', () => { + mockNow(0) + start({ markLimit: 2, traceLimit: 0 }) + nowSpy.mockClear() + + mark('dropped') + expect(traceStart('dropped')).toBe(-1) + + expect(nowSpy).not.toHaveBeenCalled() + }) + + it('trace id 模式保存 start/timestamp/duration/info 并保持 start 顺序', () => { + const info = { moduleId: 1 } + mockNow(100, 101, 102, 105, 110, 111) start() - measureStart('request') - measureEnd('request') + const parent = traceStart('parent') + const child = traceStart('child') + traceEnd(child) + traceEnd(parent, info) end() - const request = captured!.get('request')! - expect(request.count).toBe(1) - expect(request.sum).toBeGreaterThanOrEqual(0) + + expect(traces).toEqual({ + events: [ + { name: 'parent', start: 1, timestamp: 101, duration: 9, info }, + { name: 'child', start: 2, timestamp: 102, duration: 3 } + ], + dropped: 0, + incomplete: 0 + }) + expect(Object.prototype.hasOwnProperty.call(traces!.events[1], 'info')).toBe(false) }) - it('measureEnd 消费起点后重复调用不再聚合', () => { + it('trace name 模式覆盖旧起点并统计 incomplete', () => { + mockNow(0, 1, 2, 3, 4, 5) start() - measureStart('request') - measureEnd('request') - measureEnd('request') + traceStart('request', true) + traceStart('request', true) + traceEnd('request') + traceStart('hanging', true) end() - expect(captured!.get('request')!.count).toBe(1) + + expect(traces).toEqual({ + events: [ + { name: 'request', start: 2, timestamp: 2, duration: 1 } + ], + dropped: 0, + incomplete: 2 + }) }) - it('mark 只写入有序时间线,同名事件不合并', () => { + it('trace 重复 end 只完成一次', () => { + mockNow(0, 1, 3, 4) start() - mark('ready') - mark('ready') + const id = traceStart('task') + traceEnd(id) + traceEnd(id) end() - expect(captured!.size).toBe(0) - expect(timeline!.events.map(event => event.name)).toEqual(['start', 'ready', 'ready', 'end']) + expect(traces!.events).toEqual([ + { name: 'task', start: 1, timestamp: 1, duration: 2 } + ]) }) - it('mark 不会注册 measure 起点', () => { - start() - mark('request') - measureEnd('request') + it('trace 自定义容量生效,溢出 id 返回 -1', () => { + mockNow(0, 1, 2, 3, 4) + start({ traceLimit: 1 }) + const accepted = traceStart('accepted') + expect(traceStart('dropped')).toBe(-1) + traceEnd(accepted) end() - expect(captured!.has('request')).toBe(false) - expect(timeline!.events.map(event => event.name)).toEqual(['start', 'request', 'end']) + expect(traces!.events).toHaveLength(1) + expect(traces!.dropped).toBe(1) }) - it('未录制时 mark 被丢弃,start/end 仍自动生成边界', () => { - mark('lost') + it('上一窗口遗留 id 不能结束下一窗口事件', () => { + mockNow(0, 1, 2, 10, 11, 12, 13) start() + const stale = traceStart('stale') end() - expect(timeline!.events.map(event => event.name)).toEqual(['start', 'end']) + + start() + const current = traceStart('current') + traceEnd(stale) + traceEnd(current) + end() + + expect(traces).toEqual({ + events: [ + { name: 'current', start: 1, timestamp: 11, duration: 1 } + ], + dropped: 0, + incomplete: 0 + }) + }) + + it('mark 保存 info,start/end 边界不携带 info', () => { + const info = { source: 'cache' } + mockNow(10, 12, 20) + start({ markLimit: 3 }) + mark('ready', info) + mark('dropped', { ignored: true }) + end() + + expect(marks).toEqual({ + events: [ + { name: 'start', start: 0, timestamp: 10 }, + { name: 'ready', start: 2, timestamp: 12, info }, + { name: 'end', start: 10, timestamp: 20 } + ], + dropped: 1 + }) }) - it('end 支持局部 reporter,与全局 reporter 共享结果', () => { - let localMeasures: Map | undefined - let localTimeline: MarkTimeline | undefined + it('全局与局部 reporter 共享三类结果引用', () => { + let localAggregates: Map | undefined + let localMarks: MarkTimeline | undefined + let localTraces: TraceTimeline | undefined + mockNow(0, 1) start() - const id = scopeStart('foo'); scopeEnd(id) - end((measures, marks) => { - localMeasures = measures - localTimeline = marks + end((result, markTimeline, traceTimeline) => { + localAggregates = result + localMarks = markTimeline + localTraces = traceTimeline }) - expect(captured).toBe(localMeasures) - expect(timeline).toBe(localTimeline) + + expect(aggregates).toBe(localAggregates) + expect(marks).toBe(localMarks) + expect(traces).toBe(localTraces) }) }) diff --git a/packages/perf/src/bus.ts b/packages/perf/src/bus.ts index 7cb8ad3e46..b86827c4c5 100644 --- a/packages/perf/src/bus.ts +++ b/packages/perf/src/bus.ts @@ -1,81 +1,180 @@ -import type { AggResult, MarkTimeline, Reporter } from './types' +import type { + AggrResult, + MarkEvent, + MarkTimeline, + PerfStartOptions, + Reporter, + TraceTimeline +} from './types' import { consoleReporter } from './reporters/console' -const MARK_LIMIT = 256 +const DEFAULT_MARK_LIMIT = 1024 +const DEFAULT_TRACE_LIMIT = 1024 + +interface PendingTraceEvent { + name: string + start: number + timestamp: number + duration?: number + info?: unknown +} + +interface PendingTraceTimeline { + events: PendingTraceEvent[] + dropped: number + incomplete: number +} // 默认 reporter 是 consoleReporter——业务侧不调 setReporter 也能在 console 看到聚合表。 let _reporter: Reporter | undefined = consoleReporter -// 录制状态机:未 start 时所有 pushMeasure / pushMark 立即丢弃。 +// 录制状态机:未 start 时所有 pushAggr / pushMark / trace 操作立即丢弃。 let _recording = false let recordingStart = 0 +let markLimit = DEFAULT_MARK_LIMIT +let traceLimit = DEFAULT_TRACE_LIMIT // 实时聚合容器:push 阶段直接累加,end 时回填 avg。 -// measure 不保留原始事件;mark 只保留最多 256 条有序时间线事件。 +// aggr 不保留原始事件;mark 与 trace 分别使用独立的有界时间线。 // 每次 start 重建新 Map 而非 clear:end 交给 reporter 的引用就是该窗口的私有数据, // 业务侧异步消费也不会被下一次窗口覆盖。窗口级别一次 Map 分配可忽略。 -let aggMap = new Map() -let timeline: MarkTimeline = { events: [], dropped: 0 } +let aggrMap = new Map() +let markTimeline: MarkTimeline = { events: [], dropped: 0 } +let traceTimeline: PendingTraceTimeline = { events: [], dropped: 0, incomplete: 0 } -function runReporter (reporter: Reporter, agg: Map, marks: MarkTimeline) { +function runReporter ( + reporter: Reporter, + aggregates: Map, + marks: MarkTimeline, + traces: TraceTimeline +) { try { - reporter(agg, marks) + reporter(aggregates, marks, traces) } catch (e) { // 故意吞掉 reporter 错误,不影响业务;reporter 自己应对异常负责。 } } +function normalizeMarkLimit (limit: number | undefined): number { + return typeof limit === 'number' && Number.isInteger(limit) && limit >= 2 + ? limit + : DEFAULT_MARK_LIMIT +} + +function normalizeTraceLimit (limit: number | undefined): number { + return typeof limit === 'number' && Number.isInteger(limit) && limit >= 0 + ? limit + : DEFAULT_TRACE_LIMIT +} + +function finishTraceTimeline (): TraceTimeline { + const events = traceTimeline.events + let completed = 0 + let incomplete = 0 + events.forEach((event) => { + if (event.duration === undefined) { + incomplete++ + } else { + events[completed++] = event + } + }) + events.length = completed + traceTimeline.incomplete = incomplete + return traceTimeline as TraceTimeline +} + export const bus = { setReporter (r: Reporter | undefined) { _reporter = r }, - start (startedAt: number) { + start (startedAt: number, options?: PerfStartOptions) { // 重复 start 视为幂等:沿用已有窗口,不清空已采集的数据; // 想强制重开新窗口,先 end 再 start。 if (_recording) return _recording = true recordingStart = startedAt - aggMap = new Map() - timeline = { events: [{ name: 'start', at: 0 }], dropped: 0 } + markLimit = normalizeMarkLimit(options?.markLimit) + traceLimit = normalizeTraceLimit(options?.traceLimit) + aggrMap = new Map() + markTimeline = { + events: [{ name: 'start', start: 0, timestamp: startedAt }], + dropped: 0 + } + traceTimeline = { events: [], dropped: 0, incomplete: 0 } }, end (endedAt: number, reporter?: Reporter) { // 未 start 直接 end 是 noop,不报错也不调 reporter。 if (!_recording) return - timeline.events.push({ name: 'end', at: endedAt - recordingStart }) + markTimeline.events.push({ + name: 'end', + start: endedAt - recordingStart, + timestamp: endedAt + }) _recording = false // 最后一次性回填 avg,避免 push 阶段反复算除法。 - aggMap.forEach((s) => { + aggrMap.forEach((s) => { s.avg = s.count ? s.sum / s.count : 0 }) + const traces = finishTraceTimeline() // 全局 reporter 先于局部 reporter,但共享同一份 Map 和 timeline 实例—— // reporter 不应修改它们(如需保留请自行 clone)。 - if (_reporter) runReporter(_reporter, aggMap, timeline) - if (reporter) runReporter(reporter, aggMap, timeline) + if (_reporter) runReporter(_reporter, aggrMap, markTimeline, traces) + if (reporter) runReporter(reporter, aggrMap, markTimeline, traces) }, isRecording (): boolean { return _recording }, - pushMeasure (name: string, dur: number) { + pushAggr (name: string, dur: number) { if (!_recording) return - let s = aggMap.get(name) + let s = aggrMap.get(name) if (!s) { s = { count: 0, sum: 0, avg: 0, max: 0 } - aggMap.set(name, s) + aggrMap.set(name, s) } s.count++ s.sum += dur if (dur > s.max) s.max = dur }, - pushMark (name: string, timestamp: number) { + pushMark (name: string, getTimestamp: () => number, info?: unknown) { if (!_recording) return - // 为 end 固定预留最后一个位置,因此显式 mark 最多保留 254 条。 - if (timeline.events.length < MARK_LIMIT - 1) { - timeline.events.push({ name, at: timestamp - recordingStart }) - } else { - timeline.dropped++ + // 为 end 固定预留最后一个位置。 + if (markTimeline.events.length >= markLimit - 1) { + markTimeline.dropped++ + return } + const timestamp = getTimestamp() + const event: MarkEvent = { + name, + start: timestamp - recordingStart, + timestamp + } + if (info !== undefined) event.info = info + markTimeline.events.push(event) + }, + + reserveTrace (name: string, getStartedAt: () => number): number { + if (!_recording) return -1 + if (traceTimeline.events.length >= traceLimit) { + traceTimeline.dropped++ + return -1 + } + const startedAt = getStartedAt() + traceTimeline.events.push({ + name, + start: startedAt - recordingStart, + timestamp: startedAt + }) + return traceTimeline.events.length - 1 + }, + + finishTrace (eventIndex: number, endedAt: number, info?: unknown) { + if (!_recording) return + const event = traceTimeline.events[eventIndex] + if (!event || event.duration !== undefined) return + event.duration = endedAt - event.timestamp + if (info !== undefined) event.info = info } } diff --git a/packages/perf/src/impl.ts b/packages/perf/src/impl.ts index 14ecb813b9..a967b54ac2 100644 --- a/packages/perf/src/impl.ts +++ b/packages/perf/src/impl.ts @@ -1,5 +1,5 @@ import { bus } from './bus' -import type { Reporter } from './types' +import type { PerfStartOptions, Reporter } from './types' // 优先 performance.now(DOM / RN web)→ Hermes nativePerformanceNow → Date.now 兜底。 // Hermes 的 nativePerformanceNow 是 globalThis 上的专有 API,标准 lib.dom 类型里没有, @@ -18,76 +18,138 @@ const now: () => number = (() => { return () => Date.now() })() -// 跨作用域的起止配对靠 measure name 匹配;同步代码内首选用 scopeStart/scopeEnd。 -const measureStarts = new Map() +// name 模式通过名称配对;同名 start 后一次覆盖前一次。 +const namedAggrStarts = new Map() -// scopeStart/scopeEnd 用平行数组持有进行中的 scope,避免每次 scope 分配闭包对象。 -// stackName / stackStart 同步增长;freeList 回收已结束的槽位 id。 +// aggr id 模式用平行数组持有进行中的区段,避免每次 start 分配闭包对象。 +// aggrNames / aggrStarts 同步增长;aggrFreeList 回收已结束的槽位 id。 // 不依赖严格栈序(freeList 处理乱序结束与提前 end);React render 实际就是栈式, // freeList 池稳态后槽位数 == 最大并发深度,不再增长。 -const stackName: (string | null)[] = [] -const stackStart: number[] = [] -const freeList: number[] = [] -let stackTop = 0 +const aggrNames: (string | null)[] = [] +const aggrStarts: number[] = [] +const aggrFreeList: number[] = [] +let aggrTop = 0 /** - * 起一段 scope,返回 id 句柄;未录制时返回 -1,调用方据此跳过 scopeEnd。 - * 录制态下也仅做:状态判断、freeList/stackTop 取 id、一次 now()、两次数组下标写。 + * 起一段聚合统计,默认返回 id 句柄;useName 为 true 时改用 name 配对。 + * 录制态下也仅做:状态判断、freeList/aggrTop 取 id、一次 now()、两次数组下标写。 * 全程无对象 / 闭包分配——这是高频 render 场景的核心优化。 */ -export function scopeStart (name: string): number { +export function aggrStart (name: string, useName?: false): number +export function aggrStart (name: string, useName: true): void +export function aggrStart (name: string, useName: boolean): number | void +export function aggrStart (name: string, useName = false): number | void { + if (useName) { + namedAggrStarts.set(name, now()) + return + } if (!bus.isRecording()) return -1 - const id = freeList.length > 0 ? freeList.pop()! : stackTop++ - stackName[id] = name - stackStart[id] = now() + const id = aggrFreeList.length > 0 ? aggrFreeList.pop()! : aggrTop++ + aggrNames[id] = name + aggrStarts[id] = now() return id } /** - * 关闭 id 对应的 scope,把时长累加进聚合。 - * id < 0(未录制时 scopeStart 的返回)或已被 end 过都安全 no-op。 + * 关闭 id 或 name 对应的聚合区段,把时长累加进同名桶。 */ -export function scopeEnd (id: number): void { - if (id < 0) return - const name = stackName[id] - if (name === null) return - const dur = now() - stackStart[id] +export function aggrEnd (target: number | string): void { + if (typeof target === 'string') { + const startedAt = namedAggrStarts.get(target) + if (startedAt === undefined) return + namedAggrStarts.delete(target) + bus.pushAggr(target, now() - startedAt) + return + } + if (target < 0) return + const name = aggrNames[target] + if (name == null) return + const dur = now() - aggrStarts[target] // 清 name 表示该槽空闲,避免重复 end 重复累加;id 回收进 freeList。 - stackName[id] = null - freeList.push(id) - bus.pushMeasure(name, dur) + aggrNames[target] = null + aggrFreeList.push(target) + bus.pushAggr(name, dur) } /** * 向当前录制窗口追加一条有序时间线事件;未录制时不读取时钟。 */ -export function mark (name: string) { +export function mark (name: string, info?: unknown) { if (!bus.isRecording()) return - bus.pushMark(name, now()) + bus.pushMark(name, now, info) } /** - * 注册一段跨作用域 measure 的具名起点。 + * 起一段区段序列,默认返回 id 句柄;useName 为 true 时改用 name 配对。 */ -export function measureStart (name: string) { - measureStarts.set(name, now()) +let nextTraceId = 0 +const traceIdToEvent = new Map() +const traceNameToEvent = new Map() + +export function traceStart (name: string, useName?: false): number +export function traceStart (name: string, useName: true): void +export function traceStart (name: string, useName: boolean): number | void +export function traceStart (name: string, useName = false): number | void { + if (!bus.isRecording()) { + if (!useName) return -1 + return + } + const eventIndex = bus.reserveTrace(name, now) + if (eventIndex < 0) { + if (!useName) return -1 + return + } + if (useName) { + traceNameToEvent.set(name, eventIndex) + return + } + const id = nextTraceId++ + traceIdToEvent.set(id, eventIndex) + return id } /** - * 结束同名 measure 并聚合耗时;起点命中后立即消费,重复结束安全 noop。 + * 结束 id 或 name 对应的 trace,回填 duration 与可选 info。 */ -export function measureEnd (name: string) { - const startedAt = measureStarts.get(name) - if (startedAt === undefined) return - measureStarts.delete(name) - bus.pushMeasure(name, now() - startedAt) +export function traceEnd (target: number | string, info?: unknown): void { + if (!bus.isRecording()) return + const eventIndex = typeof target === 'string' + ? traceNameToEvent.get(target) + : traceIdToEvent.get(target) + if (eventIndex === undefined) return + if (typeof target === 'string') { + traceNameToEvent.delete(target) + } else { + traceIdToEvent.delete(target) + } + bus.finishTrace(eventIndex, now(), info) +} + +// 旧聚类 API 保持原签名,只复用 aggr 实现。 +export function scopeStart (name: string): number { + return aggrStart(name) +} + +export function scopeEnd (id: number): void { + aggrEnd(id) +} + +export function measureStart (name: string): void { + aggrStart(name, true) +} + +export function measureEnd (name: string): void { + aggrEnd(name) } // 录制窗口控制 -export const start = () => bus.start(now()) +export const start = (options?: PerfStartOptions) => bus.start(now(), options) export const end = (reporter?: Reporter) => { if (!bus.isRecording()) return - bus.end(now(), reporter) + const endedAt = now() + traceIdToEvent.clear() + traceNameToEvent.clear() + bus.end(endedAt, reporter) } // reporter 注册 API diff --git a/packages/perf/src/index.ts b/packages/perf/src/index.ts index 3029920818..e51ac7d654 100644 --- a/packages/perf/src/index.ts +++ b/packages/perf/src/index.ts @@ -13,6 +13,11 @@ import * as reporters from './reporters/console' // // @mpxjs/perf 包内部不感知分组(framework / user / ...)——分组开关只由调用方 // 的字面量条件 `if (__mpx_perf_framework__)` / `if (__mpx_perf_user__)` 决定。 +export const aggrStart: typeof impl.aggrStart = __mpx_perf__ ? impl.aggrStart : noop.aggrStart +export const aggrEnd = __mpx_perf__ ? impl.aggrEnd : noop.aggrEnd +export const traceStart: typeof impl.traceStart = __mpx_perf__ ? impl.traceStart : noop.traceStart +export const traceEnd = __mpx_perf__ ? impl.traceEnd : noop.traceEnd + export const scopeStart = __mpx_perf__ ? impl.scopeStart : noop.scopeStart export const scopeEnd = __mpx_perf__ ? impl.scopeEnd : noop.scopeEnd export const mark = __mpx_perf__ ? impl.mark : noop.mark @@ -30,4 +35,12 @@ export const clearReporter = __mpx_perf__ ? impl.clearReporter : noop.clearRepor export const createConsoleReporter = __mpx_perf__ ? reporters.createConsoleReporter : noop.createConsoleReporter export const consoleReporter = __mpx_perf__ ? reporters.consoleReporter : noop.consoleReporter -export type { Reporter, AggResult, MarkEvent, MarkTimeline } from './types' +export type { + AggrResult, + MarkEvent, + MarkTimeline, + PerfStartOptions, + Reporter, + TraceEvent, + TraceTimeline +} from './types' diff --git a/packages/perf/src/noop.ts b/packages/perf/src/noop.ts index 6d6c3975d5..3f88e82477 100644 --- a/packages/perf/src/noop.ts +++ b/packages/perf/src/noop.ts @@ -6,7 +6,23 @@ // 整个模块的存在意义就是「空函数」——给 eslint 关掉 no-empty-function 比逐行 // disable 更直观。 /* eslint-disable @typescript-eslint/no-empty-function */ -import type { Reporter } from './types' +import type { PerfStartOptions, Reporter } from './types' + +export function aggrStart (_name: string, _useName?: false): number +export function aggrStart (_name: string, _useName: true): void +export function aggrStart (_name: string, _useName: boolean): number | void +export function aggrStart (_name: string, useName = false): number | void { + if (!useName) return -1 +} +export const aggrEnd = (_target: number | string) => {} + +export function traceStart (_name: string, _useName?: false): number +export function traceStart (_name: string, _useName: true): void +export function traceStart (_name: string, _useName: boolean): number | void +export function traceStart (_name: string, useName = false): number | void { + if (!useName) return -1 +} +export const traceEnd = (_target: number | string, _info?: unknown) => {} // scopeStart 关闭态恒返回 -1,与开启态未录制时的语义一致; // 调用方 `let id = -1; ...; perf.scopeEnd(id)` 在关闭态下被 inline 后等价于 @@ -14,11 +30,11 @@ import type { Reporter } from './types' export const scopeStart = (_name: string): number => -1 export const scopeEnd = (_id: number) => {} -export const mark = (_name: string) => {} +export const mark = (_name: string, _info?: unknown) => {} export const measureStart = (_name: string) => {} export const measureEnd = (_name: string) => {} -export const start = () => {} +export const start = (_options?: PerfStartOptions) => {} export const end = (_reporter?: Reporter) => {} export const setReporter = (_r: Reporter) => {} @@ -31,5 +47,5 @@ export const clearReporter = () => {} // 业务侧典型用法: // import { consoleReporter, setReporter } from '@mpxjs/perf' // setReporter(consoleReporter) // 关闭态下 consoleReporter 是 noop reporter -export const consoleReporter: Reporter = (_agg) => {} -export const createConsoleReporter = (_options?: object): Reporter => (_agg) => {} +export const consoleReporter: Reporter = (_aggregates) => {} +export const createConsoleReporter = (_options?: object): Reporter => (_aggregates) => {} diff --git a/packages/perf/src/reporters/console.ts b/packages/perf/src/reporters/console.ts index c775292f56..8ec775bd61 100644 --- a/packages/perf/src/reporters/console.ts +++ b/packages/perf/src/reporters/console.ts @@ -1,15 +1,20 @@ -import type { AggResult, MarkTimeline, Reporter } from '../types' +import type { + AggrResult, + MarkTimeline, + Reporter, + TraceTimeline +} from '../types' export interface ConsoleReporterOptions { /** 排序字段,默认按 sum 降序 */ sortBy?: 'sum' | 'avg' | 'max' | 'count' - /** 仅打印事件名匹配该正则 / 字符串前缀的桶 */ + /** 仅打印事件名匹配该正则 / 字符串前缀的数据 */ filter?: RegExp | string /** 是否带 console.group 头,默认 true */ header?: boolean } -interface Row { +interface AggrRow { name: string count: number sum: number @@ -17,10 +22,16 @@ interface Row { max: number } -interface TimelineRow { +interface SequenceRow { index: number - at: number + start: number + timestamp: number name: string + info?: unknown +} + +interface TraceRow extends SequenceRow { + duration: number } function pad (s: string, width: number, right = false): string { @@ -33,6 +44,18 @@ function fmtMs (n: number): string { return n.toFixed(2) + 'ms' } +function fmtInfo (info: unknown): string { + try { + const json = JSON.stringify(info) + if (json !== undefined) return json + } catch (e) {} + try { + return String(info) + } catch (e) { + return '[unprintable]' + } +} + function matchesFilter (name: string, filter?: RegExp | string): boolean { if (!filter) return true if (typeof filter === 'string') return name.startsWith(filter) @@ -40,118 +63,210 @@ function matchesFilter (name: string, filter?: RegExp | string): boolean { return filter.test(name) } +function formatAggrRows (rows: AggrRow[]): string { + let nameW = 'name'.length + let countW = 'count'.length + let sumW = 'sum'.length + let avgW = 'avg'.length + let maxW = 'max'.length + const cells = rows.map(row => { + const cell = { + name: row.name, + count: String(row.count), + sum: fmtMs(row.sum), + avg: fmtMs(row.avg), + max: fmtMs(row.max) + } + if (cell.name.length > nameW) nameW = cell.name.length + if (cell.count.length > countW) countW = cell.count.length + if (cell.sum.length > sumW) sumW = cell.sum.length + if (cell.avg.length > avgW) avgW = cell.avg.length + if (cell.max.length > maxW) maxW = cell.max.length + return cell + }) + + const header = `${pad('name', nameW)} ${pad('count', countW, true)} ${pad('sum', sumW, true)} ${pad('avg', avgW, true)} ${pad('max', maxW, true)}` + const separator = `${'-'.repeat(nameW)} ${'-'.repeat(countW)} ${'-'.repeat(sumW)} ${'-'.repeat(avgW)} ${'-'.repeat(maxW)}` + const body = cells.map(cell => + `${pad(cell.name, nameW)} ${pad(cell.count, countW, true)} ${pad(cell.sum, sumW, true)} ${pad(cell.avg, avgW, true)} ${pad(cell.max, maxW, true)}` + ) + return [header, separator, ...body].join('\n') +} + +function formatTraceRows (rows: TraceRow[]): string { + const showInfo = rows.some(row => row.info !== undefined) + let indexW = 'index'.length + let startW = 'start'.length + let timestampW = 'timestamp'.length + let durationW = 'duration'.length + let nameW = 'name'.length + let infoW = 'info'.length + const cells = rows.map(row => { + const cell = { + index: String(row.index), + start: fmtMs(row.start), + timestamp: fmtMs(row.timestamp), + duration: fmtMs(row.duration), + name: row.name, + info: row.info === undefined ? '' : fmtInfo(row.info) + } + if (cell.index.length > indexW) indexW = cell.index.length + if (cell.start.length > startW) startW = cell.start.length + if (cell.timestamp.length > timestampW) timestampW = cell.timestamp.length + if (cell.duration.length > durationW) durationW = cell.duration.length + if (cell.name.length > nameW) nameW = cell.name.length + if (cell.info.length > infoW) infoW = cell.info.length + return cell + }) + + const infoHeader = showInfo ? ` ${pad('info', infoW)}` : '' + const infoSeparator = showInfo ? ` ${'-'.repeat(infoW)}` : '' + const header = `${pad('index', indexW, true)} ${pad('start', startW, true)} ${pad('timestamp', timestampW, true)} ${pad('duration', durationW, true)} ${pad('name', nameW)}${infoHeader}` + const separator = `${'-'.repeat(indexW)} ${'-'.repeat(startW)} ${'-'.repeat(timestampW)} ${'-'.repeat(durationW)} ${'-'.repeat(nameW)}${infoSeparator}` + const body = cells.map(cell => { + const info = showInfo ? ` ${pad(cell.info, infoW)}` : '' + return `${pad(cell.index, indexW, true)} ${pad(cell.start, startW, true)} ${pad(cell.timestamp, timestampW, true)} ${pad(cell.duration, durationW, true)} ${pad(cell.name, nameW)}${info}` + }) + return [header, separator, ...body].join('\n') +} + +function formatMarkRows (rows: SequenceRow[]): string { + const showInfo = rows.some(row => row.info !== undefined) + let indexW = 'index'.length + let startW = 'start'.length + let timestampW = 'timestamp'.length + let nameW = 'name'.length + let infoW = 'info'.length + const cells = rows.map(row => { + const cell = { + index: String(row.index), + start: fmtMs(row.start), + timestamp: fmtMs(row.timestamp), + name: row.name, + info: row.info === undefined ? '' : fmtInfo(row.info) + } + if (cell.index.length > indexW) indexW = cell.index.length + if (cell.start.length > startW) startW = cell.start.length + if (cell.timestamp.length > timestampW) timestampW = cell.timestamp.length + if (cell.name.length > nameW) nameW = cell.name.length + if (cell.info.length > infoW) infoW = cell.info.length + return cell + }) + + const infoHeader = showInfo ? ` ${pad('info', infoW)}` : '' + const infoSeparator = showInfo ? ` ${'-'.repeat(infoW)}` : '' + const header = `${pad('index', indexW, true)} ${pad('start', startW, true)} ${pad('timestamp', timestampW, true)} ${pad('name', nameW)}${infoHeader}` + const separator = `${'-'.repeat(indexW)} ${'-'.repeat(startW)} ${'-'.repeat(timestampW)} ${'-'.repeat(nameW)}${infoSeparator}` + const body = cells.map(cell => { + const info = showInfo ? ` ${pad(cell.info, infoW)}` : '' + return `${pad(cell.index, indexW, true)} ${pad(cell.start, startW, true)} ${pad(cell.timestamp, timestampW, true)} ${pad(cell.name, nameW)}${info}` + }) + return [header, separator, ...body].join('\n') +} + /** * 工厂函数:根据 options 生成一个 console reporter。 * - * measure 直接读取实时聚合结果,mark 则读取有界的有序时间线。 - * - * 输出形式刻意避开 console.table —— React Native 远程调试 / Hermes inspector - * 对 console.table 的支持参差不齐(典型表现是把每行渲染成 `{…}` 不展开), - * 这里用对齐字符串 + 单条 console.log 输出,跨 RN / 浏览器 / Node 一致可读。 + * 聚合结果按配置排序;trace 与 mark 保持采集顺序。输出使用对齐字符串, + * 避免 React Native 远程调试 / Hermes inspector 对 console.table 的兼容差异。 */ export function createConsoleReporter (options: ConsoleReporterOptions = {}): Reporter { const { sortBy = 'sum', filter, header = true } = options - return (agg: Map, timeline?: MarkTimeline) => { - const rows: Row[] = [] + return ( + aggregates: Map, + marks?: MarkTimeline, + traces?: TraceTimeline + ) => { + const aggrRows: AggrRow[] = [] let totalCount = 0 - agg.forEach((s, name) => { + aggregates.forEach((result, name) => { if (!matchesFilter(name, filter)) return - totalCount += s.count - rows.push({ name, count: s.count, sum: s.sum, avg: s.avg, max: s.max }) - }) - - rows.sort((a, b) => b[sortBy] - a[sortBy]) - - // 列宽:取 max(列名长度, 各行该列字符串长度),保证终端 / RN console 都对齐 - let nameW = 'name'.length - let countW = 'count'.length - let sumW = 'sum'.length - let avgW = 'avg'.length - let maxW = 'max'.length - const cells = rows.map(r => { - const c = { - name: r.name, - count: String(r.count), - sum: fmtMs(r.sum), - avg: fmtMs(r.avg), - max: fmtMs(r.max) - } - if (c.name.length > nameW) nameW = c.name.length - if (c.count.length > countW) countW = c.count.length - if (c.sum.length > sumW) sumW = c.sum.length - if (c.avg.length > avgW) avgW = c.avg.length - if (c.max.length > maxW) maxW = c.max.length - return c + totalCount += result.count + aggrRows.push({ + name, + count: result.count, + sum: result.sum, + avg: result.avg, + max: result.max + }) }) + aggrRows.sort((a, b) => b[sortBy] - a[sortBy]) - const headerLine = `${pad('name', nameW)} ${pad('count', countW, true)} ${pad('sum', sumW, true)} ${pad('avg', avgW, true)} ${pad('max', maxW, true)}` - const sepLine = `${'-'.repeat(nameW)} ${'-'.repeat(countW)} ${'-'.repeat(sumW)} ${'-'.repeat(avgW)} ${'-'.repeat(maxW)}` - const bodyLines = cells.map(c => - `${pad(c.name, nameW)} ${pad(c.count, countW, true)} ${pad(c.sum, sumW, true)} ${pad(c.avg, avgW, true)} ${pad(c.max, maxW, true)}` - ) + // 兼容外部只传聚合 Map 的旧式手动调用。 + if (!marks && !traces) { + const title = `[mpx perf] ${aggrRows.length} buckets / ${totalCount} samples` + const content = aggrRows.length ? formatAggrRows(aggrRows) : '(empty)' + printConsole(title, content, header) + return + } - let title = `[mpx perf] ${rows.length} buckets / ${totalCount} samples` - let content = rows.length ? [headerLine, sepLine, ...bodyLines].join('\n') : '(empty)' + const traceRows: TraceRow[] = [] + if (traces) { + traces.events.forEach((event, index) => { + if (!matchesFilter(event.name, filter)) return + traceRows.push({ + index, + start: event.start, + timestamp: event.timestamp, + duration: event.duration, + name: event.name, + info: event.info + }) + }) + } - if (timeline) { - const timelineRows: TimelineRow[] = [] - const lastIndex = timeline.events.length - 1 - timeline.events.forEach((event, index) => { + const markRows: SequenceRow[] = [] + if (marks) { + const lastIndex = marks.events.length - 1 + marks.events.forEach((event, index) => { const boundary = (index === 0 && event.name === 'start') || (index === lastIndex && event.name === 'end') - if (boundary || matchesFilter(event.name, filter)) { - timelineRows.push({ index, at: event.at, name: event.name }) - } - }) - - let indexW = 'index'.length - let atW = 'at'.length - let timelineNameW = 'name'.length - const timelineCells = timelineRows.map(row => { - const cell = { - index: String(row.index), - at: fmtMs(row.at), - name: row.name - } - if (cell.index.length > indexW) indexW = cell.index.length - if (cell.at.length > atW) atW = cell.at.length - if (cell.name.length > timelineNameW) timelineNameW = cell.name.length - return cell + if (!boundary && !matchesFilter(event.name, filter)) return + markRows.push({ + index, + start: event.start, + timestamp: event.timestamp, + name: event.name, + info: event.info + }) }) - const timelineHeader = `${pad('index', indexW, true)} ${pad('at', atW, true)} ${pad('name', timelineNameW)}` - const timelineSep = `${'-'.repeat(indexW)} ${'-'.repeat(atW)} ${'-'.repeat(timelineNameW)}` - const timelineBody = timelineCells.map(cell => - `${pad(cell.index, indexW, true)} ${pad(cell.at, atW, true)} ${pad(cell.name, timelineNameW)}` - ) - const sections: string[] = [] - if (rows.length) sections.push(['measures', headerLine, sepLine, ...bodyLines].join('\n')) - if (timelineRows.length) sections.push(['timeline', timelineHeader, timelineSep, ...timelineBody].join('\n')) - if (timeline.dropped > 0) { - sections.push(`[mpx perf] mark timeline truncated: ${timeline.dropped} events dropped after limit 256`) - } - title = `[mpx perf] ${rows.length} measure ${rows.length === 1 ? 'bucket' : 'buckets'} / ${timelineRows.length} marks` - content = sections.length ? sections.join('\n\n') : '(empty)' } - const text = `${title}\n${content}` - - /* eslint-disable no-console */ - if (header && typeof console.group === 'function') { - console.group(title) - console.log(content) - } else { - console.log(text) + const sections: string[] = [] + if (aggrRows.length) sections.push(['aggregates', formatAggrRows(aggrRows)].join('\n')) + if (traceRows.length) sections.push(['traces', formatTraceRows(traceRows)].join('\n')) + if (markRows.length) sections.push(['marks', formatMarkRows(markRows)].join('\n')) + if (marks && marks.dropped > 0) { + sections.push(`[mpx perf] mark timeline truncated: ${marks.dropped} events dropped`) } - if (header && typeof console.groupEnd === 'function') { - console.groupEnd() + if (traces && traces.dropped > 0) { + sections.push(`[mpx perf] trace timeline truncated: ${traces.dropped} events dropped`) } - /* eslint-enable no-console */ + if (traces && traces.incomplete > 0) { + sections.push(`[mpx perf] trace timeline incomplete: ${traces.incomplete} events unfinished`) + } + + const title = `[mpx perf] ${aggrRows.length} aggregate ${aggrRows.length === 1 ? 'bucket' : 'buckets'} / ${traceRows.length} traces / ${markRows.length} marks` + printConsole(title, sections.length ? sections.join('\n\n') : '(empty)', header) } } -/** - * 默认 reporter:bus 在未被 setReporter 替换时使用它。 - * 行为等价于 createConsoleReporter() 默认参数。 - */ +function printConsole (title: string, content: string, header: boolean): void { + const text = `${title}\n${content}` + + /* eslint-disable no-console */ + if (header && typeof console.group === 'function') { + console.group(title) + console.log(content) + } else { + console.log(text) + } + if (header && typeof console.groupEnd === 'function') { + console.groupEnd() + } + /* eslint-enable no-console */ +} + +/** 默认 reporter,行为等价于 createConsoleReporter()。 */ export const consoleReporter: Reporter = createConsoleReporter() diff --git a/packages/perf/src/types.ts b/packages/perf/src/types.ts index d37f898254..73741cbb4d 100644 --- a/packages/perf/src/types.ts +++ b/packages/perf/src/types.ts @@ -5,7 +5,7 @@ * - avg: 均值(end() 时一次性回填) * - max: 最大时长(ms) */ -export interface AggResult { +export interface AggrResult { count: number sum: number avg: number @@ -16,7 +16,11 @@ export interface AggResult { export interface MarkEvent { name: string /** 相对当前录制窗口 start() 的毫秒偏移。 */ - at: number + start: number + /** mark 发生时读取的原始时间戳。 */ + timestamp: number + /** 用户自定义信息,按引用保存。 */ + info?: unknown } /** 当前录制窗口的有界 mark 时间线。 */ @@ -26,14 +30,45 @@ export interface MarkTimeline { dropped: number } +/** 时间线中的单个区段事件。 */ +export interface TraceEvent { + name: string + /** 相对当前录制窗口 start() 的毫秒偏移。 */ + start: number + /** traceStart() 读取的原始开始时间戳。 */ + timestamp: number + /** 区段持续时间(ms)。 */ + duration: number + /** 用户自定义信息,按引用保存。 */ + info?: unknown +} + +/** 当前录制窗口的有界 trace 时间线。 */ +export interface TraceTimeline { + /** 只包含已完成区段,顺序与 traceStart 调用顺序一致。 */ + events: TraceEvent[] + /** 超过容量上限后被丢弃的 trace 数量。 */ + dropped: number + /** 已开始但窗口结束前未成功结束的区段数量。 */ + incomplete: number +} + +export interface PerfStartOptions { + /** MarkTimeline 最大事件数,包含 start/end 边界,默认 1024。 */ + markLimit?: number + /** TraceTimeline 最大区段数,默认 1024。 */ + traceLimit?: number +} + /** * Reporter:bus.end() 同步把当前录制窗口的聚合结果交给它。 * end(reporter?) 传入的局部 reporter 会与全局 reporter 同批触发。 * - * 第一个入参是窗口期间实时累加的 `Map`;第二个入参是 - * 有序 mark 时间线。timeline 保持可选,以兼容手动单参数调用 reporter。 + * 第一个入参是窗口期间实时累加的 `Map`;第二、三个入参 + * 分别是有序 mark 和 trace 时间线。时间线保持可选,以兼容手动调用 reporter。 */ export type Reporter = ( - measures: Map, - timeline?: MarkTimeline + aggregates: Map, + marks?: MarkTimeline, + traces?: TraceTimeline ) => void diff --git a/solutions/rn-image-onload-size-resolution.md b/solutions/rn-image-onload-size-resolution.md new file mode 100644 index 0000000000..97ee54b9d9 --- /dev/null +++ b/solutions/rn-image-onload-size-resolution.md @@ -0,0 +1,563 @@ +# Mpx2RN 图像尺寸链路统一方案(位图 `onLoad` / SVG `onLayout`) + +## 结论 + +对于 Mpx2RN 中需要原图尺寸参与布局计算的非 SVG 图片,统一以实际渲染节点的 `onLoad` 事件作为尺寸来源,移除 `Image.getSize` 及其尺寸缓存链路。SVG 继续以 `react-native-svg` 节点的 `onLayout` 作为尺寸来源,同时修复当前尺寸暂存不完整导致的事件顺序问题。 + +本方案同时覆盖: + +- `` 的 `widthFix`、`heightFix` 和裁剪类 mode; +- `view` 的 `background-image` 在 `background-size: auto/contain/cover` 等需要原图比例的场景; +- React Native `Image` 与 `enable-fast-image` 两条渲染链路; +- 远程 URI 与本地静态资源; +- `SvgCssUri`/`LocalSvg` 的 `onLayout` 尺寸暂存与布局计算。 + +图片在尺寸尚未就绪时仍立即挂载,但使用非零尺寸并设置透明,避免 `display: none`、条件渲染或 `0 × 0` 导致图片不发起加载。`onLoad` 返回当前图片的原始宽高后,再完成布局计算和显示。 + +SVG 不改为 `onLoad`,也不额外解析源文件的固有宽高;本次只修复现有 `onLayout` 链路中的状态完整性和无效更新。 + +## 实施基线 + +本方案直接基于当前本地代码 `93f9f7a5bfc69037ef2f5a676bf97e3df52bf013` 落地: + +- 以当前 `mpx-image.tsx`、`mpx-view.tsx` 的实现和测试为唯一代码基线; +- 不依赖任何尚未合入的分支、补丁或中间实现; +- 实施时从当前 `Image.getSize`、`loaded/show`、尺寸缓存和 SVG `onLayout` 状态链路直接改造; +- 若实施前本地主干发生变化,先重新核对相同调用链,再按本文目标做最小适配。 + +## 背景 + +当前本地实现包含三条彼此不同的尺寸链路。 + +### `` 非 SVG + +- `widthFix`、`heightFix` 和裁剪类 mode 在 effect 中调用 `getImageSize`; +- 远程字符串最终调用 `RNImage.getSize`,本地 asset 尝试从 `resolveAssetSource` 同步取宽高; +- layout mode 的内部图片被 `loaded` 条件阻挡,getSize 未回调时实际图片不会挂载,因此也无法依靠真实图片事件恢复; +- `bindload` 的 `onImageLoad` 只服务公开事件:优先读取 `nativeEvent.source`,缺失时再次调用 `getImageSize`,没有参与 layout mode 的主尺寸计算。 + +### `view + background-image` + +- `needImageSize` 为 true 时调用 `Image.getSize`; +- 尺寸按 URI 缓存在 `sizeCacheRef`,并通过 `sizeInfo + version + show` 驱动计算和显示; +- getSize 未回调时背景图片不会挂载; +- `src` 变化后的旧尺寸在 effect 执行前仍保留,首次 render 存在读取上一个来源状态的窗口。 + +### `` SVG + +- 字符串资源渲染为 `SvgCssUri`,本地 asset 渲染为 `LocalSvg`; +- 两者通过 `onLayout` 的 `nativeEvent.layout.width/height` 获取实际布局尺寸,不调用 `Image.getSize`; +- 当前 `onSvgLoad` 只把 `imageHeight` 和 ratio 写入 `state.current`,却没有暂存 `imageWidth`; +- 外层 `onLayout` 又要求 `imageWidth && imageHeight && ratio` 同时存在,导致 SVG `onLayout` 先发生、容器 `onLayout` 后发生时无法继续计算; +- `onSvgLoad` 在计算条件尚未满足时提前执行 `setImageHeight(height)`,由于 ratio 仍为 0,此次 render 没有有效消费者。 + +实际位图组件必须完成加载才能展示,因此它的 `onLoad` 是更接近最终渲染结果的尺寸来源。非 SVG 链路收敛到 `onLoad`,SVG 链路补齐 `onLayout` 的完整状态暂存,可以同时解决回调可靠性、事件顺序和状态复杂度问题。 + +## 目标 + +- 非 SVG 图片原始尺寸只从实际渲染节点的 `onLoad` 获取; +- `src` 变更后,旧图片尺寸不能参与新图片布局; +- `onLoad` 和容器 `onLayout` 无论谁先到达,最终计算结果一致; +- 尺寸未就绪时图片节点已挂载,且能够正常触发加载; +- React Native `Image` 与 `FastImage` 使用相同的状态模型; +- `` 的 `bindload`/`binderror` 对外语义保持不变; +- SVG `onLayout` 与容器 `onLayout` 无论谁先到达,最终计算结果一致; +- SVG 只在布局计算条件满足时一次性更新 React 尺寸状态; +- 不新增业务 API,不改变渐变背景和无需原图尺寸的普通图片展示行为。 + +## 非目标 + +- 不在本次方案中新增跨实例或全局图片尺寸缓存; +- 不修改 `renderImage` 对 React Native `Image`/`FastImage` 的选择策略; +- 不调整图片下载、磁盘缓存、预加载和解码策略; +- 不改变 Web 端图片或滚动组件行为; +- 不扩展 background-image 的语法能力; +- 不使用 `onLoad` 替代 SVG 当前的 `onLayout` 链路; +- 不解析 SVG XML 的 `width`、`height` 或 `viewBox` 来推导源文件固有尺寸; +- 不在本次方案中重写 `react-native-svg` 的渲染和错误处理能力。 + +## 非 SVG 统一链路 + +```text +src 变化 + │ + ├─ 生成 sourceKey,当前尺寸只接受相同 sourceKey 的结果 + │ + ├─ 立即挂载实际 Image/FastImage + │ └─ 待计算时使用 1 × 1、opacity: 0 的临时样式 + │ + ├─ 容器 onLayout(仅需要时) + │ └─ 保存容器尺寸 + │ + └─ 图片 onLoad(非 SVG 实际图片统一监听) + ├─ RN Image:读取 nativeEvent.source.width/height + ├─ FastImage:读取 nativeEvent.width/height + ├─ 校验事件仍属于当前 sourceKey + ├─ 保存原图尺寸 + ├─ 原图尺寸和容器尺寸满足计算条件后更新布局 + ├─ 显示图片 + └─ 独立触发 bindload +``` + +核心约束是:图片是否可以按最终样式显示由“当前 `sourceKey` 的尺寸是否就绪”决定,而不是由某个异步回调是否曾经执行过决定。 + +## 状态模型 + +### 1. 图片尺寸携带来源标识 + +尺寸状态不能只保存宽高,必须同时记录对应的图片来源: + +```ts +interface ResolvedImageSize { + sourceKey: string + width: number + height: number +} +``` + +消费尺寸时始终做来源匹配: + +```ts +const currentImageSize = imageSizeInfo?.sourceKey === sourceKey + ? imageSizeInfo + : null +``` + +这样 `src` 从 A 切到 B 的首次 render 会立即把 A 的尺寸视为无效,不需要等待 `useEffect` 再清空状态,也不会用 A 的比例短暂计算 B。 + +### 2. 使用 `sourceKey` 隔离动态图片 + +`sourceKey` 同时用于: + +- 判断尺寸是否属于当前图片; +- 作为实际图片节点的 `key`,切换来源时重建底层图片节点; +- 在 `onLoad`/`onLayout`/`onError` 中拒绝已经过期的原生事件。 + +字符串 URL 直接使用 URI;本地静态资源使用 `Image.resolveAssetSource` 得到的 URI。`resolveAssetSource` 只用于标识资源和 SVG 判断,不再用于提供尺寸。source 类型、RN Image/FastImage 渲染器等会改变底层节点身份的字段也应纳入 key,或由 React 的组件类型变化保证重建。 + +如果图片 source 除 URI 外还包含会改变资源内容的 header 等字段,`sourceKey` 需要包含这些有效字段。调用方若以相同 URI 表达不同内容,也应通过 query 或 source 字段使资源身份发生变化。 + +图片节点的 `key` 是主要隔离手段。组件内可额外用 `useLayoutEffect` 同步 `currentSourceKeyRef`,在原生层仍投递已卸载节点事件时进行防御性校验;不要在 render 阶段递增请求序号或修改 ref。 + +### 3. 就绪条件由输入推导 + +不要继续维护一个可能和输入脱节的 `show` 布尔状态。显示条件应由当前输入和已解析状态推导: + +```ts +const imageSizeReady = !needImageSize || !!currentImageSize +const layoutReady = !needLayout || !!layoutInfo +const backgroundReady = imageSizeReady && layoutReady +``` + +对于 ``,同样用当前来源尺寸、当前容器尺寸以及 mode 推导是否可以生成最终样式。 + +### 4. `onLoad` 事件尺寸归一化 + +React Native `Image` 和 `FastImage` 的事件结构不同,增加一个内部小工具统一读取: + +```ts +function getLoadedImageSize (evt: NativeSyntheticEvent) { + const nativeEvent = evt.nativeEvent + const source = nativeEvent.source + const width = source?.width || nativeEvent.width || 0 + const height = source?.height || nativeEvent.height || 0 + + return width > 0 && height > 0 + ? { width, height } + : null +} +``` + +该工具只负责兼容事件结构,不发请求、不缓存、不提供默认宽高。若两个消费点的类型声明存在差异,可把入参约束为包含 `nativeEvent` 的最小结构,避免使用 `any` 扩散到业务计算。 + +### 5. 非法尺寸不进入布局状态 + +只有 `width > 0 && height > 0` 时才把尺寸标记为就绪。事件缺失尺寸时: + +- 不回退到 `Image.getSize`; +- 不使用上一个 `src` 的尺寸; +- 不写入 `0` 或默认尺寸冒充原图尺寸; +- 开发环境输出一次可定位的 warning; +- `` 的 `bindload` 仍由真实加载事件触发,不和内部“尺寸已就绪”状态绑定;异常情况下事件未携带有效尺寸时,detail 明确返回 `{ width: 0, height: 0 }`,同时不把内部布局标记为 ready。 + +### 6. 提前采集,按需消费 + +非 SVG 的实际图片节点统一绑定内部 `onLoad` 并记录当前来源尺寸,即使当前 mode 或 background-size 暂时不需要原图比例。原因是消费条件本身可以动态变化: + +- `` 可能从 `scaleToFill` 切换到 `widthFix`; +- background-size 可能从固定数值切换到 `auto`、`contain` 或 `cover`; +- 图片在切换前可能已经加载完成,之后新增 handler 不会让 `onLoad` 重新触发。 + +记录尺寸不等于立即执行布局计算。无需原图尺寸时只保存当前来源的轻量尺寸信息,不运行 mode/background 计算;消费条件变化触发的正常 render 可以直接读取已记录尺寸,不重新挂载或请求图片。若使用 state 会造成无消费者场景的额外 render,可使用带 `sourceKey` 的当前尺寸 ref,并仅在当前确有消费者时更新 version。 + +## `` 改造 + +涉及文件: + +- `packages/webpack-plugin/lib/runtime/components/react/mpx-image.tsx` +- 可能复用的事件尺寸工具位于 `packages/webpack-plugin/lib/runtime/components/react/utils.tsx` + +### 删除旧链路 + +- 删除内部 `getImageSize` 尺寸获取函数; +- 删除 layout mode 中调用 `Image.getSize`/`resolveAssetSource` 获取宽高的 effect; +- 删除依赖 getSize 成功或失败才挂载图片的逻辑; +- `resolveAssetSource` 仅保留 source 标准化、URI 提取和 SVG 判断用途; +- `onImageLoad` 不再在事件缺少尺寸时回退到 `getImageSize`。 + +### 图片始终挂载 + +`widthFix`、`heightFix` 和裁剪类 mode 当前会在 `loaded` 后才渲染内部图片。改造后外层容器和实际图片同时挂载: + +- 最终尺寸可计算时使用现有 `modeStyle`; +- 尚未取得原图尺寸时,内部图片使用非零临时样式,例如 `{ width: 1, height: 1, opacity: 0 }`; +- 不使用 `display: none`、条件渲染或 `width/height: 0`; +- 外层容器可继续保留当前/default 尺寸,避免页面布局在图片加载前完全塌陷; +- 当前图片尺寸就绪后再显示,不允许新图片以旧图片比例短暂出现。 + +临时样式只应用于内部实际图片,不覆盖外层 `` 节点对外暴露的默认尺寸和布局信息。 + +### 合并 `onLoad` 内部处理与公开事件 + +所有非 SVG 实际图片节点都绑定统一内部 `onLoad`,保证动态 mode 切换后仍可消费已经完成的加载结果。用户是否绑定 `bindload` 只决定是否分发公开事件,不决定内部 handler 是否存在。 + +处理顺序为: + +1. 从事件归一化宽高; +2. 校验 `sourceKey`; +3. 有有效宽高时记录当前来源尺寸; +4. 当前 mode 消费原图尺寸时再更新布局状态; +5. 用户绑定 `bindload` 时,基于同一次真实事件生成 Mpx `load` 事件并调用; +6. 不因为内部尺寸已经存在而跳过 `bindload`。 + +删除异步 `getSize` 后,`evt.persist()` 不再是内部回调所必需;若 `getCustomEvent` 同步完成事件转换,可以一并删除。 + +### 兼容两种事件结构 + +- React Native `Image`:尺寸通常位于 `evt.nativeEvent.source`; +- `FastImage`:尺寸通常位于 `evt.nativeEvent.width/height`。 + +两者必须进入同一个 `resolveImageSize` 流程,不能分别维护 ready 状态或回退策略。 + +### `onLoad` 与 `onLayout` 时序 + +保留当前容器 `onLayout` 的职责,但不假设事件先后顺序: + +- `onLayout` 先发生:保存容器尺寸,等待当前图片 `onLoad`; +- `onLoad` 先发生:保存当前图片尺寸,等待需要的容器 `onLayout`; +- 两者均满足后:调用现有 `setViewSize` 和 mode 计算逻辑; +- 容器后续尺寸变化:复用已加载的当前图片尺寸重新计算,不重新加载图片。 + +`widthFix` 只需容器宽度和图片比例,`heightFix` 只需容器高度和图片比例;裁剪类 mode 仍需完整容器宽高。现有计算函数和 mode 映射保持不变。 + +### `src` 变化 + +`src` 变化后的 render 立即发生以下变化: + +- 新 `sourceKey` 使旧尺寸失效; +- 图片节点因 `key` 变化重新挂载; +- 新图片进入透明待加载状态; +- 外层容器可暂时保留上一次或默认布局尺寸,但内部新图片不使用旧比例; +- 新 `onLoad` 到达后使用新尺寸完成布局并显示。 + +旧图片的迟到 `onLoad`/`onError` 必须被当前 source 校验拒绝,不能覆盖新图片的状态,也不能触发新图片对应的公开事件。 + +### 错误处理 + +- 当前来源加载失败时触发现有 `binderror`; +- 不恢复旧图片尺寸,不把失败标记为 ready; +- 不新增 getSize 兜底; +- 错误后若切换到新 `src`,新节点按完整加载流程重新开始。 + +### SVG `onLayout` 状态修复 + +SVG 继续使用实际 `SvgCssUri`/`LocalSvg` 节点的 `onLayout` 尺寸,不接入非 SVG 的 `onLoad` 事件归一化工具。需要修复的是当前 `onSvgLoad` 对事件顺序的处理。 + +当前逻辑只暂存高度: + +```ts +state.current.imageHeight = height +setImageHeight(height) +state.current.ratio = !width ? 0 : height / width +``` + +但容器 `onLayout` 的恢复条件要求 ref 中同时存在 `imageWidth`、`imageHeight` 和 ratio。改造后先完整、同步地保存本次 SVG 布局结果: + +```ts +const ratio = width ? height / width : 0 + +state.current.imageSourceKey = sourceKey +state.current.imageWidth = width +state.current.imageHeight = height +state.current.ratio = ratio +``` + +若继续沿用当前扁平的 `ImageState`,需增加 `imageSourceKey?: string`;容器 `onLayout` 恢复计算前同时校验它等于当前 `sourceKey`。如果非 SVG 改造已经把尺寸收敛为 `ResolvedImageSize`,SVG 直接复用该带来源标识的状态结构,不再增加平行字段。 + +只有当前 mode 所需的容器尺寸已经存在时,才一次性更新 React state: + +```ts +if (state.current.imageSourceKey === sourceKey && (isWidthFixMode + ? state.current.viewWidth + : isHeightFixMode + ? state.current.viewHeight + : state.current.viewWidth && state.current.viewHeight)) { + setRatio(ratio) + setImageWidth(width) + setImageHeight(height) + setViewSize(state.current.viewWidth, state.current.viewHeight, ratio) +} +``` + +同时删除条件判断前的 `setImageHeight(height)`: + +- 条件满足时,分支内部已经更新相同 state; +- 条件不满足时,ratio 仍为 0,`modeStyle` 不消费单独变化的 imageHeight; +- `bindload` 直接使用事件局部变量,不依赖 React state; +- 删除后避免一次没有可见结果的中间 render。 + +修复后的两个事件顺序必须等价: + +```text +容器 onLayout → SVG onLayout + └─ SVG 事件读取已有容器尺寸,直接完成计算 + +SVG onLayout → 容器 onLayout + ├─ SVG 事件完整暂存 width/height/ratio + └─ 容器事件读取完整暂存结果,完成计算 +``` + +SVG 的 width/height 仍表示 `react-native-svg` 节点的实际布局尺寸,不宣称是 SVG 文件的固有尺寸。动态 `src` 场景下,该尺寸也必须携带 `sourceKey`,旧 SVG 节点的迟到 `onLayout` 不能覆盖当前来源。 + +公开事件保持现状:`bindload` 仍由 SVG `onLayout` 生成,detail 使用同一次事件的 width/height;本次不把它改造成资源网络加载完成事件。 + +## `view + background-image` 改造 + +涉及文件: + +- `packages/webpack-plugin/lib/runtime/components/react/mpx-view.tsx` +- 与 `` 共用时,事件尺寸归一化工具位于 `utils.tsx` + +### 删除旧链路 + +- 删除 `Image.getSize` 调用; +- 删除 `sizeCacheRef`; +- 删除 getSize 回调的取消标识和回调驱动的 `show`; +- 删除仅为触发 getSize/隐藏节点而存在的平台分支; +- 保留 `backgroundSize`、`backgroundPosition`、`imageStyleToProps` 等现有计算函数。 + +本地或跨组件尺寸缓存不在此次替代方案中保留。实际图片组件自身已有资源缓存能力,布局尺寸状态只服务于当前组件和当前 source。 + +### 实际背景图片始终存在 + +当 `type === 'image' && src` 时始终渲染实际 `Image`/`FastImage`: + +- 不需要原图尺寸和容器尺寸时,直接使用最终样式显示; +- 仅等待容器布局时,使用临时透明样式挂载,布局就绪后切换最终样式; +- 需要原图尺寸时,通过同一节点的 `onLoad` 获取; +- 同时需要原图尺寸和容器尺寸时,二者都就绪后计算最终样式; +- pending 与 ready 阶段保持相同 `sourceKey`,只更新样式,不二次挂载和二次请求。 + +临时节点必须是将来真正显示的背景图片节点,而不是额外创建一张仅用于测量的隐藏图片。 + +### 状态更新 + +删除 URI → Size 的 `sizeCacheRef` Map,但保留组件当前来源的轻量尺寸状态。建议把 `sizeInfo` 改为带 `sourceKey` 的 ref,把 `layoutInfo` 保留为容器 ref,并继续用 version 驱动确有消费者的重算: + +- 当前 `needImageSize` 为 true 时,尺寸 ref 更新后有明确的 version 更新驱动重算; +- 当前 `needImageSize` 为 false 时只记录尺寸,不触发无效 render;后续 background-size 变化产生的 render 会直接消费该 ref; +- `currentImageSize` 按 `sourceKey` 派生; +- `backgroundReady` 按当前输入派生; +- src 变化不依赖 effect 清空旧 ref; +- 相同宽高不重复触发 render。 + +`show` 状态不再保留,避免它和 ref/version 形成第二套就绪状态。若实际实现改用直接的 `useState`,也必须满足相同 source 隔离,并确认固定 background-size 场景新增的一次 render 可以接受。 + +### 背景图片 `onLoad` + +所有非 SVG 背景图片都保留内部 `onLoad` 来采集尺寸;仅当 `needImageSize` 为 true 时,事件才更新 version 并驱动布局计算。固定数值 background-size 等不消费原图尺寸的场景只写当前来源尺寸 ref,不额外 render。 + +处理流程为: + +1. handler 闭包携带本次渲染的 `sourceKey`; +2. 归一化 RN Image/FastImage 的事件尺寸; +3. 拒绝非当前 source 的事件; +4. 保存 `{ sourceKey, width, height }`; +5. 当前 `needImageSize` 为 true 时,与 `layoutInfo` 一起驱动 `imageStyleToProps`; +6. 用最终背景样式显示同一个图片节点; +7. 后续 background-size 从固定值切换为需要原图比例时,直接消费已保存的当前尺寸。 + +`view` 的 background-image 当前没有对外 `bindload`/`binderror`,本次不新增公开事件。尺寸无效或加载失败时保持背景不可见,并在开发环境提供诊断信息。 + +### 渐变背景保持原样 + +`linear-gradient` 不依赖图片加载事件: + +- `type === 'linear'` 继续只依赖现有 layout 计算; +- 不为渐变创建 Image 节点; +- 不将图片 source 状态混入渐变状态; +- 斜向渐变、百分比尺寸等现有逻辑不在本次重构范围内。 + +## 关键时序 + +### 初次加载 + +```text +render(source=A) + ├─ mount Image(A, 1×1, opacity=0) + ├─ onLayout(container) + └─ onLoad(A, intrinsicSize) + └─ calculate → render Image(A, finalStyle, opacity=1) +``` + +`onLoad` 和 `onLayout` 顺序反转时结果相同。 + +### 快速切换来源 + +```text +render A → mount key=A +render B → unmount key=A, mount key=B, A size immediately invalid +onLoad A → sourceKey mismatch, ignore +onLoad B → accept B size, calculate and show B +``` + +即使 A 的事件晚于 B,也不能把 B 从 ready 状态覆盖回 pending。 + +### 容器尺寸变化 + +```text +Image(A) already loaded +onLayout(new container size) + └─ reuse current A intrinsic size → recalculate style +``` + +容器变化不触发新的图片请求,也不需要再次等待 `onLoad`。 + +## 兼容性与取舍 + +### 收益 + +- 避免 `Image.getSize` 不回调导致永久不显示; +- 每张图片只保留实际渲染组件这一条加载链路; +- RN Image/FastImage 行为由同一事件归一化层收敛; +- 移除尺寸缓存、取消标志、双回调竞态和多组 show 状态; +- 动态 `src` 的正确性可以通过 sourceKey 明确定义和测试。 + +### 代价 + +- `widthFix`、`heightFix`、裁剪 mode 和依赖原图比例的背景,需要在真实图片加载后才能得到最终布局; +- 本地静态资源即使能从 `resolveAssetSource` 同步取得宽高,也统一等待 `onLoad`,会多一次 pending → ready render; +- pending 阶段可能暂时保留默认或旧的外层占位尺寸,但不会显示使用错误比例的新图片。 + +这些代价是单一事实来源的直接结果。现有 `getSize` 同样需要等待资源信息,并不能消除首次尺寸计算;统一 `onLoad` 后反而减少了一条可能重复的请求和状态链路。 + +### 平台范围 + +方案应在 iOS、Android 和 Harmony RN 输出上采用相同逻辑,不设置 iOS 专用的隐藏图片分支。平台差异只允许存在于底层事件结构归一化中。 + +## 实施步骤 + +建议基于当前本地代码按以下提交顺序完成,便于逐步审查和回滚: + +1. 增加 RN Image/FastImage 的 `onLoad` 尺寸归一化工具及单元测试; +2. 改造 `mpx-image.tsx`,移除 getSize,确保 layout mode 始终挂载实际图片; +3. 在 `mpx-image.tsx` 中补全 SVG width/height/ratio 暂存并删除提前的 `setImageHeight`; +4. 补齐 `` 非 SVG/SVG 的 mode、事件顺序和动态 src 测试; +5. 改造 `mpx-view.tsx`,移除 getSize、尺寸缓存和 show 状态; +6. 补齐 background-image 尺寸、布局时序和动态 src 测试; +7. 在 RN Image/FastImage 两种配置下执行相关 eslint、jest 和必要的真机回归。 + +事件尺寸工具只有在两处事件结构完全一致时才抽到 `utils.tsx`。若类型或错误语义不同,保留两个很小的局部函数,避免为了复用引入复杂抽象。 + +## 测试方案 + +### 尺寸事件归一化 + +- RN Image:读取 `nativeEvent.source.width/height`; +- FastImage:读取 `nativeEvent.width/height`; +- source 尺寸优先级明确; +- 缺失、0 或负数尺寸返回 null; +- 不调用 `Image.getSize`。 + +### `` + +- `widthFix`:onLayout → onLoad 与 onLoad → onLayout 结果一致; +- `heightFix`:按原图比例计算最终宽度; +- 裁剪类 mode:取得原图尺寸后计算缩放与定位; +- pending 阶段实际图片已挂载,样式非 0 且透明; +- `scaleToFill`、`aspectFit`、`aspectFill` 等无需内部尺寸的模式不增加无效状态更新; +- 从 `scaleToFill` 动态切换到 `widthFix` 时,复用已采集尺寸且不重新请求图片; +- RN Image 与 FastImage 均能驱动相同布局; +- A → B 快速切换时,A 的迟到事件不改变 B 状态; +- B 已 ready 后 A 再迟到,B 不退回 pending; +- `bindload` 每次真实加载只触发一次,并携带对应来源尺寸; +- 内部已经取得尺寸时仍正常触发 `bindload`; +- 当前来源错误触发 `binderror`,不复用旧尺寸; +- 远程 URI 与本地静态资源均覆盖; +- SVG 继续通过 `onLayout` 获取节点布局尺寸,不调用 `Image.getSize` 或位图 `onLoad` 工具; +- 容器 `onLayout` → SVG `onLayout` 时正确计算最终尺寸; +- SVG `onLayout` → 容器 `onLayout` 时,ref 已完整保存 width/height/ratio,后续正确恢复计算; +- SVG 未满足容器计算条件时不提前发布只有 imageHeight 的 React state; +- SVG `bindload` 仍携带同一次 `onLayout` 的 width/height; +- `SvgCssUri` 与 `LocalSvg` 均覆盖; +- SVG 动态 A → B 时,A 的迟到 `onLayout` 不覆盖 B 的尺寸。 + +### `view + background-image` + +- 固定 background-size 不依赖原图尺寸; +- 固定 background-size 动态切换到 `auto/contain/cover` 时,复用已采集尺寸且不重新请求图片; +- `auto auto` 使用原图宽高; +- `auto + 数值/百分比` 和 `数值/百分比 + auto` 按比例计算; +- `contain`、`cover` 在不同容器比例下计算正确; +- 百分比 background-position 同时消费图片尺寸和容器尺寸; +- onLayout → onLoad 与 onLoad → onLayout 结果一致; +- pending 阶段同一个背景图片节点已挂载且透明; +- ready 后只更新样式,不因测量额外挂载第二张图片; +- A → B 以及 A → B → A 时尺寸不串用; +- 图片加载失败时不显示错误比例背景; +- RN Image/FastImage、iOS/Android/Harmony 的状态判断一致; +- linear-gradient 相关现有测试保持通过。 + +建议新增或扩展: + +- `packages/webpack-plugin/test/runtime/react-native/mpx-image-size.spec.ts`; +- `packages/webpack-plugin/test/runtime/react-native/mpx-view-background-image.spec.ts`。 + +测试中应把 `Image.getSize` mock 为抛错或记录调用,并断言相关场景调用次数为 0,避免未来重新引入双链路。 + +## 验收标准 + +- 非 SVG 原图尺寸链路中不存在 `Image.getSize` 调用; +- `` layout mode 与需要原图尺寸的 background-image 在 pending 阶段都已挂载实际图片; +- pending 图片使用非零尺寸且不可见; +- 当前布局只消费当前 `sourceKey` 的尺寸; +- 动态 `src` 和迟到事件不会发生跨来源污染; +- RN Image 与 FastImage 的加载事件都能得到正确尺寸; +- `bindload`/`binderror` 语义不依赖内部尺寸 ready 状态; +- SVG `onLayout` 会完整暂存当前来源的 width/height/ratio; +- SVG 不再在布局条件不足时单独调用 `setImageHeight`; +- SVG 与容器 `onLayout` 的先后顺序不影响最终 mode 计算; +- SVG 仍使用节点布局尺寸并维持现有 `bindload` 语义; +- linear-gradient 行为无回归; +- 相关 eslint 与 jest 全部通过; +- 至少完成 iOS 低版本设备或等价环境、Android 和 FastImage 开关的人工回归。 + +## 回滚策略 + +改造按组件拆分提交: + +- 若 `` 出现回归,可单独回滚 `mpx-image.tsx` 与对应测试; +- 若 background-image 出现回归,可单独回滚 `mpx-view.tsx` 与对应测试; +- 事件尺寸归一化工具只有在无调用方后再回滚。 + +SVG 修复与非 SVG onLoad 改造应拆成可独立回滚的提交:SVG 回滚只恢复 `onSvgLoad` 及其测试,不影响位图尺寸来源。非 SVG 回滚只恢复具体组件实现,不保留一半 getSize、一半 onLoad 的临时双链路;若必须短期恢复旧实现,应完整恢复该组件原链路并记录平台问题,避免重新引入两个并发尺寸来源。 + +## 与既有方案的关系 + +本方案是图像原始尺寸获取链路的专项决策,实施时优先级高于以下既有文档中的相关段落: + +- [`rn-local-image-support.md`](rn-local-image-support.md) 中通过 `getImageSize`/`resolveAssetSource` 获取尺寸的建议,由本方案的真实节点 `onLoad` 取代;本地资源 source 转换和 SVG 判断仍保留; +- [`rn-mpx-view-performance-optimization.md`](rn-mpx-view-performance-optimization.md) 中针对 `Image.getSize` 增加尺寸缓存的建议不再实施;其他与背景计算和渲染性能有关的结论仍有效; +- [`rn-mpx-image-performance-optimization.md`](rn-mpx-image-performance-optimization.md) 中不引入全局尺寸缓存的方向与本方案一致,其余优化建议不受影响。 + +如果后续实现与这些文档发生冲突,以本方案对“非 SVG 原图尺寸来源、source 隔离、pending 挂载方式,以及 SVG onLayout 完整状态暂存”的定义为准。 diff --git a/solutions/rn-runtime-perf-profile.md b/solutions/rn-runtime-perf-profile.md new file mode 100644 index 0000000000..36feb8ae63 --- /dev/null +++ b/solutions/rn-runtime-perf-profile.md @@ -0,0 +1,728 @@ +# Mpx Perf 三类统计 API 与区段序列 Profile 方案 + +## 背景 + +`@mpxjs/perf` 当前提供两类数据: + +- `scopeStart/scopeEnd`、`measureStart/measureEnd` 将多次区段耗时实时聚合为按 name 分桶的结果 Map。 +- `mark` 将瞬时事件按发生顺序写入 `MarkTimeline`。 + +其中 `scope` 与 `measure` 的最终产物完全相同,区别只在起止配对方式: + +- `scopeStart(name)` 返回数字 id,`scopeEnd(id)` 通过 id 配对,适合同步、嵌套和同名并发区段。 +- `measureStart(name)` 不返回 id,`measureEnd(name)` 通过 name 配对,适合跨作用域区段,但同名并发时后一次 start 会覆盖前一次。 + +这形成了两套名称和四个公开 API,却只对应一种“区段聚类统计”能力。同时,现有时间线只能表达点事件,无法记录每次模块执行的开始时间与持续时长,因此不能生成模块火焰图、区段瀑布图或完整的执行 profile。 + +本方案将 Perf 的采集能力明确拆成三类: + +| 统计类型 | 新 API | 输出 | 典型用途 | +| --- | --- | --- | --- | +| 区段聚类统计 | `aggrStart/aggrEnd` | `Map` | 高频 render、hook、函数耗时的 count/sum/avg/max | +| 区段序列统计 | `traceStart/traceEnd` | `TraceTimeline` | 模块加载火焰图、嵌套调用 profile、异步区段瀑布图 | +| 点序列统计 | `mark` | `MarkTimeline` | 数据就绪、首次渲染、页面可交互等里程碑 | + +录制窗口 `start/end`、Reporter 注册和编译期 DCE 机制保持不变。 + +## 目标 + +1. 新增可保留每次区段起点与持续时长的有界序列,支持生成模块火焰图一类 profile。 +2. 使用 `aggrStart/aggrEnd` 统一现有 `scope` 与 `measure` 的聚类统计实现。 +3. `aggrEnd` 和 `traceEnd` 均可通过数字 id 或字符串 name 配对。 +4. `aggrStart` 和 `traceStart` 默认使用 id 模式;需要 name 模式时显式传入 `useName: true`。 +5. `traceEnd(target, info?)` 与 `mark(name, info?)` 支持保存用户自定义信息。 +6. `MarkEvent` 与 `TraceEvent` 统一使用 `start` 记录窗口内相对位置,并使用 `timestamp` 记录原始开始时间戳。 +7. 保留 `scopeStart/scopeEnd`、`measureStart/measureEnd` 导出,并让它们内部调用 `aggrStart/aggrEnd`,兼容现有调用方。 +8. 保持聚类热路径的零对象、零闭包分配特性;mark 与 trace 使用独立、默认 1024 且可由 `start(options)` 覆盖的窗口容量限制。 +9. 延续 `__mpx_perf__` 顶层三元分流与调用方字面量门禁,关闭态继续保持零残留。 + +## 非目标 + +1. 不实现采样型 CPU profiler,不读取 Hermes 原生调用栈。 +2. 不在首期内置火焰图 UI 或文件写入能力;Perf 提供稳定的区段序列,Reporter 可将其转换为 Chrome Trace、Perfetto 或业务 APM 格式。 +3. 不为序列事件计算 p50/p95 等统计;需要聚类结果时使用 `aggrStart/aggrEnd`。 +4. 不修改 webpack-plugin 的 `perf.enable`、`perf.probes` 和分组常量。 +5. 不增加线上动态开关。 +6. 不自动复制、序列化或校验用户传入的 `info`。 + +## API 设计 + +### 1. 区段聚类统计:`aggrStart/aggrEnd` + +```ts +export function aggrStart (name: string, useName?: false): number +export function aggrStart (name: string, useName: true): void +export function aggrStart (name: string, useName: boolean): number | void +export function aggrEnd (target: number | string): void +``` + +`useName` 只决定配对方式,不改变最终聚合桶: + +| 调用方式 | start 行为 | end 入参 | 并发语义 | +| --- | --- | --- | --- | +| `aggrStart(name)` | 返回数字 id,未录制时返回 `-1` | 同一个 id | 支持嵌套、乱序结束和同名并发 | +| `aggrStart(name, false)` | 同默认模式 | 同一个 id | 同默认模式 | +| `aggrStart(name, true)` | 以 name 保存起点,不返回 id | 同一个 name | 同名 start 后一次覆盖前一次 | + +同步高频路径使用默认 id 模式: + +```ts +import { aggrStart, aggrEnd } from '@mpxjs/perf' + +let id = -1 +if (__mpx_perf_framework__) id = aggrStart('view:render:style') +const style = useTransformStyle(props) +if (__mpx_perf_framework__) aggrEnd(id) +``` + +跨作用域场景使用 name 模式: + +```ts +if (__mpx_perf_user__) aggrStart('goods:request', true) + +loadPageData().finally(() => { + if (__mpx_perf_user__) aggrEnd('goods:request') +}) +``` + +两种方式都向同一个聚合 Map 写入 `{ count, sum, avg, max }`,不会进入区段序列或点序列。 + +为保持现有 `measureStart` 行为,聚类统计的 name 模式允许在录制窗口外保存起点;只有 `aggrEnd(name)` 发生在录制中时,最终样本才会被 bus 接收。id 模式继续沿用 `scopeStart` 的行为,未录制时直接返回 `-1` 且不读取时钟。 + +### 2. 区段序列统计:`traceStart/traceEnd` + +```ts +export function traceStart (name: string, useName?: false): number +export function traceStart (name: string, useName: true): void +export function traceStart (name: string, useName: boolean): number | void +export function traceEnd (target: number | string, info?: unknown): void +``` + +配对规则与聚类 API 一致: + +```ts +const appId = traceStart('module:app') +const routerId = traceStart('module:router') +traceEnd(routerId, { moduleId: 42 }) +traceEnd(appId, { moduleId: 1 }) +``` + +产出的区段序列按 `traceStart` 调用顺序排列: + +```ts +{ + events: [ + { name: 'module:app', start: 1.2, timestamp: 1001.2, duration: 8.7, info: { moduleId: 1 } }, + { name: 'module:router', start: 2.1, timestamp: 1002.1, duration: 3.4, info: { moduleId: 42 } } + ], + dropped: 0, + incomplete: 0 +} +``` + +`start` 是相对当前录制窗口 `start()` 的毫秒偏移,`timestamp` 是 `traceStart` 读取的原始开始时间戳,`duration` 是区段持续时间。父子区段可通过时间包含关系还原为火焰图;发生交叉而非严格嵌套的异步区段,可由 Reporter 分配到不同 lane 展示。 + +name 模式用于不方便传递 id 的跨作用域区段: + +```ts +if (__mpx_perf_user__) traceStart('request:goods', true) + +loadPageData().finally(() => { + if (__mpx_perf_user__) { + traceEnd('request:goods', { status: 'fulfilled' }) + } +}) +``` + +同名并发、递归调用或严格嵌套场景必须使用 id 模式。name 模式沿用“后一次 start 覆盖前一次”的简单语义,被覆盖而无法结束的前一区段会计入 `TraceTimeline.incomplete`。 + +与聚类 name 模式不同,trace 必须在录制窗口中 start:未录制时两种 `traceStart` 模式均为 noop,id 模式返回 `-1`。这是因为 trace 的 `start` 必须相对一个确定的窗口起点。 + +### 3. 点序列统计:`mark` + +```ts +export function mark (name: string, info?: unknown): void +``` + +`mark` 保持现有点时间线语义,只增加可选 `info`: + +```ts +if (__mpx_perf_user__) { + mark('goods:data-ready', { + source: 'cache', + itemCount: 20 + }) +} +``` + +同名 mark 继续保留为多条独立事件。未传 `info` 时不要求事件对象包含 `info` 属性;内建 start/end 边界也不携带 `info`。 + +### 4. 录制窗口配置 + +```ts +export interface PerfStartOptions { + /** MarkTimeline 最大事件数,包含 start/end 边界,默认 1024。 */ + markLimit?: number + /** TraceTimeline 最大区段数,默认 1024。 */ + traceLimit?: number +} + +export function start (options?: PerfStartOptions): void +``` + +不传配置时两类序列的最大存储个数均为 1024;用户可在每次开启新窗口时分别覆盖: + +```ts +start({ + markLimit: 2048, + traceLimit: 4096 +}) +``` + +配置只在真正创建新窗口时读取并固定。录制中重复调用 `start(options)` 继续保持幂等,不清空数据,也不修改当前窗口容量。 + +`markLimit` 必须是不小于 2 的整数,为 start/end 边界各保留一个位置;`traceLimit` 必须是非负整数,传 `0` 可关闭当前窗口的 trace 存储。无效值回退到对应默认值 1024。 + +### 5. 完整导出面 + +```ts +import { + // 区段聚类统计 + aggrStart, + aggrEnd, + + // 区段序列统计 + traceStart, + traceEnd, + + // 点序列统计 + mark, + + // 旧聚类 API 兼容导出 + scopeStart, + scopeEnd, + measureStart, + measureEnd, + + // 录制窗口与 Reporter + start, + end, + setReporter, + clearReporter, + createConsoleReporter, + consoleReporter +} from '@mpxjs/perf' +``` + +`start/end` 是整个采集窗口的控制 API,不属于上述三类探针。 + +## 数据结构 + +### 聚类结果 + +现有聚合结果的字段结构保持不变,类型名统一调整为 `AggrResult`: + +```ts +export interface AggrResult { + count: number + sum: number + avg: number + max: number +} +``` + +### 点序列 + +`MarkEvent` 统一调整时间字段: + +```ts +export interface MarkEvent { + name: string + /** 相对当前录制窗口 start() 的毫秒偏移。 */ + start: number + /** mark 发生时读取的原始时间戳。 */ + timestamp: number + info?: unknown +} + +export interface MarkTimeline { + events: MarkEvent[] + /** 达到容量上限后被丢弃的显式 mark 数量。 */ + dropped: number +} +``` + +内建窗口边界也使用同一结构: + +- start 边界为 `{ name: 'start', start: 0, timestamp: startedAt }`。 +- end 边界为 `{ name: 'end', start: endedAt - startedAt, timestamp: endedAt }`。 + +### 区段序列 + +```ts +export interface TraceEvent { + name: string + /** 相对当前录制窗口 start() 的毫秒偏移。 */ + start: number + /** traceStart() 读取的原始开始时间戳。 */ + timestamp: number + /** 区段持续时间,单位为 ms。 */ + duration: number + /** traceEnd() 传入的用户自定义信息,按引用保存。 */ + info?: unknown +} + +export interface TraceTimeline { + /** 只包含已完成区段,顺序与 traceStart 调用顺序一致。 */ + events: TraceEvent[] + /** 达到容量上限后被丢弃的 trace 数量。 */ + dropped: number + /** 已成功 start、但窗口结束前未成功 end 的区段数量。 */ + incomplete: number +} +``` + +两类事件的时间字段使用同一个 `now()` 时钟源: + +- `timestamp` 保存事件发生或区段开始时 `now()` 返回的原始值。 +- `start = timestamp - recordingStart`,用于当前窗口内的相对定位。 +- `timestamp` 不保证是 Unix epoch;当时钟源为 `performance.now()` 或 Hermes `nativePerformanceNow()` 时,它相对各自的 performance time origin。 +- 同一个录制窗口及同一运行环境中的 timestamp 可以直接比较,也可以用于 Chrome Trace/Perfetto;跨设备或跨进程对齐不在本方案范围内。 + +`dropped` 只统计容量已满后被直接丢弃的 trace/mark 数量;这些数据不进入事件数组,也不保存时间与 info。`incomplete` 只统计已经占用 trace 序列位置、但没有形成完整持续时间的区段。 + +### Reporter + +在现有两个参数后追加可选的第三参数: + +```ts +export type Reporter = ( + aggregates: Map, + marks?: MarkTimeline, + traces?: TraceTimeline +) => void +``` + +选择第三参数而不是整体改为对象入参,是为了兼容当前 Reporter: + +- `(aggregates) => {}` 继续忽略后两个参数。 +- `(aggregates, marks) => {}` 的第二参数语义保持不变。 +- 新 Reporter 可读取第三个 `TraceTimeline`。 +- 外部手动调用 Reporter 时仍可只传一个或两个参数。 + +通过正常 `start/end` 结束的窗口会始终传入 marks 与 traces;即使没有显式 trace,第三参数也是空的 `TraceTimeline`。 + +## 区段序列的顺序与容量 + +### start 顺序是权威顺序 + +嵌套调用的结束顺序与开始顺序相反: + +```text +traceStart(A) + traceStart(B) + traceEnd(B) +traceEnd(A) +``` + +如果只在 `traceEnd` 时 push,序列会得到 `[B, A]`,不符合火焰图从父到子的阅读顺序。因此 trace 在 start 时预留事件槽位,在 end 时回填 `duration` 和 `info`: + +1. `traceStart` 向当前 `TraceTimeline` 预留一个内部事件,记录 `name/start/timestamp`。 +2. id 模式返回独立数字句柄;name 模式保存 `name → eventIndex`。 +3. `traceEnd` 找到事件并回填 `duration/info`,重复 end 安全 noop。 +4. 窗口结束时原地压缩未完成事件,Reporter 最终只看到完整 `TraceEvent`。 + +这样无需在 `end()` 时排序,低精度时钟下多个区段 `start/timestamp` 相同也不会丢失调用顺序。 + +### 独立有界序列 + +两类序列使用独立的窗口级容量,默认值均为 1024: + +```ts +const DEFAULT_MARK_LIMIT = 1024 +const DEFAULT_TRACE_LIMIT = 1024 +``` + +- MarkTimeline 的 `markLimit` 包含 1 个 start 和 1 个 end;默认最多保存 1022 个显式 mark,总 events 长度不超过 1024。 +- TraceTimeline 的 `traceLimit` 只计算 trace 区段;默认最多预留 1024 个区段,不额外写入 start/end 边界。 +- 两类容量相互独立,mark 过多不会挤掉 trace,反之亦然。 +- mark 达到上限后执行 `timeline.dropped++` 并直接 return,不保存新增事件。 +- trace 达到上限后执行 `traceTimeline.dropped++` 并直接 return:id 模式返回 `-1`,name 模式不注册起点。 +- 不使用 `shift` 或循环覆盖,始终保留窗口前缀,避免满容量后的线性移动成本。 + +`dropped` 是溢出后的唯一统计,不记录被丢弃事件的 name、时间或 info。自定义 Reporter 如果需要完整数据,应在 `start({ markLimit, traceLimit })` 时提高容量。 + +## 运行时实现 + +### 1. 聚类起点统一 + +`impl.ts` 将现有 scope 平行数组和 measure name Map 收敛到 `aggrStart/aggrEnd`: + +```ts +const aggrNames: (string | null)[] = [] +const aggrStarts: number[] = [] +const aggrFreeList: number[] = [] +const namedAggrStarts = new Map() + +export function aggrStart (name: string, useName = false): number | void { + if (useName) { + namedAggrStarts.set(name, now()) + return + } + if (!bus.isRecording()) return -1 + // 复用现有 scope 数组槽位逻辑。 +} + +export function aggrEnd (target: number | string): void { + if (typeof target === 'string') { + // 消费 namedAggrStarts,再调用 bus.pushAggr。 + return + } + // 消费数字槽位,再调用 bus.pushAggr。 +} +``` + +实现时不需要为了统一 API 再抽象一层通用“计时器类”。id 与 name 两条分支继续直接操作各自最合适的数据结构,避免改变高频 id 路径的成本。 + +bus 内部的 `pushMeasure` 同步重命名为 `pushAggr`,现有聚合 Map 变量重命名为 `aggrMap`,聚类相关缩写统一使用 `aggr/Aggr`。 + +### 2. Trace 预留与完成 + +bus 新增两个内部方法: + +```ts +bus.reserveTrace(name, startedAt): number +bus.finishTrace(eventIndex, endedAt, info?): void +``` + +`reserveTrace` 先检查 `traceTimeline.events.length >= traceLimit`;容量已满时只执行 `traceTimeline.dropped++` 并返回 `-1`,不创建事件或注册配对映射。容量未满时才预留内部槽位,并写入: + +```ts +event.timestamp = startedAt +event.start = startedAt - recordingStart +``` + +`finishTrace` 只允许完成当前窗口中尚未结束的事件,使用 `endedAt - event.timestamp` 计算 duration。 + +`impl.ts` 维护配对索引: + +```ts +let nextTraceId = 0 +const traceIdToEvent = new Map() +const traceNameToEvent = new Map() +``` + +id 使用单调递增值,而不是直接暴露事件数组下标,窗口结束时清空映射。这样上一窗口未消费的旧 id 不会误结束下一窗口相同下标的新事件。name 模式直接保存 eventIndex,同名覆盖语义与聚类 name 模式一致。 + +窗口结束后清空两个进行中映射;未完成的内部事件由 bus 计入 `incomplete` 并从 Reporter 可见的 events 中移除。 + +### 3. Mark info + +`pushMark` 增加 `info` 参数,在通过录制状态和容量检查后才创建事件对象: + +```ts +bus.pushMark(name, now(), info) +``` + +bus 将第二个参数同时保存为 `event.timestamp`,并计算 `event.start = event.timestamp - recordingStart`。 + +`pushMark` 在 `timeline.events.length >= markLimit - 1` 时只执行 `timeline.dropped++` 并直接 return,始终为 end 边界预留最后一个位置。 + +`info` 直接按引用保存: + +- 不做浅拷贝或深拷贝,避免额外开销。 +- Reporter 执行前调用方若修改该对象,看到的是修改后的内容。 +- 循环引用、不可序列化值或大对象由调用方负责。 +- 默认 console Reporter 的 info 格式化失败不能影响业务或其他统计输出。 + +### 4. 录制窗口 + +`impl.start(options?)` 读取当前时间并调用 `bus.start(startedAt, options)`。bus 在真正创建窗口时归一化 `markLimit/traceLimit`,无效值回退到默认值 1024,然后新建三个窗口容器: + +1. `Map`。 +2. 带 start 边界的 `MarkTimeline`。 +3. 空的 `TraceTimeline`。 + +`bus.end(endedAt, reporter?)` 的顺序为: + +1. 写入带 `start/timestamp` 的 MarkTimeline end 边界。 +2. 关闭录制状态。 +3. 回填聚类结果的 `avg`。 +4. 原地移除未完成 trace,并计算 `incomplete`。 +5. 依次调用全局 Reporter 和局部 Reporter,传入同一份 aggregates、marks、traces 引用。 + +重复 start 继续幂等,传入的新 options 也被忽略;未 start 或重复 end 继续 noop。TraceTimeline 与 MarkTimeline 共用同一个 `recordingStart`,两者时间坐标可直接关联。 + +### 5. 导出、noop 与 DCE + +`index.ts` 继续为每个新 API 使用独立顶层三元: + +```ts +export const aggrStart = __mpx_perf__ ? impl.aggrStart : noop.aggrStart +export const aggrEnd = __mpx_perf__ ? impl.aggrEnd : noop.aggrEnd +export const traceStart = __mpx_perf__ ? impl.traceStart : noop.traceStart +export const traceEnd = __mpx_perf__ ? impl.traceEnd : noop.traceEnd +export const mark = __mpx_perf__ ? impl.mark : noop.mark +``` + +`noop.ts` 提供相同签名的空实现,包括 `start(_options?)`;id 模式的探针 start 恒返回 `-1`。旧 API 兼容包装也分别从 impl/noop 导出,不能在 `index.ts` 中创建会让 impl 保持活引用的新闭包。 + +调用方仍必须直接使用字面量门禁: + +```ts +if (__mpx_perf_user__) { + const id = traceStart('module:goods') + // ... + traceEnd(id) +} +``` + +框架现有 `scopeStart/scopeEnd` 点位无需立即迁移,关闭态 DCE 链路也不需要修改 webpack-plugin。 + +## 旧 API 兼容 + +四个旧导出保持原函数签名,由 impl 中的薄包装调用新聚类 API: + +```ts +export function scopeStart (name: string): number { + return aggrStart(name) +} + +export function scopeEnd (id: number): void { + aggrEnd(id) +} + +export function measureStart (name: string): void { + aggrStart(name, true) +} + +export function measureEnd (name: string): void { + aggrEnd(name) +} +``` + +兼容关系: + +| 旧 API | 内部映射 | 兼容语义 | +| --- | --- | --- | +| `scopeStart(name)` | `aggrStart(name)` | 返回 id;未录制返回 `-1` | +| `scopeEnd(id)` | `aggrEnd(id)` | 负 id 和重复 end 均 noop | +| `measureStart(name)` | `aggrStart(name, true)` | 同名 start 后一次覆盖前一次 | +| `measureEnd(name)` | `aggrEnd(name)` | 命中后消费起点,重复 end noop | + +旧 API 在类型与文档中标记为兼容 API,但本次不设置移除版本。仓库内已有框架探针可以保持不变;新接入和新文档优先使用 `aggrStart/aggrEnd`。 + +Reporter 的第三参数和 `MarkEvent.info/timestamp` 是加法扩展;`MarkEvent.at` 则直接重命名为 `start`。已有一、二参数 Reporter 的函数签名保持兼容,但读取点序列时间的代码需要从 `event.at` 迁移为 `event.start`。 + +聚合结果类型同步统一为 `AggrResult`,不保留旧类型别名;使用方若显式导入旧类型,需要同步修改类型导入。旧函数 API 的运行时兼容与该类型命名迁移相互独立。 + +## Console Reporter + +`createConsoleReporter` 调整为三个独立区块: + +```text +[mpx perf] 2 aggregate buckets / 3 traces / 4 marks +aggregates +name count sum avg max +-------------------- ----- ------- ------- ------- +view:render:total 12 18.20ms 1.52ms 3.42ms + +traces +index start timestamp duration name info +----- ------- --------- -------- --------------- ----------------- + 0 1.20ms 1001.20ms 8.70ms module:app {"moduleId":1} + 1 2.10ms 1002.10ms 3.40ms module:router {"moduleId":42} + +marks +index start timestamp name info +----- ------- --------- ------------------ -------------------- + 0 0.00ms 1000.00ms start + 1 10.20ms 1010.20ms goods:data-ready {"source":"cache"} + 2 12.00ms 1012.00ms end +``` + +规则如下: + +- `sortBy` 只影响 aggregates。 +- `filter` 同时作用于 aggregate、trace 和显式 mark 名称,仍不隐藏内建 start/end。 +- trace 与 mark 均保持原始顺序,不按名称或耗时排序。 +- 仅当对应数据非空时输出 aggregates/traces 区块;marks 始终至少包含 start/end。 +- 仅在至少一项存在 info 时显示 info 列。 +- `info` 优先 `JSON.stringify`,失败时回退为安全字符串,不能让默认 Reporter 整体中断。 +- 分别提示 mark `dropped`、trace `dropped` 和 trace `incomplete`。 + +## 火焰图与 Chrome Trace 对接 + +`TraceEvent` 可以无损映射为 Chrome Trace Event Format 的 Complete Event: + +| TraceEvent | Chrome Trace | +| --- | --- | +| `name` | `name` | +| `timestamp * 1000` | `ts`,单位转换为 µs | +| `duration * 1000` | `dur`,单位转换为 µs | +| 固定值 | `ph: 'X'` | +| `info` | `args` | + +MarkEvent 使用相同的 `timestamp * 1000` 映射为 Instant Event(`ph: 'i'`);`start` 保留为窗口内相对位置,可用于单窗口 console 或 APM 展示。首期不把转换器和文件写入逻辑放入核心包: + +- RN、Node 和浏览器的文件输出能力不同,核心包不应引入平台依赖。 +- 业务 Reporter 通常还需要补充 `pid/tid/cat` 等业务字段。 +- 保留原始毫秒时间线可以同时服务 console、APM、Chrome Trace 和 Perfetto。 + +后续若出现多处重复转换,再独立增加纯函数 Reporter/helper,不在本次提前抽象。 + +## 性能与内存 + +| 能力 | 未录制 | 录制中 | +| --- | --- | --- | +| aggr id 模式 | 状态判断后返回 `-1` | 延续数组槽位 + freeList;稳态零对象、零闭包分配 | +| aggr name 模式 | 延续现有 Map 起点语义 | 一次 Map set/get/delete;最终只保留聚合桶 | +| trace | 状态判断后 noop | 每个被接受区段一个内部事件对象和一条进行中映射,默认最多 1024 条 | +| mark | 状态判断后 noop | 每个显式 mark 一个事件对象,默认最多 1022 条,加 start/end 后总量最多 1024 | + +`info` 只保存引用,其实际对象大小不受 Perf 控制。文档应明确建议只传递小型、可序列化的诊断字段,不要传组件实例、完整 props、响应体或大数组。 + +聚类 API 仍是高频路径首选。Trace/mark 需要保留逐次事件,只适用于诊断窗口和有限插桩点,不能替换 render 循环中的聚类统计。 + +## 变更范围 + +### 实现阶段 + +- `packages/perf/src/types.ts` + - 现有聚合结果类型直接重命名为 `AggrResult`。 + - `MarkEvent.at` 重命名为 `start`,并新增 `timestamp/info`。 + - 新增 `TraceEvent/TraceTimeline`。 + - 新增 `PerfStartOptions`。 + - Reporter 增加可选第三参数。 +- `packages/perf/src/bus.ts` + - `pushMeasure` 重命名为 `pushAggr`,现有聚合 Map 变量重命名为 `aggrMap`。 + - 增加 TraceTimeline、trace 预留/完成/压缩,以及默认 1024、可覆盖的独立序列容量。 + - `pushMark` 接收 info。 +- `packages/perf/src/impl.ts` + - 新增统一的 `aggrStart/aggrEnd`。 + - 新增 `traceStart/traceEnd`。 + - `start(options?)` 将窗口容量配置传入 bus。 + - 将四个旧 API 改为薄兼容包装。 +- `packages/perf/src/noop.ts` + - 对齐新 API 和 Reporter 签名。 +- `packages/perf/src/index.ts` + - 用现有顶层三元导出新 API 与类型。 +- `packages/perf/src/reporters/console.ts` + - 输出 aggregates、traces、marks 与 info/截断信息。 + +不需要修改 webpack-plugin 的 perf 配置、DefinePlugin 常量或现有框架探针调用点。 + +### 实现时必须同步的文档与 Skill + +- `packages/perf/README.md` + - 三类能力、配对模式、窗口容量配置、info、Reporter、类型重命名和 profile 对接示例。 +- `docs-vitepress/guide/advance/perf.md` + - 新 API 参考、旧 API 兼容说明、TraceTimeline 与火焰图用法。 +- `packages/perf/AGENTS.md` + - 更新导出面、核心模块、调用链与性能约束。 +- `.agents/skills/mpx2rn/references/rn-script-reference.md` + - 同步 Mpx2RN Perf API 和序列能力。 + +本方案文件本身不改变用户 API,因此本轮不修改上述文档与 Skill;实际实现必须在同一次变更中完成同步。 + +## 测试方案 + +### 聚类 API + +1. id 模式生成正确的 count/sum/avg/max。 +2. id 模式支持嵌套、乱序结束和同名并发。 +3. name 模式使用 name 配对,同名 start 后一次覆盖前一次。 +4. `aggrEnd` 对负 id、缺失 name、重复 end 安全 noop。 +5. id 模式未录制时返回 `-1` 且不读取时钟。 +6. `scope*` 与 `measure*` 的结果和现有行为一致,并验证它们调用统一聚类实现。 + +### TraceTimeline + +1. id 与 name 两种模式均可完成区段。 +2. 嵌套区段按 start 顺序输出,而不是 end 顺序。 +3. `start` 等于 `timestamp - recordingStart`,`duration` 基于同一时钟计算正确。 +4. `traceEnd` 保存 info 引用;不传 info 时保持可选。 +5. 同一个 id/name 重复 end 只完成一次。 +6. 未录制时 trace 不读取时钟、不分配事件,id 模式返回 `-1`。 +7. 窗口结束前未 end 的区段不进入 events,并准确增加 `incomplete`。 +8. name 模式覆盖和窗口结束清理不会污染下一窗口。 +9. 默认容量为 1024;第 1025 个 trace 只增加 `dropped`,id 模式返回 `-1`,且 events 不超过 1024。 +10. `start({ traceLimit })` 可覆盖当前窗口容量,传 `0` 时所有 trace 均被丢弃并累计 dropped。 +11. 上一窗口遗留 id 不能结束下一窗口的新事件。 + +### MarkTimeline + +1. `mark(name, info)` 将 info 保存到对应事件。 +2. 每个 mark 的 `start` 等于 `timestamp - recordingStart`。 +3. 同名 mark 继续保序且不合并。 +4. start/end 边界具有正确的 `start/timestamp`,且不携带 info。 +5. 默认总容量为 1024:保留 start、前 1022 个显式 mark 和 end,后续 mark 只增加 dropped。 +6. `start({ markLimit })` 可覆盖当前窗口容量,并始终为 end 预留最后一个位置。 + +### Reporter 与 Console + +1. 一参数、二参数旧 Reporter 继续通过类型检查并正常运行。 +2. 全局与局部 Reporter 收到同一份 aggregates、marks、traces 引用。 +3. trace/mark 的 start、timestamp、顺序、filter、info 和三类截断提示正确。 +4. info 含循环引用或 stringify 抛错时 console Reporter 不影响业务。 +5. 未传第三参数手动调用 console Reporter 时保持兼容输出。 + +### 录制窗口配置 + +1. `start()` 默认使用 `markLimit: 1024` 与 `traceLimit: 1024`。 +2. `start({ markLimit, traceLimit })` 分别覆盖两类序列容量。 +3. 录制中重复 `start(options)` 保持幂等,不修改当前窗口容量。 +4. 无效配置回退到对应默认值。 +5. 新窗口重新读取配置,不继承上一窗口的自定义容量。 + +### 构建与 DCE + +1. `perf.enable: false` 时产物不包含新 API 实现、事件名或 TraceTimeline 字符串。 +2. 对应 probe 分组关闭时,调用方 trace/mark/aggr 代码和名称字面量被移除。 +3. `dist/index.js` 继续保留顶层三元结构,TypeScript 声明包含 overload 与新类型。 +4. 声明文件导出 `AggrResult` 且不再导出旧聚合结果类型。 +5. 聚类 id 路径没有新增事件对象或闭包分配。 + +实现完成后执行: + +```sh +npx eslint --ext .ts packages/perf/src packages/perf/__tests__ +npm run test -w @mpxjs/perf -- --runInBand +npm run build -w @mpxjs/perf +npm run docs:build +git diff --check +``` + +## 验收标准 + +1. Perf 对外明确提供 `aggr*`、`trace*`、`mark` 三类探针 API。 +2. `aggrEnd` 与 `traceEnd` 均可接收数字 id 或字符串 name。 +3. `aggrStart/traceStart` 默认返回 id,传 `useName: true` 时使用 name 配对。 +4. `traceEnd` 与 `mark` 的 info 在 Reporter 中可读取,且核心采集层不复制或序列化它。 +5. 嵌套 trace 在 Reporter 中保持 start 顺序,并能依赖 `start/timestamp/duration` 还原火焰图。 +6. 未完成、容量丢弃和正常完成的 trace 可被明确区分。 +7. 四个旧聚类 API 保持导出和原签名,内部只复用新的聚类实现,不维护第二套状态。 +8. 聚类相关缩写统一为 `aggr/Aggr`,包括 `AggrResult`、`aggrMap` 与内部方法/变量;不再混用其他缩写前缀。 +9. 现有一、二参数 Reporter 函数签名保持兼容;MarkTimeline 消费代码按要求由 `at` 迁移到 `start`。 +10. 聚类 id 热路径成本不回退;mark 与 trace 使用独立、默认 1024 且可由 `start(options)` 覆盖的窗口上限。 +11. 超出容量的数据只增加对应 dropped,随后直接 return,不保存事件、时间、name 或 info。 +12. 关闭态产物继续保持探针实现、依赖和名称字符串零残留。 +13. 实现、测试、README、正式文档、AGENTS 与 Mpx2RN Skill 在同一次代码变更中同步完成。 + +## 方案取舍 + +### 为什么使用 `aggr` + +这类 API 不保留每次区段,只把同名样本合并到一个桶。`aggr` 保留了 aggregate 的主要词干,比过度缩写更容易辨认,又明显短于完整的 `aggregate`;相比 `scope` 和 `measure`,它也能直接表达聚合输出。 + +### 为什么使用 `trace` 而不是 `profile` + +本能力通过显式 start/end 产生区段时间线,不是采样型 profiler。`trace` 与 Chrome Trace/Perfetto 的 Complete Event 语义一致,也能自然覆盖火焰图、瀑布图和普通区段列表。`profileStart/profileEnd` 容易让使用者误以为它控制整个 profiler,与现有录制窗口 `start/end` 的职责重叠。 + +### 为什么保留 id 与 name 两种模式 + +id 模式支持同名并发、递归和嵌套,并保留高频同步路径的最低开销;name 模式便于跨作用域传递。统一成一组 API 可以消除重复命名,但不能用 name 模式替代 id 模式,否则会破坏现有 scope 的并发语义。 + +### 为什么 Reporter 继续使用位置参数 + +把 Reporter 改成 `reporter({ aggregates, traces, marks })` 更整齐,但会直接破坏所有现有 Reporter。追加可选第三参数虽然不如对象参数易扩展,但本次只新增一种输出,兼容收益更高。若未来继续增加第四类输出,再单独设计 Reporter v2,而不是提前制造 breaking change。