论渲染差异

Hacker News Top 工具

摘要

博客文章宣布推出CodeView,这是一个虚拟化优先的React组件,可高效渲染大型代码差异,归属于六个月前发布的Diffs库。

暂无内容
查看原文
查看缓存全文

缓存时间: 2026/05/29 19:20

# 关于渲染差异 来源:https://pierre.computer/writing/on-rendering-diffs ## PIERRE COMPUTER COMPANY █ 发布于 2026年5月29日,作者 @amadeus (https://x.com/amadeus) `` ██████╗ ██╗███████╗███████╗███████╗ ██╔══██╗██║██╔════╝██╔════╝██╔════╝ ██████████╗ ██║ ██║██║█████╗ █████╗ ███████╗ ╚═════════╝ ██║ ██║██║██╔══╝ ██╔══╝ ╚════██║ ██████╔╝██║██║ ██║ ███████║ ╚═════╝ ╚═╝╚═╝ ╚═╝ ╚══════╝ `` `` ██╗ ██████╗ ██╗███████╗███████╗███████╗ ██║ ██╔══██╗██║██╔════╝██╔════╝██╔════╝ ██████████╗ ██║ ██║██║█████╗ █████╗ ███████╗ ╚═══██╔═══╝ ██║ ██║██║██╔══╝ ██╔══╝ ╚════██║ ██║ ██████╔╝██║██║ ██║ ███████║ ╚═╝ ╚═════╝ ╚═╝╚═╝ ╚═╝ ╚══════╝ `` 你打开一个拉取请求,期望了解发生了什么变化。 对于中小型改动,一切都很顺利。代码可读,文件都在,你可以滚动、添加评论,整个过程无缝衔接。 然后你打开了一个较大的改动。也许改动是由智能体生成的实现、测试、fixture 和快照。也许这个分支只是碰巧涉及了比预期更多的文件。不管怎样,审查体验开始下降。它可能一次只显示一个文件,或者需要每个文件单独加载后才能阅读,甚至让基本的导航变得卡顿。 其中一些是针对真正困难问题的合理权衡。但它们仍然有代价:审查者会感觉到工具的限制,产品团队不得不为这些限制构建变通方案。 Diff 渲染很重要,但对大多数工具来说,它并不是产品的核心。产品是围绕代码发生的事情:审查工作流、自动化、智能体输出、CI 结果和协作。代码审查应该支持这些工作,而不是成为每个团队都必须从头搭建的东西。 这就是大约 6 个月前,我们发布了 Diffs (https://diffs.com/) 的原因。我们的目标是让代码和 diff 渲染部分能够正常工作,这样团队就可以把时间花在围绕它的产品上。 最初我们只发布了基本组件:`File` 和 `FileDiff`。我们很快收到了关于性能问题的反馈,所以我们随后添加了一个简单的虚拟化器,避免渲染不在视野范围内的代码,并提供了一个 API 将语法高亮移动到工作线程中。这个简单的虚拟化器有所帮助,但它只是一个权宜之计。仍然存在大量的 O(n×m) 复杂度、高内存使用率和虚拟化空白区域。缺少的是一个更高级的组件,它能够管理整个审查界面并处理与规模相关的难题。 这个缺失的层次变成了 `CodeView`:一个以虚拟化为优先的组件,用于审查代码和差异。而我们围绕一个故意设定的不可能目标来构建它: > 你应该能够直接渲染任何 diff。 当然,这并不是字面意思。浏览器、计算和内存都有物理限制。但实际来说,我认为我们已经非常接近了,我想分享一下我们是如何做到的。 如果你觉得长篇博文很无聊,可以直接去 DiffsHub.com (https://diffshub.com/) 看看 `CodeView` 的演示页面,在那里你几乎可以查看 GitHub 能发给我们的任何 PR 或 diff。几乎任何 diff,无论规模多大,几乎瞬间就能呈现。 > diffshub[dot]com 从 GitHub 获取任何公开的 diff,无论多大,都能通过 DiffsHub 近乎即时地虚拟化渲染。旨在展示我们全新的 CodeView 组件。尝试一下,只需将地址栏中的 `github` 替换为 `diffshub` 即可。pic.twitter.com/5X30YwbpHn (https://t.co/5X30YwbpHn) — Pierre (@pierrecomputer) 2026年5月20日 (https://x.com/pierrecomputer/status/2057174934674124941?ref_src=twsrc%5Etfw) 你可以在 npm 上查看最新版本的 diffs 包中的 CodeView 组件及更多内容:@pierre/diffs (https://www.npmjs.com/package/@pierre/diffs),或者阅读文档 (https://diffs.com/docs#codeview)。 ## DIFF 看起来简单,直到它们不再简单 表面上看,在浏览器中渲染 diff 似乎并不太难。只是文本,对吧?浏览器天生就是用来获取原始 HTML 并将其转化为你可看可交互的东西。代码毕竟只是文本。 但是一个好的审查界面需要的不仅仅是文本。它需要语法高亮、行号、注释、评论、主题、分栏和统一布局、换行模式,以及足够的可定制性来融入别人的产品。每一个特性都会增加成本和复杂性。语法高亮增加处理时间并膨胀 DOM 数量。评论涉及额外的布局复杂性,我们不能完全控制,但它们仍然必须与你现有的设计系统无缝配合。 有了 `CodeView`,我们将这种每个文件的复杂性放大;对于一个单独的 diff 来说很廉价的工作,现在在大型审查中有了显著的成本。我们可以大致将问题分为三类: - **渲染** — DOM 复杂度迅速增长,浏览器在滚动或与页面交互时可能会过载。 - **处理** — 每个文件或 diff 的操作都会被放大,因此在孤立环境中很快的工作,当重复数千次时可能会变得昂贵。 - **内存** — 大型文件和 diff 会被转换为渲染数据结构,这可能会逼近浏览器内存限制并导致垃圾回收更加频繁。 我们简单的虚拟化器帮助解决了一些渲染问题,将高亮移到主线程外帮助解决了部分处理问题。但 `CodeView` 需要将渲染、内存和处理视为同一问题的相互关联部分。 ## 虚拟化 虚拟化,或称窗口化,是解决渲染问题的一种方法。最简单的形式是,只渲染视口附近的内容部分。当你滚动时,虚拟化器会渲染新进入视口的内容,并移除移出屏幕的内容。 保持 DOM 小巧有很多好处:更低的内存使用、更少的布局工作、更少的绘制工作、更少的需要浏览器管理的元素。代价是虚拟化器必须估计或测量所有内容的高度,并且必须动态协调这些变化。 增加这种复杂性的一个因素是,浏览器通常将滚动合成与 JavaScript 执行分开管理。这有助于滚动对用户交互的响应更灵敏,但也意味着 JavaScript 很容易滞后于滚动更新。这在使用滚动条进行大幅跳跃或极其快速地滚动时最为明显——虚拟化器跟不上,你会滚入空白区域,然后 JavaScript 才有时间渲染更新后的内容。 点击查看旧虚拟化器中的空白现象 ### 常见的虚拟化技术 有几种常见的在浏览器中虚拟化内容的方法,每种都有其自身的权衡。 最常见的方法是创建一个真正的可滚动区域,其总估计高度为内容高度,然后将可见项目定位到它们所属的位置。这保持了滚动的原生性:滚动条、动量、输入处理和可访问性都保留给浏览器。权衡之处在于,渲染窗口可能会落后于视觉滚动位置。快速滚动和大的滚动条跳跃可能会在 JavaScript 有机会渲染下一段范围之前暴露空白空间。你可以通过渲染视口外更大的缓冲区来减少这种情况,但这又回给了一些虚拟化本来应该省下的 DOM、布局和内存。 另一种方法是将可见内容放在一个粘性或固定容器中,并通过 `requestAnimationFrame` 更新其显示内容。在这种模型中,空白是不可能出现的:内容容器不会随滚动位置移动而滚出视线;它只是看起来像在移动。但是,如果 JavaScript 跟不上,那么滚动可能会卡顿,因为 JavaScript 现在成为了渲染更新路径的一部分。浏览器的行为也很重要。例如,Safari 目前即使在更高刷新率的显示器上也将 `requestAnimationFrame` 限制在 60Hz,这使得这种方法在这些设备上的感觉比原生滚动更差。 更极端的方法是完全模拟滚动:没有原生的可滚动区域,只有一个自定义视口、一个假的滚动条,以及通过 `requestAnimationFrame` 在用户*移动*文档时更新内容。这可以避免浏览器滚动大小限制,因为滚动位置现在是你的状态,而不是浏览器的。但代价更大:你现在要负责让滚动感觉原生、可访问且在不同操作系统和浏览器中正确。 ### 反向粘性技术 对于 `CodeView`,许多这些虚拟化权衡是不可接受的。原生浏览器滚动至关重要。基于 WebKit 的环境需要感觉良好,因为 Tauri 是开发者工具的常见目标。而空白是不可接受的。 这让我们困在了各种方法之间,没有一种完全合适。经过一些实验和挫折后,我们想出了一种混合方法,可以保持滚动原生,基本上将定位与 `requestAnimationFrame` 更新解耦,并使空白实际上不可能出现。 我们称我们的新技术为“反向粘性技术”,但在讨论它是如何工作之前,先快速了解一下 `sticky` 定位的基本原理。粘性定位的典型用例是确保可滚动列表中的节标题在你滚动时保持在视线中。你在节标题上设置 `position: sticky; top: 0`,然后当它们通常应该滚动出视野时,它们会固定在滚动视图的顶部,而下面的内容则在它们下方滚动。 节标题 1(固定) 项目 1 项目 2 项目 3 项目 4 项目 5 节标题 2(固定) 项目 6 项目 7 项目 8 项目 9 项目 10 节标题 3(固定) 项目 11 项目 12 项目 13 项目 14 项目 15 项目 16 项目 17 项目 18 项目 19 项目 20 对于 `CodeView`,我们反转了通常的粘性行为。当你向下滚动时,不是将渲染内容的顶部固定到视口顶部,而是渲染区域的底部边缘在你滚动超过它时粘到视口底部。当你向上滚动时,顶部边缘粘到视口顶部。 这给了我们原生滚动,同时视口保持在渲染范围内。如果 JavaScript 落后,渲染区域会粘在一个边缘上,而不是滚动走并暴露空白空间。我们可以通过负的 `top` 和 `bottom` 粘性偏移来获得这种行为,两者都使用相同的公式计算:`(contentHeight - viewportHeight) * -1`。 所以回到我们为自己设定的目标:我们保留了原生滚动,渲染更新不需要每帧完美来保持滚动流畅,即使大的跳跃也不能滚动过渲染内容进入空白区域。 `` ┌────────────────────────────────────────────────────┐ │ ┌────────────────────────────────────────────────┐ │ │ │ │ │ │ │ Full-height content element │ │ │ │ │ │ │ │ ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ │ │ │ │ ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ │ │ │ │ ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ │ │ │ │ ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ │ │ │ │ ▓▓▓▓▓ ▓▓▓▓▓ │ │ │ │ ▓▓▓▓▓ Buffer element ▓▓▓▓▓ │ │ │ │ ▓▓▓▓▓ before virtualized content ▓▓▓▓▓ │ │ │ │ ▓▓▓▓▓ ▓▓▓▓▓ │ │ │ │ ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ │ │ │ │ ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ │ │ │ │ ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ │ │ │ │ ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ │ │ │ │ │ │ │ │ ┌────────────────────────────────────────────┐ │ │ │ │ │░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░│ │ │ ┌────────────────────────────────────────────────────────╖ │ ▀ ▀ ▀ ▓▓▓ Browser ▓▓▓ ║ ├────────────────────────────────────────────────────────╢ │ │ │ │░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░│ │ │ ║ │ │ │ │░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░│ │ │ ║ │ │ │ │░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░│ │ │ ║ │ │ │ │░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░│ │ │ ║ │ │ │ │░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░│ │ │ ║ │ │ │ │░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░│ │ │ ║ │ │ │ │░░░░░░░░░░░░ ░░░░░░░░░░░░│ │ │ ║ │ │ │ │░░░░░░░░░░░░ Rendered content ░░░░░░░░░░░░│ │ │ ║ │ │ │ │░░░░░░░░░░░░ ░░░░░░░░░░░░│ │ │ ║ │ │ │ │░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░│ │ │ ║ │ │ │ │░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░│ │ │ ║ │ │ │ │░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░│ │ │ ║ │ │ │ │░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░│ │ │ ║ │ │ │ │░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░│ │ │ ║ │ │ │ │░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░│ │ │ ║ ╘════════════════════════════════════════════════════════╝ │ │ │░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░│ │ | │ │ └────────────────────────────────────────────┘ │ | │ │ │ | │ │ │ | │ │ │ | │ │ │ | │ │ │ | │ │ │ | │ └────────────────────────────────────────────────┘ | └────────────────────────────────────────────────────┘ `` > 当我们朝着“不可能出现空白”努力时,Safari 仍然找到了让我们心碎的方式。在足够剧烈的滚动下,它会在合成层上出现积压并暴露空白空间。通常需要一些操作才能触发,但在技术上仍然是可能的。 ## 可扩展的布局 有了虚拟化,下一个问题是计算可滚动区域的布局和大小。当虚拟化器的估计值接近实际情况时,它的工作效果最好。糟糕的估计意味着更多的渲染后纠正工作:测量 DOM、更新项目位置、调整滚动高度,有时还要修复滚动偏移以保持当前内容在正确位置。这种情况发生得越频繁,页面就越有可能卡顿或使滚动条跳动。 幸运的是,第一遍估算相当廉价。文件基本上就是 `lineHeight * totalLines`。Diff 只稍微复杂一点,因为我们已经有了解析的行数和 hunk 元数据。在此基础上,我们只需将 hunk 分隔符加入估算。简化后,看起来像这样:`(lineHeight * diff.splitLineCount) + (diff.hunks.length * hunkSeparatorHeight)`。 ### 渲染行范围 有了粗略的估算,`CodeView` 可以确定哪些文件应该被渲染。然后,每个渲染的文件或 diff 会获取视口大小和位置,并使用这些信息来内部决定要渲染哪些行。 这种架构来自之前的 `Virtualizer`,但 `CodeView` 促使我们优化了一些昂贵的路径。旧的实现可能会从头开始遍历一个文件或 diff 来找到渲染范围的起点和终点。对于大多数文件和 diff,这个成本实际上是不可见的。但一旦我们开始测试更大的变更集,它就变成了一个问题。一个有数十万行代码的 hunk 可能变得极其昂贵,因为查找仍然必须从头开始。 为了解决这个问题,我们添加了一个缓存的“位置到行”检查点系统。这使我们能够使用二分查找来找到更接近的起点,然后再进行原始的遍历。

相似文章

Show HN: Codiff,本地差异审查工具

Hacker News Top

Codiff 是一款轻量级本地 diff 查看器,用于审查 Git 暂存和未暂存的更改,支持基于 LLM 的逐步讲解和内联审查评论。

markdown-svg-renderer

Simon Willison's Blog

一个支持实时预览、表格和代码块格式化的 Markdown 渲染工具,并针对 SVG 代码块提供内联预览和标签式代码视图的特殊处理。