引言:毫秒级的秘密
在 Linear 中更新一个 issue 只需要几毫秒。传统的 CRUD 应用做同样的事情需要大约 300 毫秒。他们是怎么做到的?性能没有灵丹妙药。真正的答案是,它从底层开始就建立在正确的基础之上,然后通过无数次的决策不断改进。我的目标就是带你了解一些让 Linear 体验如此流畅的技术,并帮助你实现同样的效果。
浏览器中的数据库
大多数 Web 应用都陷在同一个循环里:用户点击 → 浏览器发起 HTTP 请求 → 服务器查询数据库并返回 → 浏览器重新渲染。最终的结果就是,用户在几百毫秒内看着 spinner、骨架屏或者冻结的 UI,等待网络响应。
Linear 颠覆了这种传统关系。 UI 读取的实际数据库就在浏览器中,存在 IndexedDB 里。变更先在本地应用,然后异步推送到服务器,服务器再通过 WebSocket 将增量广播回其他客户端。
在我看来,这是 Linear 性能最核心的部分。当你的目标是构建一个快速的 Web 应用时,最大的瓶颈就是网络。任何在客户端和服务器之间传输的数据都要花费数百毫秒。最好的方法就是彻底消除网络请求——而这正是 Linear 所做的。
这里有一个例子,展示了 Linear 的请求有多么简洁:
// 传统 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 是同步地基于本地内存中的更新进行重新渲染的。
Linear 的联合创始人 Tuomas 在 2024 年的一次会议上说过:"我写的第一行代码就是同步引擎,这对创业公司来说非常不寻常。"从第一天起,Linear 就明确了他们要采用的方法以及需要做出的权衡。
我知道大多数人不会像 Linear 那样构建自定义同步引擎来让应用变快,而且他们也不需要。大多数场景下,使用 Tanstack Query 和 SWR 这类库配合乐观更新(optimistic updates)已经可以做到非常接近的效果。核心思想很简单:UI 的响应速度不应该依赖于网络延迟。
// 使用 SWR 的乐观更新
mutate(
`/api/issues/${issue.id}`,
{ ...issue, title: "Faster app launch" },
false
);
Linear 的技术栈一览
Linear 建立在你能找到的最简单的技术栈之上:React、TypeScript、MobX、Postgres、CDN。没有边缘数据库,没有 React Server Components,没有花哨的框架。
前端: React + react-dom、MobX(observable 图,颗粒级重新渲染)、TypeScript、Rolldown-Vite + plugin-react-oxc、ProseMirror + y-prosemirror(富文本编辑器,Yjs CRDT 实时协作)、Radix UI(弹窗、菜单、焦点管理)、Emotion + StyleX、Comlink(Worker RPC)、idb(IndexedDB 封装)、graphql-request(GraphQL 传输)、Sentry(错误监控)、Inter Variable 字体
后端: Node.js + TypeScript、PostgreSQL on Cloud SQL(issue 表 300 路分区)、Memorystore Redis(事件总线 + 缓存 + 同步游标)、turbopuffer(相似 issue 检测)、Kubernetes on GCP、Cloudflare Workers(多区域边缘代理)
让首次加载感觉瞬间完成
Linear 的构建工具演进史:Parcel → Rollup → Vite → Rolldown。每次迁移都是出于同一个目标:减少 JavaScript 和 CSS 的体积,并改善开发者体验。根据他们自己的博客文章,结果是:代码量减少 50%,压缩后体积减小 30%,冷缓存页面加载速度提升 10% 到 30%,活动 issue 视图的首屏渲染时间在 Safari 上降低了 59%,内存使用量降低了 70% 到 80%。
即使有了所有这些优化,Linear 仍然需要传输相当数量的代码:大约 21 MB 的压缩后 JavaScript。区别在于,它被激进地拆分成数百个按需加载的路由级别代码块。
// vite.config.ts(与实际代码块图匹配)
export default defineConfig({
plugins: [react()],
build: {
target: "esnext", // 没有旧语法,没有 polyfill
cssMinify: "lightningcss",
modulePreload: { polyfill: false },
rollupOptions: {
output: {
manualChunks(id) {
if (id.includes("node_modules")) {
const pkg = id.match(/node_modules\/([^/]+)/)?.[1];
if (pkg) return `vendor-${pkg}`;
}
},
},
},
},
});
教训不在于选择哪个构建工具,而在于:放弃对老旧浏览器的支持、使用原生 ESM、以及激进地进行代码拆分。每一步都很小,但叠加起来,Linear 的首屏 JavaScript 减少了一半,构建时间降低了一个数量级。
加载后的预加载
Linear 的做法是:在 JavaScript 运行之前,浏览器就已经看到完整的依赖列表,并并行发出请求。等到入口脚本执行到第一个 import 时,这些代码块已经缓存在浏览器中了。
<script type="module" crossorigin src="..."></script>
<link rel="modulepreload" crossorigin href="...">
<!-- 后面还有很多... -->
Service Worker 让速度更进一步: Linear 其余的代码块由 Service Worker 在后台缓存,在首次页面加载后在后台静默拉取。登录后几秒钟内,整个应用就已经在缓存中了。这意味着后续导航完全跳过网络,即使用户离线也能正常使用。
渲染优先,认证靠后
Linear 的做法很简单:内联启动脚本只检查 localStorage.ApplicationStore 是否存在。如果存在,说明用户以前用过,工作区已经在 IndexedDB 中了。如果不存在,就直接显示登录布局。实际的会话令牌在 cookie 里。包逻辑从不尝试去解析它,只是渲染已有的数据,然后让下一个请求在会话过期时返回 401。客户端信任本地数据,服务器是正确性的最终来源,两者异步协调。
同步引擎
让 Linear 快起来的核心,归根结底是一个决策:服务器是同步目标,而不是 UI 的真相来源。
1. 数据已经在本地了: 应用启动时不会从服务器获取工作区,而是从 IndexedDB 水合到内存中的 MobX 对象池。Issue 和 Comment 两个最大的表是按需惰性水合的——这是"数据层面的代码拆分"。
2. 变更不等待网络: 当你更改一个 issue 的状态时,三件事几乎同时发生:MobX observable 更新让 UI 反映变更、变更写入 IndexedDB 中的持久化事务队列、变更排队等待服务器处理。用户永远不会等待看到自己的变更效果。
3. 一个增量,更新一个单元格: 当服务器确认一个变更时,由于 Linear 中每个模型的每个属性都是自己的 observable,MobX 确切知道哪些组件依赖哪些字段。一个字段的更新只重新渲染读取该字段的组件,而不是父列表、侧边栏。50 个 issue 的更新就是 50 个单元格的重新渲染,而不是列表的重新渲染。
为速度而设计
Linear 的另一个基石是它将键盘作为导航和完成工作的主要工具。每个常见操作都有快捷键。命令面板一键打开。命令面板(⌘K)允许用户搜索几乎任何 Linear 中的操作。它非常快,因为它搜索的是本地 MobX 对象池,而不是服务器。
动画
浏览器有三层属性变化:合成属性(transform、opacity)交给 GPU 处理;触发重绘的属性(color、background-color)跳过布局但仍需重绘像素;触发布局的属性(width、height、margin)强制浏览器重新计算每个后续元素的位置。永远不要动画化这些属性。
/* Linear 的做法 */
.row:hover {
background-color: var(--color-bg-hover);
transition: background-color 0.12s;
}
.icon-arrow {
transform: translateX(0);
transition: transform 0.15s;
}
Linear 的大部分动画都引用了它们的源位置。持续时间保持短促:大多数过渡在 0.1s 到 0.25s 之间,远低于行业标准的 200-350ms。
总结
没有什么单一的东西能让一个应用性能卓越。它是成百上千个正确决策的累积。Linear 选择服务器作为同步目标而不是真相来源,数据库在浏览器中,变更本地优先应用后台协调,首次加载用更小的块传输更少的代码,Service Worker 在后台预缓存,认证基于本地状态假设,动画保持在 GPU 上执行。难的不是实现,而是日复一日对工艺的坚持。