# JSON 预览器代码折叠调研 > 如何在 bench 的虚拟滚动 JSON 预览器里实现 VS Code 式的代码块折叠——主流实现机制考证 + 对现有架构的嫁接方案。 > > 调研日期:2026-09-09 · 方法:VS Code / CodeMirror 6 源码级考证 + JSON 查看器生态惯例 + 现有代码精读 --- ## 1. 背景与结论 `/api/log/token` 的响应 pretty-print 后 2~4MB、约两万行,折叠对象/数组能显著降低阅读成本。现有预览器(`json-text-view.vue` + `json-text-processor.ts`)架构: - 纯函数 processor 切行 + 正则浅切词高亮(仅可视窗口行执行) - 等高行(20px)虚拟滚动:spacer 撑高 + translateY 窗口偏移 + overscan + trailing 节流 - model 层行选区状态机(锚点行号,DOM Selection 只是同步目标) **结论:完全可行,且现有架构条件优越。** 主流方案的本质是同一个模式——**折叠范围(model 行号对)+ 折叠位集合 + 可见性投影(隐藏行从视图流移除)**。现有代码"选区存 model 行号、渲染走投影"已经是 model/view 分离,加折叠只是把"目前可视坐标 = model 坐标的 1:1 映射"变成"折叠时跳过隐藏行的映射"。计算方式用**括号栈行级扫描**(VS Code 官方 JSON 语言服务同款),2 万行 O(n) 扫描不到 1ms。 ## 2. 主流实现机制 ### 2.1 VS Code / Monaco(源码考证) | 关注点 | 机制 | 出处 | |--------|------|------| | 数据结构 | `FoldingRange` = `(startLine, endLine)` 行号对;`FoldingRegions` 平行数组(`Uint32Array` ×2)+ BitField 折叠位 | `foldingRanges.ts` | | 核心语义 | `hidesLine(line) = start < line ≤ end`——**折叠头行(start)永远可见**,隐藏 `start+1 .. end`(闭括号行也在隐藏区) | 同上 | | 视图投影 | `HiddenRangeModel` 派生隐藏区间,view model 把隐藏行**从视图流移除**(非 CSS 遮盖);视图行号重排,gutter 显示 **model 行号**(跳号显示) | `hiddenRangeModel.ts` | | 视觉呈现 | chevron 画在首行 gutter;首行行尾追加 `…` 摘要(`afterContentClassName: 'inline-folded'`) | `foldingDecorations.ts` | | gutter 交互 | 点击 chevron 图标本身才折叠/展开;控件默认 `mouseover`(悬停显示,已折叠区常显) | `folding.ts` | | 滚动稳定 | `StableEditorScrollState`:折叠前记录视口顶部锚点(**model 行号** + 视口内像素偏移),折叠后反推新 scrollTop——折叠视口上方的块时视口不跳变 | `stableEditorScroll.ts` | ### 2.2 CodeMirror 6(源码 + 官方文档) - 折叠态是 editor state 的一部分:`foldState = StateField`,`foldEffect/unfoldEffect`(doc offset 区间)事务驱动 - 折叠 = `Decoration.replace({widget})` 把区间收成一个占位 widget(默认 `…`,可自定义「N lines」);**点击占位符展开** - 与自身 viewport 虚拟渲染天然兼容:替换后可视行数减少,viewport 按行块计算自然适配 - **选区保护**:`foldState.update` 末尾检查选区头——落进折叠区时该折叠自动清除(光标移入即展开) ### 2.3 JSON 查看器生态(Chrome DevTools / Firefox JSONView / JSON Hero) - 折叠锚点都在**键名行**,摘要写在折叠行行尾(`▸ {…}` / `N keys` / `N items`) - 默认**全展开**;树形工具无行号槽、无快捷键;编辑器系才有快捷键(`Ctrl+K Ctrl+[` 系) - 无 hover 展开预览的先例(摘要数字已够) ## 3. 折叠范围计算:括号栈(选型定论) | 路线 | 原理 | JSON pretty-print 适用性 | |------|------|--------------------------| | 缩进推导(VS Code 通用 fallback) | 逐行缩进层级栈式配对 | 需处理空行归属、offSide 语义,JSON 上是绕路 | | **括号栈行级扫描**(VS Code JSON 语言服务同款) | 行尾 `{`/`[` 开块压栈;行首 `}`/`]` 收栈 | **零误判且更便宜**(官方 `jsonFolding.ts` 同款) | 选括号栈。pretty-print 后两条铁律:**每行至多一个括号、缩进即结构**(`JSON.stringify` 产物中字符串值不会跨行,行尾判定安全)。规则: - 开块行:行尾为 `{` 或 `[` → 记录 `start`,压栈 - 闭块行:行首 token 为 `}` 或 `]` → 弹栈,产出 `{start, end: 闭括号行 − 1}` - **`end = 闭括号前一行`**(与 VS Code LSP `FoldingRange`、jsonFolding 惯例一致) - **单行块不产出折叠**(`closeLine > start + 1` 才产出,同 jsonFolding 的 `line > startLine + 1`) - 顺带记录 `depth`(顶层块 = 1)与 `kind`(object/array),供摘要与「折叠全部」用 ## 4. 嫁接到现有架构:改动点映射 现有代码的隐藏前提是**可视坐标 = model 坐标**。加折叠 = 显式建立映射。逐点: | 现有代码 | 现状 | 折叠后 | |----------|------|--------| | `lineList` | model 全量行数组 | 不变(model 层真相) | | `computeVisibleRowWindow(lineList.length, …)` | 输入 model 行数 | 输入 `visibleModel.length`;输出为**可视索引**窗口 | | `visibleLineList` 行号 `index + 1` | model 行号直取 | `visibleModel[visIdx]` 反查 model 行号;gutter 显示 model 行号(跳号,VS Code 惯例) | | `totalHeightPx` | `lineList.length × 20` | `visibleModel.length × 20` | | `gutterWidth` | model 总行数 | **仍用 model 总行数**(跳号显示最大行号不变) | | `resolveLineFromPointer` | 几何换算直出 model 行号 | 先出可视索引,反查 model 行号(选区保持 model 坐标;几何反查天然不会命中隐藏行) | | 边缘自动滚动 `lineAtEdge` | model 行号 | 同上反查 | | v-for `:key` | model 行号 | 不变(Vue 复用稳定) | | `isLineSelected` | model 行号判断 | 不变(隐藏行不渲染,高亮自动正确) | **新增 model 层状态**: 1. `folds: JsonFoldRange[]`——扫描产物,按 start 升序天然嵌套 2. `collapsedSet: Set`——已折叠集合(`isCollapsed` O(1)) 3. `visibleModel: number[]`——投影数组:未被隐藏的 model 行号按序排列(2 万行 ≈ 80KB;全量重建 <1ms,先全量,不做局部增量) ## 5. 三个关键难点与解法 ### 5.1 scrollTop 跳变补偿 VS Code `StableEditorScrollState` 的等高行等价实现: ``` 折叠事务前:anchorModel = visibleModel[floor(scrollTop / 20)] deltaPx = scrollTop − visIdx × 20 (scrollTop = 0 时跳过) 折叠事务中:改 collapsedSet → 全量重建 visibleModel 折叠事务后:newScrollTop = findVisibleIndex(anchorModel) × 20 + deltaPx ``` 锚点必须是 **model 行号**——折叠只改变 model 行的可视索引,不改变行本身。 ### 5.2 选区与隐藏行冲突 采用 **Monaco 语义(收缩)** 而非 CM6 语义(自动展开)——查看器无编辑,收缩更符合阅读直觉: - 选区端点落入隐藏区(`start < line ≤ end`)→ 钳制到折叠头行 `start` - 复制语义不受损:选区提取按 model 行号在 `lineList` 纯数据操作,折叠区内内容依然完整可复制 - `syncDomSelection` 需改为:选区端点先钳制到可见,再转可视索引,与可视窗口求交集后索引 DOM(原实现 model/可视坐标混用,折叠后会错位) ### 5.3 非 JSON 响应降级与状态重置 - `props.json === null`(非 JSON 降级为纯文本)→ **不扫描、无 chevron、无摘要**:raw 文本无可靠括号结构(HTML 里的 `{` 会误判),与现有降级链路一致 - **切换响应(`props.json` 变化)时必须重置 `collapsedSet`**——新数据的 folds 是新扫描结果,旧行号语义已失效 ## 6. 交互清单(按生态惯例取舍) | 项 | 决策 | 依据 | |----|------|------| | gutter chevron(hover 显 / 已折叠常显) | ✅ | VS Code `mouseover` 控件惯例 | | 行尾摘要 `{…} N 行`,点击展开 | ✅ | 编辑器系 `…` + JSON 工具系行数计数的并存;行数可预判展开长度,适合日志大 JSON | | 全部折叠(收 depth=1 顶层)/ 全部展开,放响应 meta 行 | ✅ | 2 万行场景最高频操作 | | 默认全展开 | ✅ | 生态一致,不自作主张 | | 快捷键 `Ctrl+K Ctrl+[` 系 | ❌ 暂缓 | 查看器无键盘编辑流 | | hover 展开预览 | ❌ | 生态无先例 | | 分层折叠(折叠到 depth N) | 🕐 后议 | 可用「折叠到第 N 层」一键替代 | | 折叠态持久化 | ❌ | bench「无持久化、刷新即重置」哲学 | ## 7. 实施拆分 1. **纯函数先行**(`json-text-processor.ts` + 单测):`scanJsonFolds` / `buildVisibleProjection` / `findVisibleIndex` / `clampLineToVisible` 2. **视图接线**(`json-text-view.vue`):投影层改造上表 8 接入点 + chevron + 行尾摘要 + scrollTop 补偿事务 + `defineExpose`(collapseAll/expandAll/hasFolds) 3. **meta 行按钮**(`endpoint-panel.vue`):折叠全部 / 展开全部 4. **回归**:全量测试 + 新增折叠用例(重点:折叠后滚动、嵌套折叠不重复计数、补偿无跳变)