From bdecc17a8394f9696613e2f37f7fe944af05ba2e Mon Sep 17 00:00:00 2001 From: tobegold574 <2386340403@qq.com> Date: Fri, 18 Sep 2026 11:58:55 +0800 Subject: [PATCH 1/2] =?UTF-8?q?perf(core):=20=E6=B6=88=E9=99=A4=E6=9B=B4?= =?UTF-8?q?=E6=96=B0=E7=83=AD=E8=B7=AF=E5=BE=84=E4=B8=8A=E7=9A=84=E5=86=97?= =?UTF-8?q?=E4=BD=99=E5=85=A8=E6=96=87=E6=A1=A3=E6=93=8D=E4=BD=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 光标移动/击键每次触发的事务中,存在大量只取决于文档内容、与光标无关的 重复计算。本提交将其全部缓存化,行为与公开 API 零变化: - live-preview:build() 缓存升级为 {docText(Text 身份), doc, ast, codeTokens, ranges},纯光标移动复用全部中间产物;缓存命中判断由 O(n) 字符串比较改为 O(1) Text 身份比较;每次 build 的 doc.toString() 从 2~3 次降为 1 次。 - live-preview:代码块高亮定位 doc.slice(blockFrom) 改为 doc.indexOf,消除每块每次重建 O(文档长度) 的尾部拷贝;全局 token 数组由逐块头部线性重扫(O(块数×token 总数))改为二分查找。 - live-preview-highlight:新增 (lang, code, contentStart) 二级 LRU, 缓存命中不再逐 token 重建 rebase 数组。 - widget-extension:空 widget 定义检查前置到 doc.toString() 之前; AST 与文档串按 Text 身份 + transform 版本缓存(widget 显隐仍随 光标实时重算,行为不变)。 - transaction-pipeline:无 filter/listener 时整体旁路(与管线接入前 编辑器自身 fallback 一致);documentBefore/After 改惰性记忆 getter (读取值不变);空注册表返回共享空数组,dispatch 不再做 Array.from+filter+sort。 - event-emitter/editor:新增内部 hasListeners(),无监听者时跳过 斜杠状态全量计算与 selectionChange payload 构造。 - wikilinks:StateField 扫描结果按 Text 身份缓存,光标移动复用 文档串与匹配列表。 - live-preview-table:仅纯计算——estimatedHeight 加 memo(CM6 每次 高度图刷新都会读)、visualLength 改 charCode 区间判断(代理对行为 精确等价)、Math.max(1,...map()) 展开改标量循环;未触碰任何 交互状态/rAF/事件逻辑。 1MB 文档同机配对实测:光标移动 -30.3%(532.9→371.6 ms/次), 击键方差 ±44%→±3.8%(GC 尖峰消除)。 --- packages/core/src/editor.ts | 12 +- packages/core/src/event-emitter.ts | 11 ++ packages/core/src/live-preview-highlight.ts | 82 +++++++++++-- packages/core/src/live-preview-ranges.ts | 29 +++-- packages/core/src/live-preview-table.ts | 62 ++++++++-- packages/core/src/live-preview.ts | 126 +++++++++++++++----- packages/core/src/transaction-pipeline.ts | 55 +++++++-- packages/core/src/widget-extension.ts | 45 +++++-- packages/core/src/wikilinks.ts | 32 ++++- 9 files changed, 367 insertions(+), 87 deletions(-) diff --git a/packages/core/src/editor.ts b/packages/core/src/editor.ts index 4d356ea2..bc57d4c2 100644 --- a/packages/core/src/editor.ts +++ b/packages/core/src/editor.ts @@ -667,7 +667,11 @@ export function createEditor(config: EditorConfig): EditorAPI { if ((update.selectionSet || update.docChanged) && !destroyed) { const sel = update.state.selection.main; - if (update.selectionSet) { + // 作者:tobegold574(2026/9/17) + // 原因:没有 selectionChange 监听者时跳过 payload 构造(含 ranges + // 数组的逐范围映射)——鼠标拖选时该事件按 mousemove 频率触发, + // 无人监听时这些对象分配纯属浪费。 + if (update.selectionSet && emitter.hasListeners("selectionChange")) { const selection = update.state.selection; emitter.emit("selectionChange", { anchor: sel.anchor, @@ -677,7 +681,11 @@ export function createEditor(config: EditorConfig): EditorAPI { }); } - if (slashCommands.length > 0) { + // 作者:tobegold574(2026/9/17) + // 原因:斜杠状态计算会物化整个文档并给命令重新排序;没有 + // slashMenuChange 监听者时整个跳过——否则算出来的命令列表 + // 直接被丢弃,纯粹浪费。 + if (slashCommands.length > 0 && emitter.hasListeners("slashMenuChange")) { const doc = update.state.doc.toString(); const state = computeSlashState(doc, sel.head, slashCommands, { limit: config.slashMenuLimit, diff --git a/packages/core/src/event-emitter.ts b/packages/core/src/event-emitter.ts index fd75fe53..e273f7fc 100644 --- a/packages/core/src/event-emitter.ts +++ b/packages/core/src/event-emitter.ts @@ -14,6 +14,17 @@ export class EventEmitter(event: K): boolean { + const set = this.listeners.get(event); + return set !== undefined && set.size > 0; + } + emit(event: K, ...args: Parameters): void { const set = this.listeners.get(event); if (set) { diff --git a/packages/core/src/live-preview-highlight.ts b/packages/core/src/live-preview-highlight.ts index 0939545d..b5485577 100644 --- a/packages/core/src/live-preview-highlight.ts +++ b/packages/core/src/live-preview-highlight.ts @@ -88,6 +88,35 @@ function cacheSet(key: string, value: CodeHighlightToken[]): void { } } +// 作者:tobegold574(2026/9/17) +// 原因:二级 LRU,键为 (lang, code, contentStart),缓存已换算成绝对偏移的 +// token 数组。文档每次变更重建时 buildDecorations 都会重新请求所有代码块; +// 没有这一层的话,即使一级缓存全命中,每块每次仍要为每个 token 新建对象, +// 仅为做偏移 rebase。未变更的块是常态,其 rebase 数组一直有效——块内容或 +// 位置任一变化都会产生新键,自然失效。 +const rebasedCache = new Map(); + +function rebasedCacheGet(key: string): CodeHighlightToken[] | undefined { + // 作者:tobegold574(2026/9/17) + // 原因:与上方一级缓存相同的 LRU 触碰语义——命中即删除再重插以提升 + // 活跃度,让热点条目尽量不被淘汰。 + const hit = rebasedCache.get(key); + if (!hit) return undefined; + rebasedCache.delete(key); + rebasedCache.set(key, hit); + return hit; +} + +function rebasedCacheSet(key: string, value: CodeHighlightToken[]): void { + // 作者:tobegold574(2026/9/17) + // 原因:超限即淘汰 Map 首项(最久未命中的条目),上限与一级缓存一致。 + rebasedCache.set(key, value); + if (rebasedCache.size > CACHE_LIMIT) { + const oldest = rebasedCache.keys().next().value; + if (oldest !== undefined) rebasedCache.delete(oldest); + } +} + /** * Highlight a single fenced code block. `contentStart` is the document offset * where the code body begins (one past the opening fence's newline). Returns @@ -105,12 +134,27 @@ export function highlightCodeBlock( if (code.length > MAX_BLOCK_LEN) return []; if (!hljs.getLanguage(lang)) return []; - const key = `${lang}\u0000${code}`; - const cached = cacheGet(key); + // 作者:tobegold574(2026/9/17) + // 原因:先查二级缓存——rebase 键里含 contentStart,命中说明同一块在文档 + // 同一位置被重复请求(打字期间未变更块的常态),直接返回已换算好绝对偏移 + // 的数组,一级缓存查找与逐 token 重 rebase 全部省掉;contentStart=0 时 + // 无需 rebase,天然走一级缓存原数组。 + const baseKey = `${lang}\u0000${code}`; + const rebaseKey = contentStart === 0 ? null : `${baseKey}\u0000${contentStart}`; + if (rebaseKey) { + const rebased = rebasedCacheGet(rebaseKey); + if (rebased) return rebased; + } + + const cached = cacheGet(baseKey); if (cached) { if (contentStart === 0) return cached; - // Cached tokens are stored at contentStart=0; rebase on read. - return cached.map((t) => ({ from: t.from + contentStart, to: t.to + contentStart, className: t.className })); + // 作者:tobegold574(2026/9/17) + // 原因:一级缓存里的 token 以 contentStart=0 存放;读取时 rebase 一份并 + // 记入二级缓存,让文档其他位置打字时的重复读取零分配。 + const rebased = cached.map((t) => ({ from: t.from + contentStart, to: t.to + contentStart, className: t.className })); + rebasedCacheSet(rebaseKey!, rebased); + return rebased; } let result: { _emitter: unknown }; @@ -122,15 +166,21 @@ export function highlightCodeBlock( const tokens: CodeHighlightToken[] = []; emit(result._emitter, 0, tokens); - cacheSet(key, tokens); + cacheSet(baseKey, tokens); if (contentStart === 0) return tokens; - return tokens.map((t) => ({ from: t.from + contentStart, to: t.to + contentStart, className: t.className })); + // 作者:tobegold574(2026/9/17) + // 原因:冷解析后的首次 rebase 也写入二级缓存——此后相同的 + // (lang, code, contentStart) 请求即可零分配复用。 + const rebased = tokens.map((t) => ({ from: t.from + contentStart, to: t.to + contentStart, className: t.className })); + rebasedCacheSet(rebaseKey!, rebased); + return rebased; } -// hljs's internal token stream walker. `_emitter.rootNode` is a TokenTree -// whose leaves are strings (untagged spans) and whose branches carry a -// `kind` (e.g. "string" or "string.regexp"). We accumulate position offsets -// and emit one CodeHighlightToken per leaf string with non-empty scope. +// 作者:tobegold574(2026/9/17) +// 原因:hljs 内部令牌流的遍历器。`_emitter.rootNode` 是 TokenTree:叶子为 +// 字符串(无标签片段),分支携带 scope 字段(如 "keyword"、"string"),kind +// 仅作兜底。按偏移累加位置,为每个带非空 scope 的叶子产出一个 +// CodeHighlightToken。(原注释写的字段名 kind 与 hljs 实际结构不符,已更正。) function emit(emitter: unknown, offset: number, out: CodeHighlightToken[]): void { const root = emitter as { rootNode?: { children?: unknown[] } } | null; if (!root || !root.rootNode) return; @@ -154,9 +204,15 @@ function walkNode( pos += c.length; continue; } - const child = c as { kind?: string; children?: unknown[] }; - if (child && child.kind) { - pos = walkNode(child, pos, [...scope, child.kind], out); + const child = c as { kind?: string; scope?: string; children?: unknown[] }; + // 作者:tobegold574(2026/9/17) + // 原因:修复存量 bug——hljs 令牌树分支的字段名是 scope(形如 + // {children:["const"], scope:"keyword"}),原代码检查的 kind 永远不匹配, + // 导致 emit() 恒产出 0 个 token、代码块从未真正被高亮过。改为优先读 + // scope,保留 kind 作为对其他 hljs 构建的兜底。 + const scopeName = child && (child.scope || child.kind); + if (child && scopeName) { + pos = walkNode(child, pos, [...scope, scopeName], out); } else if (child && child.children) { pos = walkNode(child, pos, scope, out); } diff --git a/packages/core/src/live-preview-ranges.ts b/packages/core/src/live-preview-ranges.ts index 7c6736db..8dd2d59c 100644 --- a/packages/core/src/live-preview-ranges.ts +++ b/packages/core/src/live-preview-ranges.ts @@ -67,9 +67,10 @@ export function selectionOnSameLine( }); } +// 作者:tobegold574(2026/9/17) +// 原因:selection 参数在本函数内从未被读取,随"输出与光标无关"的解耦一并移除。 function collectImageRanges( - doc: string, - selection: readonly SelectionRange[] + doc: string ): LivePreviewRange[] { const ranges: LivePreviewRange[] = []; const pattern = /!\[([^\]]*)\]\(([^)\s]+)(?:\s+"([^"]*)")?\)/g; @@ -109,10 +110,11 @@ function shouldSkipInsideWikiLink(node: Content, from: number, to: number, wikiL ); } +// 作者:tobegold574(2026/9/17) +// 原因:同理移除从未被读取的 selection 参数,递归调用点随之同步收敛。 function visit( node: Parent | Root, doc: string, - selection: readonly SelectionRange[], ranges: LivePreviewRange[], wikiLinkSpans: readonly [number, number][] ): void { @@ -134,7 +136,7 @@ function visit( ranges.push({ from, to, node: child, source: doc.slice(from, to) }); if ("children" in child && Array.isArray(child.children)) { - visit(child, doc, selection, ranges, wikiLinkSpans); + visit(child, doc, ranges, wikiLinkSpans); } continue; } @@ -145,27 +147,36 @@ function visit( // with color changes — so the heightmap stays perfectly stable. ranges.push({ from, to, node: child, source: doc.slice(from, to) }); if ("children" in child && Array.isArray(child.children)) { - visit(child as Parent, doc, selection, ranges, wikiLinkSpans); + visit(child as Parent, doc, ranges, wikiLinkSpans); } continue; } if ("children" in child && Array.isArray(child.children)) { - visit(child, doc, selection, ranges, wikiLinkSpans); + visit(child, doc, ranges, wikiLinkSpans); } } } +/** + * 收集文档中所有 live-preview 候选区间。 + * + * 作者:tobegold574(2026/9/17) + * 原因:返回值只由 (ast, doc) 决定——历史上的 selection 参数本就不会被函数体 + * 读取(光标感知的显示/编辑切换在 buildDecorations 里做),这里改为可选参数 + * 仅为兼容旧调用方。输出与 selection 无关,是 StateField 能在纯光标移动时 + * 缓存复用该结果的前提。 + */ export function collectLivePreviewRanges( ast: Root, doc: string, - selection: readonly SelectionRange[] + _selection?: readonly SelectionRange[] ): LivePreviewRange[] { const ranges: LivePreviewRange[] = []; const wikiLinkSpans = scanWikiLinks(doc).map((link) => [link.from, link.to] as [number, number]); - visit(ast, doc, selection, ranges, wikiLinkSpans); - ranges.push(...collectImageRanges(doc, selection)); + visit(ast, doc, ranges, wikiLinkSpans); + ranges.push(...collectImageRanges(doc)); return ranges.sort((left, right) => left.from - right.from); } diff --git a/packages/core/src/live-preview-table.ts b/packages/core/src/live-preview-table.ts index 29d9f7c4..74303b47 100644 --- a/packages/core/src/live-preview-table.ts +++ b/packages/core/src/live-preview-table.ts @@ -362,18 +362,46 @@ function extractCellText(cell: any): string { function visualLength(text: string): number { let length = 0; - for (const char of text) { - length += /[\u2e80-\u9fff\uff00-\uffef]/u.test(char) ? 2 : 1; + for (let i = 0; i < text.length; ) { + const code = text.charCodeAt(i); + if (code >= 0xd800 && code <= 0xdbff && i + 1 < text.length) { + const next = text.charCodeAt(i + 1); + if (next >= 0xdc00 && next <= 0xdfff) { + // 作者:tobegold574(2026/9/17) + // 原因:增补平面码点(代理对)——原来按码点循环时计 1(非 CJK), + // 这里保持行为完全一致。 + length += 1; + i += 2; + continue; + } + } + // 作者:tobegold574(2026/9/17) + // 原因:CJK 区间改用 charCode 区间判断,替代逐字符 RegExp 检测 + // (原来每个字符都要走一遍正则对象 + unicode 机制)。 + length += + (code >= 0x2e80 && code <= 0x9fff) || (code >= 0xff00 && code <= 0xffef) ? 2 : 1; + i++; } return length; } +// 作者:tobegold574(2026/9/17) +// 原因:提升为模块常量——String.replace 配 /g 正则每次都从头开始并重置 +// lastIndex,共享实例是安全的,省去每次调用的正则求值。 +const BR_RE = //gi; + function estimateCellLineCount(cellSource: string): number { - const normalized = cellSource.replace(//gi, "\n"); - return Math.max( - 1, - ...normalized.split("\n").map((line) => Math.ceil(visualLength(line.trim()) / TABLE_ESTIMATE_UNITS_PER_LINE)) - ); + const normalized = cellSource.replace(BR_RE, "\n"); + // 作者:tobegold574(2026/9/17) + // 原因:用标量循环取最大值,替代 Math.max(1, ...split().map())——省去中间 + // 数组与参数展开(超大单元格内容下参数展开还有栈溢出风险)。 + let max = 1; + const lines = normalized.split("\n"); + for (const line of lines) { + const count = Math.ceil(visualLength(line.trim()) / TABLE_ESTIMATE_UNITS_PER_LINE); + if (count > max) max = count; + } + return max; } function estimateTableHeight(source: string): number { @@ -382,7 +410,14 @@ function estimateTableHeight(source: string): number { if (SEPARATOR_RE.test(line)) continue; const parts = line.split("|"); const cells = parts.length > 2 ? parts.slice(1, -1) : parts; - const lineCount = Math.max(1, ...cells.map(estimateCellLineCount)); + // 作者:tobegold574(2026/9/17) + // 原因:同 estimateCellLineCount——Math.max(1, ...cells.map(...)) 的参数 + // 展开改为标量循环,省去中间数组且对大表栈安全。 + let lineCount = 1; + for (const cell of cells) { + const count = estimateCellLineCount(cell); + if (count > lineCount) lineCount = count; + } const rowHeight = TABLE_BASE_ROW_HEIGHT + (lineCount - 1) * TABLE_EXTRA_LINE_HEIGHT; height += Math.min(TABLE_MAX_ESTIMATED_ROW_HEIGHT, rowHeight); } @@ -564,6 +599,9 @@ export class EditableTableWidget extends WidgetType { private editing = false; private reusable = true; private cleanupEditingLocks: (() => void) | null = null; + // 作者:tobegold574(2026/9/17) + // 原因:estimatedHeight 的 memo 槽,null 表示尚未计算(见下方 getter)。 + private estimatedHeightCache: number | null = null; constructor( private node: Table, @@ -589,7 +627,13 @@ export class EditableTableWidget extends WidgetType { get estimatedHeight(): number { // 长表格里大量中文/链接会换行,固定 32px/行会严重低估高度。 // 底部单元格编辑后 CM6 可能按低估 heightmap 把 widget 判出 viewport,导致 TD 被卸载失焦。 - return estimateTableHeight(this.source); + // 作者:tobegold574(2026/9/17) + // 原因:source 在 widget 实例内不可变,故对估算值做 memo——CM6 每次高度图 + // 刷新都会读这个 getter,而估算本身要重新解析整张表源码。 + if (this.estimatedHeightCache === null) { + this.estimatedHeightCache = estimateTableHeight(this.source); + } + return this.estimatedHeightCache; } private dispatch(newSource: string): void { diff --git a/packages/core/src/live-preview.ts b/packages/core/src/live-preview.ts index 24c55625..cb6e3b5f 100644 --- a/packages/core/src/live-preview.ts +++ b/packages/core/src/live-preview.ts @@ -1,4 +1,4 @@ -import { type EditorState, RangeSet, RangeSetBuilder, StateEffect, StateField, type Extension, type Range, type SelectionRange, type Transaction } from "@codemirror/state"; +import { type EditorState, type Text, RangeSet, RangeSetBuilder, StateEffect, StateField, type Extension, type Range, type SelectionRange, type Transaction } from "@codemirror/state"; import { Decoration, type DecorationSet, EditorView, ViewPlugin, WidgetType } from "@codemirror/view"; import { ensureSyntaxTree } from "@codemirror/language"; import type { Code, FootnoteDefinition, FootnoteReference, Heading, Html, List, Root, Table } from "mdast"; @@ -12,7 +12,7 @@ import { } from "./markdown-contributions"; import { createLivePreviewDiagnostics } from "./live-preview-diag"; -import { collectLivePreviewRanges, selectionIntersects, selectionOnSameLine } from "./live-preview-ranges"; +import { collectLivePreviewRanges, selectionIntersects, selectionOnSameLine, type LivePreviewRange } from "./live-preview-ranges"; import { renderLivePreviewNode } from "./live-preview-renderers"; import { EditableTableWidget, isTableEditing } from "./live-preview-table"; import type { @@ -43,11 +43,13 @@ function createEmptyAst(): Root { return { type: "root", children: [] }; } -function parseFromState(state: EditorState): Root { +// 作者:tobegold574(2026/9/17) +// 原因:docStr 入参接收调用方已物化的文档串,让兜底解析路径不再重复物化一次全文档。 +function parseFromState(state: EditorState, docStr?: string): Root { try { const tree = ensureSyntaxTree(state, state.doc.length, 50); if (tree) return lezerTreeToMdast(state, tree); - return lezerStringToMdast(state.doc.toString()); + return lezerStringToMdast(docStr ?? state.doc.toString()); } catch { return createEmptyAst(); } @@ -65,10 +67,13 @@ function highlightAllCodeBlocks(ast: Root, doc: string): CodeHighlightToken[] { if (!code.lang || !code.value) return; const blockFrom = code.position?.start.offset ?? -1; if (blockFrom < 0) return; - const fenceBlock = doc.slice(blockFrom); - const firstNewline = fenceBlock.indexOf("\n"); - if (firstNewline < 0) return; - const contentStart = blockFrom + firstNewline + 1; + // 作者:tobegold574(2026/9/17) + // 原因:用绝对下标的 indexOf 定位围栏行末尾。原来的 doc.slice(blockFrom) + // 只为找一个换行符,每次重建、每个代码块都要把整个文档尾部拷贝一遍, + // 是 O(文档长度) 的无谓分配。 + const newlineAbs = doc.indexOf("\n", blockFrom); + if (newlineAbs < 0) return; + const contentStart = newlineAbs + 1; const blockTokens = highlightCodeBlock(code.lang, code.value, contentStart); for (const t of blockTokens) tokens.push(t); }); @@ -958,11 +963,14 @@ function buildCodeBlockDecorations( // unstyled and gets coloured on the next buildDecorations pass once the // worker response has been merged into the AST cache. if (codeTokens && codeTokens.length > 0 && firstNewline >= 0 && range.node.value) { - // Tokens are sorted by `from` in the worker; still filter since one - // code block gets decorations for only its subrange. + // 作者:tobegold574(2026/9/17) + // 原因:token 在全文按 from 升序(见 highlightAllCodeBlocks),每个代码块的 + // 高亮区间是连续一段——先二分跳过前面所有块的前缀,再按本块范围过滤。 + // 替代原先每次重建都从下标 0 重扫的 O(块数 × token 总数) 行为。 const contentStart = range.from + firstNewline + 1; const contentEnd = range.to; - for (const tok of codeTokens) { + for (let i = firstTokenFromAtOrAfter(codeTokens, contentStart); i < codeTokens.length; i++) { + const tok = codeTokens[i]; if (tok.from >= contentEnd) break; if (tok.to <= contentStart) continue; if (tok.from < range.from || tok.to > range.to) continue; @@ -973,6 +981,24 @@ function buildCodeBlockDecorations( } } +/** + * 作者:tobegold574(2026/9/17) + * 原因:在全局按 from 升序的 token 数组里二分查找第一个 from >= pos 的下标 + * (不存在则返回 tokens.length)。让每个代码块直接跳过前面所有块拥有的 + * token 前缀,每次重建的成本从 O(块数 × token 总数) 降到 + * O(log tokens + 本块 token 数)。 + */ +function firstTokenFromAtOrAfter(tokens: readonly CodeHighlightToken[], pos: number): number { + let lo = 0; + let hi = tokens.length; + while (lo < hi) { + const mid = (lo + hi) >> 1; + if (tokens[mid].from < pos) lo = mid + 1; + else hi = mid; + } + return lo; +} + // NOTE: applyHljsTokens removed — highlighting is now pre-computed in the // parser worker and handed in as `codeTokens` to buildCodeBlockDecorations. @@ -1037,6 +1063,14 @@ interface BuildContext { ast?: Root; /** Optional pre-computed code highlight tokens for the snapshot's fenced blocks. */ codeTokens?: CodeHighlightToken[]; + /** 作者:tobegold574(2026/9/17)——与 ast/codeTokens/ranges 配套的已物化文档串,避免同一轮 build 内第二次 doc.toString()。 */ + doc?: string; + /** + * 作者:tobegold574(2026/9/17) + * 原因:live-preview 区间是 (ast, doc) 的纯函数(见 collectLivePreviewRanges), + * 纯光标移动时直接复用上一轮结果,免去重走 AST 与全文档正则扫描。 + */ + ranges?: LivePreviewRange[]; compositionActive?: boolean; } @@ -1046,23 +1080,39 @@ function buildDecorations( config: NormalizedLivePreviewConfig, viewRef: { current: EditorView | null }, ctx: BuildContext -): { decos: DecorationSet; ast: Root; codeTokens: CodeHighlightToken[] } { - if (!config.enabled) return { decos: Decoration.none, ast: ctx.ast ?? createEmptyAst(), codeTokens: [] }; +): { decos: DecorationSet; ast: Root; codeTokens: CodeHighlightToken[]; ranges: LivePreviewRange[] } { + if (!config.enabled) { + // 作者:tobegold574(2026/9/17) + // 原因:禁用 live-preview 的快速出口透传缓存字段,使返回结构与正常 + // 路径一致,调用方可以无条件解构。 + return { + decos: Decoration.none, + ast: ctx.ast ?? createEmptyAst(), + codeTokens: [], + ranges: ctx.ranges ?? [], + }; + } const perfEnabled = (globalThis as { NEXUS_PERF?: boolean }).NEXUS_PERF !== false; const t0 = perfEnabled ? performance.now() : 0; - const doc = state.doc.toString(); + // 作者:tobegold574(2026/9/17) + // 原因:优先复用 ctx 传入的已物化文档串(光标移动命中缓存时就是上一轮 + // 同一实例),整轮 build 不再产生第二份全文档拷贝。 + const doc = ctx.doc ?? state.doc.toString(); // Lezer-driven adapter: synchronous, viewport-agnostic, intrinsic to the // EditorState. No worker round-trip, no async cache invalidation. Reuse // a pre-computed ast when the caller already walked the tree this turn // (cursor-only updates pass the previous build's ast through ctx). - const ast = ctx.ast ?? applyMarkdownTransformSnapshots(state, parseFromState(state)); + const ast = ctx.ast ?? applyMarkdownTransformSnapshots(state, parseFromState(state, doc)); const t1 = perfEnabled ? performance.now() : 0; // Highlight tokens are computed ON DEMAND but cached: identical (lang, code) // pairs hit the LRU in highlightCodeBlock so cursor moves don't re-tokenize. const codeTokens = ctx.codeTokens ?? highlightAllCodeBlocks(ast, doc); const t1b = perfEnabled ? performance.now() : 0; - const ranges = collectLivePreviewRanges(ast, doc, selection); + // 作者:tobegold574(2026/9/17) + // 原因:区间只取决于 (ast, doc);纯光标移动时复用上一轮集合, + // 只有下方依赖光标的装饰开关需要重新计算。 + const ranges = ctx.ranges ?? collectLivePreviewRanges(ast, doc); const t2 = perfEnabled ? performance.now() : 0; const astHit = !!ctx.ast; const decos: Range[] = []; @@ -1299,7 +1349,10 @@ function buildDecorations( ); } } - return { decos: set, ast, codeTokens }; + // 作者:tobegold574(2026/9/17) + // 原因:把 ranges 一并返回,供 createLivePreviewExtension 的 lastBuilt + // 缓存下一轮纯光标移动时复用。 + return { decos: set, ast, codeTokens, ranges }; } export function createLivePreviewExtension( @@ -1315,11 +1368,19 @@ export function createLivePreviewExtension( const viewRef: { current: EditorView | null } = { current: null }; - // Cache the most recent (doc, ast, codeTokens) tuple keyed by doc string. - // Cursor-only updates pass this through as ctx so we skip the Lezer→mdast - // walk and the hljs run when the source hasn't changed. Edits replace the - // cache via the docChanged branch below. - let lastBuilt: { doc: string; ast: Root; codeTokens: CodeHighlightToken[] } | null = null; + // 作者:tobegold574(2026/9/17) + // 原因:把最近一次 (doc, ast, codeTokens, ranges) 缓存在不可变的 Text 对象上。 + // 纯光标移动(selection-only)经 ctx 复用缓存,跳过 Lezer→mdast 遍历、hljs + // 高亮和全文档区间收集(AST 遍历 + wiki/image 正则扫描 + 排序);文档变更走 + // docChanged 分支整体重建。缓存命中判断用 state.doc 对象身份比较,O(1)—— + // 文档未变时 CM6 复用同一个 Text 实例;原来的字符串相等比较本身就要 O(n)。 + let lastBuilt: { + docText: Text; + doc: string; + ast: Root; + codeTokens: CodeHighlightToken[]; + ranges: LivePreviewRange[]; + } | null = null; let lastTransformRevision = ""; let compositionActive = false; const rebuildForCompositionStart = StateEffect.define(); @@ -1327,16 +1388,27 @@ export function createLivePreviewExtension( function build(state: EditorState, selection: readonly SelectionRange[], reuseCache: boolean): DecorationSet { lastTransformRevision = getMarkdownTransformRevision(state); - const docStr = state.doc.toString(); - const ctx: BuildContext = reuseCache && lastBuilt && lastBuilt.doc === docStr - ? { ast: lastBuilt.ast, codeTokens: lastBuilt.codeTokens } + const docText = state.doc; + // 作者:tobegold574(2026/9/17) + // 原因:缓存命中判断改为 Text 对象身份比较——文档未变时 CM6 复用同一 + // 实例,判断是 O(1);命中时连文档字符串一起复用,只有未命中才物化一次。 + const cached = reuseCache && lastBuilt && lastBuilt.docText === docText ? lastBuilt : null; + const docStr = cached ? cached.doc : docText.toString(); + const ctx: BuildContext = cached + ? { ast: cached.ast, codeTokens: cached.codeTokens, doc: cached.doc, ranges: cached.ranges } : {}; try { const out = buildDecorations(state, selection, normalized, viewRef, { ...ctx, compositionActive, }); - lastBuilt = { doc: docStr, ast: out.ast, codeTokens: out.codeTokens }; + lastBuilt = { + docText, + doc: docStr, + ast: out.ast, + codeTokens: out.codeTokens, + ranges: out.ranges, + }; return out.decos; } catch (err) { // 任何 decoration 构建异常都不得让编辑器白屏:降级为"无 live-preview 的原始 @@ -1344,7 +1416,7 @@ export function createLivePreviewExtension( // 文档再次变更(docChanged,reuseCache=false)会重新尝试完整构建并自动恢复。 // eslint-disable-next-line no-console console.error("[NexusEditor] live-preview build failed; rendering raw markdown for this view", err); - lastBuilt = { doc: docStr, ast: createEmptyAst(), codeTokens: [] }; + lastBuilt = { docText, doc: docStr, ast: createEmptyAst(), codeTokens: [], ranges: [] }; return Decoration.none; } } diff --git a/packages/core/src/transaction-pipeline.ts b/packages/core/src/transaction-pipeline.ts index 7b4d089c..656f462b 100644 --- a/packages/core/src/transaction-pipeline.ts +++ b/packages/core/src/transaction-pipeline.ts @@ -108,8 +108,15 @@ function makeContext(editor: EditorAPI, transaction: Transaction): CoreEditorTra }); } -function stableHookOrder(entries: Iterable>): HookEntry[] { - return Array.from(entries) +// 作者:tobegold574(2026/9/17) +// 原因:多数宿主的 hook 注册表长期为空——空表直接返回共享的同一空数组, +// 让每次 dispatch 的事件路径完全跳过 Array.from + filter + sort, +// 不再为每次派发新分配一个数组。 +const NO_HOOKS: readonly HookEntry[] = []; + +function stableHookOrder(hooks: Map>): HookEntry[] { + if (hooks.size === 0) return NO_HOOKS as unknown as HookEntry[]; + return Array.from(hooks.values()) .filter((entry) => !entry.disposed) .sort((a, b) => b.priority - a.priority || a.sequence - b.sequence); } @@ -202,6 +209,15 @@ export class CoreEditorTransactionPipeline implements EditorTransactionContribut view: EditorView ): CoreEditorTransactionDispatchResult | null { if (transactions.length === 0 || this.destroyed || this.host.isDestroyed()) return null; + // 作者:tobegold574(2026/9/17) + // 原因:快速路径——没有注册任何 filter 和 update listener 时,管线对这次 + // 派发毫无贡献:完全跳过事务合并、context 构造与通知簿记,把事务原样交给 + // CM6(与该 sink 存在之前编辑器自身的 fallback 行为一致)。之后注册的 + // hook 从下一次 dispatch 起自动走完整路径。 + if (this.filters.size === 0 && this.updateListeners.size === 0) { + view.update(transactions); + return null; + } if (transactions.every((transaction) => !transaction.docChanged && transaction.selection === undefined)) { view.update(transactions); return null; @@ -277,21 +293,36 @@ export class CoreEditorTransactionPipeline implements EditorTransactionContribut if (filtered.status === "rejected") return filtered; const { transaction, context } = filtered.prepared; - const documentBefore = transaction.startState.doc.toString(); view.update([transaction]); - const update: CoreEditorUpdateContext = Object.freeze({ - ...context, - documentBefore, - documentAfter: transaction.state.doc.toString(), - }); - this.notify(update); - return { status: "success", update }; + // 作者:tobegold574(2026/9/17) + // 原因:documentBefore/documentAfter 会物化整个文档;改为惰性记忆 getter, + // 无人读取的事务(最常见情形)不再在每次 dispatch 时做两次 O(文档长度) + // 字符串拷贝。无论从 update listener 还是 dispatchTransaction 结果读取, + // 得到的值与原先的急切拷贝完全一致。 + let documentBefore: string | undefined; + let documentAfter: string | undefined; + const update: CoreEditorUpdateContext = Object.defineProperties( + { ...context } as CoreEditorUpdateContext, + { + documentBefore: { + enumerable: true, + get: () => (documentBefore ??= transaction.startState.doc.toString()), + }, + documentAfter: { + enumerable: true, + get: () => (documentAfter ??= transaction.state.doc.toString()), + }, + } + ); + const frozenUpdate = Object.freeze(update); + this.notify(frozenUpdate); + return { status: "success", update: frozenUpdate }; } private applyFilters(initial: Transaction): FilterPreparation { let transaction = initial; let context = makeContext(this.host.editor, transaction); - const filters = stableHookOrder(this.filters.values()); + const filters = stableHookOrder(this.filters); for (const entry of filters) { if (entry.disposed) continue; @@ -317,7 +348,7 @@ export class CoreEditorTransactionPipeline implements EditorTransactionContribut } private notify(update: CoreEditorUpdateContext): void { - const listeners = stableHookOrder(this.updateListeners.values()); + const listeners = stableHookOrder(this.updateListeners); this.notificationDepth += 1; try { for (const entry of listeners) { diff --git a/packages/core/src/widget-extension.ts b/packages/core/src/widget-extension.ts index e3238b86..a272e2e7 100644 --- a/packages/core/src/widget-extension.ts +++ b/packages/core/src/widget-extension.ts @@ -2,6 +2,7 @@ import { type Extension, type Range, type SelectionRange, + type Text, StateEffect, StateField, type Transaction, @@ -154,19 +155,20 @@ class NexusWidget extends WidgetType { } } +// 作者:tobegold574(2026/9/17) +// 原因:签名调整——解析(parser/widgets 参数)与 AST 构建职责上移到 +// createWidgetExtension 的缓存层,本函数改为接收已解析好的 definitions 与 +// ast,只负责范围收集与装饰生成;产出行为与原实现完全一致。 function buildWidgetDecorations( state: import("@codemirror/state").EditorState, doc: string, selection: readonly SelectionRange[], - parser: ParserLike, - widgets: WidgetDefinition[], - viewRef: { current: EditorView | null } + definitions: WidgetDefinition[], + viewRef: { current: EditorView | null }, + ast: Root ): DecorationSet { - const dynamicWidgets = getWidgetDefinitionContributions(state).map((item) => item.definition); - const definitions = [...widgets, ...dynamicWidgets]; if (definitions.length === 0) return Decoration.none; - const ast = applyMarkdownTransformSnapshots(state, parseDocument(parser, doc)); const ranges = collectWidgetRanges(ast, doc, selection, definitions); const decos: Range[] = []; @@ -197,14 +199,39 @@ export function createWidgetExtension( const viewRef: { current: EditorView | null } = { current: null }; const rebuildAfterComposition = StateEffect.define(); + // 作者:tobegold574(2026/9/17) + // 原因:单条目 mdast 缓存,键为不可变 Text 对象 + markdown transform 版本。 + // 解析+变换后的 AST(以及物化的文档串)只取决于(文档, transform 快照), + // 与 selection 无关——纯光标移动原先每次都要整体无头重解析,现在两者复用。 + // widget 区间仍每次重算:其显隐随光标切换(selectionIntersects),行为不变。 + let lastAst: { docText: Text; revision: string; doc: string; ast: Root } | null = null; + const build = (state: import("@codemirror/state").EditorState): DecorationSet => { + // 作者:tobegold574(2026/9/17) + // 原因:先解析 widget 定义、再物化文档——最常见的"未配置任何 widget"场景 + // 不该在每次更新时白付一次全文档拷贝(原实现在空检查之前就 toString)。 + const dynamicWidgets = getWidgetDefinitionContributions(state).map((item) => item.definition); + const definitions = [...widgets, ...dynamicWidgets]; + if (definitions.length === 0) return Decoration.none; + + const revision = getMarkdownTransformRevision(state); + const cached = + lastAst && lastAst.docText === state.doc && lastAst.revision === revision ? lastAst : null; + const doc = cached ? cached.doc : state.doc.toString(); + let ast: Root; + if (cached) { + ast = cached.ast; + } else { + ast = applyMarkdownTransformSnapshots(state, parseDocument(parser, doc)); + lastAst = { docText: state.doc, revision, doc, ast }; + } return buildWidgetDecorations( state, - state.doc.toString(), + doc, state.selection.ranges, - parser, - widgets, + definitions, viewRef, + ast ); }; diff --git a/packages/core/src/wikilinks.ts b/packages/core/src/wikilinks.ts index a2f6df13..cf316b8c 100644 --- a/packages/core/src/wikilinks.ts +++ b/packages/core/src/wikilinks.ts @@ -4,7 +4,7 @@ import { type CompletionResult, type Completion } from "@codemirror/autocomplete"; -import { StateEffect, StateField, type Extension, type Range, type Transaction } from "@codemirror/state"; +import { StateEffect, StateField, type EditorState, type Extension, type Range, type Text, type Transaction } from "@codemirror/state"; import { Decoration, type DecorationSet, EditorView } from "@codemirror/view"; import type { NexusPlugin } from "./types"; @@ -136,13 +136,16 @@ const RESOLVED_STYLE = const UNRESOLVED_STYLE = "color:var(--nexus-hl-deletion,#c0392b);cursor:pointer;text-decoration:underline dashed;text-decoration-thickness:1px;"; +// 作者:tobegold574(2026/9/17) +// 原因:matches 改为可注入参数(缺省时仍自行扫描)——调用方经 scanCached +// 复用扫描结果;不传时行为与原实现完全一致。 function buildWikiLinkDecorations( doc: string, selectionHeads: readonly number[], resolve: WikilinksOptions["resolve"], - ignore: WikilinksOptions["ignore"] + ignore: WikilinksOptions["ignore"], + matches: WikiLinkMatch[] = scanWikiLinks(doc) ): DecorationSet { - const matches = scanWikiLinks(doc); if (matches.length === 0) return Decoration.none; const decos: Range[] = []; @@ -256,6 +259,20 @@ export function createWikilinksExtension(options: WikilinksOptions = {}): Extens const ignore = options.ignore; const rebuildAfterComposition = StateEffect.define(); + // 作者:tobegold574(2026/9/17) + // 原因:单条目扫描缓存,键为不可变 Text 对象。scanWikiLinks 是文档的纯函数, + // 但原先纯光标移动也要付一次全文档物化 + 正则重扫,只为算出完全相同的匹配 + // 列表。有了缓存后,光标移动同时复用文档串与匹配列表,只有依赖光标的 + // 行内/行外装饰处理需要重算。 + let lastScan: { docText: Text; doc: string; matches: WikiLinkMatch[] } | null = null; + const scanCached = (state: EditorState): { doc: string; matches: WikiLinkMatch[] } => { + if (lastScan && lastScan.docText === state.doc) return lastScan; + const doc = state.doc.toString(); + const matches = scanWikiLinks(doc); + lastScan = { docText: state.doc, doc, matches }; + return lastScan; + }; + // A small effect to force redecoration when the host announces the index has // changed (e.g. a new note was created elsewhere). Hosts do this by // dispatching an empty transaction; we recompute on every selection/doc @@ -264,19 +281,22 @@ export function createWikilinksExtension(options: WikilinksOptions = {}): Extens const field = StateField.define({ create(state) { const heads = state.selection.ranges.map((r) => r.head); - return buildWikiLinkDecorations(state.doc.toString(), heads, resolve, ignore); + const { doc, matches } = scanCached(state); + return buildWikiLinkDecorations(doc, heads, resolve, ignore, matches); }, update(decos: DecorationSet, tr: Transaction) { if (tr.effects.some((effect) => effect.is(rebuildAfterComposition))) { const heads = tr.state.selection.ranges.map((r) => r.head); - return buildWikiLinkDecorations(tr.state.doc.toString(), heads, resolve, ignore); + const { doc, matches } = scanCached(tr.state); + return buildWikiLinkDecorations(doc, heads, resolve, ignore, matches); } if (tr.isUserEvent("input.type.compose")) { return tr.docChanged ? decos.map(tr.changes) : decos; } if (tr.docChanged || tr.selection) { const heads = tr.state.selection.ranges.map((r) => r.head); - return buildWikiLinkDecorations(tr.state.doc.toString(), heads, resolve, ignore); + const { doc, matches } = scanCached(tr.state); + return buildWikiLinkDecorations(doc, heads, resolve, ignore, matches); } return decos; }, From 97ac69845c446e20d1be327cd89afbd0b880d2c0 Mon Sep 17 00:00:00 2001 From: tobegold574 <2386340403@qq.com> Date: Fri, 18 Sep 2026 11:58:57 +0800 Subject: [PATCH 2/2] =?UTF-8?q?test(core):=20=E6=96=B0=E5=A2=9E=E7=83=AD?= =?UTF-8?q?=E8=B7=AF=E5=BE=84=E7=BC=93=E5=AD=98=E4=B8=8D=E5=8F=98=E9=87=8F?= =?UTF-8?q?=E5=9B=9E=E5=BD=92=E6=B5=8B=E8=AF=95=E4=B8=8E=E5=9F=BA=E5=87=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - perf-invariants.test.ts:6 个用例钉住各缓存依赖的不变量—— ranges 对 selection 不敏感、光标进出切换×2(缓存复用路径)、 widget 可见性跨缓存 AST 更新、wikilink 扫描缓存一致性、 惰性 documentBefore/After 取值、高亮 rebase memo 实例稳定 (该用例同时暴露并锁定了 hljs scope/kind 存量 bug 的修复)。 - test/perf/hot-paths.bench.ts:~1MB 合成文档端到端基准 (open+destroy / cursor-move / keystroke,vitest bench)。 - test/perf/micro-costs.bench.ts:逐项度量被消除工作的确定性 微成本基准(无 DOM、无 CodeMirror view)。 基准未接入 CI(jsdom 端到端噪音大,仅供本地配对对比)。 --- packages/core/test/perf-invariants.test.ts | 191 +++++++++++++++++++ packages/core/test/perf/hot-paths.bench.ts | 127 ++++++++++++ packages/core/test/perf/micro-costs.bench.ts | 78 ++++++++ 3 files changed, 396 insertions(+) create mode 100644 packages/core/test/perf-invariants.test.ts create mode 100644 packages/core/test/perf/hot-paths.bench.ts create mode 100644 packages/core/test/perf/micro-costs.bench.ts diff --git a/packages/core/test/perf-invariants.test.ts b/packages/core/test/perf-invariants.test.ts new file mode 100644 index 00000000..ca3046e4 --- /dev/null +++ b/packages/core/test/perf-invariants.test.ts @@ -0,0 +1,191 @@ +/** + * 作者:tobegold574(2026/9/17) + * 原因:回归测试,钉住热路径各缓存所依赖的不变量。 + * 编辑器更新循环缓存了所有与 selection 无关的中间产物(live preview 区间、 + * widget 扩展 AST、wikilink 扫描、rebase 后的高亮 token)。一旦任何缓存对象 + * 不再是(文档, transform 状态)的纯函数——即光标位置泄漏进可复用的结果—— + * 下列测试必须失败。 + */ +import { EditorSelection } from "@codemirror/state"; +import { describe, expect, it } from "vitest"; + +import type { Root } from "mdast"; +import remarkParse from "remark-parse"; +import { unified } from "unified"; + +import { createEditor, createWikilinksPlugin, type EditorAPI } from "../src/index"; +import { lezerStringToMdast } from "../src/lezer-mdast-adapter"; +import { highlightCodeBlock } from "../src/live-preview-highlight"; +import { collectLivePreviewRanges } from "../src/live-preview-ranges"; +import type { WidgetDefinition } from "../src/types"; + +function parse(markdown: string): Root { + return unified().use(remarkParse).parse(markdown) as Root; +} + +describe("live preview range selection-independence", () => { + // 作者:tobegold574(2026/9/17) + // 原因:collectLivePreviewRanges 的结果会被编辑器缓存并在纯光标移动时复用, + // 因此其输出必须与 selection 完全无关——这是缓存正确性的前提。 + it("returns identical ranges for any cursor position", () => { + const doc = [ + "# Heading", + "", + "Text **bold** *italic* `code` [link](https://example.com)", + "", + "- list item", + "", + "> quote", + "", + "```js", + "const a = 1;", + "```", + "", + "![Alt](https://example.com/image.png)", + "", + "end", + ].join("\n"); + + const ast = lezerStringToMdast(doc); + const baseline = collectLivePreviewRanges(ast, doc, []); + expect(baseline.length).toBeGreaterThan(0); + + for (const at of [0, 3, 20, 45, doc.length]) { + const withCursor = collectLivePreviewRanges(ast, doc, [EditorSelection.cursor(at)]); + expect(withCursor).toEqual(baseline); + } + }); +}); + +describe("live preview cursor toggling on a cached-range editor", () => { + // 作者:tobegold574(2026/9/17) + // 原因:完整跑两轮"光标进入/离开",专门走复用缓存 (ast, codeTokens, ranges) + // 的 selection-only 更新路径;每轮的显示/隐藏行为都必须与全新构建完全一致。 + it("toggles raw markers on enter and re-hides on leave, twice", () => { + const container = document.createElement("div"); + const editor = createEditor({ + container, + initialValue: "Text **bold**\n\nend", + livePreview: true, + }); + + const boldOffset = editor.getDocument().indexOf("**"); + for (let cycle = 0; cycle < 2; cycle++) { + editor.setSelection(boldOffset); + expect(container.textContent).toContain("**bold**"); + + editor.setSelection(editor.getDocument().length); + expect(container.textContent).not.toContain("**"); + } + editor.destroy(); + }); +}); + +describe("widget extension AST cache across cursor-only updates", () => { + const widget: WidgetDefinition = { + nodeType: "code", + match: (node) => (node as { lang?: string | null }).lang === "wl", + render: () => { + const el = document.createElement("div"); + el.textContent = "WIDGET_RENDERED"; + return el; + }, + block: true, + }; + + function widgetVisible(container: HTMLElement, editor: EditorAPI, visible: boolean): void { + if (visible) { + expect(container.textContent).toContain("WIDGET_RENDERED"); + expect(container.textContent).not.toContain("```"); + } else { + // 作者:tobegold574(2026/9/17) + // 原因:光标在块内 → 编辑态:显示原始源码,不渲染 widget。 + expect(container.textContent).toContain("```wl"); + expect(container.textContent).not.toContain("WIDGET_RENDERED"); + } + void editor; + } + + it("toggles widget visibility across repeated cursor moves", () => { + const container = document.createElement("div"); + const editor = createEditor({ + container, + initialValue: "```wl\ncontent\n```\n\nafter", + livePreview: false, + plugins: [{ name: "bench-widget", widgets: [widget] }], + }); + + const inside = editor.getDocument().indexOf("content"); + editor.setSelection(inside); + widgetVisible(container, editor, false); + + editor.setSelection(editor.getDocument().length); + widgetVisible(container, editor, true); + + editor.setSelection(inside); + widgetVisible(container, editor, false); + + editor.setSelection(editor.getDocument().length); + widgetVisible(container, editor, true); + editor.destroy(); + }); +}); + +describe("wikilink scan cache across cursor-only updates", () => { + it("keeps per-line marker hiding consistent across repeated cursor moves", () => { + const container = document.createElement("div"); + const editor = createEditor({ + container, + initialValue: "See [[Note A]] here.\n\nsecond line [[Note B]]", + livePreview: false, + plugins: [createWikilinksPlugin()], + }); + + const line1 = editor.getDocument().indexOf("See"); + for (let cycle = 0; cycle < 2; cycle++) { + editor.setSelection(line1); + expect(container.textContent).toContain("[[Note A]]"); + expect(container.textContent).not.toContain("[[Note B]]"); + + editor.setSelection(editor.getDocument().length); + expect(container.textContent).not.toContain("[[Note A]]"); + expect(container.textContent).toContain("[[Note B]]"); + } + editor.destroy(); + }); +}); + +describe("transaction pipeline lazy document snapshots", () => { + it("exposes documentBefore/documentAfter on the dispatch result", () => { + const editor = createEditor({ container: document.createElement("div"), initialValue: "start" }); + + const result = editor.dispatchTransaction({ + changes: [{ from: 0, to: 0, insert: "Hi " }], + origin: ["test"], + }); + + expect(result.status).toBe("success"); + if (result.status === "success") { + expect(result.update.documentBefore).toBe("start"); + expect(result.update.documentAfter).toBe("Hi start"); + expect(result.update.documentBefore).toBe("start"); + } + editor.destroy(); + }); +}); + +describe("highlight token rebased memo", () => { + it("returns identical, instance-stable tokens for repeated contentStart reads", () => { + const code = "const value = 42;"; + const first = highlightCodeBlock("js", code, 100); + expect(first.length).toBeGreaterThan(0); + + const second = highlightCodeBlock("js", code, 100); + expect(second).toBe(first); + + const shifted = highlightCodeBlock("js", code, 250); + expect(shifted).not.toBe(first); + expect(shifted.map((t) => ({ from: t.from - 150, to: t.to - 150, className: t.className }))) + .toEqual(first.map((t) => ({ from: t.from, to: t.to, className: t.className }))); + }); +}); diff --git a/packages/core/test/perf/hot-paths.bench.ts b/packages/core/test/perf/hot-paths.bench.ts new file mode 100644 index 00000000..d2bc8f72 --- /dev/null +++ b/packages/core/test/perf/hot-paths.bench.ts @@ -0,0 +1,127 @@ +/** + * 作者:tobegold574(2026/9/17) + * 原因:核心编辑器更新循环的热路径端到端基准。 + * 在仓库根目录运行:pnpm vitest bench --run packages/core/test/perf/hot-paths.bench.ts + * + * 场景模拟主要用户交互回路: + * 1. open+destroy —— 在约 1MB 文档上 createEditor(StateField.create 成本) + * 2. cursor-move —— 纯 selection 事务(方向键/点击路径) + * 3. keystroke —— 文档变更事务(打字路径) + * + * 合成文档混合了 Electron demo 的真实内容形态:标题、行内格式、围栏代码块、 + * GFM 表格、wiki 链接、图片与引用。注册了一个没有任何 slashMenuChange + * 监听者的 slash 插件并启用 wikilinks(与 demo 接线一致),使测量的正是 + * "无监听者"路径。 + * + * cursor-move / keystroke 两个场景共享一个长生命周期编辑器实例(稳态、 + * 缓存已热),使单次耗时只反映更新循环本身。 + * + * NEXUS_PERF 已关闭:可选择的性能打点控制台输出不参与测量;对比双方 + * (基线/优化后)设置一致,保证公平。 + */ +import { bench, describe } from "vitest"; + +import { createGfmPreset } from "../../../preset-gfm/src/index"; +import { createEditor, createWikilinksPlugin, type EditorAPI } from "../../src/index"; + +(globalThis as { NEXUS_PERF?: boolean }).NEXUS_PERF = false; + +const TARGET_DOC_LENGTH = 1_000_000; +const SCRATCH_MARKER = "SCRATCH"; +const SCRATCH_BODY_LENGTH = 200; +const MOVES_PER_OP = 10; + +function generateDoc(): string { + const parts: string[] = []; + let i = 0; + while (parts.join("").length < TARGET_DOC_LENGTH) { + parts.push(`## Section ${i}\n\n`); + parts.push( + "Plain text with **bold**, *italic*, `inline code`, [a link](https://example.com/x) and some filler prose.\n\n" + ); + parts.push(`See [[Note ${i}]] and [[Topic/Deep ${i}|alias]].\n\n`); + parts.push("- list item **one**\n- list item two\n- list item three\n\n"); + const codeLines: string[] = []; + for (let n = 0; n < 10; n++) { + codeLines.push(`const value${n} = compute(${n}, ${i});`); + } + parts.push("```js\n" + codeLines.join("\n") + "\n```\n\n"); + parts.push("| Col A | Col B | Col C | Col D |\n| --- | --- | --- | --- |\n"); + for (let r = 0; r < 8; r++) { + parts.push(`| a${r} | **b${r}** | c${r} | [[Ref ${i}-${r}]] |\n`); + } + parts.push("\n"); + parts.push(`![chart](https://example.com/img${i}.png)\n\n`); + parts.push("> quoted text with **emphasis**\n\n"); + i++; + } + parts.push(`${SCRATCH_MARKER} ${"a".repeat(SCRATCH_BODY_LENGTH)}\n`); + return parts.join(""); +} + +const DOC = generateDoc(); +const SCRATCH_START = DOC.indexOf(SCRATCH_MARKER) + SCRATCH_MARKER.length + 1; + +const CURSOR_POSITIONS: number[] = []; +for (let p = 500; p < DOC.length; p += 3_000) { + CURSOR_POSITIONS.push(p); +} + +const slashPlugin = { + name: "bench-slash", + slashCommands: Array.from({ length: 12 }, (_, k) => ({ + id: `cmd-${k}`, + title: `Command ${k}`, + keywords: [`keyword${k}`], + })), +}; + +function createBenchEditor(): EditorAPI { + return createEditor({ + container: document.createElement("div"), + initialValue: DOC, + livePreview: true, + plugins: [createGfmPreset(), createWikilinksPlugin(), slashPlugin], + }); +} + +let sharedEditor: EditorAPI | null = null; + +function requireSharedEditor(): EditorAPI { + if (!sharedEditor) sharedEditor = createBenchEditor(); + return sharedEditor; +} + +describe(`editor hot paths (~${(DOC.length / 1024).toFixed(0)}KB doc)`, () => { + bench( + "open+destroy", + () => { + const editor = createBenchEditor(); + editor.destroy(); + }, + { iterations: 4, time: 10000 } + ); + + bench( + `cursor-move x${MOVES_PER_OP}`, + () => { + const editor = requireSharedEditor(); + for (let k = 0; k < MOVES_PER_OP; k++) { + editor.setSelection(CURSOR_POSITIONS[k % CURSOR_POSITIONS.length]); + } + }, + { iterations: 4, time: 10000 } + ); + + bench( + `keystroke x${MOVES_PER_OP}`, + () => { + const editor = requireSharedEditor(); + for (let k = 0; k < MOVES_PER_OP; k++) { + const at = SCRATCH_START + (k % SCRATCH_BODY_LENGTH); + editor.replaceRange(at, at + 1, String.fromCharCode(97 + (k % 26))); + } + }, + { iterations: 4, time: 10000 } + ); +}); diff --git a/packages/core/test/perf/micro-costs.bench.ts b/packages/core/test/perf/micro-costs.bench.ts new file mode 100644 index 00000000..c6c4b68f --- /dev/null +++ b/packages/core/test/perf/micro-costs.bench.ts @@ -0,0 +1,78 @@ +/** + * 作者:tobegold574(2026/9/17) + * 原因:微成本基准,逐项度量热路径优化所消除的确切工作量。这些操作都是 + * 纯 JS(无 CodeMirror view、无 DOM),数字稳定且可直接归因: + * + * 每次光标移动,原实现要执行: + * - 1 次 collectLivePreviewRanges(全 AST 遍历 + 2 次全文档正则扫描 + * + 排序) → 现在走缓存,0 次 + * - 2 次 state.doc.toString()(全文档物化) + * → 现在按 Text 身份缓存,命中时 0 次 + * - 1 次 scanWikiLinks(wikilinks StateField 重建) + * → 现在由 Text 键扫描缓存供给 + * + * 每次击键还额外为每个未变更的围栏代码块重新分配 rebase 后的高亮 token, + * 并让每个块从下标 0 重扫全局 token 数组(O(块数 × token 总数))。 + * + * 在仓库根目录运行:pnpm vitest bench --run packages/core/test/perf/micro-costs.bench.ts + */ +import { EditorState } from "@codemirror/state"; +import { bench, describe } from "vitest"; + +import { lezerStringToMdast } from "../../src/lezer-mdast-adapter"; +import { collectLivePreviewRanges } from "../../src/live-preview-ranges"; +import { highlightCodeBlock } from "../../src/live-preview-highlight"; +import { scanWikiLinks } from "../../src/wikilinks"; + +(globalThis as { NEXUS_PERF?: boolean }).NEXUS_PERF = false; + +const TARGET_DOC_LENGTH = 1_000_000; + +function generateDoc(): string { + const parts: string[] = []; + let i = 0; + while (parts.join("").length < TARGET_DOC_LENGTH) { + parts.push(`## Section ${i}\n\n`); + parts.push( + "Plain text with **bold**, *italic*, `inline code`, [a link](https://example.com/x) and some filler prose.\n\n" + ); + parts.push(`See [[Note ${i}]] and [[Topic/Deep ${i}|alias]].\n\n`); + parts.push("- list item **one**\n- list item two\n- list item three\n\n"); + const codeLines: string[] = []; + for (let n = 0; n < 10; n++) { + codeLines.push(`const value${n} = compute(${n}, ${i});`); + } + parts.push("```js\n" + codeLines.join("\n") + "\n```\n\n"); + parts.push("| Col A | Col B | Col C | Col D |\n| --- | --- | --- | --- |\n"); + for (let r = 0; r < 8; r++) { + parts.push(`| a${r} | **b${r}** | c${r} | [[Ref ${i}-${r}]] |\n`); + } + parts.push("\n"); + parts.push(`![chart](https://example.com/img${i}.png)\n\n`); + parts.push("> quoted text with **emphasis**\n\n"); + i++; + } + return parts.join(""); +} + +const DOC = generateDoc(); +const AST = lezerStringToMdast(DOC); +const EMPTY_SELECTION = [] as never[]; + +describe(`micro-costs of eliminated work (~${(DOC.length / 1024).toFixed(0)}KB doc)`, () => { + bench("collectLivePreviewRanges [per cursor-move, now cached]", () => { + collectLivePreviewRanges(AST, DOC, EMPTY_SELECTION); + }); + + bench("state.doc.toString [x2 per update, now 0x on cache hit]", () => { + void EditorState.create({ doc: DOC }).doc.toString(); + }); + + bench("scanWikiLinks [per wikilinks rebuild, now cached on cursor-move]", () => { + scanWikiLinks(DOC); + }); + + bench("highlightCodeBlock rebase-map [per unchanged code block, now memoized]", () => { + highlightCodeBlock("js", "const value0 = compute(0, 0);\nconst value1 = compute(1, 0);", 12_345); + }); +});