perf(editor): reduce long-document decoration work and fix fold navigation

This commit is contained in:
2026-09-06 14:30:56 +08:00
parent 750e17212e
commit 874e916106
23 changed files with 9398 additions and 54 deletions
+42
View File
@@ -0,0 +1,42 @@
# 长文渲染压测
使用真实无头 Chrome 和 Chrome DevTools Protocol,加载当前 Vite 工作区。样本由 `fixture.js` 确定性生成,包含至少 25000、60000、120000 汉字、H1–H3、表格、代码块、提示框、链接和行内格式,不读写真实 Vault。
## 运行
仓库根目录启动独立服务:
```powershell
npm --prefix frontend run dev -- --port 5175 --strictPort
```
在另一个终端执行(Python 环境需安装 websockets):
```powershell
backend/.venv/Scripts/python.exe frontend/tests/performance/run-stress.py --url http://127.0.0.1:5175/tests/performance/stress.html --runs 3 --output .local-plans/stress-results.json
```
可用 `--chrome` 指定 Chromium 路径,用 `--sizes 25000 60000 120000` 指定样本。脚本使用独立临时浏览器配置,结束后关闭测试进程;不接管用户 Chrome。样本在每次导航后重建,不向后端保存。
## 指标口径
- openMs:组件挂载到编辑器完成初始化及两个 animation frame;不包含模块下载与 Vite 编译。
- selectionMs30 次光标选区事务的同步耗时;insertMs:20 次插入“压测输入”的事务耗时。
- foldMs:6 次全折叠/展开按钮操作的同步耗时。
- previewMs:同一正文静态渲染三次,包含 HTML 插入与两个 animation frame。第一次包含首次高亮初始化,后两次为热运行。
- longTasks:浏览器 Long Tasks API,包含整个测量过程;heapUsedBytes 为单次采样,不代表峰值或泄漏结论。
- integrity:序列化输出保留插入内容与文末标记。常规单元测试另外覆盖 Markdown 往返。
这些是开发模式下的微基准,不等于真实键盘/输入法的端到端延迟,不覆盖滚动帧率、自动保存网络、向量计算或 Mermaid 图表压力。不同机器、后台负载和缓存状态会影响结果,不能把一次结果作为通用 SLA。重型图表应单独使用已有 `tests/visual/mermaid-matrix.html` 验证。
打开 stress.html 后也可通过控制台调用 `await runBenchmark(25000)` 查看 JSON 结果。页面只用于测试,不在正式路由中注册。
## 连续滚轮与折叠定位
```powershell
backend/.venv/Scripts/python.exe frontend/tests/performance/run-stress.py --url http://127.0.0.1:5175/tests/performance/stress.html --scroll --runs 2 --output .local-plans/scroll-results.json
```
`--scroll` 使用纸间时光主题及高度受限的编辑区,派发 120 次真实 CDP 滚轮事件(先向下再向上),记录 animation frame 间隔与长任务;随后从文末全部折叠,记录滚动位置和光标位置。可追加 `--profile --sizes 120000 --runs 1` 保存 CPU profile,用 Chrome DevTools Performance 面板导入。采样会增加开销,勿将 profile 结果与无采样结果直接比较。
帧间隔包含无头浏览器、CDP 调度和布局开销,不等同于用户设备的 FPS。滚轮模式不验证输入法、保存或图表渲染。当前测试容器改为有限高度的 flex 布局,早期普通事务报告使用的容器布局不同,跨版本比较应分别保留同一布局下的基线。
+12
View File
@@ -0,0 +1,12 @@
/** Deterministic CJK prose plus headings, tables, code and callouts; no user documents. */
export function makeStressDocument(minHan = 25000) {
const prose = '本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。'
let source = '# 长文渲染压力测试\n\n', han = 0, section = 0
while (han < minHan) {
source += `## 第 ${++section} 节:知识整理\n\n${prose.repeat(3)}\n\n### 小结 ${section}\n\n重点包含 **强调文字**、\`inlineCode\` 和 [链接](https://example.com)。\n\n`
han += prose.length * 3
if (section % 8 === 0) source += '> [!TIP] 验收提示\n> 内容需要保留,折叠后仍可展开。\n\n| 项目 | 状态 |\n| --- | --- |\n| 渲染 | 待验证 |\n\n```javascript\nconst note = { title: "长文测试", ready: true };\nconsole.log(note);\n```\n\n'
}
source += '\n## 文末校验\n\n结束标记:长文内容完整。\n'
return source
}
+81
View File
@@ -0,0 +1,81 @@
"""Real Chromium benchmark. Run with backend/.venv/Scripts/python.exe; requires websockets.
Vite must be serving the frontend. Uses an isolated disposable browser profile.
"""
import argparse, asyncio, json, pathlib, subprocess, tempfile, urllib.request
import websockets
async def main(args):
with tempfile.TemporaryDirectory(prefix='notes-stress-') as profile:
process = subprocess.Popen([args.chrome, '--headless=new', '--no-first-run', '--no-proxy-server', '--no-default-browser-check', '--disable-background-networking', '--disable-background-timer-throttling', '--disable-renderer-backgrounding', '--remote-debugging-port=0', '--window-size=1440,1000', f'--user-data-dir={profile}', 'about:blank'], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
try:
port_file = pathlib.Path(profile) / 'DevToolsActivePort'
for _ in range(100):
if port_file.exists(): break
await asyncio.sleep(.1)
port = port_file.read_text().splitlines()[0]
with urllib.request.urlopen(f'http://127.0.0.1:{port}/json') as response: target = next(item for item in json.load(response) if item['type'] == 'page')
async with websockets.connect(target['webSocketDebuggerUrl'], max_size=100_000_000) as socket:
sequence = 0
async def call(method, params=None):
nonlocal sequence
sequence += 1; request = sequence
await socket.send(json.dumps({'id':request,'method':method,'params':params or {}}))
while True:
response = json.loads(await asyncio.wait_for(socket.recv(), 180))
if response.get('method') in ['Runtime.exceptionThrown','Log.entryAdded','Network.loadingFailed']: print(json.dumps(response),flush=True)
if response.get('id') == request:
if 'error' in response: raise RuntimeError(response['error'])
return response.get('result', {})
await call('Runtime.enable')
await call('Log.enable')
await call('Network.enable')
await asyncio.sleep(1)
results=[]
for size in args.sizes:
for repeat in range(args.runs):
navigation = await call('Page.navigate', {'url':args.url})
if navigation.get('errorText'): raise RuntimeError(navigation['errorText'])
for _ in range(600):
state = await call('Runtime.evaluate', {'expression':'typeof window.runBenchmark', 'returnByValue':True})
if state.get('result',{}).get('value')=='function':break
await asyncio.sleep(.1)
else: raise RuntimeError('Benchmark page did not load; check the Vite URL and browser errors')
expression = f'window.prepareScrollBenchmark({size})' if args.scroll else f'window.runBenchmark({size})'
response = await call('Runtime.evaluate', {'expression':expression,'awaitPromise':True,'returnByValue':True})
if args.scroll and 'exceptionDetails' not in response:
point = response['result']['value']
if args.profile:
await call('Profiler.enable'); await call('Profiler.start')
await call('Input.dispatchMouseEvent', {'type':'mouseMoved', **point})
for step in range(120):
await call('Input.dispatchMouseEvent', {'type':'mouseWheel', **point, 'deltaX':0,'deltaY':900 if step < 90 else -900})
await asyncio.sleep(.016)
if args.profile:
profile_data = await call('Profiler.stop')
pathlib.Path(args.output + f'.{size}.{repeat+1}.cpuprofile').write_text(json.dumps(profile_data['profile']),encoding='utf-8')
await call('Runtime.evaluate', {'expression':'new Promise(r => setTimeout(r, 150))','awaitPromise':True})
response = await call('Runtime.evaluate', {'expression':'window.finishScrollBenchmark()','awaitPromise':True,'returnByValue':True})
if 'exceptionDetails' in response: raise RuntimeError(response['exceptionDetails'])
result = response['result']['value']; result['repeat']=repeat+1
results.append(result)
print(json.dumps(result,ensure_ascii=False),flush=True)
pathlib.Path(args.output).write_text(json.dumps(results,ensure_ascii=False,indent=2),encoding='utf-8')
finally:
process.terminate()
try: process.wait(timeout=10)
except subprocess.TimeoutExpired: process.kill(); process.wait()
await asyncio.sleep(.5)
if __name__=='__main__':
parser=argparse.ArgumentParser()
parser.add_argument('--chrome',default='C:/Program Files/Google/Chrome/Application/chrome.exe')
parser.add_argument('--url',default='http://127.0.0.1:5173/tests/performance/stress.html')
parser.add_argument('--sizes',nargs='+',type=int,default=[25000,60000,120000])
parser.add_argument('--runs',type=int,default=3)
parser.add_argument('--output',required=True)
parser.add_argument('--profile',action='store_true',help='Save CPU profiles for scroll runs')
parser.add_argument('--scroll',action='store_true',help='Dispatch real wheel events and check fold-to-top')
args = parser.parse_args()
if args.runs < 1 or any(size < 1 for size in args.sizes): parser.error('runs and sizes must be positive')
pathlib.Path(args.output).parent.mkdir(parents=True, exist_ok=True)
asyncio.run(main(args))
+85
View File
@@ -0,0 +1,85 @@
<!doctype html><html><head><meta charset="utf-8"><title>长文渲染压测</title></head>
<body><div id="app"></div><pre id="report">通过 run-stress.py 运行,或在控制台调用 runBenchmark(25000)。</pre>
<script type="module">
import { createApp, h, ref, nextTick } from 'vue'
import { createPinia } from 'pinia'
import Editor from '/src/features/editor/VisualMarkdownEditor.vue'
import { editorViewCtx } from '@milkdown/kit/core'
import { TextSelection } from '@milkdown/kit/prose/state'
import { getMarkdown } from '@milkdown/kit/utils'
import { renderMarkdown } from '/src/utils/markdown.ts'
import { useEditorStore } from '/src/stores/editor.ts'
import { getCommunityThemePreviewCss } from '/src/services/themePackageService.ts'
import { makeStressDocument } from './fixture.js'
import '/src/styles/tokens.css'
import '/src/styles/features.css'
const frame = () => new Promise(resolve => requestAnimationFrame(() => resolve()))
const settle = async () => { await nextTick(); await frame(); await frame() }
const summarize = values => { const sorted = [...values].sort((a,b)=>a-b); return {median:sorted[Math.floor(sorted.length/2)],p95:sorted[Math.min(sorted.length-1,Math.ceil(sorted.length*.95)-1)],max:sorted.at(-1)} }
window.runBenchmark = async (size = 25000) => {
const source = makeStressDocument(size), target = document.getElementById('app')
const pinia = createPinia(), component = ref(), tasks = []
const observer = new PerformanceObserver(list => tasks.push(...list.getEntries().map(t => t.duration)))
observer.observe({type:'longtask',buffered:false})
const app = createApp({render:()=>h(Editor,{ref:component,initialContent:source})}).use(pinia)
const result = {requestedHan:size,hanCharacters:(source.match(/\p{Script=Han}/gu)||[]).length,sourceCharacters:source.length,userAgent:navigator.userAgent,viewport:[innerWidth,innerHeight]}
const start = performance.now(); app.mount(target)
try {
const deadline = performance.now()+120000
while (!component.value?.getEditor() || target.querySelector('.milkdown-host.loading')) { if(performance.now()>deadline)throw Error('Editor startup timeout'); await frame() }
await settle(); result.openMs = performance.now()-start
const editor = component.value.getEditor(), view = editor.action(ctx=>ctx.get(editorViewCtx))
result.domNodes = target.querySelectorAll('*').length
const measure = async (count, operation) => { const values=[]; for(let i=0;i<count;i++) {const start=performance.now(); operation(i); values.push(performance.now()-start); await settle()} return summarize(values) }
const positions=[]; view.state.doc.descendants((node,pos)=>{if(node.isTextblock && !node.type.spec.code)positions.push(pos+1)})
result.selectionMs = await measure(30, i => view.dispatch(view.state.tr.setSelection(TextSelection.create(view.state.doc,positions[Math.floor(i*positions.length/30)]))))
result.insertMs = await measure(20, () => view.dispatch(view.state.tr.insertText('压测输入')))
result.foldMs = await measure(6, () => target.querySelector('.section-actions button').click())
const serialized = editor.action(getMarkdown())
if(!serialized.includes('压测输入') || !serialized.includes('结束标记:长文内容完整。'))throw Error('Content integrity check failed')
result.integrity = true
const preview = document.createElement('div'); document.body.append(preview)
const previews=[]
for(let i=0;i<3;i++){const start=performance.now(); preview.innerHTML=await renderMarkdown(source); await settle(); previews.push(performance.now()-start)}
result.previewMs=previews
result.previewNodes=preview.querySelectorAll('*').length; preview.remove()
await settle(); result.longTasks={count:tasks.length,...summarize(tasks.length?tasks:[0])}
result.heapUsedBytes=performance.memory?.usedJSHeapSize
return result
} finally {
observer.disconnect(); useEditorStore(pinia).closeFile(); app.unmount()
document.getElementById('report').textContent=JSON.stringify(result,null,2)
}
}
window.prepareScrollBenchmark = async (size = 25000) => {
const source = makeStressDocument(size), target = document.getElementById('app')
document.documentElement.dataset.theme = 'paper-moments'
const style = document.createElement('style'); style.textContent = getCommunityThemePreviewCss('paper-moments'); document.head.append(style)
const pinia = createPinia(), component = ref()
const app = createApp({render:()=>h(Editor,{ref:component,initialContent:source})}).use(pinia)
app.mount(target)
const deadline = performance.now()+120000
while (!component.value?.getEditor() || target.querySelector('.milkdown-host.loading')) { if(performance.now()>deadline)throw Error('Editor startup timeout'); await frame() }
await settle()
const scroller=target.querySelector('.milkdown-host'), gaps=[], tasks=[]
let raf=0, last=performance.now()
const tick = now => {gaps.push(now-last);last=now;raf=requestAnimationFrame(tick)}
const observer = new PerformanceObserver(list=>tasks.push(...list.getEntries().map(t=>t.duration)))
observer.observe({type:'longtask',buffered:false});raf=requestAnimationFrame(tick)
window.finishScrollBenchmark = async () => {
cancelAnimationFrame(raf);observer.disconnect()
const result={requestedHan:size,theme:'paper-moments',frameGapsMs:summarize(gaps),frames:gaps.length,framesOver25ms:gaps.filter(g=>g>25).length,longTasks:tasks,scrollTop:scroller.scrollTop,scrollHeight:scroller.scrollHeight}
const editor=component.value.getEditor(),view=editor.action(ctx=>ctx.get(editorViewCtx))
view.dispatch(view.state.tr.setSelection(TextSelection.near(view.state.doc.resolve(view.state.doc.content.size-1))))
scroller.scrollTop=scroller.scrollHeight;await settle()
target.querySelector('.section-actions button').click();await settle()
result.foldedScrollTop=scroller.scrollTop
result.caretAfterFold=view.state.selection.from
useEditorStore(pinia).closeFile();app.unmount();style.remove()
return result
}
const rect=scroller.getBoundingClientRect()
return {x:rect.left+rect.width/2,y:rect.top+rect.height/2}
}
</script><style>html,body{margin:0;height:100%;}#app{display:flex;flex-direction:column;height:90vh;max-width:1200px;margin:auto;}#report{white-space:pre-wrap;}</style></body></html>