perf(leafer-x-webfont): 脏节点集增量扫描,百万节点画布可正常运行

全量 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)。
This commit is contained in:
崮生(子虚) 2026-08-15 20:19:27 +08:00
parent bf98a49a79
commit b385bc89bc
5 changed files with 433 additions and 53 deletions

163
_prof_million.entry.ts Normal file
View File

@ -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<string, Array<(e: object) => void>>()
private nextId = 1
private ids = new Map<number, [string, (e: object) => 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<void> {
console.log('=== 百万节点压力测试 ===\n')
/* ---------- 场景 A100 万 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`)
/* ---------- 场景 Edestroy 清理 ---------- */
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()

View File

@ -1,10 +1,13 @@
/**
* fs import
* import ESM hoistingllrt/node
* implInterface interface.ts 使
* fs import
* import ESM hoistingllrt/node
* import implInterface interface.ts
* 使
* - tsdown define __RUNTIME__ require fsReady resolve
* - devtsx ESMfsReady = 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() {
/** devtsx ESM下适配器经动态 import 注册,必须先等它完成 */
await fsReady;
/** 最早期恢复累计计数:之后的请求计数会累加在历史值之上 */
await initStats();

View File

@ -1,21 +1,35 @@
/**
* fs
*
* __RUNTIME__ tsdown tree-shake
* - LLRT __RUNTIME__="llrt"llrt.ts
* - Node __RUNTIME__="node" devtsx__RUNTIME__ node.ts
* __RUNTIME__ tsdown define
* - LLRT __RUNTIME__="llrt"require("./llrt") bundle
* - Node __RUNTIME__="node"require("./node") bundle
* - devtsx 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<void>;
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 };

View File

@ -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 targetchild.add
* flush walk O() O()
* Leafer 线trackChanges
*
*/
private dirty = new Set<ILeaferNode>()
/** 整树结构变化(首次扫描/模板替换时置位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 / newValuePropertyEvent
* 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<string, { loader: IFontFaceLoader; fontName: string; family: string; chars: Set<string> }>()
/** 判定节点是否 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<string, { loader: IFontFaceLoader; fontName: string; family: string; chars: Set<string> }>, 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<string, { loader: IFontFaceLoader; fontName: string; family: string; chars: Set<string> }>, visited: Set<ILeaferNode>): 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<string, { loader: IFontFaceLoader; fontName: string; family: string; chars: Set<string> }>()
this.walkInto(this.leafer, groups, new Set())
this.commit(groups)
}
/** 提交聚合桶:内容指纹短路(字符串精确相等),无变化时 loader.update 调用归零 */
private commit(groups: Map<string, { loader: IFontFaceLoader; fontName: string; family: string; chars: Set<string> }>): 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<string, { loader: IFontFaceLoader; fontName: string; family: string; chars: Set<string> }>()
/** 每次 flush 的 visited 必须新建:跨 flush 的同一节点内容可能已变 */
const visited = new Set<ILeaferNode>()
for (const node of this.dirty) {
/** 脏节点本身 + 其子树都可能有 Textchild.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()
}
/**
* flushemit
*
*/
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
}
}

View File

@ -0,0 +1,88 @@
/**
* leafer
*
* _prof_million.entry.ts leafer
* @leafer-ui/node + HTTP APIlocalhost:8087
* 1. Textchild.add
* 2. textproperty.change
* 3. refresh()
*
* npx tsx scripts/test-leafer-incremental.mts
* pnpm dev:backend8087
*/
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<void> {
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] 初始 TextwaitViewReady 全量 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] 改 textproperty.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] 新增 Textchild.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()