关于 linear-local-first-architecture
基于 Linear 技术分析整理的本地优先架构技能,适用于打造响应迅速的效率工具,涵盖本地数据、后台同步、加载与性能优化。
主要功能
- 01在 IndexedDB 中存储工作区数据
- 02先本地应用变更再同步
- 03先渲染本地数据再验证身份
- 04将代码拆分为细粒度模块
- 05使用细粒度可观察数据
- 06优先支持键盘操作
适用场景
- 01设计支持离线的效率应用
- 02让更新无需等待网络即可响应
- 03缩短初始加载并改善缓存复用
- 04构建快速的键盘驱动工作流
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ boraoztunc/ skills --skill linear-local-first-architecture安装后在 Claude Code 中输入/linear-local-first-architecture确认已加载。
详细介绍
何时使用此技能
- 你正在构建一款感知速度对用户体验至关重要的效率工具
- 用户反馈说应用“感觉很慢”,尽管网络延迟在合理范围内
- 你需要从用户工作流中消除加载旋转图标和骨架屏状态
- 你正在设计具备离线能力的本地优先应用
- 你想实现能带来即时响应感的乐观更新
- 你正在为高级用户设计键盘优先界面
核心原则
-
网络是瓶颈——尽可能消除它。 每次网络请求都会耗费数百毫秒;最好的优化是完全避免请求,直接读取本地状态。
-
将浏览器视为每位用户的数据库。 将完整工作区存储在 IndexedDB 中,并加载到内存中的可观察图结构;UI 从本地状态读取数据,而非服务器。
-
先在本地应用变更,再异步同步。 立即更新本地可观察状态,让 UI 同步重新渲染,然后将事务加入队列,在后台同步到服务器。
-
先渲染,再验证身份。 如果本地数据存在,就立即渲染,并在后台验证会话;只有服务器拒绝时才重定向到登录页面。
-
将代码拆分为更多小块,减少每块代码的体积。 激进的代码拆分、仅面向现代浏览器的构建目标,以及按包拆分的供应商代码块,可以减少初始负载并提升缓存粒度。
-
只为合成属性添加动画。 将动画限制在
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 的事实来源。