This repository has been archived on 2026-10-09. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
new-api-key-bench/docs/JSON预览器代码折叠技术博客.md
skycurtain a77cd71888 feat(view): JSON 折叠交互——chevron、行尾摘要、滚动补偿与折叠全部
- json-text-view:投影层接线(可视窗口/spacer/行号反查/指针换算/边缘滚动八接入点);折叠事务带 scrollTop 补偿(锚点=视口顶部 model 行号,折叠视口上方块无跳变);选区端点写入前钳制,syncDomSelection 修正坐标混用隐患;props.json 变化重置折叠态;chevron(hover 显/已折叠常显)与行尾摘要 {…} N 行;非 JSON 响应折叠 UI 整体关闭(hasFolds 门控)
- endpoint-panel:meta 行「折叠全部/展开全部」(收 depth=1 顶层块,经 defineExpose 通道)
- 新增 6 个视图层测试(折叠渲染/chevron 交互/选区隔离/补偿无跳变/状态重置/非 JSON 降级)
- 附技术博客 docs/JSON预览器代码折叠技术博客.md
- 全量 89/89 四绿(test/type-check/lint/build)
2026-09-09 14:17:42 +08:00

12 KiB
Raw Permalink Blame History

给 JSON 预览器加上代码折叠:一次"投影层"的扩展实践

这篇博客记录 bench 项目 JSON 预览组件(json-text-view.vue)新增代码块折叠的完整过程:为什么折叠在虚拟滚动上不是"再渲染一个树"的问题,VS Code 和 CodeMirror 6 的折叠机制长什么样,以及把折叠嫁接到"等高行虚拟滚动 + model 层选区"架构上的具体改法。是前一篇《给两万行 JSON 做一个不卡、可选中、还能拖拽选行的预览器》的续篇,目标读者不变——想理解"编辑器类组件"设计思路的工程师(也就是我自己的学习笔记)。

一、问题:两万行的 JSON,少一半是"看不见的括号"

前一篇的预览器解决了"卡"和"选不中",但 /api/log/token 两万行的响应里,真正有信息量的键值对可能只占一半——剩下的一半是结构性的括号行和被嵌套展开的深层字段。读数据时人脑做的事情和折叠功能完全一致:盯着顶层 key,跳过暂时不关心的子树。

所以目标是 VS Code 式的代码块折叠:点击折叠箭头,一个对象/数组收成一行 {…} N 行;再点开,原样展开。听起来像"给列表加个树形控件",但对虚拟滚动组件来说,这个需求击中了架构的一个隐藏假设——

现有架构里,"可视坐标 = model 坐标"是一个 1:1 的隐含等式。 行号 gutter 显示的数字、指针几何换算出的行号、选区存储的行号、滚动高度公式里的行数,全部是同一个数。折叠一旦介入,这个等式就断了:model 里第 100 行,可能在屏幕上第 80 行的位置(前面 20 行被折叠藏掉了)。折叠功能的全部工作,就是把这个隐含等式改造成显式映射,并且不能改坏任何一处旧换算。

二、先看标准答案:VS Code 与 CodeMirror 6 怎么做

动手前先考证了两家主流编辑器的源码级实现,结论是它们共享同一个模式,只是实现层不同。

VS Code / Monaco 的折叠是"隐藏行模型"。 折叠范围就是 (startLine, endLine) 行号对;HiddenRangeModel 从折叠状态派生出隐藏区间,view model 把隐藏行从视图流里移除——不是 CSS 藏起来,是真的不渲染。语义上有一个关键细节:折叠头的 start 行永远可见,隐藏的是 start+1 .. end。折叠后 gutter 仍然显示 model 行号(跳号显示),折叠箭头画在首行行号槽,首行行尾追加 …。折叠视口上方的块时,StableEditorScrollState 会先记下"视口顶部是哪一行(model 行号) + 视口内像素偏移",折叠重排后反推新的 scrollTop——所以你折叠眼前的块,内容纹丝不动。

CodeMirror 6 的折叠是"替换装饰"。 折叠集合是 editor state 的一部分(StateField<DecorationSet>),foldEffect/unfoldEffect 事务驱动;被折叠的区间用 Decoration.replace 收成一个占位 widget(默认 …,可自定义)。它和 CM6 自带的 viewport 虚拟渲染天然兼容:替换后可视行数变少,viewport 按新高度算就是了。还有个有趣的细节:光标移进折叠区时,该折叠自动清除——编辑器必须保证光标永远落在可见文档上。

两者的公共抽象,正好就是我们要的: 折叠范围(model 坐标)+ 折叠位集合 + 可见性投影(隐藏行不进视图流)。状态永远存 model 坐标,渲染永远是投影——和前一篇选区设计的原则完全同构。

JSON 查看器生态(Chrome DevTools、JSON Hero 之类)的交互惯例也顺带考证了:折叠箭头在键名处、折叠后行尾显示摘要、默认全展开、没有快捷键依赖。这些直接决定了我们的交互清单。

三、折叠范围怎么算:括号栈,别用缩进

VS Code 对通用文本用"缩进推导"算折叠范围(逐行比较缩进层级),但对 JSON 有专门的实现:官方 vscode-json-languageservice 的 jsonFolding.ts 用的是词法扫描 + 括号配对栈。

我们选括号栈,而且比它还简单。依据是 pretty-print JSON 的两条铁律:

  1. 每行至多一个结构括号(开括号在行尾,或整行就是闭括号)
  2. 字符串值不会跨行(JSON.stringify 不产生多行字符串,字符串里的 { 被引号包裹)

于是逐行扫描只需判断两端:

行尾(trimEnd 后)是 { 或 [  → 开块,压栈 {startLine, kind}
行首(首个非空白)是 } 或 ]  → 弹栈,产出 fold{ startLine, endLine: closeLine, depth, hiddenLineCount }

单遍 O(n),两万行不到 1ms。三个细节值得记:

end 是闭括号行本身,不是"闭括号前一行"。 LSP 的 FoldingRange 文档写的是"end line 是闭括号前一行",但那是 0-based 记法——0-based 的"闭括号前一行"换到 1-based 恰好就是闭括号行。我第一版按字面理解实现了 1-based 的"前一行",结果折叠后每个对象都留下一个孤儿 } 行,被自己写的测试当场抓住。混用 0/1-based 行号体系是编辑器领域的经典坑,值得写进注释。

单行块不产出折叠。 {"a": 1} 占一行,折叠它没有任何意义;只有 closeLine > startLine + 1 才产出。

非法结构不抛错。 闭括号比开括号多时弹空栈,跳过该行继续扫。调用方(视图层)会用 props.json === null 把非 JSON 响应挡在扫描之外,这里是第二道保险。

四、嫁接到虚拟滚动:八个接入点

visibleModel: number[] 是新增的核心数据结构——可见性投影:未被折叠隐藏的 model 行号按序排列。无折叠时它就是 [1..N] 的恒等投影;折叠 20 行后,它比 lineList 短 20。有了它,原架构里所有"行数"的消费者逐个换算:

接入点 原实现 折叠后
可视窗口计算 输入 model 总行数 输入 visibleModel.length(输出变成可视索引区间)
窗口行渲染 lineList[index],行号 index+1 visibleModel[index] 反查 model 行号,再取原文
撑高 spacer model 行数 × 20px 可视行数 × 20px(滚动条随之缩短,这个细节很重要)
gutter 宽度 model 总行数 不变——跳号显示,最大行号还是 model 的
指针 → 行号 几何换算直出 先可视索引,再 visibleModel 反查(隐藏行不在投影里,天然不会被点到)
边缘自动滚动 同上 同上
v-for 的 key model 行号 不变(Vue 复用节点稳定)
选区高亮判断 model 行号 不变(隐藏行不渲染,高亮自动正确)

这张表里最微妙的是 gutter 宽度不动:折叠后可见的最大行号没变(还是 model 最后一行),宽度按它算,折叠展开时行号列不会抖动。

而窗口计算、spacer 高度换用可视行数,意味着滚动条的长短随折叠实时变化——折叠一半内容,滚动条变长一倍。这正是 Chrome DevTools / VS Code 的手感。

五、三个真正的难点

5.1 折叠视口上方的块,屏幕为什么会跳

折叠让"锚点行之前"的可视行数变少,如果 scrollTop 不动,锚点行会向上瞬移。VS Code 的解法(StableEditorScrollState)在等高行体系下可以做得非常轻:

折叠前:anchor = 视口顶部那行的 model 行号,delta = scrollTop − 可视索引 × 20
折叠中:改折叠位 → 重建 visibleModel
折叠后:scrollTop = 锚点的新可视索引 × 20 + delta

锚点必须是 model 行号——折叠改变的是 model 行的可视索引,不是行本身。另有一个边角:锚点行恰好落在被折叠的区间里时,先把它钳制到折叠头行(它必然可见),补偿基准不失效。jsdom 测这条链路还顺带踩了两个环境坑:fake timers 下 scrollTop 是只读的(用 getter/setter 模拟),赋值不触发 scroll 事件(真实浏览器会,测试里手动补派发)。

5.2 选区划过隐藏区,存哪、停在谁身上

选区真相在 model 层(前一篇的设计),这一原则不变;变化的是写入时的钳制:拖拽把选区划进隐藏区,端点停在折叠头行上(Monaco adjustSelections 的语义:收缩,而不是自动展开折叠——那是编辑器的需求,查看器收缩更符合阅读直觉)。

顺带修掉一个存量隐患:原 syncDomSelection 里 model 行号和可视索引是混用的(无折叠时恰好等价,所以没暴露)。折叠上线后这个隐患变成必然错位,一并改为"钳制 → 反查可视索引 → 与窗口求交集"。

5.3 非 JSON 响应:折叠特性整体关门

props.json === null 时预览器降级为纯文本展示。raw 文本没有可靠的括号结构(HTML 里的 { 会骗过扫描器),所以折叠三件套(扫描、chevron、摘要)全部以 hasFolds 门控。这也是测试抓出来的:第一版 chevron 的占位 span 在纯文本行上也渲染了,行高虽不受影响,但"非 JSON 页面出现幽灵元素"不可接受。

六、交互层:抄谁,不抄谁

按生态惯例取舍后的清单:

  • 抄 VS Code:chevron 在 gutter 侧(hover 显示、已折叠常显)、折叠后行尾摘要、跳号显示 model 行号
  • 抄 JSON 工具系:摘要带数量——但我们显示的是"隐藏行数"({…} 8 行)而不是 keys 数。理由:查看器的对象可能巨大,"8 行"直接预告了展开后的篇幅,对"要不要展开"这个决策更实用
  • 不抄:快捷键(查看器没有键盘编辑流)、hover 展开预览(无生态先例)、折叠态持久化(工具"刷新即重置"的哲学)、默认折叠(生态一致地默认全展开)
  • 自创一处:"折叠全部"只收 depth=1 的顶层块——日志 JSON 最常见的诉求是"把每条日志收成一行",嵌套折叠位保留不动,展开顶层即可恢复原状

七、复盘:投影层的可扩展性,是被验证出来的

这次扩展让前一篇结尾的原则经受了一次实战检验:

"真相只放一份,放在离 DOM 最远的那层"——但真相不止一个坐标系。

model 坐标系(行号、内容、选区)和 view 坐标系(可视索引、scrollTop、spacer 高度)在无折叠时是同一个,折叠功能把它们拆开并用 visibleModel 显式连接。整个扩展过程没有动 model 层一行代码,所有改动集中在"投影的生成规则"和"两个坐标系的换算点"上——八个接入点里,四个是"把 model 行数换成可视行数"的一行改动,两个是反查方向的颠倒,真正的新逻辑只有折叠事务和钳制。

测试方面有个值得一提的节奏:第一版实现带着"end = 闭括号前一行"的语义偏差上线,单测当场红了两条;视图层第一版带着 SFC 里的非法 continue 编译失败,vitest 的编译环节先于 type-check 报错。纯函数先行、TDD 锁死语义,这套流程在这类"几何换算密集"的组件上回报极高——坐标系换算的 bug 靠肉眼 review 几乎不可能发现,但一个精确的期望值数组就能锁死它。

如果要继续往编辑器方向走,折叠是继选区之后第二块拼图。下一步的自然延伸:折叠态进 store 的序列化(Monaco/CM6 都支持持久化)、按 depth 分层折叠(实现已留好 depth 字段)、以及把"行号选区"升级为"(行,列) 坐标"——那时 model 层才真正需要为列而设计。


附:代码与测试索引

  • src/lib/json-text-processor.ts — 纯函数层新增:scanJsonFolds(括号栈扫描)、buildVisibleProjection(可见性投影)、findVisibleIndex(行号反查)、clampLineToVisible(选区钳制)
  • src/components/json-text-view.vue — 视图层:投影接线(八接入点)、折叠事务(scrollTop 补偿)、chevron 与行尾摘要
  • src/components/endpoint-panel.vue — meta 行「折叠全部 / 展开全部」按钮
  • docs/JSON预览器代码折叠调研.md — 机制考证全文(VS Code/CM6 源码结论、交互惯例清单)
  • 测试:纯函数 20 用例 + 视图层 6 用例(折叠渲染、chevron 交互、选区隔离、补偿无跳变、状态重置、非 JSON 降级)