mcpField
L
linear-local-first-architecture

linear-local-first-architecture

github.com/boraoztunc开发431176 次安装11 周前更新
代码仓库

关于 linear-local-first-architecture

基于 Linear 技术分析整理的本地优先架构技能,适用于打造响应迅速的效率工具,涵盖本地数据、后台同步、加载与性能优化。

主要功能

  1. 01在 IndexedDB 中存储工作区数据
  2. 02先本地应用变更再同步
  3. 03先渲染本地数据再验证身份
  4. 04将代码拆分为细粒度模块
  5. 05使用细粒度可观察数据
  6. 06优先支持键盘操作

适用场景

  1. 01设计支持离线的效率应用
  2. 02让更新无需等待网络即可响应
  3. 03缩短初始加载并改善缓存复用
  4. 04构建快速的键盘驱动工作流

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/boraoztunc/skills --skill linear-local-first-architecture

安装后在 Claude Code 中输入/linear-local-first-architecture确认已加载。

详细介绍

何时使用此技能

  • 你正在构建一款感知速度对用户体验至关重要的效率工具
  • 用户反馈说应用“感觉很慢”,尽管网络延迟在合理范围内
  • 你需要从用户工作流中消除加载旋转图标和骨架屏状态
  • 你正在设计具备离线能力的本地优先应用
  • 你想实现能带来即时响应感的乐观更新
  • 你正在为高级用户设计键盘优先界面

核心原则

  1. 网络是瓶颈——尽可能消除它。 每次网络请求都会耗费数百毫秒;最好的优化是完全避免请求,直接读取本地状态。

  2. 将浏览器视为每位用户的数据库。 将完整工作区存储在 IndexedDB 中,并加载到内存中的可观察图结构;UI 从本地状态读取数据,而非服务器。

  3. 先在本地应用变更,再异步同步。 立即更新本地可观察状态,让 UI 同步重新渲染,然后将事务加入队列,在后台同步到服务器。

  4. 先渲染,再验证身份。 如果本地数据存在,就立即渲染,并在后台验证会话;只有服务器拒绝时才重定向到登录页面。

  5. 将代码拆分为更多小块,减少每块代码的体积。 激进的代码拆分、仅面向现代浏览器的构建目标,以及按包拆分的供应商代码块,可以减少初始负载并提升缓存粒度。

  6. 只为合成属性添加动画。 将动画限制在 transform 和 opacity 上,以便让 GPU 处理;不要为 width、height 或 margin 等会触发布局的属性添加动画。

技巧

搭建本地优先的数据架构

将工作区存储在 IndexedDB 中,并加载到内存中的可观察存储(Linear 使用 MobX)。UI 查询本地存储,而非服务器。

// A traditional web app updating the server
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();
}

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

第一行更新内存中的数据存储(MobX 可观察状态)。第二行将事务加入队列,由同步引擎批量处理并发送到服务器。本地更新会让 UI 同步重新渲染——无需旋转图标,也无需等待。

使用标准库实现乐观更新

如果你不打算构建自定义同步引擎,可以使用 Tanstack Query 或 SWR 等库实现乐观更新:

// optimistic mutation with SWR
mutate(
  `/api/issues/${issue.id}`,
  { ...issue, title: "Faster app launch" },
  false
);

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

关键在于:UI 的响应速度不应取决于网络延迟。用户感知到的速度取决于界面响应有多快,而非服务器响应有多快。

通过仅面向现代浏览器的构建减少包体积

仅针对现代浏览器,移除旧版 polyfill,并进行激进的代码拆分:

// vite.config.ts (reconstruction; matches observed chunk graph)
export default defineConfig({
  plugins: [react()],
  build: {
    target: "esnext",            // no legacy syntax, no polyfills
    cssMinify: "lightningcss",
    modulePreload: { polyfill: false },
    rollupOptions: {
      output: {
        // One chunk per npm package > ~3 KB. Cache invalidation
        // becomes per-library instead of per-app-revision.
        manualChunks(id) {
          if (id.includes("node_modules")) {
            const pkg = id.match(/node_modules\/([^/]+)/)?.[1];
            if (pkg) return `vendor-${pkg}`;
          }
        },
      },
    },
  },
});

将每个 npm 包拆分到独立代码块中。传统的 vendor.js 一旦有任何依赖项更新,整个依赖图的缓存就会失效;按包拆分能让缓存失效范围更精细。

并行预加载关键代码块

通过在 <head> 中声明所有关键代码块,消除“获取 → 解析 → 获取导入内容”的瀑布式流程:

<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 属性一致,因此浏览器会复用已缓存的请求。冷启动加载过程从串行瀑布式流程变为单次并行加载。

使用 Service Worker 预缓存其余资源

首次页面加载后,使用 Service Worker 在后台预缓存路由级代码块:

// The service worker has a precache manifest baked into its source,
// around 1,200 hashed assets covering route chunks, icons, and fonts.
// It pulls them down lazily after the first page load.

打开登录页面几秒内,完整应用就会存入缓存。后续导航将完全跳过网络请求;Service Worker 会直接从缓存中响应。结合 IndexedDB,应用即可离线运行。

内联关键 CSS 和启动逻辑

内联足够的 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>

内联 JavaScript 会在绘制前恢复上次已知的外壳样式令牌(侧边栏背景、宽度、深色模式):

<script>
// Electron context — lets CSS branch on native chrome.
if (navigator.userAgent.includes("Electron") && navigator.userAgent.includes("Linear")) document.documentElement.classList.add("electron");

// No local store → no workspace data → render the auth layout.
if (localStorage.getItem("ApplicationStore") === null) document.documentElement.classList.add("logged-out");

// Restore last-known shell tokens (sidebar bg, width, dark mode) before paint.
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");

// Compact sidebar to a sliver when the user opens links in the desktop app.
if (JSON.parse(localStorage.getItem("userSettings") || "{}").openLinksInDesktop) document.documentElement.style.setProperty("--sidebar-width", "8px");

</script>

等到第一个 JavaScript 包加载完成时,加载屏幕已经应用了正确的主题、尺寸和位置。

优化字体加载

以正确的 CORS 模式预加载可变字体,避免重复获取:


<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");
}
/* Italic and Berkeley Mono follow the same shape, single woff2 each. */

可变字体通过单个 woff2 文件覆盖完整的 100–900 字重范围。font-display: swap 会立即渲染回退字体栈,并在 Inter 加载后切换为该字体。预加载中的 crossorigin="anonymous" 可让浏览器在 CSS 引用该字体时复用缓存。

默认已通过身份验证,在后台验证

不要阻塞等待会话验证,而是检查是否存在本地数据:

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

如果存在 ApplicationStore,说明用户之前使用过此应用,且其工作区位于 IndexedDB 中。立即渲染工作区。实际的会话令牌存储在 Cookie 中;让下一次请求(WebSocket 握手、同步增量或任意 HTTP 调用)在会话过期时返回 401,然后重定向到登录页面。

流程应关注“有没有内容可展示”,而不是“会话是否有效”。

使用细粒度可观察对象,实现精准重渲染

让每个模型上的每个属性都成为独立的可观察对象。服务器发来增量更新时,写入对应的可观察对象。框架(MobX)会准确知道哪些组件依赖哪些字段,只重新渲染这些组件。

更新单个问题的一个字段时,只会重新渲染读取该字段的组件——不是父列表,不是侧边栏,而是一个单元格。更新 50 个问题会触发 50 次单元格重渲染,而不是重新渲染整个列表。

优先为键盘操作设计交互

每个常用操作都应有快捷键。单个字母用于编辑当前聚焦的问题,双字母组合用于导航,修饰键用于全局操作。在 UI 中随处显示快捷键。

实现一个全局命令面板(⌘ k),用于搜索操作、问题、项目、标签、状态变更、导航和设置。搜索直接针对本地 MobX 对象池执行,无需请求服务器,可即时返回结果。

只为合成属性添加动画

浏览器中的属性变更分为三个层级:

  • 合成属性(transform、opacity):由 GPU 加速,不依赖主线程
  • 触发绘制的属性(color、background-color、border-color、fill):跳过布局,但会重绘像素
  • 触发布局的属性(width、height、top、left、margin、padding):强制重新计算后续所有元素

切勿为触发布局的属性添加动画:

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

/* What you'd write if you didn't know better */
.row:hover {
  margin-left: 2px;       /* triggers layout for every row beneath */
  transition: all 0.2s;   /* and now you're animating margin */
}
缩短动画时长

默认使用比行业惯例更短的过渡时长:

/* variables form Linear's stylesheet */

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

采用非对称计时:呼出时立即显示,关闭时用 150ms 淡出。这样界面会显得能迅速响应用户意图。

反模式

❌ 不要等网络请求完成后再更新 UI。 先在本地应用变更,在后台同步,只有服务器拒绝时才回滚。

❌ 不要为 width、height、margin、padding、top 或 left 等触发布局的属性添加动画。 仅使用 transform 和 opacity。

❌ 不要因身份验证而阻塞初始渲染。 如果存在本地数据,就立即渲染并异步验证会话。

❌ 不要将所有第三方代码打包到单个代码块中。 将每个 npm 包拆分成独立代码块,使缓存失效按库处理,而非按应用版本处理。

❌ 不要为频繁交互使用过长的动画时长(>250ms)。 较短的过渡会让应用显得更快;较长的时长应留给不常发生且影响较大的场景。

❌ 如果数据已在本地,不要在每次导航时都重新获取。 启动时从 IndexedDB 加载数据,按需延迟加载大型表格,并将服务器视为同步目标,而不是 UI 的事实来源。

来源

Linear 为什么这么快?技术拆解

评论

支持 Markdown