feat(editor): 大文档异步分片载入(docs/20)

>6 万字符文档按顶层结构安全切分:第一片立即建编辑器,其余片逐片解析
追加、片间让出主线程,窗格左下角显示载入进度;载入期间可切标签、编辑
其他文档。追加期静音回写、状态保持磁盘完整原文,载完一次性对齐。
分片与整篇解析的序列化等价有测试锁定(tests/chunk-load.test.ts)。
此前试做的已关闭重开缓存已按用户要求整笔撤回。
This commit is contained in:
Muse
2026-11-14 15:16:23 +08:00
parent 2d1cd3045a
commit 2d0843dd97
5 changed files with 325 additions and 1 deletions

48
docs/20-async-loading.md Normal file
View File

@@ -0,0 +1,48 @@
# 大文档异步载入(2026-11-14,取代已撤回的「重开缓存」方案)
## 需求(用户口径)
大文件打开慢可以接受,但不能阻塞其他操作:打开要在后台进行,给出加载中
状态,用户能切标签、编辑别的文档、点菜单。
## 为什么不是真 Web Worker 线程
实测探针:Milkdown 的解析管线离不开 DOM——headless Editor 在 Node 里
create() 直接抛 `document is not defined`(core 的 editorView 插件在装配期
就引用 document.body,其后 ProseMirror 视图构建也需要真实 DOM)。
把整套 DOM 模拟进 Worker 不可靠,故采用主线程分片让行达到同样的
「不阻塞」效果:每片工作控制在几十毫秒量级,片间让出主线程。
## 方案(src/editor/chunkLoader.ts + EditorPane)
- 阈值:内容 > 60,000 字符且可切分时走分片;否则维持同步打开。
- 切分(splitMarkdownChunks):围栏感知扫描,只在「空行 + ATX 标题 /
围栏开始 / 分隔线」处切——这些位置之前的块必然已闭合,片段独立解析
与整篇解析等价(等价性有自动化测试锁定);按约 24,000 字符攒片。
文档含链接引用定义或脚注定义时不切(定义可位于文末被前文引用),
整篇一片同步解析(与此前行为一致)。
- 载入:第一片直接建编辑器(马上能看、能滚);其余片借一台隐藏的同厂
编辑器取 parserCtx 逐片解析,经 JSON 转交给目标编辑器(两台编辑器的
schema 是不同实例,直接插节点会被 ProseMirror 静默丢弃——实测踩过,
已加「追加未生效即抛错」的断言),追加到文末(不进撤销历史),每片后
setTimeout(0) 让行并更新进度。
- 进度:窗格左下角徽标「正在载入文档… N%(可切换到其他文档继续工作)」,
不遮挡已载入内容;切到别的标签时它在隐藏窗格里继续载完。
- 状态一致性:追加期间静音序列化回写,标签状态里始终是磁盘上的完整
原文(自动保存/恢复/外部变更检测都不受影响);载完一次性以最终序列化
对齐(不标脏)。载入中编辑已载入部分是允许的,最终对齐会把编辑一并
收进去。若某片解析/追加失败:解除静音、把已载入部分如实回写为
未保存状态,绝不冒充完整文档。
## 验证
- tests/chunk-load.test.ts 5 例:切分拼接逐字还原、围栏内假标题不切、
引用定义整篇一片、小文档不分片;66K 字符富结构文档(标题/宽松列表/
任务/表格/围栏/Mermaid/公式/引用/分隔线)分片载入的序列化与整篇解析
**逐字相等**。全量 vitest 94 通过 + 2 跳过;tsc 0;构建通过。
- 无头浏览器实测(596K 字符文档):徽标从 13% 起显示;载入中真实鼠标
点击切到其他标签立即生效(徽标在其隐藏窗格里继续涨到 22%…),在其
编辑器里打字实时落字;全程无页面错误;载完 180 个二级标题齐全。
总耗时约 10.6 秒为本机无头慢速口径,WebView2 真机应明显更快;
关键不在总时长,而在全程界面可操作。
- 待真机验:桌面模式打开 100K+ 文档时的进度徽标与切换手感。

View File

@@ -7,6 +7,7 @@ import { createMarkdownEditor, getMarkdown, type CursorInfo } from '../editor/ed
import { findBlockIndex, type CursorAnchor } from '../editor/cursorSync' import { findBlockIndex, type CursorAnchor } from '../editor/cursorSync'
import { buildTextIndex, findInIndex, type FindOptions, type MatchRange } from '../editor/search' import { buildTextIndex, findInIndex, type FindOptions, type MatchRange } from '../editor/search'
import { setFocusModeEnabled, refreshFocusDecorations } from '../editor/plugins' import { setFocusModeEnabled, refreshFocusDecorations } from '../editor/plugins'
import { prepareChunkLoad } from '../editor/chunkLoader'
export interface EditorPaneApi { export interface EditorPaneApi {
editor: Editor | null editor: Editor | null
@@ -60,6 +61,7 @@ export default function EditorPane({ tabId, initialMarkdown, onMarkdown, onFinal
const editorObjRef = useRef<Editor | null>(null) const editorObjRef = useRef<Editor | null>(null)
const [tableBar, setTableBar] = useState<{ top: number; left: number } | null>(null) const [tableBar, setTableBar] = useState<{ top: number; left: number } | null>(null)
const [ready, setReady] = useState(false) const [ready, setReady] = useState(false)
const [loadProgress, setLoadProgress] = useState<{ done: number; total: number } | null>(null)
const tableElRef = useRef<HTMLElement | null>(null) const tableElRef = useRef<HTMLElement | null>(null)
const updateTableBar = useCallback(() => { const updateTableBar = useCallback(() => {
@@ -77,6 +79,11 @@ export default function EditorPane({ tabId, initialMarkdown, onMarkdown, onFinal
useEffect(() => { useEffect(() => {
if (!hostRef.current) return if (!hostRef.current) return
let cancelled = false
// 大文档分片载入(docs/20):第一片建编辑器,其余逐片追加;追加期间静音
// 序列化回写——标签状态里始终是磁盘上的完整原文,载完再一次性对齐。
const chunkLoad = prepareChunkLoad(initialRef.current)
let muted = chunkLoad !== null
const centerCursorLine = () => { const centerCursorLine = () => {
// 打字机模式:把光标行滚到编辑区垂直中央附近 // 打字机模式:把光标行滚到编辑区垂直中央附近
window.requestAnimationFrame(() => { window.requestAnimationFrame(() => {
@@ -96,13 +103,14 @@ export default function EditorPane({ tabId, initialMarkdown, onMarkdown, onFinal
} catch { /* 视图未就绪时跳过 */ } } catch { /* 视图未就绪时跳过 */ }
}) })
} }
const editor = createMarkdownEditor(hostRef.current, initialRef.current, { const editor = createMarkdownEditor(hostRef.current, chunkLoad ? chunkLoad.firstChunk : initialRef.current, {
onMarkdown: (md) => { onMarkdown: (md) => {
// 文档变化后已算出的查找命中立即过期:下一次查找操作会先按新内容重算, // 文档变化后已算出的查找命中立即过期:下一次查找操作会先按新内容重算,
// 杜绝替换打在偏移后的错误位置上(审计高危)。 // 杜绝替换打在偏移后的错误位置上(审计高危)。
if (findRef.current.matches.length > 0) { if (findRef.current.matches.length > 0) {
findRef.current = { matches: [], current: -1, query: findRef.current.query, opts: findRef.current.opts } findRef.current = { matches: [], current: -1, query: findRef.current.query, opts: findRef.current.opts }
} }
if (muted) return // 分片追加期间的部分序列化不回写(状态里是完整原文)
callbacksRef.current.onMarkdown(tabId, md) callbacksRef.current.onMarkdown(tabId, md)
}, },
onCursor: (c) => { onCursor: (c) => {
@@ -230,12 +238,39 @@ export default function EditorPane({ tabId, initialMarkdown, onMarkdown, onFinal
editor.action((ctx) => refreshFocusDecorations(ctx.get(editorViewCtx) as never)) editor.action((ctx) => refreshFocusDecorations(ctx.get(editorViewCtx) as never))
} catch { /* 忽略 */ } } catch { /* 忽略 */ }
callbacksRef.current.onReady({ editor, jumpToHeading, getAnchor, setCursorByAnchor, findAll, gotoMatch, replaceCurrent, replaceAll, clearFind }) callbacksRef.current.onReady({ editor, jumpToHeading, getAnchor, setCursorByAnchor, findAll, gotoMatch, replaceCurrent, replaceAll, clearFind })
if (chunkLoad) {
setLoadProgress({ done: 1, total: chunkLoad.totalChunks })
void chunkLoad.appendRest(
editor,
(done, total) => { if (!cancelled) setLoadProgress({ done, total }) },
() => cancelled,
).then(() => {
if (cancelled) return
muted = false
setLoadProgress(null)
try {
// 载完一次性对齐:编辑器最终序列化与状态里的原文对齐(不标脏)
const full = getMarkdown(editor)
if (callbacksRef.current.onSyncInitial) callbacksRef.current.onSyncInitial(tabId, full)
else callbacksRef.current.onMarkdown(tabId, full)
} catch { /* 忽略 */ }
}).catch((e) => {
if (cancelled) return
// 载入失败:解除静音并把已载入的部分如实回写为未保存状态,
// 绝不冒充完整文档(关窗会提示、保存需用户确认)。
muted = false
setLoadProgress(null)
console.error('分片载入失败', e)
try { callbacksRef.current.onMarkdown(tabId, getMarkdown(editor)) } catch { /* 忽略 */ }
})
}
}) })
.catch((e) => console.error('编辑器创建失败', e)) .catch((e) => console.error('编辑器创建失败', e))
const scrollParent = wrapRef.current?.closest('.editor-scroll') const scrollParent = wrapRef.current?.closest('.editor-scroll')
const onScroll = () => { if (editorObjRef.current) updateTableBar() } const onScroll = () => { if (editorObjRef.current) updateTableBar() }
scrollParent?.addEventListener('scroll', onScroll, { passive: true }) scrollParent?.addEventListener('scroll', onScroll, { passive: true })
return () => { return () => {
cancelled = true
scrollParent?.removeEventListener('scroll', onScroll) scrollParent?.removeEventListener('scroll', onScroll)
editorObjRef.current = null editorObjRef.current = null
setTableBar(null) setTableBar(null)
@@ -271,6 +306,12 @@ export default function EditorPane({ tabId, initialMarkdown, onMarkdown, onFinal
<span>正在打开文档…</span> <span>正在打开文档…</span>
</div> </div>
)} )}
{ready && loadProgress && (
<div className="pane-loading-badge">
<span>正在载入文档… {Math.round((loadProgress.done / loadProgress.total) * 100)}%(可切换到其他文档继续工作)</span>
<div className="pane-progress"><div style={{ width: `${(loadProgress.done / loadProgress.total) * 100}%` }} /></div>
</div>
)}
</div> </div>
) )
} }

127
src/editor/chunkLoader.ts Normal file
View File

@@ -0,0 +1,127 @@
// 大文档分片异步载入(docs/20):整篇同步解析会把主线程占死、期间什么都点不动。
// 改为按顶层结构切成小片:第一片直接建编辑器(立刻有内容可看),其余片逐片解析、
// 逐片追加,每片之间让出主线程,用户可以切标签、编辑已载入部分、点菜单;
// 窗格上显示载入进度。解析仍走与整篇相同的 Milkdown 管线(借一台隐藏的同厂
// 编辑器取 parserCtx),不另造解析器。
import { parserCtx, editorViewCtx, type Editor } from '@milkdown/kit/core'
import { createMarkdownEditor } from './editor'
/** 超过这个字符数走分片载入(小文档同步解析本来就快) */
export const ASYNC_LOAD_THRESHOLD = 60_000
/** 每片目标大小(字符):单片解析控制在几十毫秒量级 */
const CHUNK_TARGET = 24_000
const FENCE_RE = /^\s{0,3}(```+|~~~+)/
const HEADING_RE = /^#{1,6}\s/
const HR_RE = /^\s{0,3}(\*\*\*+|---+|___+)\s*$/
const LINK_DEF_RE = /^\s{0,3}\[[^\]]+\]:\s*\S+/
const FOOTNOTE_DEF_RE = /^\[\^[^\]]+\]:/
/**
* 把 Markdown 切成可独立解析的顶层片段,拼接回去与原文逐字相同。
* 只在「空行 + ATX 标题 / 围栏开始 / 分隔线」处切:这些位置前面的块必然已经
* 闭合(标题、围栏、分隔线都不会被列表/引用等懒延续吞进去),片段内结构自洽。
* 文档含链接引用定义或脚注定义时不切(定义可能在文末、被前文引用),整篇一片。
*/
export function splitMarkdownChunks(markdown: string): string[] {
const lines = markdown.split('\n')
for (const line of lines) {
if (LINK_DEF_RE.test(line) || FOOTNOTE_DEF_RE.test(line)) return [markdown]
}
const cuts: number[] = [] // 切点 = 行索引(在该行之前切开)
let inFence = false
let fenceChar = ''
let fenceLen = 0
for (let i = 0; i < lines.length; i++) {
const line = lines[i]
const fence = FENCE_RE.exec(line)
if (fence) {
const marker = fence[1]
if (!inFence) { inFence = true; fenceChar = marker[0]; fenceLen = marker.length }
else if (marker[0] === fenceChar && marker.length >= fenceLen) { inFence = false }
continue
}
if (inFence) continue
if (line.trim() !== '' || i + 1 >= lines.length) continue
const next = lines[i + 1]
if (HEADING_RE.test(next) || FENCE_RE.test(next) || HR_RE.test(next)) cuts.push(i + 1)
}
if (cuts.length === 0) return [markdown]
// 按目标大小合并相邻片段:切点很多时攒够一片再切,避免碎成几百片
const chunks: string[] = []
let startLine = 0
let startOffset = 0
const lineOffset = (idx: number) => {
let off = 0
for (let i = 0; i < idx; i++) off += lines[i].length + 1
return off
}
for (const cut of cuts) {
const off = lineOffset(cut)
if (off - startOffset >= CHUNK_TARGET && cut > startLine) {
chunks.push(markdown.slice(startOffset, off))
startLine = cut
startOffset = off
}
}
chunks.push(markdown.slice(startOffset))
return chunks.filter((c) => c.length > 0)
}
let parserEditorPromise: Promise<Editor> | null = null
/** 隐藏的解析专用编辑器(与正式编辑器同厂,保证解析管线与 schema 完全一致) */
function ensureParserEditor(): Promise<Editor> {
if (!parserEditorPromise) {
const host = document.createElement('div')
host.style.display = 'none'
document.body.appendChild(host)
const editor = createMarkdownEditor(host, '', {})
parserEditorPromise = editor.create().then(() => editor)
}
return parserEditorPromise
}
const yieldToMain = () => new Promise<void>((resolve) => { window.setTimeout(resolve, 0) })
export interface ChunkLoadHandle {
/** 第一片(拿去建可见编辑器)与其余片总数 */
firstChunk: string
totalChunks: number
/** 把剩余片逐片解析并追加到 editor 末尾;cancelled 返回 true 时中止 */
appendRest: (editor: Editor, onProgress: (done: number, total: number) => void, isCancelled: () => boolean) => Promise<void>
}
/** 为大文档准备分片载入;小文档或不可切分时返回 null(调用方走同步路径) */
export function prepareChunkLoad(markdown: string): ChunkLoadHandle | null {
if (markdown.length <= ASYNC_LOAD_THRESHOLD) return null
const chunks = splitMarkdownChunks(markdown)
if (chunks.length <= 1) return null
return {
firstChunk: chunks[0],
totalChunks: chunks.length,
appendRest: async (editor, onProgress, isCancelled) => {
const parser = await ensureParserEditor()
for (let i = 1; i < chunks.length; i++) {
if (isCancelled()) return
const fragDoc = parser.action((ctx) => ctx.get(parserCtx)(chunks[i]))
// 跨编辑器传递必须走 JSON:两台编辑器各自建了一套 schema 实例,
// 直接插对方的节点会被 ProseMirror 静默丢弃(实测踩过)。
const json = fragDoc.toJSON() as unknown
editor.action((ctx) => {
const view = ctx.get(editorViewCtx)
const node = view.state.schema.nodeFromJSON(json)
const before = view.state.doc.childCount
const tr = view.state.tr.insert(view.state.doc.content.size, node.content)
tr.setMeta('addToHistory', false)
view.dispatch(tr)
if (view.state.doc.childCount <= before && node.childCount > 0) {
throw new Error('分片追加未生效')
}
})
onProgress(i + 1, chunks.length)
await yieldToMain()
}
},
}
}

View File

@@ -111,6 +111,9 @@ button { font: inherit; color: inherit; }
.pane-loading { position: absolute; inset: 0; display: flex; flex-direction: column; gap: 12px; align-items: center; justify-content: center; color: var(--text-dim); font-size: 13px; background: var(--doc-paper); z-index: 5; } .pane-loading { position: absolute; inset: 0; display: flex; flex-direction: column; gap: 12px; align-items: center; justify-content: center; color: var(--text-dim); font-size: 13px; background: var(--doc-paper); z-index: 5; }
.pane-spinner { width: 26px; height: 26px; border-radius: 50%; border: 3px solid var(--border); border-top-color: var(--accent); animation: pane-spin 0.8s linear infinite; } .pane-spinner { width: 26px; height: 26px; border-radius: 50%; border: 3px solid var(--border); border-top-color: var(--accent); animation: pane-spin 0.8s linear infinite; }
@keyframes pane-spin { to { transform: rotate(360deg); } } @keyframes pane-spin { to { transform: rotate(360deg); } }
.pane-loading-badge { position: absolute; left: 14px; bottom: 14px; z-index: 6; background: var(--bg-panel); border: 1px solid var(--border); border-radius: 9px; padding: 8px 12px; font-size: 12.5px; color: var(--text-dim); box-shadow: 0 6px 18px rgba(15, 23, 42, 0.12); display: flex; flex-direction: column; gap: 6px; pointer-events: none; }
.pane-progress { width: 180px; height: 4px; border-radius: 2px; background: var(--border); overflow: hidden; }
.pane-progress > div { height: 100%; background: var(--accent); border-radius: 2px; transition: width 0.15s ease; }
.editor-host { max-width: 880px; margin: 0 auto; padding: 34px 48px 140px; } .editor-host { max-width: 880px; margin: 0 auto; padding: 34px 48px 140px; }
.source-cm { height: 100%; max-width: 980px; margin: 0 auto; color: var(--doc-ink); caret-color: var(--doc-ink); } .source-cm { height: 100%; max-width: 980px; margin: 0 auto; color: var(--doc-ink); caret-color: var(--doc-ink); }
.source-cm .cm-editor { height: 100%; outline: none; } .source-cm .cm-editor { height: 100%; outline: none; }

105
tests/chunk-load.test.ts Normal file
View File

@@ -0,0 +1,105 @@
// 分片载入(docs/20):切分安全性 + 分片解析拼接与整篇解析的序列化等价。
import { describe, it, expect, afterEach } from 'vitest'
import type { Editor } from '@milkdown/kit/core'
import { createMarkdownEditor, getMarkdown } from '../src/editor/editor'
import { splitMarkdownChunks, prepareChunkLoad } from '../src/editor/chunkLoader'
let editors: Editor[] = []
afterEach(async () => {
for (const e of editors) { try { await e.destroy() } catch { /* ignore */ } }
editors = []
document.body.innerHTML = ''
})
async function load(md: string): Promise<Editor> {
const root = document.createElement('div')
document.body.appendChild(root)
const editor = createMarkdownEditor(root, md)
editors.push(editor)
await editor.create()
return editor
}
/** 生成超过阈值的富结构文档:多标题、宽松列表、任务、表格、公式、Mermaid、含空行围栏 */
function bigDoc(): string {
const parts: string[] = []
for (let s = 1; s <= 20; s++) {
parts.push(`## 第 ${s} 节`)
parts.push('')
parts.push(`第 ${s} 节的正文段落,包含**加粗**、*斜体*与行内公式 $a_${s}+b$,以及足够长的中文填充文本。`.repeat(60))
parts.push('')
parts.push('- 条目甲')
parts.push('')
parts.push('- [ ] 任务乙')
parts.push('')
parts.push('| 列一 | 列二 |')
parts.push('| --- | --- |')
parts.push(`| 值 ${s} | ${s} |`)
parts.push('')
parts.push('```text')
parts.push(`code ${s} 第一行`)
parts.push('')
parts.push('# 这行在围栏里不是标题')
parts.push('')
parts.push('```')
parts.push('')
parts.push('> 引用第一段')
parts.push('>')
parts.push('> 引用第二段')
parts.push('')
parts.push('```mermaid')
parts.push('graph TD')
parts.push(' A-->B')
parts.push('```')
parts.push('')
parts.push(`$$\nE_${s} = mc^2\n$$`)
parts.push('')
parts.push('---')
parts.push('')
}
return parts.join('\n')
}
describe('splitMarkdownChunks', () => {
it('拼接回去与原文逐字相同,片数 > 1', () => {
const md = bigDoc()
expect(md.length).toBeGreaterThan(60_000)
const chunks = splitMarkdownChunks(md)
expect(chunks.length).toBeGreaterThan(1)
expect(chunks.join('')).toBe(md)
})
it('围栏内的空行与 # 行不触发切分', () => {
const md = `# 顶\n\n\`\`\`text\n第一行\n\n# 假标题\n\n\`\`\`\n\n正文 ${'填充'.repeat(40)}`
const chunks = splitMarkdownChunks(md)
expect(chunks.join('')).toBe(md)
// 没有合法切点时整篇一片(小文档本来也一片)
expect(chunks.length).toBe(1)
})
it('含链接引用定义时整篇一片(定义可能在文末被前文引用)', () => {
const md = `${bigDoc()}\n\n[ref1]: https://example.com\n`
expect(splitMarkdownChunks(md).length).toBe(1)
expect(prepareChunkLoad(md)).toBeNull()
})
it('小文档不走分片', () => {
expect(prepareChunkLoad('# 小\n\n正文')).toBeNull()
})
})
describe('分片载入与整篇解析等价', () => {
it('逐片追加后的序列化 == 整篇解析的序列化', async () => {
const md = bigDoc()
const whole = getMarkdown(await load(md))
const handle = prepareChunkLoad(md)
expect(handle).not.toBeNull()
if (!handle) return
const editor = await load(handle.firstChunk)
const progress: number[] = []
await handle.appendRest(editor, (done) => { progress.push(done) }, () => false)
expect(progress[progress.length - 1]).toBe(handle.totalChunks)
const chunked = getMarkdown(editor)
expect(chunked).toBe(whole)
}, 60_000)
})