Linear 为何如此快速?技术剖析

Hacker News Top 新闻

摘要

本文对项目管理工具 Linear 如何实现快速性能进行了技术剖析,通过使用浏览器端数据库(IndexedDB)、本地优先变更和同步引擎,消除了用户交互中的网络延迟。

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

缓存时间: 2026/06/08 03:16

# Linear 为何如此快速?技术剖析 来源:https://performance.dev/how-is-linear-so-fast-a-technical-breakdown ## Linear 为何如此快速?技术剖析 在 Linear 中更新一个 issue 只需几毫秒。传统的 CRUD 应用完成同样的操作大约需要 300 毫秒。他们是怎么做到的?并没有什么银弹般的性能秘诀。实际上,它是从零开始建立在正确的基础之上,然后通过无数决策不断改进而成。我的目标是带你了解一些让 Linear 拥有如此体验的技术,并帮助你实现同样的效果。 ## 本文将涵盖的内容 (https://performance.dev/how-is-linear-so-fast-a-technical-breakdown#what-ill-cover) - 浏览器中的数据库 - 让首次加载感觉瞬间完成 - 同步引擎 - 为速度而设计 - 动画 快速声明:我从未在 Linear (https://linear.app/)工作过,也从未见过他们的代码。我所分享的一切都来自我的个人经验、研究他们的应用、阅读他们的博客文章或观看他们的会议演讲。我只是喜欢构建 Web 应用,并从他们公测开始就一直使用 Linear。另外,这篇文章的题图来自 Meg Wayne (https://x.com/megxwayne) 的视频,她在 Linear 的工作非常出色。 --- ## 浏览器中的数据库 (https://performance.dev/how-is-linear-so-fast-a-technical-breakdown#database-in-the-browser) 大多数 Web 应用都陷入同一个循环:用户点击,浏览器发送 HTTP 请求,服务器查询数据库并返回结果,浏览器重新渲染。最终结果是,在应用等待网络响应的几百毫秒里,用户看到的是 spinner、骨架屏或冻结的界面。 Linear 颠覆了这种传统关系。UI 实际读取的数据库就在浏览器中,位于 IndexedDB。变更首先在本地生效,然后异步推送到服务器,服务器再通过 WebSocket 将增量广播给其他客户端。 在我看来,这是 Linear 性能最关键的一点。当你的目标是构建一个快速的 Web 应用时,最大的瓶颈就是网络。客户端和服务器之间发送的任何数据都需要花费几百毫秒。最好的方法就是完全消除网络请求的必要——这正是 Linear 所做的。我会反复强调这一点:构建出色 Web 应用的秘诀是将所有网络请求对用户隐藏起来。你能避免的加载状态越多越好。 下面是一个例子,展示了 Linear 的请求有多简单: ```js // 传统 Web 应用更新服务器 async function updateIssue({ issue }) { showSpinner(); const response = await fetch(`/api/issues/${issue.id}`, { method: "PATCH", body: JSON.stringify({ title: issue.title }), }); const updated = await response.json(); setIssue(updated) hideSpinner(); } // 对比 Linear issue.title = "Faster app launch"; issue.save(); ``` 第一行 `issue.title = "Faster app launch"` 更新了内存中的数据存储(在 Linear 中是 MobX observable)。第二行 `issue.save();` 将事务加入队列,由他们的同步引擎批量处理并刷新到服务器。关键在于,UI 会基于本地的、内存中的更新同步重新渲染。没有 spinner,因为没有需要等待的东西——数据在后台同步。这就是将浏览器视为每个用户数据库的魔力所在。 Linear 的联合创始人之一 Tuomas (https://x.com/artman) 在 2024 年的一次会议上说过:“实际上我写的第一行代码就是同步引擎,这对于初创公司来说是非常不常见的做法。”Linear 从一开始就知道他们想要采取的方法以及需要做出的权衡。 Linear 创建 issue 时没有 spinner 或延迟 我知道大多数人不会像 Linear 那样构建自定义同步引擎,只为让应用感觉快速,而且他们也不需要。对于大多数用例,像 Tanstack Query (https://tanstack.com/query/latest) 和 SWR (https://swr.vercel.app/) 这样的库可以通过乐观更新达到惊人的接近效果。大多数 Web 应用感觉缓慢,是因为 UI 在更新状态之前要等待每个网络请求完成。对于大多数用例,网络请求都会成功,所以你应该利用这一点,乐观地更新状态。 ```js // 使用 SWR 的乐观变更 mutate( `/api/issues/${issue.id}`, { ...issue, title: "Faster app launch" }, false ); // 对比 Linear issue.title = "Faster app launch"; issue.save(); ``` 核心思想很简单:UI 的响应性不应依赖于网络延迟。用户感知的速度取决于界面的反应速度,而不是服务器的响应速度。乐观请求是你所能做出的最高杠杆改进之一: - 消除不必要的 spinner - 立即更新状态 - 在后台验证 - 仅在需要时回滚 Linear 的基础正是基于这一原则,这使得应用感觉原生且快速。 ### 一窥 Linear 的技术栈 (https://performance.dev/how-is-linear-so-fast-a-technical-breakdown#a-peek-into-linears-stack) Linear 建立在你能找到的最简单的技术栈上:React、TypeScript、MobX、Postgres、CDN。没有边缘数据库,没有 React Server Components,也没有花哨的框架。 ``` 前端 React + react-dom (UI 运行时) MobX (可观察图,细粒度重渲染) TypeScript (端到端单一语言) Rolldown-Vite + plugin-react-oxc (2025 年中;之前是 Rollup;再之前是 Parcel) ProseMirror + y-prosemirror (富文本编辑器;Yjs CRDT 用于实时协作) Radix UI primitives (弹出框、菜单、焦点陷阱) Emotion + StyleX (Emotion 运行时 + StyleX 编译为原子 CSS) Comlink (Worker RPC) idb (IndexedDB 包装器,支持本地优先存储) graphql-request (GraphQL 传输到同步服务器) Sentry (错误监控) Inter Variable (单个 woff2,font-display: swap) 后端 Node.js + TypeScript (所有服务器代码单一语言) Cloud SQL 上的 PostgreSQL (issues 表按 300 路分区) Memorystore Redis (事件总线 + 缓存 + 同步游标) turbopuffer (相似 issue 检测,向量数据库) GCP 上的 Kubernetes (每个关注点一个工作负载) Cloudflare Workers (多区域边缘代理) 其他客户端 桌面端:Electron (相同 Web JS,原生 Chrome) 移动端:Swift (iOS) + Kotlin (独立的完整重实现) 营销站点 Next.js (静态) styled-components 内联 SVG sprite ``` 最让我印象深刻的是他们坚持使用客户端渲染。CSR 常因初始加载慢而受到批评,但通过正确的架构和设计,它可以感觉瞬间完成。我也非常喜欢它带来的简洁性。将应用完全放在客户端,形成了更清晰的心智模型,并消除了服务端渲染应用带来的许多复杂性。你不必时刻思考自己是在服务端还是客户端,window 对象是否可访问,或者是否设置了正确的缓存头。简洁性和由此带来的约束有其美妙之处。 那么,Linear 是如何让他们的客户端渲染应用感觉瞬间完成的呢? --- ## 让首次加载感觉瞬间完成 (https://performance.dev/how-is-linear-so-fast-a-technical-breakdown#making-the-first-load-feel-instant) 我一直非常关注首次加载,Linear 显然也是如此。尤其是对于生产力工具,你能够真正开始工作之前所花费的时间是最重要的细节之一。没有人愿意等待几秒钟才能加载一个新标签页。 首先,你需要了解是什么让初始加载变慢。对于客户端应用,需要请求 `index.html`,然后它会请求所有的 JavaScript 和 CSS,接着运行某种身份验证,最后发出一些 API 请求来显示应用。 ### Linear 的打包工具演变:Parcel、Rollup、Vite、Rolldown (https://performance.dev/how-is-linear-so-fast-a-technical-breakdown#linears-bundler-arc-parcel-rollup-vite-rolldown) 让应用感觉瞬间完成的第一步远在运行时之前就已经开始。它始于构建时。记住,网络是瓶颈,因此发送最少的 JavaScript 和 CSS 对于快速加载至关重要。 据我所知,Linear 已经重写了他们的构建管道四次:Parcel → Rollup → Vite → Rolldown。每次迁移都出于同样的目标:减少发送的 JavaScript 和 CSS 量,并改善开发者体验。根据他们自己的博客文章,他们声称: - 减少 50% 的发送代码。 - 压缩后体积缩小 30%。 - 冷缓存页面加载速度提升 10% 到 30%。 - 活动问题视图的首次绘制时间(在 Safari 上)下降了 59%。 - 内存使用量降低了 70% 到 80%。 这些改进大部分来自于一系列决策的组合:仅针对现代浏览器、更好的死代码消除以及激进的代码分割。放弃对旧版浏览器的支持是最大的胜利(不再需要 polyfill、ES5 转译、nomodule 回退),但死代码和分块工作同样重要。 即使进行了所有这些优化,Linear 仍然发送了相当数量的代码:大约 21 MB 的压缩后 JavaScript。不同之处在于,它被激进地分割成数百个路由级别的代码块,按需加载。 ```js // vite.config.ts (重构;与观察到的代码块图匹配) export default defineConfig({ plugins: [react()], build: { target: "esnext", // 无旧版语法,无 polyfill cssMinify: "lightningcss", modulePreload: { polyfill: false }, rollupOptions: { output: { // 每个 npm 包生成一个代码块(大小 > ~3 KB)。缓存失效 // 变为按库失效,而不是按应用版本失效。 manualChunks(id) { if (id.includes("node_modules")) { const pkg = id.match(/node_modules\/([^/]+)/)?.[1]; if (pkg) return `vendor-${pkg}`; } }, }, }, }, }); ``` 这里要学的不是选择哪个打包工具,而是放弃旧版浏览器、使用原生 ESM 以及疯狂代码分割的重要性。每一步都很小。但叠加起来,它们将 Linear 的首次加载 JavaScript 量大致减半,构建时间也降低了一个数量级。因此,瞬间加载的第一个秘诀是减少为用户渲染内容所需的 JavaScript 和 CSS 量。 ### 初始加载后的预加载 (https://performance.dev/how-is-linear-so-fast-a-technical-breakdown#preloading-after-initial-load) **一旦你将 JavaScript 分割成尽可能小的代码块,就可以开始在后台进行工作了。** 但是,等等,将包分割成数百个代码块会产生一个新问题。每个代码块都会导入其他代码块,而浏览器在解析入口脚本之前并不知道这些。没有帮助的话,加载时间线会变成瀑布式:获取入口,解析它,获取它的导入,解析它们,再获取它们的导入。每一层都增加一次网络往返,这是你想要不惜一切代价避免的。 Linear 的做法是,在运行任何 JavaScript 之前,浏览器就能看到整个列表,并并行发出请求。等到入口脚本执行到第一个 `import` 时,这些代码块已经在缓存中了。下面是他们 `index.html` 中 `<head>` 的样子: ```html <link rel="modulepreload" href="/assets/chunk-abc.js" crossorigin="anonymous" /> <link rel="modulepreload" href="/assets/chunk-def.js" crossorigin="anonymous" /> <link rel="modulepreload" href="/assets/chunk-ghi.js" crossorigin="anonymous" /> ``` 每个 preload 上的 `crossorigin` 属性与入口脚本上的 `crossorigin` 匹配,这样浏览器会重用缓存的 fetch,而不是将 preload 和 import 视为不同的资源。这与字体预加载的技巧相同,应用于关键路径上的每个代码块。 冷加载时间线从顺序的瀑布式转变为单一的并行批次。网络仍然在工作,但所有工作一次性完成。 这项技术的美妙之处在于,当用户首次访问登录页面时,你可以在后台完成所有这些工作。几秒钟后,整个应用就存储在缓存中,并可以瞬间提供。理解用户将如何使用你的应用至关重要。一旦你有了这种理解,就可以开始利用它,比如像 Linear 那样在后台预加载脚本。 ### Service Worker 带来更快的速度和离线能力 (https://performance.dev/how-is-linear-so-fast-a-technical-breakdown#the-service-worker-for-even-more-speed-and-offline-capabilities) Linear 的其余部分——用户尚未访问过的视图的路由级别代码块——由 service worker 在后台缓存。service worker 在其源码中包含一个预缓存清单,大约 1200 个哈希后的资源,涵盖路由代码块、图标和字体,并在首次页面加载后延迟拉取它们。在到达登录屏幕后的几秒内,整个应用都存在于缓存中。 预加载所有分块的 JavaScript 文件以确保从缓存中瞬间加载 这带来了两个好处。后续导航完全跳过网络;service worker 直接从其缓存中响应,甚至不需要经过 HTTP 缓存。而且,当网络不可用时,应用仍然可以工作。结合本地优先的同步引擎(它已经将用户数据存储在 IndexedDB 中),Linear 可以在离线状态下使用。你可以阅读 issue、创建新 issue、编辑标题和描述、更改状态。所有操作都排入本地事务存储,并在下次连接恢复时刷新。 Modulepreload 用于应用现在需要的内容,并行获取,这样浏览器就不会阻塞于串行的导入链。Service worker 用于应用接下来需要的内容。 因此,为了让加载速度更快,Linear 的步骤是:尽可能减少代码量,将其分割成小块,并在后台预缓存。再次强调,所有这些工作的目标是让网络请求尽可能快,或者更好的是,完全消除它们。 ### 供应商代码包组成 (https://performance.dev/how-is-linear-so-fast-a-technical-breakdown#vendor-bundle-composition) 我发现一个有趣的点:Linear 使用的每个包都有自己的代码块,独立缓存。传统的 `vendor.js` 在任何依赖升级时会使整个依赖图失效。Linear 的分块将供应商缓存从单个巨大的文件转变为细粒度的。升级一个依赖项会使一个代码块失效;其余代码块仍然保持缓存。这看起来显而易见,但又是确保快速加载时间的另一个细节。 每个单独的包被分割成自己独立的 js 文件 ### 加载大型字体文件 (https://performance.dev/how-is-linear-so-fast-a-technical-breakdown#loading-massive-font-files) 字体加载是许多应用容易出错的一个细节。失败的场景很明显:半秒钟不可见文本,真实字体替换时发生布局偏移,因为 preload 不匹配而导致资源被重复获取。Linear 的设置避免了所有这三个问题: ```html <link rel="preload" href="https://static.linear.app/fonts/InterVariable.woff2?v=4.1" as="font" type="font/woff2" crossorigin="anonymous" /> ``` ```css @font-face { font-family: "Inter Variable"; font-weight: 100 900; font-display: swap; src: url(https://static.linear.app/fonts/InterVariable.woff2?v=4.1) format("woff2"); } /* 斜体和 Berkeley Mono 遵循相同的模式,各一个 woff2。 */ ``` 可变字体覆盖了 100–900 的完整字重轴,只需一个 woff2 文件,消除了按字重请求的需要。`font-display: swap` 立即渲染后备字体,并在 Inter 加载时切换。容易忽略的技巧是:preload 标签上的 `crossorigin="anonymous"`。如果没有它,浏览器会预加载字体,然后在 CSS 稍后引用它时再次获取,因为这两个请求具有不同的 CORS 模式。preload 上的 `crossorigin` 使浏览器重用缓存的那个。 这些看起来都很简单,但我总是惊讶于有多少应用正确加载字体。Linear 是一个很好的例子,它思考了细节并确保字体的加载尽可能快速和准确。 ### 内联应用外壳 (https://performance.dev/how-is-linear-so-fast-a-technical-breakdown#inlined-app-shell) 另一个让首次加载感觉快速的关键技术:在 `<head>` 中内联了足够多的 CSS,以便无需获取外部样式表就能绘制加载状态。记住,网络是瓶颈,你将始终与之斗争以确保应用感觉快速。在这种情况下,Linear 通过内联显示用户所需的关键 CSS 来消除一个网络请求。

相似文章

本地优先软件更易扩展

Lobsters Hottest

本文认为,像 Harper 语法检查器这样的本地优先软件通过在设备上运行代码来避免扩展问题,使其能够在无需额外服务器成本的情况下轻松应对流量高峰。

使用图论加速后端(2019年)

Lobsters Hottest

Sensor Tower 工程团队利用图论分析和性能分析工具,识别出后端端点缓慢的瓶颈,通过优化 Protobuf 解码和编码步骤,实现了四倍的速度提升。

Every fast write moves work somewhere else

Hacker News Top

This article explores storage engine write durability trade-offs, explaining how different storage layers (memory, local SSD, remote storage) move latency and durability, and analyzes the cost of operations in an append-only key-value store built on object storage.