读《LobeHub 性能与 DX 优化》:把成本从每次渲染挪到只发生一次
本文是对 innei.in/posts/tech/lobehub-performance-dx-optimization 一文的分析。原文以 LobeHub 这个 ChatGPT/LLM 客户端项目为例,介绍了它在前端组件、Electron 主进程、Next.js 开发体验等多个层面做的一系列优化。本文不重复原文叙述,而是把里面散落的具体改动归纳成一个可以迁移的工程框架:四类成本 × 成本位置转移。
相关笔记
- [[笔记/web/Java/JVM 原理、性能调优与故障排查]]:JVM 调优和本文思路类似,都在做"把高频小成本摊薄"。
1. 先建立一个直觉:基础设施小成本会被组件数量放大
LobeHub 这一系列优化的共同前提是:单次操作 0.1 ms 不算贵,但乘以组件数量之后是另一回事。
文章里给出一个粗略的乘法:
0.1 ms × 500 个组件 = 50 ms
流畅渲染的帧预算是 16.7 ms。一旦基础设施里的某个微小操作变成了"每个组件渲染时都要做一次",再乘以一两百个组件,就会从"可以忽略"变成"明显卡顿"。
这个直觉很重要。它解释了为什么 LobeHub 优化的是普通读者看上去根本不起眼的东西:
- 一个
<Flexbox>组件函数本身的执行; - 一次
useStyles()Hook 调用; - 一次 Tooltip 的浮层初始化;
- 一个平时不可见的 Modal 在 React 树里挂着的 Hook。
这些都是"基础设施层小动作"。优化单个不会让某个页面从 1000 ms 降到 10 ms,但合起来,可以让一个打开首页就要 mount 几百个组件的应用变得明显顺畅。
接下来所有的具体改动,都可以理解为:把这个 0.1 ms 的小动作从"每次都发生"挪到"只发生一次",或者挪到"真正需要时再发生"。
3. 替换 react-layout-kit:把 CSS-in-JS 的运行时挪到模块加载
react-layout-kit 的 Flexbox 看起来只是一个布局组件,但在 antd-style / Emotion 体系下,它内部仍然要:
组件执行
↓
读取 props
↓
构造样式对象
↓
序列化成 CSS 字符串
↓
计算 Hash
↓
查询 Emotion Cache
↓
没有命中则插入 CSS 规则
↓
返回 className
五种不同 props 组合:
<Flexbox gap={8} />
<Flexbox gap={12} />
<Flexbox gap={16} />
<Flexbox gap={8} align="center" />
<Flexbox gap={8} justify="center" />
每种都可能生成不同的 CSS:
.css-a1 { display: flex; gap: 8px; }
.css-b2 { display: flex; gap: 12px; }
.css-c3 { display: flex; gap: 16px; }
就算命中缓存,前面仍然要:执行组件函数、构造样式参数、做对象分配、做序列化、做 Hash 计算和查询。开发环境下还要保存调试信息和 source map,内存问题会更突出。
新方案:所有实例共享一份 CSS
LobeHub 自己写了一个内部 Flex,保持原 API,但底层换成固定类:
:where(.lobe-flex) {
--lobe-flex: 0 1 auto;
--lobe-flex-direction: column;
--lobe-flex-wrap: nowrap;
--lobe-flex-justify: flex-start;
--lobe-flex-align: stretch;
--lobe-flex-width: auto;
--lobe-flex-height: auto;
--lobe-flex-padding: 0;
--lobe-flex-gap: 0;
display: flex;
flex: var(--lobe-flex);
flex-direction: var(--lobe-flex-direction);
flex-wrap: var(--lobe-flex-wrap);
justify-content: var(--lobe-flex-justify);
align-items: var(--lobe-flex-align);
width: var(--lobe-flex-width);
height: var(--lobe-flex-height);
padding: var(--lobe-flex-padding);
gap: var(--lobe-flex-gap);
}
组件只把 props 映射成 CSS 变量:
<div className="lobe-flex" style={cssVars}>{children}</div>
效果是:
gap=8 → 共享 .lobe-flex + 设置 --lobe-flex-gap: 8px
gap=12 → 共享 .lobe-flex + 设置 --lobe-flex-gap: 12px
gap=16 → 共享 .lobe-flex + 设置 --lobe-flex-gap: 16px
省掉的成本:
- 不再为每个 gap 值生成新 Hash 类名;
- 不再向
<style>持续插入规则; - 不再需要 Emotion Cache 查询。
新增的成本:
- 每次渲染需要构造一个小的
cssVars对象; - 拼一下
className。
后者明显比前者便宜。文章里指出,单组件开销大约 0.1 ms,真正严重的是几百个组件 × 0.1 ms。
CSS 变量默认值为什么要写在每个 Flex 上
CSS 自定义属性默认会继承。假设:
<div class="lobe-flex" style="--lobe-flex-gap: 16px">
<div class="lobe-flex"></div>
</div>
子元素没有显式定义 --lobe-flex-gap,可能继承父元素的 16px,子布局就多出一段间距。
所以内部 CSS 在每个 .lobe-flex 上都重置默认值:
:where(.lobe-flex) {
--lobe-flex-gap: 0;
--lobe-flex-direction: column;
--lobe-flex-align: stretch;
}
这一层默认值是必要成本。读到类似的"用 CSS 变量驱动组件样式"代码时,可以直接检查是不是每一层都重置了默认值。
5. 首页常驻:React Activity 与"访问后缓存"
LobeHub 的首页(DesktopHomeLayout)里塞了很多东西:
- 侧边栏;
- 最近记录;
- 输入区域;
- 技能列表;
- 多个弹窗入口;
- 大量 store selector;
- 数据恢复逻辑。
旧路由结构大致是:
<Route path="/" element={<Home />} />
<Route path="/settings" element={<Settings />} />
进设置页再回首页,要走完整的卸载-重建链路:
Home Fiber 被卸载
↓
Home DOM 被删除
↓
Home Effect 被清理
↓
Home 子组件状态丢失
↓
返回时:
重新执行 Home
↓
重新执行所有子组件
↓
重新运行所有 Hook 和 selector
↓
重新创建 Fiber
↓
重新创建 DOM
↓
重新插入 DOM
↓
重新计算布局和绘制
文章测得 DesktopHomeLayout 返回时接近 500 ms。
改法:把首页移出路由分支
改造后的结构大致是:
<DesktopLayoutContainer>
<DesktopHomeLayout>
<DesktopHome />
</DesktopHomeLayout>
<Suspense fallback={<Loading />}>
<Outlet />
</Suspense>
</DesktopLayoutContainer>
原来 index route 不再单独创建首页组件。
首页内部根据路径判断:
const { pathname } = useLocation();
const isHomeRoute = pathname === '/';
<Activity mode={isHomeRoute ? 'visible' : 'hidden'}>
<Home />
</Activity>
Activity 在 mode="hidden" 时的行为:
display: none隐藏内容;- 保留组件的 React State;
- 保留对应 DOM;
- 清理组件的 Effect 和订阅;
- 隐藏期间仍可能以低优先级响应新 props;
- 再次显示时恢复原状态、重新创建 Effect。
可以理解为:
Fiber、State、DOM:保留
Effect、订阅:清理
视觉:display: none
返回时主要工作变成:
hidden → visible
↓
恢复 Effect
↓
浏览器重新展示已有 DOM
不再重建整个 DOM 树。
hasActivated:访问后缓存,不是启动时预渲染
实际代码里加了:
const [hasActivated, setHasActivated] = useState(isHomeRoute);
useEffect(() => {
if (isHomeRoute) setHasActivated(true);
}, [isHomeRoute]);
if (!hasActivated) return null;
逻辑是:
用户从未访问首页 → 不创建首页
用户第一次访问首页 → 创建首页
用户以后离开首页 → 隐藏但保留
这是"访问后缓存",不是"应用启动时无条件预渲染"。否则用户直接进 /settings,应用也会在后台把沉重的首页 mount 一遍,反而增加启动时间和内存。
绝对定位和代价
常驻首页容器使用:
style={{
inset: 0,
position: 'absolute',
}}
因为首页已经不属于正常路由输出,要避免它参与其他页面的文档流布局。首页可见时覆盖主内容,隐藏时由 Activity 设为不可见。
这种优化的代价是常驻占用:
- Fiber 内存;
- DOM 内存;
- 组件 State;
- 缓存数据;
- 浏览器布局对象。
适合的场景:
- 高频返回的首页;
- 编辑器;
- 聊天会话;
- 需要保留输入状态的页面。
不适合的场景:
- 很少返回的页面;
- DOM 特别庞大的报表;
- 大量
<video>/<audio>/<iframe>页面。
<video>、<audio>、<iframe> 隐藏后 DOM 仍存在,可能继续执行自身行为,需要在 Effect 清理函数里显式暂停或释放。
7. Tooltip 单例化:共享一个浮层
旧 Tooltip 的每个实例都可能拥有:
useFloating定位逻辑;- 鼠标进入/离开定时器;
- Focus 和 Hover 交互 Hook;
- Portal;
- Arrow 定位;
- Open State;
- Motion 动画;
ResizeObserver;- Scroll 监听;
- 自动更新位置逻辑。
页面里如果有 200 个图标各自包了 Tooltip:
<Tooltip title="编辑"><Button /></Tooltip>
<Tooltip title="删除"><Button /></Tooltip>
<Tooltip title="复制"><Button /></Tooltip>
即使都没打开 Tooltip,每个实例的触发器和 Hook 仍然要创建。文章里那个 TooltipFloating 接近 200 行,包含 Floating UI、debounce、AnimatePresence、动态 transformOrigin、内容切换动画、箭头逻辑、Portal 和布局动画。
单例 Tooltip 的结构
<TooltipGroup>
<Tooltip title="编辑"><Button /></Tooltip>
<Tooltip title="删除"><Button /></Tooltip>
<Tooltip title="复制"><Button /></Tooltip>
</TooltipGroup>
每个 Tooltip 只注册:
触发节点
title
placement
回调
整个 Group 共享一个真正浮层:
一个 Root
一个 Portal
一个 Floating Content
一个 Arrow
一套定位状态
鼠标进入某个触发器后,只更新当前 payload:
activeItem = {
title: '删除',
anchor: deleteButton,
placement: 'top',
};
浮层被重新定位到新 anchor,而不是每个按钮都创建完整的浮层组件树。
实际实现基于 Base UI 的 <BaseTooltip.Root handle={handle}>,由 Group Context 共享 handle。普通 Tooltip 发现自己位于 Group 内部就进入共享模式;受控 Tooltip、带 defaultOpen 或显式 standalone 的 Tooltip 保留独立实现。
为什么 Base UI 比完整 UI 库更容易优化
Base UI 属于 Headless 组件,主要提供:
- 可访问性;
- 交互状态;
- 定位;
- 键盘行为;
- ARIA;
- 基础 Portal。
样式和动画由项目自己控制。对比完整组件库,可以少承担:
- 兼容层;
- 主题包装;
- 多层 Context;
- 默认 DOM;
- 默认动画;
- 样式注入逻辑。
更准确的表述不是"Ant Design 慢、Base UI 快",而是 "LobeHub 能基于 Base UI 实现更小的组件树和共享浮层"。是否值得换基础库要看你自己的项目里这种场景有多少。
9. Electron 包体积与原生模块三件事
Electron 主进程原本可能直接依赖:
import somePackage from 'some-package';
如果构建工具把依赖全部 externalize,产物里只保留 require('some-package'),运行时必须携带完整 node_modules/some-package。整个 node_modules 还包含:
- 未使用文件;
- README;
- 类型声明;
- Source Map;
- 多模块格式;
- 测试代码;
- 可选资源;
- 重复依赖。
改为打包普通 JS 依赖
优化后的 electron-vite 不再把所有依赖 externalize。普通 JavaScript 包会被 Rollup 打入:
dist/main/index.js
dist/preload/index.js
Rollup 顺手做:
- Tree Shaking;
- 去重;
- 压缩;
- 内联依赖;
- 删除未引用代码。
electron-builder 配置:
files: [
'dist',
'resources',
'!node_modules',
...getFilesPatterns(),
]
整体排除 node_modules,只重新加入必须保留的原生依赖。
原生模块不能一起 Bundle
比如:
better-sqlite3
sharp
node-mac-permissions
这些包可能带 .node 文件,是本机动态库,不是 JavaScript。Node.js 加载它需要真实文件路径:
process.dlopen(...)
所以原生模块要同时满足三件事:
Rollup 不打包它
rollupOptions: {
external: getExternalDependencies(),
}
生成结果仍然保留 require('node-mac-permissions')。
electron-builder 把它放进安装包
files: [...getFilesPatterns()]
从 ASAR 中解包
asarUnpack: getAsarUnpackPatterns()
最终文件落在 app.asar.unpacked/node_modules/...。
项目还会递归读取原生模块的 package.json,把它的传递依赖一并加入,否则原生模块本身存在,但启动时仍可能因为缺少子依赖而失败。
三项必须一致:
external 列表 = builder include 列表 = asarUnpack 列表
少任何一项都可能"构建成功,运行时失败"。这是 Electron 打包最容易被新人踩的坑。
Electron 语言资源删除
Electron Framework 自带 Chromium 本地化资源:
en.lproj
zh_CN.lproj
ja.lproj
fr.lproj
这些是 Chromium 原生界面资源,不是应用自己的 i18n JSON。
构建完成后的 afterPack Hook 遍历:
Electron Framework.framework/
Versions/A/Resources/*.lproj
只保留英语相关目录:
new Set(['en', 'en_GB', 'en-US', 'en_US']);
不能全部删掉,Electron Framework 仍需要基本本地化资源。
11. IPC 类型安全:从字符串到 Controller 注册表
旧写法:
ipcRenderer.invoke('minimizeWindow', params);
ipcMain.handle('minimize-window', handler);
拼写不一致、TS 检查不出来、通道名 / 参数 / 返回值 / 主进程实现 / 渲染进程调用彼此独立。重构主进程方法时,调用方可能仍然编译通过,运行时才报错。
Controller + 装饰器
主进程改成:
export default class BrowserWindowsCtr extends ControllerModule {
static override readonly groupName = 'windows';
@IpcMethod()
minimizeWindow() {
BrowserWindow.getFocusedWindow()?.minimize();
return { success: true };
}
}
通道名自动推导为 windows.minimizeWindow。Controller 统一注册到 controllerIpcConstructors,既用于运行时安装 IPC Handler,也用于 TypeScript 提取服务类型。
渲染进程用 Proxy
const ipc = ensureElectronIpc();
await ipc.windows.minimizeWindow();
ensureElectronIpc() 可以用 JavaScript Proxy 收集属性访问:
ipc.windows.minimizeWindow()
↓
window.electronAPI.invoke('windows.minimizeWindow')
TypeScript 类型来自 Controller 注册表,能推导:
- 可用 Controller;
- 可用方法;
- 参数;
- 返回值。
但 TS 类型只在编译期存在。IPC 是跨进程边界,渲染进程可能被攻击,主进程仍然要做运行时参数校验,不能只依赖 TS。
13. Next.js 到 Vite:拆分前后端构建职责
LobeHub 的方案不是简单"删 Next.js",而是拆分职责:
Vite
├─ React SPA
├─ Desktop UI
├─ Mobile UI
├─ React Router
└─ 前端开发服务器
Next.js
├─ API Route
├─ Auth
├─ 服务端逻辑
├─ Serverless 部署
└─ 必要的服务端入口
开发纯 UI 时只启动 Vite,不启动完整 Next.js App Router、RSC、服务端打包、路由分析流程。
大型 Next.js 项目开发内存较高的原因
App Router 项目开发时可能同时维护:
- 客户端模块图;
- 服务端模块图;
- RSC 模块图;
- 路由树;
- Server Action;
- Route Handler;
- 中间件;
- 热更新状态;
- Source Map;
- 构建缓存。
同一个模块还可能存在客户端版本、服务端版本、RSC 引用版本。Vite 纯 SPA 模式主要维护浏览器端 ESM 图,按请求转换模块,只开发 UI 时进程模型简单很多。
文章里的 Next.js: 10 GB+,Vite: 约 1 GB 是 LobeHub 项目自身的实验结果,不是所有项目都会固定相差十倍。项目规模、插件、Source Map、依赖树、Node.js 版本都会影响。
迁移代价
迁移后要重新处理:
- App Router 到 React Router 的路由映射;
- SSR 和 SEO;
- 服务端配置向前端注入;
- 鉴权和路由守卫;
- Next API 的代理;
- Vite 与 Next 两个开发服务器;
- 静态资源路径;
- Electron 和 Web 的构建差异;
- RSC 组件边界。
这属于架构级 DX 优化,不是简单地把 next dev 改成 vite。如果当前 Next.js 用得很顺,未必值得搬。
参考
[1] LobeHub 性能与 DX 优化. innei. https://innei.in/posts/tech/lobehub-performance-dx-optimization
[2] antd-style createStaticStyles. antd-style. https://github.com/ant-design/antd-style
[3] Base UI. MUI. https://base-ui.com/
[4] React Activity (Offscreen Component). React. https://react.dev/blog/2025/04/23/react-19-updates
[5] class-variance-authority. https://github.com/joebell.co.uk/cva
[6] electron-vite. https://electron-vite.org/
[7] electron-builder. https://www.electron.build/