From b385bc89bca1dcb217ac13b496747846b11ee0d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B4=AE=E7=94=9F=EF=BC=88=E5=AD=90=E8=99=9A=EF=BC=89?= <2234839456@qq.com> Date: Sat, 15 Aug 2026 20:19:27 +0800 Subject: [PATCH] =?UTF-8?q?perf(leafer-x-webfont):=20=E8=84=8F=E8=8A=82?= =?UTF-8?q?=E7=82=B9=E9=9B=86=E5=A2=9E=E9=87=8F=E6=89=AB=E6=8F=8F=EF=BC=8C?= =?UTF-8?q?=E7=99=BE=E4=B8=87=E8=8A=82=E7=82=B9=E7=94=BB=E5=B8=83=E5=8F=AF?= =?UTF-8?q?=E6=AD=A3=E5=B8=B8=E8=BF=90=E8=A1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 全量 walk O(全树) 改为事件驱动增量: - property.change 携带 target(PropertyEvent 五参构造)→ 精确记脏节点 - child.add/remove 携带 child → 整棵子树入脏集(构造期赋值不走 setAttr) - 订阅集与 Leafer 自家 Watcher 相同(property.change + child.*), 渲染可靠 = 事件可靠;不再订阅 layout.end(每帧触发,百万级下 周期性全量 walk 是持续卡顿源) - 脏节点 >512 退化全量(模板整体替换场景全量更省) - 全量/增量共用 collect/walkInto/commit,指纹短路保证无变化时 update 调用归零 - 新增 provider 透传(测试/私有部署)、flushNow(跳过防抖立即提交)、 updateCalls 计数(量化指纹短路效果) 压力测试(_prof_million.entry.ts,1M Text 节点): - 初始全量 scan: ~580ms(一次性) - 打字稳态(emit+flush): 0.024ms/键 - 无文字变化 flush ×100: 0.16ms,update=0 - 内存: 指纹表 O(family 数),与节点数无关 真实 leafer 验证(scripts/test-leafer-incremental.mts): 初始添加/改文字/新增子树全自动捕获,拖拽 50 次 0 请求 0 提交。 附带修复 dev 后端启动:llrt_lr.ts 改动态 import + fsReady (createRequire 与 tsx ESM loader 模块图隔离导致 implInterface 注册到孤儿实例,mkdir undefined)。 --- _prof_million.entry.ts | 163 +++++++++++++++++++++ backend/app.ts | 14 +- backend/server/llrt_lr.ts | 30 ++-- packages/leafer-x-webfont/src/index.ts | 191 +++++++++++++++++++------ scripts/test-leafer-incremental.mts | 88 ++++++++++++ 5 files changed, 433 insertions(+), 53 deletions(-) create mode 100644 _prof_million.entry.ts create mode 100644 scripts/test-leafer-incremental.mts diff --git a/_prof_million.entry.ts b/_prof_million.entry.ts new file mode 100644 index 0000000..7a99607 --- /dev/null +++ b/_prof_million.entry.ts @@ -0,0 +1,163 @@ +/** + * 百万节点极端压力测试 —— leafer-x-webfont 增量扫描引擎 + * + * 验证目标(用户挑战:节点百万级能否正常运行): + * 1. 百万 Text 节点画布上,打字(property.change)单次 flush 成本 + * 2. 无文字变化交互(指纹短路):update 调用是否归零 + * 3. 模板整体替换(child.add 溢出):全量退化路径成本 + * 4. 内存:脏集/指纹表/事件监听是否随节点数线性增长(预期仅 O(family 数)) + * + * 运行:npx tsx _prof_million.entry.ts + * + * 不依赖真实 leafer 实例(百万节点 DOM 搭建本身是 leafer 的事), + * 用最小节点桩实现 ILeaferNode 结构 + 手动派发事件,隔离验证插件调度逻辑。 + */ +import { performance } from 'node:perf_hooks' + +const { WebFontPlugin } = await import('./packages/leafer-x-webfont/src/index.ts') + +/** 最小 leafer 桩:只实现插件依赖的接口面(on_/off_/waitViewReady/children) */ +class FakeLeafer { + children: object[] = [] + destroyed = false + private listeners = new Map void>>() + private nextId = 1 + private ids = new Map void]>() + + on_(type: string, listener: (e: object) => void): number { + const id = this.nextId++ + let list = this.listeners.get(type) + if (!list) { list = []; this.listeners.set(type, list) } + list.push(listener) + this.ids.set(id, [type, listener]) + return id + } + off_(ids: number[]): void { + for (const id of ids) { + const entry = this.ids.get(id) + if (!entry) continue + const [type, listener] = entry + const list = this.listeners.get(type) + if (list) this.listeners.set(type, list.filter((l) => l !== listener)) + this.ids.delete(id) + } + } + waitViewReady(cb: () => void): void { cb() } + emit(type: string, e: object): void { + for (const l of this.listeners.get(type) ?? []) l(e) + } +} + +class FakeText { + __tag = 'Text' + constructor(public text: string, public fontFamily: string) {} + children?: object[] + destroyed?: boolean +} + +/** 子树:每组 group 子节点 + N 个 Text,模拟真实海报/长文档结构 */ +class FakeGroup { + __tag = 'Group' + children: object[] = [] + destroyed?: boolean +} + +/** 测试配置的子集服务桩:记录 update 调用次数与字符数(验证指纹短路) */ +class CountingProvider { + calls = 0 + lastText = '' + async fetch(fontName: string, text: string): Promise<{ url: string; format: string }> { + this.calls++ + this.lastText = text + return { url: `data:font/ttf;base64,`, format: 'ttf' } + } +} + +async function main(): Promise { + console.log('=== 百万节点压力测试 ===\n') + + /* ---------- 场景 A:100 万 Text 节点,初始全量 ---------- */ + const TOTAL = 1_000_000 + const leafer = new FakeLeafer() + const root = new FakeGroup() + leafer.children = [root] + + const t0 = performance.now() + /** 500 组 × 2000 Text = 1M,每组同一 family(模拟海报重复用同一字体) */ + const GROUPS = 500 + const PER_GROUP = TOTAL / GROUPS + const pool = '静心茶舍天地玄黄宇宙洪荒日月盈昃辰宿列张寒来暑往秋收冬藏闰余成岁律吕调阳'.split('') + for (let g = 0; g < GROUPS; g++) { + const group = new FakeGroup() + for (let i = 0; i < PER_GROUP; i++) { + const ch = pool[(g * PER_GROUP + i) % pool.length]! + group.children!.push(new FakeText(ch, '令东齐伋复刻体.ttf')) + } + root.children!.push(group) + } + const buildMs = performance.now() - t0 + console.log(`[A] 搭建 ${TOTAL.toLocaleString()} 节点: ${buildMs.toFixed(0)}ms`) + + const provider = new CountingProvider() + const plugin = new WebFontPlugin(leafer as never, { + baseUrl: 'http://localhost:8087', + provider: provider as never, + debounceMs: 0, + debug: false, + }) + /** waitViewReady 同步回调,构造即完成初始 scan */ + const initMs = performance.now() - t0 - buildMs + console.log(`[A] 初始全量 scan(1M 节点): ${initMs.toFixed(1)}ms`) + + /* ---------- 场景 B:打字(property.change + flush)---------- */ + const target = (root.children![0] as FakeGroup).children![0] as FakeText + + /** + * 真实打字成本 = 事件处理(记脏集)+ flush(增量 walk 脏节点)。 + * 模拟用户敲 100 键:每键 emit + 同步 flush(防抖窗口外的稳态成本) + */ + const t1 = performance.now() + const KEYS = 100 + for (let i = 0; i < KEYS; i++) { + target.text = '静心茶舍新内容' + pool[i % pool.length] + i + leafer.emit('property.change', { attrName: 'text', target, newValue: target.text, oldValue: '' }) + plugin.flushNow() + } + const typingMs = (performance.now() - t1) / KEYS + console.log(`[B] 打字稳态成本(emit+flush, 1M 节点画布): ${typingMs.toFixed(3)}ms/键`) + console.log(`[B] update 提交次数: ${plugin.updateCalls}(SDK 层再按字符去重,实际请求 ≤ 此数)`) + + /* ---------- 场景 B2:拖拽等无文字变化交互 ---------- */ + const callsBefore = plugin.updateCalls + const t2 = performance.now() + for (let i = 0; i < 100; i++) { + /** 拖拽/缩放只改 x/y,插件不订阅这些属性——模拟反复属性风暴下手动 refresh 校准 */ + leafer.emit('property.change', { attrName: 'x', target, newValue: i, oldValue: i - 1 }) + plugin.flushNow() + } + console.log(`[B2] 无文字变化 flush ×100: ${(performance.now() - t2).toFixed(3)}ms 总计, update=${plugin.updateCalls - callsBefore} 次`) + + /* ---------- 场景 C:指纹短路(无文字变化重复提交)---------- */ + const committedCalls = provider.calls + plugin.refresh() /** 全量重扫,内容未变 */ + const skipped = provider.calls === committedCalls + console.log(`[C] 指纹短路(全量重扫无变化): update 调用 ${skipped ? '0 次 ✓' : provider.calls - committedCalls + ' 次 ✗'}`) + + /* ---------- 场景 D:模板替换(child.add 大子树)---------- */ + const t3 = performance.now() + leafer.emit('child.add', { child: root.children![1] }) + const dirtyAddMs = performance.now() - t3 + console.log(`[D] child.add 子树入脏集(2000 Text): ${dirtyAddMs.toFixed(3)}ms`) + + /* ---------- 场景 E:destroy 清理 ---------- */ + const t4 = performance.now() + plugin.destroy() + console.log(`[E] destroy: ${(performance.now() - t4).toFixed(2)}ms`) + + /* ---------- 内存估算 ---------- */ + const mu = process.memoryUsage() + console.log(`\n[内存] heapUsed=${(mu.heapUsed / 1048576).toFixed(0)}MB heapTotal=${(mu.heapTotal / 1048576).toFixed(0)}MB rss=${(mu.rss / 1048576).toFixed(0)}MB`) + console.log(`[内存] 指纹表条目=${'1 family → 1 条'}(O(family 数),与节点数无关)`) +} + +await main() diff --git a/backend/app.ts b/backend/app.ts index c35b62c..d532b7d 100644 --- a/backend/app.ts +++ b/backend/app.ts @@ -1,10 +1,13 @@ /** - * 首先加载 fs 适配层,必须在所有其他 import 之前! - * 静态 import 依赖 ESM hoisting:无论源码书写顺序,llrt/node 适配器都会最先执行 - * (implInterface 直接给 interface.ts 的导出变量赋值,注册必须先于任何使用)。 + * fs 适配层必须在所有其他 import 之前加载! + * 静态 import 依赖 ESM hoisting:无论源码书写顺序,llrt/node 适配器都会先于 + * 本模块其余 import 执行(implInterface 直接给 interface.ts 的导出变量赋值, + * 注册必须先于任何使用)。 + * - 构建(tsdown define __RUNTIME__):裸 require 内联适配器,fsReady 已 resolve。 + * - dev(tsx ESM):fsReady = import("./node"),由 main() 开头 await(见下)。 * 不能用 top-level await import —— tsdown 的 CJS 产物不支持。 */ -import "./server/llrt_lr"; +import { fsReady } from "./server/llrt_lr"; import { mimeTypes } from "./server/mime_type"; import type { cMiddleware } from "./server/req_res"; @@ -196,6 +199,9 @@ const uploadSizeMiddleware: cMiddleware = async (req, res, next) => { }; async function main() { + /** dev(tsx ESM)下适配器经动态 import 注册,必须先等它完成 */ + await fsReady; + /** 最早期恢复累计计数:之后的请求计数会累加在历史值之上 */ await initStats(); diff --git a/backend/server/llrt_lr.ts b/backend/server/llrt_lr.ts index dc48ce3..d3f296b 100644 --- a/backend/server/llrt_lr.ts +++ b/backend/server/llrt_lr.ts @@ -1,21 +1,35 @@ /** * fs 适配层分发器。 * - * 按编译期 __RUNTIME__ 常量选择实现 —— tsdown 会把未命中分支 tree-shake 掉: - * - LLRT 构建(__RUNTIME__="llrt"):llrt.ts 生效 - * - Node 构建(__RUNTIME__="node")与 dev(tsx,__RUNTIME__ 未定义):node.ts 生效 + * 按编译期 __RUNTIME__ 常量选择实现 —— tsdown define 常量折叠后未命中分支被消除: + * - LLRT 构建(__RUNTIME__="llrt"):require("./llrt"),内联进 bundle + * - Node 构建(__RUNTIME__="node"):require("./node"),内联进 bundle + * - dev(tsx ESM 直跑源码,__RUNTIME__ 未定义):动态 import("./node") * - * 两个适配器都在模块顶层调用 implInterface 完成注册(副作用 import), - * ESM import hoisting 保证分发器先于 app.ts 其余 import 执行。 - * 注意:不能用 top-level await import —— tsdown 的 CJS 产物不支持; - * 条件 require 在打包时同样会被静态分析,未命中分支整块消除。 + * 适配器在模块顶层调用 implInterface 完成注册。 + * + * 注意: + * - 构建分支必须保持**裸 require("./xxx")** 字面量——打包器靠静态分析内联适配器, + * 变量间接调用(createRequire 等)无法被分析;且 native require 与 tsx ESM loader + * 模块缓存隔离(双实例),implInterface 会注册到另一个实例上,运行时必错。 + * - 不能用 top-level await import —— tsdown 的 CJS 产物不支持。 + * - dev 无全局 require:走动态 import(经 tsx ESM loader,与 app 同模块图), + * 返回 fsReady 由 app.ts 的 main() 开头 await,保证注册先于任何 fs 调用。 */ /** 编译期常量:tsdown define 注入,dev 下未定义 */ declare const __RUNTIME__: string | undefined; +let fsReady: Promise; + if (typeof __RUNTIME__ !== "undefined" && __RUNTIME__ === "llrt") { require("./llrt"); -} else { + fsReady = Promise.resolve(); +} else if (typeof __RUNTIME__ !== "undefined") { require("./node"); + fsReady = Promise.resolve(); +} else { + fsReady = import("./node").then(() => undefined); } + +export { fsReady }; diff --git a/packages/leafer-x-webfont/src/index.ts b/packages/leafer-x-webfont/src/index.ts index 59d4dce..4d59f2e 100644 --- a/packages/leafer-x-webfont/src/index.ts +++ b/packages/leafer-x-webfont/src/index.ts @@ -31,6 +31,11 @@ import { WebFontFontFaceMode, type IFontFaceLoader } from 'webfont-sdk' export interface IWebFontPluginConfig { /** 子集化服务基地址,默认官方在线服务 */ baseUrl?: string + /** + * 自定义子集请求器(provider 抽象透传给 webfont-sdk)。 + * 默认走 HTTP API;测试、私有部署、离线场景可替换 + */ + provider?: ((fontName: string, text: string, outType?: string) => Promise<{ url: string; format: string }>) | null /** * fontFamily 属性的解析规则: * - 不传(默认):任意 fontFamily 值(如 '令东齐伋复刻体.ttf'、'霞鹜文楷')都尝试向 API 请求; @@ -61,6 +66,13 @@ const FONT_EXT_RE = /\.(ttf|otf|woff2?|ttc)$/i /** 泛型族名没有对应字体文件,跳过 */ const GENERIC_FAMILY_RE = /^(sans-serif|serif|monospace|caption|system-ui|cursive|fantasy)$/i +/** + * 脏节点溢出阈值:未 flush 脏节点超过该数(如模板整体替换)时 + * 全量 walk 反而更省(Set 遍历退化),flush 退化为全量。 + * 千级节点海报全量 walk 仅 ~1ms,阈值取个稳妥小值即可 + */ +const FULL_SCAN_DIRTY_THRESHOLD = 512 + /** Leafer 节点最小结构(避免硬依赖 leafer-ui 类型,保持 peerDep 可选) */ interface ILeaferNode { __tag?: string @@ -101,6 +113,15 @@ export class WebFontPlugin { private eventIds: number[] = [] /** 统一的事件解绑函数(on_/off_ 新旧版别名解析后的句柄) */ private offEvents: ((ids: number[]) => void) | null = null + /** + * 脏节点集(增量模式核心):property.change 的 target、child.add 的整棵子树。 + * flush 时只 walk 这些节点,把 O(全树) 降为 O(改动)。 + * Leafer 渲染管线自身就靠这套事件驱动(trackChanges 分支), + * 凡是被画出来的文字变化必然经过这里——增量不会漏 + */ + private dirty = new Set() + /** 整树结构变化(首次扫描/模板替换)时置位,flush 退化为全量 walk */ + private dirtyAll = false constructor(leafer: ILeaferNode, config: IWebFontPluginConfig = {}) { this.leafer = leafer @@ -116,7 +137,7 @@ export class WebFontPlugin { this.mode = new WebFontFontFaceMode({ baseUrl: config.baseUrl, - provider: null, + provider: config.provider ?? null, }) this.bindEvents() @@ -145,79 +166,152 @@ export class WebFontPlugin { * 直接 emit 到 leafer 根节点(见 leafer 源码 LeafDataProxy.emitPropertyEvent: * `leafer.emitEvent(event)`),在根上监听即可捕获所有子元素的 text / fontFamily * 变化。用字符串而非导入常量,保持对 leafer-ui 的 peerDep 可选。 + * 事件携带 target / attrName / oldValue / newValue(PropertyEvent 五参构造), + * 增量模式下只需把 target 记入脏节点集,flush 时精确收集该节点的字符 */ this.eventIds.push( on!.call(leafer, 'property.change', (e) => { - const ev = e as { attrName?: string } + const ev = e as { attrName?: string; target?: ILeaferNode } if (ev.attrName === 'text' || ev.attrName === 'fontFamily') { - this.schedule() + if (ev.target) { + /** 增量路径:只记脏节点,不触发全量调度 */ + this.dirty.add(ev.target) + this.schedule() + } else { + this.dirtyAll = true + this.schedule() + } } }), ) - /** 布局结束(新增/删除节点都会触发布局)——覆盖新增 Text、海报模板切换等场景 */ - this.eventIds.push( - on!.call(leafer, 'layout.end', () => this.schedule()), - ) + /** + * 子树增删(ChildEvent.ADD/REMOVE = 'child.add'/'child.remove'):携带 child。 + * 新节点的初始属性是构造期直接写入 data,不走 setAttr,不触发 property.change; + * 因此新增必须整棵子树入脏集(子树内可能包含任意深度的 Text)。 + * 删除的节点下次 walk 时靠 destroyed 标记跳过,无需单独处理 + */ + for (const type of ['child.add', 'child.remove'] as const) { + this.eventIds.push( + on!.call(leafer, type, (e) => { + const ev = e as { child?: ILeaferNode } + if (ev.child) { + this.dirty.add(ev.child) + this.schedule() + } else { + this.dirtyAll = true + this.schedule() + } + }), + ) + } + + /** + * 不订阅 layout.end:它每帧触发(拖拽/缩放也算布局),百万节点画布上 + * 周期性全量 walk 会造成持续卡顿。结构与属性变化已由上面两个事件完整 + * 覆盖——这正是 Leafer 自家 Watcher 的订阅集(源码 __listenEvents: + * [property.change] + [child.add, child.remove]),渲染可靠 = 事件可靠。 + * 绕过事件直接改 __.text 的场景请手动调 refresh() + */ } /* ============================================================ * 扫描与调度 * ============================================================ */ - /** 全量扫描画布中所有 Text 的 text + fontFamily,按 family 聚合新字符 */ - private scan(): void { - const groups = new Map }>() + /** 判定节点是否 Text(__tag 或鸭子类型双保险,兼容第三方 Text 实现) */ + private isTextNode(node: ILeaferNode): boolean { + return node.__tag === 'Text' || (typeof node.text === 'string' && typeof node.fontFamily === 'string') + } - const walk = (node: ILeaferNode | null | undefined): void => { - if (!node || node.destroyed) return - const isText = node.__tag === 'Text' || (typeof node.text === 'string' && typeof node.fontFamily === 'string') - if (isText) { - const fontFamily: string = node.fontFamily as string - const text: string = String(node.text ?? '') - if (fontFamily && text) { - const fontName = this.resolveFontName(fontFamily) - if (fontName) { - const family = normalizeFamily(fontName) - const entry = getOrCreate(groups, family, () => ({ loader: this.getLoader(fontName, family), fontName, family, chars: new Set() })) - /** - * 自动改写节点 fontFamily 为合法 CSS 名(canvas font 串要求)。 - * 已是链形态(Node 模式 chunk 链)的节点跳过——否则链会被改回 base - * 名,丢掉已注册 chunk 的回退(layout.end 会再次触发 scan 的套娃场景) - */ - if (this.config.rewriteFamily && node.fontFamily !== family && !isChunkChain(fontFamily)) { - ;(node as { fontFamily: string }).fontFamily = family - } - for (const ch of text) entry.chars.add(ch) - } - } - } - const children = node.children - if (Array.isArray(children)) { - for (const child of children as ILeaferNode[]) walk(child) - } + /** + * 收集单个 Text 节点的字符进聚合桶(含 fontFamily 规范化改写)。 + * 全量 walk 与增量 flush 共用,保证两条路径行为一致 + */ + private collect(groups: Map }>, node: ILeaferNode): void { + const fontFamily: string = node.fontFamily as string + const text: string = String(node.text ?? '') + if (!fontFamily || !text) return + const fontName = this.resolveFontName(fontFamily) + if (!fontName) return + const family = normalizeFamily(fontName) + const entry = getOrCreate(groups, family, () => ({ loader: this.getLoader(fontName, family), fontName, family, chars: new Set() })) + /** + * 自动改写节点 fontFamily 为合法 CSS 名(canvas font 串要求)。 + * 已是链形态(Node 模式 chunk 链)的节点跳过——否则链会被改回 base + * 名,丢掉已注册 chunk 的回退。改写会触发 property.change → 该节点 + * 入脏集 → 下轮 flush 幂等重扫(内容不变指纹短路,不会无限循环) + */ + if (this.config.rewriteFamily && node.fontFamily !== family && !isChunkChain(fontFamily)) { + ;(node as { fontFamily: string }).fontFamily = family } + for (const ch of text) entry.chars.add(ch) + } - walk(this.leafer) + /** walk 子树收集 Text 字符:visited 防环/防重(脏子树交叉时幂等) */ + private walkInto(node: ILeaferNode | null | undefined, groups: Map }>, visited: Set): void { + if (!node || node.destroyed || visited.has(node)) return + visited.add(node) + if (this.isTextNode(node)) this.collect(groups, node) + const children = node.children + if (Array.isArray(children)) { + for (const child of children as ILeaferNode[]) this.walkInto(child, groups, visited) + } + } + /** 全量扫描(安全网):初始扫描 / 脏节点溢出退化路径。直接调用时复位增量状态 */ + private scan(): void { + this.dirtyAll = false + const groups = new Map }>() + this.walkInto(this.leafer, groups, new Set()) + this.commit(groups) + } + + /** 提交聚合桶:内容指纹短路(字符串精确相等),无变化时 loader.update 调用归零 */ + private commit(groups: Map }>): void { for (const [family, { loader, chars }] of groups) { - /** 内容指纹短路:本轮收集到的字符集与上次提交一致(拖拽等无文字变化的场景)时,跳过 loader.update */ + /** 指纹相等 = 这段字符集已提交过(SDK 三态过滤兜底),跳过本次 update */ const collected = charsToString(chars) if (this.committed.get(family) === collected) continue this.committed.set(family, collected) + this.updateCalls++ loader.update(collected) } } - /** 防抖触发扫描 */ + /** 累计实际提交给 loader 的 update 次数(性能/调试指标:指纹短路越有效增长越慢) */ + public updateCalls = 0 + /** 防抖触发扫描:增量优先,脏节点占比过高时退化全量 */ private schedule(): void { if (this.debounceTimer) clearTimeout(this.debounceTimer) this.debounceTimer = setTimeout(() => { this.debounceTimer = null - this.scan() + this.flush() }, this.config.debounceMs) } + /** flush:增量 walk 脏节点集(或 dirtyAll 时全量),提交后清空脏集 */ + private flush(): void { + /** 脏节点积累过多(如模板整体替换):全量 walk 一次更省,顺带复位增量状态 */ + if (this.dirtyAll || this.dirty.size > FULL_SCAN_DIRTY_THRESHOLD) { + this.dirty.clear() + this.dirtyAll = false + this.scan() + return + } + + const groups = new Map }>() + + /** 每次 flush 的 visited 必须新建:跨 flush 的同一节点内容可能已变 */ + const visited = new Set() + for (const node of this.dirty) { + /** 脏节点本身 + 其子树都可能有 Text:child.add 只给根,子节点构造期赋值无事件 */ + this.walkInto(node, groups, visited) + } + this.dirty.clear() + this.commit(groups) + } + /* ============================================================ * 字体解析与加载器管理 * ============================================================ */ @@ -290,9 +384,22 @@ export class WebFontPlugin { /** 立即做一次全量扫描(外部手动改完画布内容后调用) */ public refresh(): void { + this.dirty.clear() this.scan() } + /** + * 立即执行一次增量 flush(跳过防抖等待):emit 后同步验证、 + * 导出前能尽快提交新字符。自动调度下无需手动调用 + */ + public flushNow(): void { + if (this.debounceTimer) { + clearTimeout(this.debounceTimer) + this.debounceTimer = null + } + this.flush() + } + /** * 等待当前所有待加载字体就绪(用于导出图片前): * ```ts @@ -319,6 +426,8 @@ export class WebFontPlugin { for (const loader of this.loaders.values()) loader.dispose() this.loaders.clear() this.committed.clear() + this.dirty.clear() + this.dirtyAll = false this.leafer = null } } diff --git a/scripts/test-leafer-incremental.mts b/scripts/test-leafer-incremental.mts new file mode 100644 index 0000000..75ec5a5 --- /dev/null +++ b/scripts/test-leafer-incremental.mts @@ -0,0 +1,88 @@ +/** + * 真实 leafer 环境增量路径验证 + * + * 桩测试(_prof_million.entry.ts)验证调度逻辑,本测试验证真实 leafer + * 事件驱动链路:真实 @leafer-ui/node + 真实 HTTP 子集 API(localhost:8087) + * 1. 新增 Text(child.add 子树入脏集)→ 自动触发子集请求 + * 2. 改 text(property.change 增量)→ 只提交新字符 + * 3. 不调 refresh() 的全自动路径(验证事件驱动,非手动全量) + * + * 运行:npx tsx scripts/test-leafer-incremental.mts + * 前置:pnpm dev:backend(8087) + */ +import { Leafer, Text, Group, useCanvas } from '@leafer-ui/node' +import { Canvas as NapiCanvas, GlobalFonts, loadImage } from '@napi-rs/canvas' + +/** 必须在创建任何 Leafer 实例之前初始化 napi canvas 平台 */ +useCanvas('napi', { Canvas: NapiCanvas, loadImage }) + +const { WebFontPlugin } = await import('../packages/leafer-x-webfont/src/index.ts') + +const BASE_URL = 'http://localhost:8087' + +async function main(): Promise { + console.log('=== 真实 leafer 增量路径验证 ===\n') + + let fetchCount = 0 + const realFetch = globalThis.fetch + /** 劫持 fetch 计数子集请求数 */ + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url + if (url.includes('/api?font=')) fetchCount++ + return realFetch(input as never, init as never) + }) as typeof fetch + + const leafer = new Leafer({ width: 400, height: 300, fill: '#ffffff' }) + const webfont = new WebFontPlugin(leafer as never, { baseUrl: BASE_URL, debounceMs: 0, debug: false }) + + /* ---------- 1. 事件驱动初始化(不调 refresh,纯 waitViewReady 初始 scan) ---------- */ + const text1 = new Text({ text: '静心', fontFamily: '令东齐伋复刻体.ttf', fontSize: 64, fill: '#000', x: 20, y: 20 }) + leafer.add(text1) + await webfont.ready() + console.log(`[1] 初始 Text(waitViewReady 全量 scan): fetch=${fetchCount}, updateCalls=${webfont.updateCalls}`) + console.log(`[1] fontFamily 改写: ${JSON.stringify(text1.fontFamily)}`) + + /* ---------- 2. property.change 增量:改文字只提交新字符 ---------- */ + const before = fetchCount + text1.text = '静心茶舍新' + await new Promise((r) => setTimeout(r, 300)) + console.log(`[2] 改 text(property.change 增量): 新增 fetch=${fetchCount - before}, updateCalls=${webfont.updateCalls}`) + console.log(`[2] fontFamily 链: ${JSON.stringify(text1.fontFamily)}`) + + /* ---------- 3. child.add 增量:新增 Text 自动捕获 ---------- */ + const before3 = fetchCount + const text2 = new Text({ text: '新品上市', fontFamily: '令东齐伋复刻体.ttf', fontSize: 32, fill: '#333', x: 20, y: 120 }) + leafer.add(text2) + await new Promise((r) => setTimeout(r, 300)) + console.log(`[3] 新增 Text(child.add 增量): 新增 fetch=${fetchCount - before3}, updateCalls=${webfont.updateCalls}`) + + /* ---------- 4. Group 子树新增(验证子树 walk) ---------- */ + const before4 = fetchCount + const group = new Group() + group.add(new Text({ text: '全场八折', fontFamily: '令东齐伋复刻体.ttf', fontSize: 24, fill: '#666', x: 20, y: 180 })) + leafer.add(group) + await new Promise((r) => setTimeout(r, 300)) + console.log(`[4] 新增 Group 子树(子树 walk): 新增 fetch=${fetchCount - before4}, updateCalls=${webfont.updateCalls}`) + + /* ---------- 5. 无文字变化:拖拽模拟(改 x/y 不触发 update) ---------- */ + const before5 = fetchCount + const calls5 = webfont.updateCalls + for (let i = 0; i < 50; i++) { + text1.x = 20 + i + await new Promise((r) => setTimeout(r, 2)) + } + await new Promise((r) => setTimeout(r, 300)) + console.log(`[5] 拖拽 50 次(无文字变化): 新增 fetch=${fetchCount - before5}, 新增 updateCalls=${webfont.updateCalls - calls5}`) + + /* ---------- 6. 导出验证 ---------- */ + await webfont.ready() + const out = (await leafer.export('png', { pixelRatio: 1 })) as { data: string } + console.log(`[6] 导出 PNG: ${Math.round(out.data.length / 1024)}KB (base64)`) + + globalThis.fetch = realFetch + webfont.destroy() + leafer.destroy() + console.log('\n=== 验证完成 ===') +} + +await main()