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
T
skycurtain 93bbea2489 feat(view): 行号点击选中与拖拽多选(model 层选区)+ 实现技术博客
选区状态存 model 层(anchor/active 两个行号)而非 DOM Selection——虚拟滚动跨窗口边界时 DOM 被卸载,原生选区必丢;单行点击是 anchor===active 的退化特例。高亮走 .line-selected class(model 驱动,重渲染不丢),DOM Selection 仅作 Ctrl+C 同步目标(窗口内边缘行 setStartBefore/setEndAfter);拖到容器边缘 rAF 自动滚动,速度每帧按指针位置重算(闭包旧值会导致速率漂移),出界外推贴边保持滚动。新增 2 用例(点击选中含 DOM Selection 文本断言、拖拽范围高亮)。附实现技术博客 docs/JSON预览器实现技术博客.md。
2026-09-08 15:20:40 +08:00

13 KiB

给两万行 JSON 做一个不卡、可选中、还能拖拽选行的预览器

这篇博客记录 bench 项目里 JSON 预览组件(json-text-view.vue)的完整实现过程:虚拟滚动、行级语法高亮、model 层选区、边缘自动滚动,以及两个被测试漏掉、最后靠现象反推出来的 bug。目标读者是有前端基础、想理解"编辑器类组件"设计思路的工程师——也就是我自己的学习笔记。

一、问题:为什么需要"预览器"而不是"渲染器"

bench 是一个接口调试工具,最重的场景是 /api/log/token:一次返回 1000 条日志,pretty-print 之后约 2~4MB 文本、两万行左右。

前作 api-playground 就是死在这里的:直接把 2~4MB 文本塞进 DOM,再对全文做正则高亮,最后用 v-html 注入——三万多个 span,主线程卡死,滚动像幻灯片。

所以要做的不是"渲染 JSON",而是"预览 JSON":像 Chrome DevTools 的 Network Response 面板那样,内容在哪都能瞬间滚到,但任何时刻 DOM 里只有一屏多的东西。

这个目标分解成四个技术点,也是本文的四个章节:

  1. 虚拟滚动:DOM 恒定
  2. 行级高亮:只算看得见的
  3. 行选区:model 层锚点(本文最有"编辑器味"的部分)
  4. 两个真实的 bug:节流丢弃事件与自动滚动速度漂移

二、虚拟滚动:一个数学题,而不是渲染题

虚拟滚动的本质是把"渲染两万行"转化为一道几何题:给定滚动位置和视口高度,应该显示第几行到第几行?

纯函数 computeVisibleRowWindow 回答这个问题:

输入:总行数 N、scrollTop、视口高度 H、行高 20px
输出:{ startIndex, endIndex, offsetYPx }

计算本身平淡无奇(Math.floor(scrollTop / 行高)),真正值得展开的是支撑它成立的四个配套机制:

1. 撑高度与放内容分离。 滚动条要"真实",滚动容器里必须有一个和内容等高的占位(scroll-spacer,高 = 行数 × 20px);但实际内容只渲染窗口内那几十行,放进另一个 visible-window 容器,用 translateY(offsetYPx) 平移到正确位置。滚动条的长短由 spacer 决定,内容的位置由 transform 决定,两者互不干扰。

2. overscan 缓冲。 只渲染可视的 20 行,滚动时边缘会闪白,所以上下各多渲染 5 行。这个数字是体验与性能的折中:太小闪白,太大浪费。

3. 行高必须是常量。 整个几何换算的前提是"每行都是 20px"。CSS 里写死 height: 20px,TS 里定义常量 JSON_VIEW_ROW_HEIGHT_PX = 20,注释互相引用提醒"改一处必须同步另一处"。这也是所有虚拟滚动库都强制等高行(或预测量行高)的原因。

4. 滚动事件的节流必须有 trailing。 这个坑后面单独讲(第四章 bug 一),这里先记住:滚动是连续高频事件,节流丢事件的话,视觉就是白屏。

这一层做完,DOM 恒定在 30 行左右(20 可视 + 10 缓冲),无论数据是一百行还是两万行。


三、行级高亮:正则切词的"够用哲学"

给 JSON 上色,标准做法是写一个 parser。但预览器的高亮只需要回答"这段文字是什么颜色",不需要回答"这段文字在语法上对不对"——预览的对象是网关已经返回的合法 JSON,合法性校验是 JSON.parse 的事。

所以用的是一條行级正则:

("(?:...)*"(?:\s*:)? | true | false | null | 数字)

字符串(含 key)、字面量、数字,一次 matchAll 全部抓出来。key 和 value 的区分靠一个小技巧:正则把 key 后面的冒号一并吞进 token,endsWith(':') 即为 key。

这里踩过一个真实的坑,也是这组件最重要的一条设计教训:

第一版只渲染切出来的 token,不渲染 token 之间的缝隙——括号、逗号、缩进全丢了,JSON 变成了散落的彩色碎片,只好用 1ch 宽的 CSS 间隙去凑,越凑越怪。

正确的抽象是完整行重建:把一行切成 plain / token / plain / token / plain 的片段序列,token 与非 token 交替,数学约束是

segments.map(s => s.text).join('') === 原行文本

渲染层拿到的是"这一行全部内容的带色标注版",而不是"这一行里被命名的部分"。约束一旦成立,括号不丢、缩进不丢,天然成立。

另一个刻意的选择:高亮输出结构化片段而非 HTML 字符串。没有 v-html,就没有注入面;着色和转义都发生在模板层,文本插值天生安全。


四、两个 bug:被"现象"揭穿的测试盲区

这两个 bug 值得各写一节,因为它们的共同点是:测试全绿,用户肉眼一看就坏。

bug 一:拖滚动条白屏

现象:滚轮滚动一切正常;按住滚动条拖拽,内容白屏,松手后用滚轮一滚又恢复。

根因藏在 VueUse useThrottleFn 的完整签名里:

useThrottleFn(fn, ms = 200, trailing = false, leading = true)
//                                    ^^^^^^^ 默认 false!

我调用时只传了 (fn, 33),于是 trailing=false。节流器的行为变成:

  • 事件间隔 > 33ms → 立即执行(leading)
  • 事件间隔 ≤ 33ms → 直接丢弃,永不补执行

而两种滚动方式的事件节奏完全不同:滚轮是离散的,间隔大,每次都过 leading 门;拖滚动条是连续的,间隔几乎恒 ≤33ms——第一帧过门后,后续事件全部被丢进垃圾桶。scrollTop 状态停在起拖位置,内容窗口自然不动,而 spacer 还撑着真实高度,视觉上就是白屏。

修复是一行:useThrottleFn(fn, 33, true)。trailing 开启后,节流窗口结束必有一次补执行,拖拽中每 33ms 内容跟随一次。

这件事的教训分两层:

  • 库函数的默认值不是文档,是陷阱。签名里默默躺着的 trailing = false,比任何 TODO 都隐蔽。
  • 测试为什么没拦住? jsdom 里容器高度是 0、没有真实布局,现有断言只检查"组件不崩、结构存在"。修复后补了一个测试:连续 5 次间隔 ≤33ms 的 scroll 事件(模拟拖拽),用 fake timers 推进 34ms,断言行号窗口跟随到了最终位置。为了验证测试本身有效,我 stash 掉修复跑了一遍——旧代码下它确实失败。测试有效性的最终判据是"能杀死 bug",不是"能通过"。

bug 二:自动滚动速度"三段式"漂移

这是行选区功能(下一章)带来的新交互:拖到容器边缘时自动滚动。现象分三段,非常诡异:

  1. 鼠标在容器内边缘 → 慢速滚动
  2. 鼠标移出容器 → 停止滚动
  3. 再移回容器内更深处 → 快速滚动

三段现象,一个根因:自动滚动的速度变量被 rAF 循环的闭包捕获了。

updateEdgeAutoScroll(pointerY) {
  const speed = 根据 pointerY 算出
  if (循环已在跑) return;        // ← 新算的 speed 被扔掉
  const step = () => {
    container.scrollTop += speed; // ← 永远用第一次的 speed
    requestAnimationFrame(step);
  }
}
  • 在边缘内移动:每次 mousemove 都算出了新 speed,但循环已在跑,新值被 return 扔掉——永远是进入边缘那一刻的旧速度(慢)
  • 移出容器:行号越界,事件处理提前 return,没人再更新速度——滚动停摆
  • 回到更深的边缘:新的 mousemove 启动了一个新循环,这次恰好在深处,速度大(快)

修复是把"指针位置"提升为循环外可变状态,step 每帧重新计算速度:

let lastPointerClientY: number | null = null; // mousemove 持续更新

const step = () => {
  const speed = computeEdgeSpeed(container, lastPointerClientY); // 每帧重算
  ...
  edgeScrollRafId = requestAnimationFrame(step);
};

顺手补了一个编辑器惯例:指针拖出容器后外推为贴边,保持最大速度继续滚动——"拖出窗口选区继续走"才是用户预期。

这一课的抽象:凡是"循环 + 外部输入"的结构,输入必须在循环体内每帧重读;闭包捕获的输入只是快照。这也是游戏循环、动画系统最常见的一类 bug。


五、行选区:model/view 分离的微缩实践

前面的滚动和高亮都是"只读"的,选区功能第一次引入了状态——它也是整个组件里最有编辑器味道的部分,值得完整展开。

5.1 需求与直觉的冲突

需求:点击行号选中该行;按住行号拖拽,选中连续多行——就是 VS Code 侧边栏行号的行为。

直觉方案是用浏览器原生能力:创建 DOM Range、window.getSelection()。点击单行时它工作得很好。但拖拽多选立刻撞上虚拟滚动的本质约束:

拖到第 3000 行时,起拖的第 245 行早已滚出视口,被虚拟滚动卸载了——DOM Range 引用的节点不存在,选区当场蒸发。

5.2 编辑器的答案:model/view 分离

Monaco、CodeMirror 这类编辑器从不把选区存在 DOM 里。它们把"文档内容 + 光标位置 + 选区范围"抽象成 model(纯数据),DOM 只是 model 在某一帧的投影。选区变化 → 改 model → 视图按需重绘;滚动卸载了某些行?没关系,model 里的选区还在。

我们的组件恰好已经有一个 model:lineList(全量行数组)本来就在内存里。于是选区只需要两个数字:

const selectionAnchorLine = ref<number | null>(null); // 按下时的行号
const selectionActiveLine = ref<number | null>(null); // 当前拖到的行号

选区范围 = 两者归一化(双向拖拽取 min/max);选中内容 = lineList.slice(start-1, end).join('\n')——纯数据操作,不依赖任何 DOM 存活,不受重渲染影响。点击单行只是 anchor === active 的退化特例,一套状态机覆盖两种交互。

5.3 视图投影的两条腿

腿一:model 高亮(视觉稳定)。 窗口内每行判断 isLineSelected(lineNumber),命中则加 .line-selected class。因为判断基于行号而非 DOM,虚拟滚动重渲染后高亮自动恢复——滚动时选中区域依然"亮着",这是 DOM Selection 做不到的。

腿二:DOM Selection 同步(功能兼容)。 视觉有了,还得让 Ctrl+C 工作——原生复制读的是 DOM Selection。所以每次拖拽更新后,把 model 选区与可视窗口求交集,对窗口内实际存在的边缘行执行 setStartBefore / setEndAfter。选区滚出窗口的部分 DOM 里没有,但用户看得见的部分永远可以复制。

一个实现细节:setStartBefore(lineEl) 而不是对文本节点 setStart(node, 0)——后者有著名的 IndexSizeError 坑(行的 firstChild 可能是空白文本节点)。对元素边界操作,对混合子节点安全。

5.4 事件链与三个细节

gutter mousedown(button===0 校验 + preventDefault)
  → anchor = active = 行号;document 上挂 mousemove/mouseup
  → mousemove:指针 y → 行号,更新 activeLine
  → mouseup(once):清拖拽态、解绑监听、停自动滚动

三个值得记下的细节:

  • mousemove 挂在 document 上,不挂 gutter。拖拽会离开 gutter,监听必须在全局层;mouseup 用 { once: true } 挂,天然防泄漏。
  • 指针 → 行号用容器几何换算,不读 event.target。拖拽中指针可能压在任意节点上(甚至容器外),clientY - rect.top + scrollTop 再除以行高,永远正确。event.target 在拖拽场景里是靠不住的。
  • mousedown 里 preventDefault。不拦的话,浏览器会启动原生文本拖选,和我们自己的状态机打架。

5.5 边缘自动滚动:编辑器标配

拖到窗口底部还想继续往下选,必须让内容自动滚起来。机制:

  • 指针距上/下边缘 24px 内进入滚动区,速度随深入程度线性增大(最大 18px/帧)
  • rAF 循环每帧 scrollTop += speed,然后按新的 scrollTop 反推 activeLine,选区跟着推进
  • 指针拖出容器:外推为贴边,保持最大速度继续(编辑器惯例);回到中部自动停;mouseup 统一清理

它的速度稳定性 bug 就是第四章的 bug 二——rAF 循环里的速度必须每帧重算,不能闭包捕获。


六、复盤:三层抽象,一个原则

回看整个组件,其实就三层,每层职责单一:

层 职责 关键约束
model lineList(全量行)+ 选区锚点(两个行号) 纯数据,永不过期
纯函数 行切分、窗口计算、token 切词、选区归一化 无副作用,vitest 直接锁死
view spacer + translateY + 窗口行 + 高亮 class model 的投影,可随时重建

而贯穿所有设计与修复的,其实是一个原则:

"真相"只放一份,放在离 DOM 最远的那层;DOM 永远只是投影。

  • 滚动真相是 scrollTop 数值(view 每帧读它)→ 拖滚动条白屏,是投影丢帧
  • 内容真相是 lineList(model)→ 全量高亮卡死,是把渲染当成了真相
  • 选区真相是两个行号(model)→ DOM Selection 丢失,是把状态存错了地方

顺带一提,这套"model 层锚点 + 视图投影"的思路,与 rxi 的二进制序列化(数据与呈现分离)、Monaco 的 model/view 分离在精神上是一致的——先把不可变的事实放进数据结构,再把易变的呈现挂在其上。未来若真要自研代码编辑器,从这里延伸下去的下一步是:把"行号选区"推广为"(行,列) 坐标 + 光标栈 + 撤销树",那才是编辑器 model 的完全体。


附:组件文件与测试索引

  • src/lib/json-text-processor.ts — 纯函数层:行切分、窗口计算、segments 切词(19 用例)
  • src/components/json-text-view.vue — 视图层:虚拟滚动、高亮渲染、行选区、边缘自动滚动
  • src/components/json-text-view.spec.ts — 组件测试:渲染内容、窗口恒定、拖拽跟随、点击/拖选行为(6 用例)