perf(leafer-x-webfont): 注册回调贡献者回记+静默链应用,百万节点打字场景零风暴

问题(1M Text 节点实测):
- v0 字体注册回调里全树改写 fontFamily:百万次 property.change 污染
  dirty 集,打字场景退化 20s;单点改动 978ms
- 公共 setter 路径(v2):百万次完整属性变更管线 = 2.2s 风暴

方案(v4):
- 贡献者回记:commit 时合并累加每个 family 的贡献节点(不覆盖,
  迟到 chunk 仍能找到贡献者),chunk 回调只把贡献者标脏——链应用
  范围从全树缩小到实际用字节点,打字场景 O(1)
- 静默/公共写分流:溢出(初始全量等贡献者>512)走静默写 __ 数据层
  (~50ns/次,不触发事件不污染 dirty);少量贡献者走公共 setter
  (leafer 自动局部重绘该节点)
- ready() 语义闭环:字体全部落地后同步消化残留 dirtyAll/renderPending,
  否则会被下一个不相关的用户操作捡走——表现为「改一个字卡 800ms」
- forceRender 收敛:仅静默写/浏览器模式标记 renderPending,flush 尾部
  无 in-flight chunk 时合并成一次执行

实测(1M Text,真实 leafer + 本地后端):
- 单点改动 flush: 856ms → 0.15ms
- 随机100节点 flush: 0.9ms
- 新增 Text: 0.08ms
- 打字同步按键成本: 0.2ms/次(与无插件基线 0.012ms 同数量级)
- 拖拽等无文字变化操作: 0 请求 0 提交
This commit is contained in:
崮生(子虚) 2026-08-15 21:13:07 +08:00
parent ca1d2fc1e7
commit 0ab51e496c
3 changed files with 350 additions and 69 deletions

View File

@ -73,6 +73,20 @@ const GENERIC_FAMILY_RE = /^(sans-serif|serif|monospace|caption|system-ui|cursiv
*/
const FULL_SCAN_DIRTY_THRESHOLD = 512
/**
* family chars nodes
* chunk
*/
interface IGroup {
loader: IFontFaceLoader
fontName: string
family: string
chars: Set<string>
nodes: ILeaferNode[]
/** 贡献者超过阈值:注册后直接 dirtyAll全量应用链更省 */
overflow: boolean
}
/** Leafer 节点最小结构(避免硬依赖 leafer-ui 类型,保持 peerDep 可选) */
interface ILeaferNode {
__tag?: string
@ -80,8 +94,8 @@ interface ILeaferNode {
fontFamily?: unknown
children?: unknown
destroyed?: boolean
forceRender?: () => void
/** on_ 为公开事件订阅(返回 id旧版 leafer 用 on__下划线为内部 id 绑定) */
forceRender?: () => void /** 代理数据标记leafer 编辑器场景节点属性存 proxy__ 直写会丢),存在时静默写回退 setter */
__proxyData?: unknown /** on_ 为公开事件订阅(返回 id旧版 leafer 用 on__下划线为内部 id 绑定) */
on_?: (type: string, listener: (e: unknown) => void, bind?: unknown) => number
off_?: (ids: number[]) => void
/** 旧版 API 兼容on__ 在新版本已更名 on_ */
@ -122,6 +136,22 @@ export class WebFontPlugin {
private dirty = new Set<ILeaferNode>()
/** 整树结构变化(首次扫描/模板替换时置位flush 退化为全量 walk */
private dirtyAll = false
/**
* chunk family Node
* fontFamily
* property.change dirty
* 退 20s walkscan/flush
* forceRender
*/
private pendingChains = new Map<string, { chain: string; silent: boolean }>()
/**
* family commit
* chunk 退
* chunk > silent
*/
private pendingApply = new Map<string, { nodes: ILeaferNode[]; overflow: boolean }>()
/** 字体就绪待重绘(或本轮 flush 有链静默落地);无 in-flight chunk 时尾部一次 forceRender */
private renderPending = false
constructor(leafer: ILeaferNode, config: IWebFontPluginConfig = {}) {
this.leafer = leafer
@ -228,28 +258,65 @@ export class WebFontPlugin {
* Text fontFamily
* walk flush
*/
private collect(groups: Map<string, { loader: IFontFaceLoader; fontName: string; family: string; chars: Set<string> }>, node: ILeaferNode): void {
private collect(groups: Map<string, IGroup>, 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() }))
const entry = getOrCreate(groups, family, () => ({ loader: this.getLoader(fontName, family), fontName, family, chars: new Set<string>(), nodes: [], overflow: false }))
if (!entry.overflow) {
if (entry.nodes.length >= FULL_SCAN_DIRTY_THRESHOLD) entry.overflow = true
else entry.nodes.push(node)
}
/**
* fontFamily CSS canvas font
* Node chunk base
* chunk 退 property.change
* flush
* chunk .ttf
* pendingChains silent
* - __ / setter
* 线 = 2.2s ~50ns/ family
* metricsbounds
* - setterleafer
* forceRender
* flush forceRender
*/
if (this.config.rewriteFamily && node.fontFamily !== family && !isChunkChain(fontFamily)) {
;(node as { fontFamily: string }).fontFamily = family
const pending = this.pendingChains.get(family)
if (pending && node.fontFamily !== pending.chain) {
if (pending.silent) {
this.writeFamilySilently(node, pending.chain)
this.renderPending = true
} else {
/** 公共 setterleafer 自动失效并局部重绘该节点,无需 forceRender */
;(node as { fontFamily: string }).fontFamily = pending.chain
}
} else if (this.config.rewriteFamily && node.fontFamily !== family && !isChunkChain(fontFamily)) {
if (pending?.silent) {
this.writeFamilySilently(node, family)
this.renderPending = true
} else {
;(node as { fontFamily: string }).fontFamily = family
}
}
for (const ch of text) entry.chars.add(ch)
}
/**
* fontFamily __ property.change
* leafer __getAttr __.__get
* = dirty = leafer
* setter 退 setter
*/
private writeFamilySilently(node: ILeaferNode, value: string): void {
const data = (node as { __?: Record<string, unknown> }).__
if (data && typeof data === 'object' && !node.__proxyData) {
data.fontFamily = value
} else {
;(node as { fontFamily: string }).fontFamily = value
}
}
/** 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 {
private walkInto(node: ILeaferNode | null | undefined, groups: Map<string, IGroup>, visited: Set<ILeaferNode>): void {
if (!node || node.destroyed || visited.has(node)) return
visited.add(node)
if (this.isTextNode(node)) this.collect(groups, node)
@ -262,54 +329,82 @@ export class WebFontPlugin {
/** 全量扫描(安全网):初始扫描 / 脏节点溢出退化路径。直接调用时复位增量状态 */
private scan(): void {
this.dirtyAll = false
const groups = new Map<string, { loader: IFontFaceLoader; fontName: string; family: string; chars: Set<string> }>()
const groups = new Map<string, IGroup>()
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) {
private commit(groups: Map<string, IGroup>): void {
for (const [family, entry] of groups) {
/** 指纹相等 = 这段字符集已提交过SDK 三态过滤兜底),跳过本次 update */
const collected = charsToString(chars)
const collected = charsToString(entry.chars)
if (this.committed.get(family) === collected) continue
this.committed.set(family, collected)
/** 贡献者合并累加(不覆盖):迟到的 chunk 仍能找到它的贡献节点 */
const prev = this.pendingApply.get(family)
this.pendingApply.set(family, {
nodes: prev ? prev.nodes.concat(entry.nodes) : entry.nodes,
overflow: (prev?.overflow ?? false) || entry.overflow,
})
this.updateCalls++
loader.update(collected)
entry.loader.update(collected)
}
}
/** 累计实际提交给 loader 的 update 次数(性能/调试指标:指纹短路越有效增长越慢) */
public updateCalls = 0
/** 防抖触发扫描:增量优先,脏节点占比过高时退化全量 */
/**
* flushtrailing
* clear+set debounce
* clear+set~1μs/ trailing
* debounceMs flush
*/
private schedule(): void {
if (this.debounceTimer) clearTimeout(this.debounceTimer)
if (this.debounceTimer) return
this.debounceTimer = setTimeout(() => {
this.debounceTimer = null
this.flush()
}, this.config.debounceMs)
}
/** flush增量 walk 脏节点集(或 dirtyAll 时全量),提交后清空脏集 */
/** 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
}
} else {
const groups = new Map<string, IGroup>()
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)
/** 每次 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)
}
this.dirty.clear()
this.commit(groups)
/**
* forceRender
* in-flight chunk renderPending true
* chunk chunk
* N chunk = N
*/
if (this.renderPending && !this.hasPendingLoads()) {
this.renderPending = false
this.leafer?.forceRender?.()
}
}
/** 是否仍有 in-flight 的子集请求 */
private hasPendingLoads(): boolean {
for (const loader of this.loaders.values()) {
if (loader.isPending()) return true
}
return false
}
/* ============================================================
@ -326,16 +421,41 @@ export class WebFontPlugin {
return fontFamily
}
/** 获取或创建family 对应的 SDK 增量加载器;注册成功后重绘画布 */
/** 获取或创建family 对应的 SDK 增量加载器;注册成功后按模式分流处理 */
private getLoader(fontName: string, family: string): IFontFaceLoader {
let loader = this.loaders.get(family)
if (!loader) {
loader = this.mode.loadFontFace(
{ fontName, family },
() => {
/** 字体注册成功后Node 端更新 fontFamily 链,浏览器端仅重绘 */
this.applyNodeFontChain(family)
this.leafer?.forceRender?.()
const chain = loader!.fontFamilyChain()
const apply = this.pendingApply.get(family)
if (chain) {
/**
* Node chunk `${family}__N` 退
* + dirtyAll walk
* setterleafer
* O() + dirty
*/
this.pendingChains.set(family, { chain, silent: !apply || apply.overflow })
if (apply) {
this.pendingApply.delete(family)
if (apply.overflow) {
this.dirtyAll = true
/** 静默链路径leafer 不知情,标记待重绘(尾部一次 forceRender */
this.renderPending = true
} else {
for (const n of apply.nodes) this.dirty.add(n)
}
}
} else {
/**
* FontFace(unicodeRange)
* flush
*/
this.renderPending = true
}
this.schedule()
},
)
this.loaders.set(family, loader)
@ -344,36 +464,6 @@ export class WebFontPlugin {
return loader
}
/**
* Node SDK fontFamily chunk family
* 使 family Text fontFamilyChain
* null no-op
*/
private applyNodeFontChain(family: string): void {
const loader = this.loaders.get(family)
const chain = loader?.fontFamilyChain()
if (!chain) return
/** 预编译匹配:原始 family 名或已改写链(链首 chunk family 以 `${family}__` 开头) */
const chainHead = new RegExp(`^["']?${escapeRegExp(family)}__\\d`)
const walk = (node: ILeaferNode | null | undefined): void => {
if (!node || node.destroyed) return
const fontFamily = node.fontFamily
if (typeof fontFamily === 'string' && fontFamily) {
const usesFamily = fontFamily === family || fontFamily.trim() === family || chainHead.test(fontFamily)
if (usesFamily && node.fontFamily !== chain) {
;(node as { fontFamily: string }).fontFamily = chain
}
}
const children = node.children
if (Array.isArray(children)) {
for (const child of children as ILeaferNode[]) walk(child)
}
}
walk(this.leafer)
}
private log(...args: unknown[]): void {
if (this.config.debug) console.log('[leafer-x-webfont]', ...args)
}
@ -410,6 +500,17 @@ export class WebFontPlugin {
*/
public async ready(): Promise<void> {
await this.mode.ready()
/**
* /
* dirtyAll / renderPending /
*
* 800ms scanready 线
*
*/
if (this.dirty.size > 0 || this.dirtyAll || this.renderPending) {
this.flushNow()
await this.mode.ready()
}
}
/** 已加载(或加载中)的字体 family 列表(调试 / 状态展示用) */
@ -436,11 +537,6 @@ export class WebFontPlugin {
*
* ============================================================ */
/** 正则元字符转义family 名可能含中文/特殊符号,构造匹配器前先转义) */
function escapeRegExp(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
/**
* chunk family Node chunk family`base__0``base__1`
* base family scan

View File

@ -0,0 +1,50 @@
/**
* text vs
*
* 501M ~3s leafer
* settertext
*
* npx tsx scripts/test-leafer-million-baseline.mts
*/
import { Leafer, Text, Group, useCanvas } from '@leafer-ui/node'
import { Canvas as NapiCanvas, loadImage } from '@napi-rs/canvas'
import { performance } from 'node:perf_hooks'
useCanvas('napi', { Canvas: NapiCanvas, loadImage })
const POOL = '静心茶舍天地玄黄宇宙洪荒'
async function buildTree(total: number) {
const leafer = new Leafer({ width: 800, height: 600, fill: '#fff' })
const GROUPS = 100
const PER = Math.floor(total / GROUPS)
const allTexts: InstanceType<typeof Text>[] = []
for (let g = 0; g < GROUPS; g++) {
const group = new Group()
for (let i = 0; i < PER; i++) {
const t = new Text({ text: POOL[i % POOL.length]!, fontFamily: 'sans-serif', fontSize: 12, fill: '#000', x: (i % 80) * 10, y: Math.floor(i / 80) * 14 })
group.add(t)
allTexts.push(t)
}
leafer.add(group)
}
return { leafer, allTexts }
}
async function main(): Promise<void> {
const TOTAL = 1_000_000
console.log(`=== 无插件基线:${TOTAL.toLocaleString()} 节点下 50 次 text 赋值 ===`)
const { leafer, allTexts } = await buildTree(TOTAL)
const victim = allTexts[Math.floor(allTexts.length / 2)]!
await new Promise((r) => setTimeout(r, 200))
const t0 = performance.now()
for (let i = 0; i < 50; i++) {
victim.text = '连续打字测试' + POOL[i % POOL.length]! + i
}
const noPluginMs = performance.now() - t0
console.log(`[无插件] 50 次赋值: ${noPluginMs.toFixed(1)}ms (${(noPluginMs / 50).toFixed(1)}ms/次)`)
leafer.destroy()
}
await main()

View File

@ -0,0 +1,135 @@
/**
* leafer +
*
* _prof_million.entry.ts @leafer-ui/node
* leafer emitPropertyEvent flush
*
* 1. 100k / 500k / 1M Text
* 2. scan + rewriteFamily property.change
* 3. text flush
* 4. text
* 5. /
* 6. updateCalls / fetch + SDK
*
* npx tsx scripts/test-leafer-million.mts
* pnpm dev:backend8087
*/
import { Leafer, Text, Group, useCanvas } from '@leafer-ui/node'
import { Canvas as NapiCanvas, loadImage } from '@napi-rs/canvas'
import { performance } from 'node:perf_hooks'
/** 必须在创建任何 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'
const POOL = '静心茶舍天地玄黄宇宙洪荒日月盈昃辰宿列张寒来暑往秋收冬藏闰余成岁律吕调阳云腾致雨露结为霜'
/** 劫持 fetch 计数子集请求 */
let fetchCount = 0
const realFetch = globalThis.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
function mem(label: string): void {
const mu = process.memoryUsage()
console.log(` [mem] ${label}: heap=${(mu.heapUsed / 1048576).toFixed(0)}MB rss=${(mu.rss / 1048576).toFixed(0)}MB`)
}
/** 阶梯压一轮:搭建 total 个 Text → 初始 scan → 单点/连续/随机改动 */
async function runStage(total: number): Promise<void> {
console.log(`\n===== 阶段:${(total / 1000).toFixed(0)}k Text 节点 =====`)
/** 搭建GROUPS 组 × 每组 N 个,全部同一 family真实海报形态 */
const t0 = performance.now()
const leafer = new Leafer({ width: 800, height: 600, fill: '#fff' })
const GROUPS = 100
const PER = Math.floor(total / GROUPS)
const allTexts: InstanceType<typeof Text>[] = []
for (let g = 0; g < GROUPS; g++) {
const group = new Group()
for (let i = 0; i < PER; i++) {
const ch = POOL[(g * PER + i) % POOL.length]!
const t = new Text({ text: ch, fontFamily: '令东齐伋复刻体.ttf', fontSize: 12, fill: '#000', x: (i % 80) * 10, y: Math.floor(i / 80) * 14 })
group.add(t)
allTexts.push(t)
}
leafer.add(group)
}
const buildMs = performance.now() - t0
console.log(`[搭建] ${(buildMs / 1000).toFixed(1)}s (${allTexts.length.toLocaleString()} Text)`)
/** 插件构造waitViewReady 已过,初始 scan 在 refresh 里显式触发以计时) */
const t1 = performance.now()
const webfont = new WebFontPlugin(leafer as never, { baseUrl: BASE_URL, debounceMs: 120 })
webfont.refresh()
const initScanMs = performance.now() - t1
/** 等初始子集请求落定 + rewriteFamily 风暴引发的脏集 flush 消化 */
await new Promise((r) => setTimeout(r, 500))
webfont.flushNow()
await webfont.ready().catch(() => undefined)
console.log(`[初始 scan] ${initScanMs.toFixed(0)}ms, updateCalls=${webfont.updateCalls}, fetch=${fetchCount}`)
mem('初始后')
/* ---------- 单节点改 text增量 flush ---------- */
const victim = allTexts[Math.floor(allTexts.length / 2)]!
const t2 = performance.now()
victim.text = '变化验证新字'
webfont.flushNow()
const singleMs = performance.now() - t2
console.log(`[单点改动] flush=${singleMs.toFixed(3)}ms, updateCalls=${webfont.updateCalls}`)
/* ---------- 连续打字:同一节点 50 次(防抖窗口内合并) ---------- */
const callsBefore = webfont.updateCalls
const fetchBefore = fetchCount
const t3 = performance.now()
for (let i = 0; i < 50; i++) {
victim.text = '连续打字测试' + POOL[i % POOL.length]! + i
}
webfont.flushNow()
await new Promise((r) => setTimeout(r, 200))
const typingMs = performance.now() - t3
console.log(`[连续打字50次] 总=${typingMs.toFixed(1)}ms, 新增update=${webfont.updateCalls - callsBefore}, 新增fetch=${fetchCount - fetchBefore}`)
/* ---------- 随机 100 节点改动(批量替换/协作场景) ---------- */
const t4 = performance.now()
for (let i = 0; i < 100; i++) {
const node = allTexts[Math.floor(Math.random() * allTexts.length)]!
node.text = '随机改动' + POOL[i % POOL.length]! + i
}
webfont.flushNow()
const randomMs = performance.now() - t4
console.log(`[随机100节点] flush=${randomMs.toFixed(1)}ms, updateCalls=${webfont.updateCalls}`)
/* ---------- 新增 Textchild.add 增量) ---------- */
const t5 = performance.now()
leafer.add(new Text({ text: '新增文字验证', fontFamily: '令东齐伋复刻体.ttf', fontSize: 24, fill: '#000', x: 0, y: 0 }))
webfont.flushNow()
const addMs = performance.now() - t5
console.log(`[新增Text] flush=${addMs.toFixed(2)}ms`)
await webfont.ready().catch(() => undefined)
mem('结束')
webfont.destroy()
leafer.destroy()
/** 给 GC 一点时间,避免下一阶段内存叠加干扰 */
globalThis.gc?.()
await new Promise((r) => setTimeout(r, 300))
}
async function main(): Promise<void> {
console.log('=== 真实 leafer 百万节点压测 ===')
console.log(`node=${process.version}, leafer-ui=@leafer-ui/node`)
await runStage(100_000)
await runStage(500_000)
await runStage(1_000_000)
globalThis.fetch = realFetch
console.log('\n=== 压测完成 ===')
}
await main()