Linear为何如此之快?技术深度解析

本文深入分析了项目管理工具Linear如何实现极快的响应速度。核心在于将数据库放在浏览器(IndexedDB),本地优先更新再异步同步服务器,避免网络等待。首次加载通过减少JavaScript体积、模块预加载、服务工作者缓存等手段实现瞬间启动。同步引擎通过本地数据库、乐观写入和细粒度MobX观察者实现流畅协作。设计上强调键盘快捷键和命令面板,动画仅使用GPU加速属性并缩短时长。文章总结了Linear从架构到细节的数百个决策,为构建高性能Web应用提供了实用指南。

我将介绍的内容

  • 浏览器中的数据库
  • 让首次加载感觉瞬间完成
  • 同步引擎
  • 为速度而设计
  • 动画

快速声明:我从未在 Linear 工作过,也从未见过他们的代码。我分享的一切都来自我个人经验、研究他们的应用、阅读他们的博客文章或观看他们的演讲。我只是喜欢构建 Web 应用,并且从他们测试版发布以来就一直在使用 Linear。另外,文章的主图来自 Meg Wayne 的一个视频,她为 Linear 所做的工作非常出色。


浏览器中的数据库

大多数 Web 应用都运行在同一个循环中。用户点击。浏览器发起 HTTP 请求。服务器查询数据库并返回。浏览器重新渲染。最终结果是加载旋转器、骨架屏或冻结的 UI,持续几百毫秒,而应用则在等待网络。

Linear 颠覆了传统关系。UI 实际读取的数据库就在浏览器中,在 IndexedDB 里。变更首先在本地执行,然后异步推送到服务器,服务器再通过 WebSocket 向其他客户端广播增量。

在我看来,这是 Linear 性能最关键的部分。当你的目标是构建一个快速的 Web 应用时,你面临的最大瓶颈就是网络。客户端和服务器之间发送的任何数据都要花费数百毫秒。最佳方法是完全避免对网络请求的需求:这正是 Linear 所做的。

我会多次重复这一点,但构建出色 Web 应用的秘诀在于向用户隐藏所有网络请求。你能避免的加载状态越多越好。

以下是一个例子,展示 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 QuerySWR 这样的库通过乐观更新可以非常接近。大多数 Web 应用感觉缓慢,是因为 UI 在更新状态之前等待每个网络请求完成。对于大多数用例,网络请求会成功,因此你应该利用这一点,乐观地更新状态。

// 使用 SWR 的乐观更新
mutate(
  `/api/issues/${issue.id}`,
  { ...issue, title: "Faster app launch" },
  false
);

// 对比 Linear
issue.title = "Faster app launch";
issue.save();

核心思想很简单:UI 响应速度不应依赖于网络延迟。用户感知的速度取决于界面反应的速度,而不是服务器响应的速度。

乐观请求是你能够做出的最具杠杆效应的改进之一:

  • 消除不必要的加载旋转器
  • 立即更新状态
  • 在后台验证
  • 仅在需要时回滚

Linear 的基础正是建立在这一原则之上,这使得应用感觉像原生应用一样快。

一窥 Linear 的技术栈

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            (所有服务器代码的单一语言)
  PostgreSQL on Cloud SQL         (issues 表按 300 路分区)
  Memorystore Redis               (事件总线 + 缓存 + 同步游标)
  turbopuffer                     (相似问题检测,向量数据库)
  Kubernetes on GCP               (每个关注点对应一个工作负载)
  Cloudflare Workers              (多区域边缘代理)

其他客户端
  桌面端: Electron               (相同的 Web JS,原生 Chrome)
  移动端: Swift (iOS) + Kotlin   (单独的完整重新实现)

营销
  Next.js                         (静态)
  styled-components
  内联 SVG sprite

对我来说最突出的点是他们坚持使用客户端渲染。CSR 常常因首次加载慢而受到批评,但通过正确的架构和设计,它可以感觉瞬间完成。

我也非常喜欢它带来的简洁性。保持应用完全客户端带来了一个更清晰的心智模型,并消除了服务器渲染应用带来的许多复杂性。你无需不断思考自己是在服务器端还是客户端,window 对象是否可访问,或者是否设置了正确的缓存头。简洁性及其带来的约束自有其美。

那么 Linear 是如何让客户端渲染的应用感觉瞬间完成的呢?


让首次加载感觉瞬间完成

我痴迷的一件事是首次加载,Linear 显然也是如此。尤其是对于生产力工具,从你真正开始工作之前所花费的时间是需要考虑的最重要细节之一。没有人想等待一个新标签页加载好几秒。

首先,你必须理解是什么让首次加载变慢。对于客户端应用,你需要请求 index.html,然后 index.html 请求所有 JavaScript 和 CSS,接着运行某种身份验证,最后发出一些 API 请求来展示应用。

Linear 的打包工具演进: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。不同之处在于它被激进地代码分割成数百个按需加载的路由级块。

// 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 数量。

首次加载后的预加载

一旦你将 JavaScript 分割成尽可能小的块,你就可以开始在后台做工作了。

但稍等,将包分割成数百个块会产生一个新问题。每个块导入其他块,而浏览器在解析入口脚本之前并不知道它们。如果没有帮助,加载时间线会变成瀑布流:获取入口,解析它,获取它的导入,解析它们,获取它们的导入。每一层都增加了一次网络往返,这是你必须不惜一切代价避免的。

Linear 的做法是,在任何 JavaScript 运行之前,浏览器能看到整个列表并并行发起请求。到入口脚本执行到第一个 import 时,这些块已经在缓存中了。

以下是它在 <head /> 中的样子(如果这是他们的 index.html):

<script type=module crossorigin
  src="https://static.linear.app/client/assets/html.2_JBQs3Q.js"></script>
<link rel=modulepreload crossorigin
  href="https://static.linear.app/client/assets/vendor-mobx.Crhy2qQc.js">
<link rel=modulepreload crossorigin
  href="https://static.linear.app/client/assets/SyncWebSocket.Djw6l_Op.js">
<link rel=modulepreload crossorigin
  href="https://static.linear.app/client/assets/DatabaseManager.DKssGAN8.js">
<!-- ...大约还有更多 -->

每个预加载标签上的 crossorigin 属性与入口脚本上的 crossorigin 匹配,因此浏览器会重用缓存的获取结果,而不是将预加载和导入视为不同的资源。与字体预加载相同的技巧,应用于关键路径上的每个块。

冷加载时间线从顺序瀑布流变成单个并行批次。网络仍然工作,只是全部同时完成。这种技术的优点在于,当用户首次访问登录页面时,你可以在后台完成所有这些工作。几秒钟内,整个应用就存储在缓存中并立即提供。

理解用户将如何使用你的应用极为重要。一旦你有了这个理解,你就可以开始利用它,例如像 Linear 那样在后台预加载脚本。

用于更高速度和离线能力的 Service Worker

Linear 的其余部分——用户尚未访问的视图的路由级块——由 service worker 在后台缓存。工作线程的源码中包含一个预缓存清单,大约有 1200 个哈希资源,涵盖路由块、图标和字体,并在首次页面加载后懒加载它们。在用户访问登录屏幕后的几秒钟内,整个应用就存放在缓存中。

预加载所有分割后的 JavaScript 文件,确保从缓存中立即加载。

这带来了两个好处。后续导航完全跳过网络;service worker 直接从其缓存中响应,甚至不经过 HTTP 缓存。并且当网络不可用时,应用仍然可以工作。结合本地优先的同步引擎(它已经在 IndexedDB 中拥有用户数据),Linear 可以离线使用。你可以阅读问题、创建新问题、编辑标题和描述、更改状态。一切都在本地事务存储中排队,并在下次连接恢复时刷新。

Modulepreload 用于应用现在需要的内容,并行获取,这样浏览器永远不会因为串行导入链而阻塞。Service worker 用于应用接下来需要的内容。

因此,要实现快速加载,Linear 的步骤是:尽可能消除代码,将其分割成小块,并在后台预缓存。再次强调,所有这些工作的目标是让网络请求尽可能快,或者更好的是,完全消除它们。

供应商包构成

我发现有趣的是,Linear 使用的每个包都有自己的块,独立缓存。传统的 vendor.js 在版本更新时会使整个依赖图失效。Linear 的分块将供应商缓存从单个巨大的文件变成了细粒度的缓存。更新一个依赖只会使一个块失效;其余块保持缓存。

这似乎是一个显而易见的好做法,也是确保快速加载时间的另一个细节。

加载大型字体文件

字体加载是很多应用处理不当的细节之一。失败的模式很明显:半秒钟的不可见文本,真实字体替换时的布局偏移,因为预加载与后续 CSS 引用不匹配而重复获取资源。Linear 的设置避免了所有这三个问题:

<!-- 在 index.html 的 <head> 中 -->
<link rel="preload"
      href="https://static.linear.app/fonts/InterVariable.woff2?v=4.1"
      as="font" type="font/woff2" crossorigin="anonymous">
<link rel="preconnect" href="https://static.linear.app" crossorigin>
@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。 */

可变字体在单个 woff2 中覆盖了整个 100-900 的权重轴,消除了每个权重的请求。font-display: swap 立即渲染后备字体,并在 Inter 加载时进行切换。容易忽略的技巧:预加载标签上的 crossorigin="anonymous"。没有它,浏览器会预加载字体,然后在 CSS 稍后引用它时再次获取,因为这两个请求具有不同的 CORS 模式。预加载上的 crossorigin 使浏览器重用缓存的字体。

这一切看起来很简单,但我总是惊讶于有多少应用错误地加载字体。Linear 是一个很好的例子,它仔细考虑了细节,确保字体加载尽可能快速准确。

内联应用壳

另一个让首次加载感觉快速的技巧:在 <head/> 中内联了足够多的 CSS,无需获取外部样式表即可绘制加载状态。记住,网络是瓶颈,你始终要与之斗争以让应用感觉快速。在这种情况下,Linear 通过内联显示用户应用壳所需的关键 CSS 来消除一个网络请求。

<style>
  :root {
    --bg-color: #f5f5f5;
    --bg-base-color: #fcfcfd;
    --bg-border-color: #e0e0e0;
    --sidebar-width: 244px;
  }
  html { background: var(--bg-color); height: 100%; }
  body { font-family: "Inter Variable", Arial, Helvetica, sans-serif; }

  #appBorders {
    border: 1px solid var(--bg-border-color);
    background: var(--bg-base-color);
    margin: 8px 8px 8px var(--sidebar-width);
    border-radius: 12px;
  }

  #logo { transform: translateZ(0); }

  @keyframes logoBackgroundPulse {
    0%   { opacity: 0; transform: scale(0.8); }
    70%  { opacity: 1; }
    100% { opacity: 0; transform: scale(1.0); }
  }
</style>
<script>performance.mark("appStart");</script>

除了 CSS,还有大量内联的 JavaScript 对于初始体验至关重要。

<script>
// Electron 上下文 — 让 CSS 根据原生 Chrome 进行分支。
if (navigator.userAgent.includes("Electron") && navigator.userAgent.includes("Linear")) document.documentElement.classList.add("electron");

// 没有本地存储 → 没有工作区数据 → 渲染认证布局。
if (localStorage.getItem("ApplicationStore") === null) document.documentElement.classList.add("logged-out");

// 在绘制之前恢复上次已知的壳样式(侧边栏背景、宽度、暗色模式)。
const c = JSON.parse(localStorage.getItem("splashScreenConfig") || "{}");
if (c.bgSidebarColor) document.documentElement.style.setProperty("--bg-sidebar-color", c.bgSidebarColor);
if (c.sidebarWidth) document.documentElement.style.setProperty("--sidebar-width", c.sidebarWidth + "px");
if (c.darkMode) document.documentElement.classList.add("dark");

// 当用户在桌面应用中打开链接时,将侧边栏压缩成一条细线。
if (JSON.parse(localStorage.getItem("userSettings") || "{}").openLinksInDesktop) document.documentElement.style.setProperty("--sidebar-width", "8px");

</script>

在任何包被解析之前,来自 index.html 的 JavaScript 读取 localStorage.splashScreenConfig,合并任何顶层的 sessionStorage 覆盖,并将用户记住的壳样式直接应用到 document.documentElement.style:侧边栏背景、基色、边框颜色、侧边栏宽度、代理工具栏高度。它检测颜色方案偏好和 Electron 上下文。它检查 localStorage.ApplicationStore 是否存在,如果不存在,则添加一个 logged-out 类,将壳切换到认证布局。

当第一个 JavaScript 包从网络到达时,加载屏幕已经根据用户是否登录正确设置了主题、大小和位置。

这给用户一种感觉,即一旦他们在 URL 栏中按下回车键,应用就已经准备就绪。没有比在初始 index.html 响应中发送初始应用壳更快的方法了。

一个显示 Linear 首次加载速度有多快的例子。

先渲染,后认证

身份验证是另一个大多数应用放弃性能预算的步骤。传统流程:获取 HTML,加载包,验证会话,获取用户,获取工作区,然后渲染。在一到三秒内用户什么也看不到。

Linear 对待认证的方式与对待变更相同。假设成功路径,然后在后台验证。这可能是他们架构中我最喜欢的部分之一,因为它允许他们在加载时几乎立即渲染完整的体验。

大多数 CRUD 应用将真实会话保存在 HttpOnly cookie 中,然后添加第二个 JS 可读的 cookie 或 /me 请求,以便前端在启动时能判断用户是否已登录。Linear 的做法更简单。不是维护一个并行的认证信号,内联启动脚本只是检查 localStorage.ApplicationStore 是否存在:

if (localStorage.getItem("ApplicationStore") === null) {
  document.documentElement.classList.add("logged-out");
}

如果存在,则用户之前在此浏览器中使用过 Linear,这意味着他们的工作区已经存在于 IndexedDB 中。这回到了我们之前讨论的第一个部分:数据库存在于浏览器中。如果不存在,则无论如何都没有内容可渲染,因此壳切换到未登录布局,登录流程接管。

Linear 的初始流程不是“你是否有有效会话”,而是“我们是否有内容给你展示”。它们实际的会话Token保存在 cookie 中。包从不尝试自作聪明。它只是渲染已有的内容,并在会话过期时让下一个请求(WebSocket 握手、同步增量、任何 HTTP 调用)以 401 失败。当发生这种情况时,客户端重定向到登录。

整个模式与架构的其他部分一致:客户端信任本地数据,服务器是正确性的真实来源,两者异步协调。就像一次变更。就像它们的同步引擎。

手动删除认证会话并刷新桌面应用。

这可能是 Linear 中我最喜欢的细节之一,我希望更多应用能有这样的行为。对于认证,假设成功路径,否则回退。如果有数据要展示:就展示它!并利用浏览器的数据存储来立即渲染。


同步引擎

Linear 快速的很大一部分原因在于一个决策的后续影响:服务器是一个同步目标,而不是 UI 的真实来源。他们的同步引擎内部已经被深入逆向工程,Tuomas 也多次就架构进行了精彩的演讲。我不会重复这些。我想做的是指出真正产生速度的三个支柱,因为速度是它们如何配合的属性,而不是任何单个支柱的属性。

1. 数据已经在那里

当应用启动时,它不会从服务器获取工作区。它从 IndexedDB 水合到内存中的 MobX 对象池,UI 的每个查询首先到达对象池。没有“正在加载问题”的状态,因为问题已经在用户的机器上。

我发现有趣的是,随着他们的扩展,他们使用与 JavaScript 包类似的原则对同步引擎中的数据进行分块。并非所有内容都一次性获取:两个最重的表 Issue 和 Comment 按需懒加载。这是数据层面的代码分割,它使引擎能够扩展:启动成本跟踪工作区结构,而不是工作区大小。一个有 10,000 个问题的工作区启动速度几乎与只有 100 个问题的工作区一样快。

点击进入一个项目,问题已经在那里。按负责人筛选,索引已经构建。无需获取,因为没有缺失。要么从你的浏览器立即加载,要么稍后作为代码分割的懒加载块。

IndexedDB:数据库就在你的浏览器中

2. 变更不等待网络

当你更改问题的状态时,几乎同时发生三件事:MobX observable 更新,使 UI 反映变化;变更被写入 IndexedDB 中持久的事务队列;并且它被排队等待发送到服务器。网络尚未被触及。

用户永远不必等待看到自己的更改。重试、回滚、跨加载的持久性,全部在后台。如果服务器拒绝,observable 会回退并出现短暂的闪烁,但实际上这几乎不会发生,因为大多数无效变更在事务创建之前就被捕获了。

正如我一直说的:网络是敌人,你必须尽一切可能避免它。Linear 的流程从本地变更开始,并将服务器视为确认步骤,而不是许可步骤。

3. 一个增量,一个单元格

当服务器确认一个变更(你的或别人的)时,更改会作为一个描述什么被移动的小型 JSON 包返回。客户端通过写入相应的 MobX observable 来应用它。

因为 Linear 中每个模型的每个属性都是自己的 observable,并且每个读取它的组件都被 observer() 包裹,MobX 确切知道哪些组件依赖于哪些字段。一个更改更新一个问题的一个字段,只会重新渲染读取该字段的组件。而不是父列表,不是侧边栏,只是一个单元格。一个更新 50 个问题的操作是 50 个单元格重新渲染,而不是列表重新渲染。这就是为什么一个繁忙的工作区在十个人同时编辑时仍能保持流畅:接收更新的成本与更改的内容成正比,而不是与屏幕上显示的内容成正比。

我构建过实时应用,流式传输股票数据和基本面,原子更新单个组件是让应用感觉高性能的关键。你想尽可能避免级联更新,Linear 正是这样做的。

更新列表中的问题,单个问题行重新渲染。

为什么这三者要配合使用

去掉任何一个,应用就会开始感觉缓慢。没有乐观写入的本地数据库仍然会在保存时旋转。没有细粒度 observable 的乐观写入仍然会在每次更新时卡顿。没有本地数据库的细粒度 observable 仍然会在初始加载时等待。Linear 的速度不是任何单个层的属性。而是系统的属性。

打包工具和加载壳使应用在首屏绘制时感觉快速。同步引擎使你开始使用后仍能感觉快速。


为速度而设计

速度不仅仅是工程问题。也是设计问题。一个完美构建的同步引擎仍然会输给一个慢速的输入模型:如果到达某个操作的最快路径需要鼠标、三个菜单和一次点击,那么无论底层引擎运行得多快,用户都要为这些步骤付出时间。

Linear 快速性的另一个基石是他们如何将键盘整合为导航和完成工作的主要工具。每一个常见操作都有快捷键。命令面板一键即达。右键菜单是自定义构建的。这些都不是偶然,而是从一开始就经过深思熟虑的设计决策。

每个操作都有快捷键

单个字母编辑焦点问题。双字母组合进行导航。修改键全局起作用。

听创始人们谈论 Linear 的早期,很明显快捷键从一开始就是基础。同步引擎的设计部分原因是为了让任何操作都可以随时执行。感觉这种设计与工程的结合贯穿了每个功能。

如果你浏览他们的 UI,你会注意到快捷键随处可见。最频繁的操作使用单个字母,因为它们使用最多。此外,每个操作都可以用鼠标完成,以免疏远初学者。

命令面板永远一键即达

⌘ k 打开一个命令面板,允许用户搜索 Linear 中几乎所有操作。问题、项目、标签、状态更改、导航、问题创建、设置、主题切换。命令非常快,因为它搜索的是本地的 MobX 对象池,而不是服务器。记住,避免网络。

架构上的回报是,整个应用都可以从单个面板访问。导航就是搜索。问题创建就是搜索。状态更改就是限定到状态的搜索。此外,命令是上下文相关的,并会适应当前的工作内容。这是一个很好的方式,可以为任何视图展示关键操作和快捷键。一个原语,到处使用,运行在已存在于内存中的数据上。

一个快速的应用需要出色的工程和设计。你可以构建完美的同步引擎和无懈可击的渲染管道,但如果设计错误,你仍然会交付一个感觉缓慢的东西。工程速度使单个交互变快。设计速度使每个交互的路径变短。

对于一个全天使用的工具,快捷键和两秒鼠标路径之间的差异会在每次操作中累积。将快捷键与全局命令面板结合起来,你就拥有了一款使用起来极快的应用。


动画

到目前为止所有的工作仍然可能被糟糕的动画毁掉。团队花费巨大的精力让应用的每个部分都快速。首次加载、更新、数据库查询等等。他们削减毫秒,以便用户永远不必等待。然后,在最后一步,某人为一个元素添加了 500ms 的高度动画。

你只应该动画少数几个属性

浏览器有三种属性变化层级,其成本取决于每个属性在渲染管线中的位置。合成属性(transformopacity)将工作交给 GPU,并在主线程之外运行。触发绘制的属性(colorbackground-colorborder-colorfill)跳过布局但仍然重新绘制像素。触发布局的属性(widthheighttopleftmarginpadding)强制浏览器重新计算页面上每个后续元素的位置。永远不要动画这些属性。我说的是永远。

/* Linear 的做法 */
.row:hover {
  background-color: var(--color-bg-hover);
  transition: background-color 0.12s;
}
.icon-arrow {
  transform: translateX(0);
  transition: transform 0.15s;
}

/* 如果你不明白,你可能会写的代码 */
.row:hover {
  margin-left: 2px;       /* 触发布局,影响下方的每一行 */
  transition: all 0.2s;   /* 现在你就在动画 margin */
}

margin-left 版本会在过渡的 200ms 内,每一帧都重新计算被悬停行下方每一行的布局。在一个长问题列表中,这就是流畅与卡顿的区别。

如果你检查 Linear 在其应用中动画的每一个属性,它仅限于少数几个,主要是那些合成属性(transformopacity),有时还有像 background-colorborder-color 这样的属性。

知道何时克制

在我看来,与只动画合成属性几乎同样重要的是知道何时根本不使用动画。很容易沉迷于动画。但在一个每天使用的工具中,你在营销网站上喜欢的动画会开始成为障碍。即使是一个小的悬停延迟,如果放错了地方,也会成为用户注意到的东西。

Linear 在这一点上做得很好。我觉得命令面板可能有点慢,但这些年我变得有点挑剔了。

列表项没有过渡,以保持响应迅速。

他们很多动画之所以有效,是因为它们引用了自己的来源。状态弹出框从状态标签扩展出来。代理面板从其切换按钮滑入。这些运动在做空间工作,告诉用户新元素来自哪里,而不是作为装饰凭空淡入。

保持持续时间短而快

/* 来自 Linear 样式表的变量 */

--speed-highlightFadeIn: 0s;
--speed-highlightFadeOut: .15s;
--speed-quickTransition: .1s;
--speed-regularTransition: .25s;
--speed-slowTransition: .35s;

大多数设计系统的默认持续时间都比应该的长。Material 的标准持续时间是 200ms,iOS 的弹簧动画接近 350ms。默认使用更短的过渡是让应用感觉更快的最简单方法之一,而 Linear 的默认值远低于行业标准。

Linear 在入场和退出的不对称时间上更进了一步。悬停高亮、弹出框和代理面板在召唤时立即出现,然后在关闭时在 150ms 内淡出。

代理窗口立即出现,但像 macOS 一样淡出。


Linear 为何如此快速

我还可以涵盖更多让 Linear 感觉快速的细节。现实情况是,没有单一的东西能让一个应用性能卓越。这是数百个正确决策的累积。

我喜欢 Linear 方法的地方在于它大多数都非常简单。没有 Next、没有 Tanstack、没有花哨的框架。他们很早就决定了什么架构最能服务用户,并一直坚持。结果是一个客户端渲染的应用比服务器渲染的应用更快(而且没有复杂性)!

大致形状如下:服务器是同步目标,而不是真实来源。数据库存在于浏览器中。变更首先在本地应用,然后在后台协调。首次加载以更多的碎片发送更少的代码,并通过 service worker 在用户仍在登录页面时预缓存其余部分。认证基于状态假定,之后验证。同步引擎从 IndexedDB 水合到每个属性的 MobX observable,因此更新 50 个问题是 50 个单元格重新渲染,而不是列表重新渲染。输入模型以键盘优先。每个常见操作都有快捷键,并配有全局命令面板。动画保持在 GPU 上,持续时间低于 100ms 的因果阈值,并且从不动画触发布局的属性。

难点不在于实现。而在于随着代码库成熟、扩展并面临新的约束,多年对工艺的坚持。

如果你还没有,我建议你去看看 Linear 亲身体验一番。


希望你们学到了一两件事!写这篇文章并深入探讨让 Linear 成为这样的细节很有趣。我只是喜欢构建世界上最好的 Web 应用,并看看其他人是怎么做的。如果你有任何反馈、建议或想联系,你可以在 X 上找我。

  • 原文链接: performance.dev/how-is-l...
  • 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
点赞 0
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
performance
performance
江湖只有他的大名,没有他的介绍。