Logo

site iconInnei | 拾一

数字游民,NodeJS全栈开发,前支付宝、小红书。
请复制 RSS 到你的阅读器,或快速订阅到 :

Inoreader Feedly Follow Feedbin Local Reader

Innei | 拾一 RSS 预览

记 LobeHub 的性能和 DX 优化

2026-01-13 23:34:08

该渲染由 Shiro API 生成,可能存在排版问题,最佳体验请前往:https://innei.in/posts/tech/lobehub-performance-dx-optimization

距离去新的工作差不多一个多月了。这段时间刚入职,其实都挺忙的,而且公司也正在筹划发布 2.0 的版本,所以我加入的时候刚好算是个好时机(忙到起飞)。

这一个月基本都是在冲刺。我发现,我每次换一个公司,刚进去的一段时间都会去做一些项目中的性能优化,以及对一些开发体验不友好的事情,做过多的调教。

尤其是现在大语言模型在编程领域的能力过于出色,到了一个人人都能写代码的时代。

在代码由 AI 快速生成的环境下,很难做到对代码质量的可控。尤其是对于 React 来说,大家往往会为了追求性能最优,而忽视了代码质量这一点。我也算是 LobeHub 的早期用户之一。在很长一段时间里,大家对它的印象其实就是很卡,特别卡。

我也在自己的服务器上部署过,可能本地部署的感知程度会稍微小一点,但不管怎么样,性能问题一直都是大家口中的痛点。

刚好在这里,我先来总结一下过去一个月我对 LobeHub 做的一些性能优化,以及它到底提升了多少性能。

Infra/Performance

react-layout-kit 替换

这个库是一个 CSS-in-JS 的 Flexbox 组件库。现在有了 Tailwind 的话,编写 Flexbox 布局也是能非常快写出来的。但由于 LobeHub 没有使用 Tailwind,所以就有了这样一个库,能够更快地编写 Flexbox 并设定其各种属性。由于是 Flexbox,所以在项目中大量使用。一个页面可能有几百个这样的组件,所以数量是非常庞大的。

通过火焰图的对比,其实单个组件的性能开销也还好,基本上在 0.1ms 左右。当然,如果是一个普通的 div 组件,这种开销通常小于 0.1ms。

但在组件使用非常多的情况下,0.1ms 的量级累积起来其实也会很大。当然,从性能上来说,其实感知的开销还好。但另外一个问题是:CSS-in-JS 这种方式,只要每个 Flexbox 的属性没有相同的地方,就会生成大量的 CSS。

由于它是 CSS-in-JS 的实现,生成的每一段 CSS 都会比较占用内存。特别是开发环境下。

下面那个测试链接也是我当时写的,主要是对比两种方案的性能、渲染时长和内存占用:

  1. 纯 CSS 的实现
  2. 基于 Emotion 的 CSS-in-JS 方案

https://css-in-js-batch.vercel.app/

可以看到,虽然两者的开销差别并不是很大,但在内存占用方面,差距达到了大概十个量级左右。

当然,在生产环境下可能没有这么夸张,但依然会有明显的性能提升。

https://github.com/lobehub/lobe-ui/pull/424/changes#diff-42328900eb42223334f9d29281070d175ac52eb619023cc07effc02fc62be8dfR6

CSS in JS hook

接下来这个问题,还是通过火焰图排查渲染的情况发现的。

在业务中,由于大量使用了动态生成的 CSS-in-JS 的 React Hook,产生了这个 Hook 的性能开销比较大的问题:

  1. 每次 Re-render 都会重新跑一遍
  2. 只要 Re-render 的数量特别大,性能上就会感到非常卡顿
  3. 另外,Hook 几乎被所有组件所使用。

所以,如果一个页面上有几百个组件同时都在运行这个 Hook,那么这次 layout 基本上一定会花费大量时间来处理。

这个问题的发现,也是通过火焰图排查到有一些比较轻的组件,渲染却用了一毫秒。在开发环境下要花一毫秒左右的时间,就感到特别奇怪。

然后去排查是哪个 Hook 导致的,最后发现是一个 useStyle 的钩子。

找到问题之后,就比较好解决了,我们只要换一种方案就行。

在这里,我们提出了一种优化方案:不再使用动态生成 CSS-in-JS 的方案,而是使用静态方案。这样的话,性能上也会提升不少,毕竟在组件的运行时上不会再有额外的开销了。

https://github.com/lobehub/lobe-ui/pull/437

https://github.com/ant-design/antd-style/pull/205

首屏离屏优化

上面这两个是比较重大的优化。这波优化全部完成之后,整个 APP 已经有了很明显的提升。

以前消息列表中的每个 Message Item 都需要花很长时间去渲染,而现在在开发环境的表现已经和之前在生产环境差不多了,在生产环境上则会更快。内存表现也是:现在可以保持在正常情况下 400-500 MB 的样子,甚至更低。

首屏的性能优化,主要做的是如何让用户从其他页面返回首屏时速度更快。

首屏的加载时间是非常影响用户体验的一个点。在 React 中,当一个页面切换到另一个页面时,上一个页面一般都会被销毁。如果首屏组件的逻辑很重,从另一个页面返回首页时,就需要等待首页的重建。

用户需要等一段时间,而这段时间页面相当于处于不可用的状态。

可以看到上面那张优化前的图。从其他路由返回到首页时,Desktop Home Layout 这个组件在重新渲染时大概花了 500ms。

使用 React 的 Offscreen(也就是 Activity 组件)优化之后,从其他页面返回到首屏的时间控制在 55ms 左右,几乎在点击的瞬间就可以完成跳转。

这是在开发环境下的火焰图表现,在生产环境下,它的速度会更快。

https://github.com/lobehub/lobe-chat/pull/10890

基础组件的优化

完成了上述内容后,在消息列表中,单个 MessageItem 的首次渲染性能有了非常大的进步。

但是,MessageItem 组件过于复杂,部分基础组件如果渲染开销较大,也会影响整个 MessageList 的性能。

首先重构了 AccordionItem 组件,这类组件在未展开时,内容区的 React 组件逻辑通常不必执行。因为它在 UI 中本身不可见,那么对应的组件逻辑也不应执行。

这是一种常见的优化手段,在比较复杂的组件中,能够显著提升性能。

https://github.com/lobehub/lobe-ui/pull/430

Tabs 也是基础的 UI 组件。在之前的 AntD 实现中,单个 Tooltip 的渲染性能开销非常大,在开发环境中平均需要 0.5ms。

在过去的多个PR中,我使用了 Base UI 以及组件单例的方案,重构了包括 Tooltip 和 Popover 两个组件。

目前在业务中,基本已经把 Tooltip 替换完毕了,Popover 的话还有剩余一些。更换之后,这两个组件的性能比之前有了很大的提升。

https://github.com/lobehub/lobe-ui/pull/448

还有其他一些基础组件也还有优化的空间,但目前还没有细看。

后续的计划如下:

  1. 稳步迁移更多组件
  2. 将组件从 AntD 迁移到 Base UI 或者其他 Headless 库

剪裁 Desktop App 体积

关于裁剪 Electron 体积的话,其实我之前也分享过经验。

首先在 Mac 上,Electron Framework 的体积是可以裁剪的。它里面自带了很多语言包,占用大约 34MB 左右。我们可以把它们全部删掉,只留一个英语(en.lproj)就可以了。需要注意的是:

  1. 如果一个都不留,程序会闪退,所以必须保留一个。
  2. 在 App 中,这些语言界面我们基本上会自己做一套 i18n 去维护,所以没必要使用它自带的。

通过这种方式,直接就能省掉 ~30MB 左右的体积。

其次,我们可以优化 ElectronBuilder 打包 node_modules 的逻辑。

一般来说,如果 Electron 主进程(Main Process)的打包没有 Native Binding 的话,我们可以利用打包器把所有的三方依赖打进我们的 Bundle 里面。这样一来,程序就可以完全脱离 node_modules 去运行。

然后可以修改 ElectronBuilder 的配置在配置中将 node_modules 排除掉。

当然,这里考虑到后面可能会使用到 Native Binding,所以我在里面做了一层兼容性。

如果有 Native Binding 的话,我们可以把单独的 Native Binding 依赖加进配置,这样一来,这个依赖就会被打到 asar.unpack 里面。

详细的配置我就贴在下面,大家有兴趣可以看一看:

https://github.com/lobehub/lobe-chat/blob/9145bc36b0af7da74741e3c5fd8ed61744e0a3d1/apps/desktop/electron-builder.mjs

https://github.com/lobehub/lobe-chat/pull/11397

经过这些处理,大概裁剪了 100MB 左右。现在 App 的体积大概在 260MB 左右。

DX

上面就是一些性能优化的点。

接下来,我想说一说影响开发体验(DX)的一些优化吧。

首先是我很早以前跟空谷提过的建议:把 i18n 的 key 扁平化,不要使用对象嵌套的方式。

这样做的原因主要有以下几点:

  1. 定位方便:你可以直接通过这个 key 在代码中找到对应的文案位置。
  2. 操作简单:如果是对象嵌套的话,你不能直接复制 key 过来(因为 key 是一层一层的,你必须手动去拼接),而扁平化的 key 则可以直接复制使用。

也是终于把这个事情给做掉了。

还有一个也是我想想安利一下的,就是我之前写过的一个库:electron-ipc-decorator。我也把这个库给实装了。

因为之前的实现需要一个 dispatch,然后再去做 subscribe,类型化也不太安全,而且 dispatch 的 type 也都是 hardcode 的一个 string 编码,我感觉不是很好。

所以我把自己这一套也换了上去,现在写起来比较方便。

https://github.com/lobehub/lobe-chat/pull/10679

剩下的话其实还是在做一些计划。

比较大的一个重构点,应该就是把 Next.js 迁移到 Vite。这个计划的工程量很大,目前还在筹划中。我也做了一些实验,应该还是有可行性的。

如果这个能成功的话,那应该也是很多开发者的一个福音了。

不管是团队内的人还是社区的人,现在应该都能很明确地感受到,现在 Next.js Server 启动的话,大概要十多个 G 的运行内存。如果你是 16G 的机器,基本上是没有办法开发的。

而通过实验做下来的结果是:Vite 可以控制开发时的运行内存,只需要大概 1G 多一点就行了。相比之下,内存占用和现在相比大概减少了 10 倍左右。

https://github.com/lobehub/lobe-chat/discussions/10830

看完了?说点什么呢

我是如何使用 AI 辅助创作的

2025-12-28 20:29:16

该渲染由 Shiro API 生成,可能存在排版问题,最佳体验请前往:https://innei.in/posts/tech/how-to-use-ai-for-assisted-creation

如标题所见,在最近的编写的一些文章中,我使用了 AI 的辅助。其实早在过去一年编写的手记中,也常规的使用这个方式,只不过更多的用于标题的生成,我对文章的取名一直非常头疼,后来就写完整篇之后让 AI 综合全文的内容帮我提取几个关键字来取几个文艺点的标题。

在最近编写的文章中,如那篇「回头看见自己」中基本都是 AI 辅助编写的。我在「此站点」中增加了一个 Q&A:

在 25.11 月发布的文章都会公开:在编写文章时是否使用了 AI 的辅助。

可能很多人会反对使用 AI 进行创作,编写博客等等。那么当你看到文章顶部的 AI 声明之后你有权利选择直接关闭。

我个人并不反对使用 AI 辅助写作,当编写技术文章时,我可以借助 AI 帮我快速根据相关实现绘制流程图,以便读者更加清晰的理解,而在前 AI 时代,则往往需要花费大量的时间,或者因为这个理由而放弃绘图。当编写生活记录时,我往往借助 AI 帮我取一个标题,我承认我不是一个大作家,写不出更好的文笔或者叙事结构,我想记录故事,而 AI 帮我整理故事。

编写这篇文章首先是澄清关于 AI 的使用,另外也同时分享如何使用 AI 写出更好的文章。

提效 or 咬文嚼字

首先是一个问题,你认为使用 AI 辅助创作会节省大量时间吗?至少对我来说,需要分为两个维度。

在编写技术类文章时,确实会大幅节省,而且比手作的内容会更加完整。正如前面所说,在手作的时候,在画流程图会花费大量时间,往往会因为时间成本而放弃;在内容结构上也会精简掉很多。而借助 AI ,它可以阅读项目的代码,再加上你告诉他当时的实现思路,它可以很迅速的分析然后编写出叙事清晰的文字、流程,甚至使用 excalidraw 绘制图表。

在编写手记生活类文章,并不会节省时间,甚至会花费很多的时间,至少对我来说是这样。AI 并不知道你的生活发生了什么,生活不是代码,无从读起。我会把所有的经历说给 AI 听,这个过程中,相当于我已经完成了一遍文章的编写。在没有借助 AI 之前,差不多到这个时间就截稿了。取上标题和分段,就直接发布了。而现在,需要让 AI 结构,整理,调整上下文过度。然后我还要校对几次确保没有把我需要表达的内容曲解。有些措辞我感觉不好,就会多次询问 AI 修改等等。花的时间不止一点。

技术类文章辅助

在编写 Better Auth 的多租户用户鉴权的构想 文章时,初次使用借助 AI 辅助作图的能力。比如解释为什么的时候,可以画一个图:

不得不说这图画的比我好太多了

再加上文章中有很多 Demo 代码,实际上都是实际业务中的代码抽离出来删掉很多多余的逻辑的,这个过程如果手动去改的话还挺容易删出问题,而且还需要增加注释和删除代码后的格式化问题,之前很多 demo 代码我都懒得管。而 AI 通过读项目,可以非常快速的写完 demo 代码,并且加上注释:

生活类文章辅助

在今年的年终总结 2025 · 仍在路上,半径之外 中,使用了 LobeHub 进行故事整理。

在开始之前我使用内置的 Agent Builder 构建了一个专门的写作助手:

随后在文档中进行内容创作:

和 Agent 讲故事

我会分段的和 Agent 讲故事,他会把故事写到文章里。你会发现这个过程中,我自己也会完全写作一遍,我的陈述和最后的插入的内容基本也是一致的。

在最后的过程中,可以进行小标题的重构:

然后进行一次 review,把很多段落重新调整,阅读一遍,发现有些措辞不太好,但是也不知道怎么改会比较好,我会询问 GPT。

找到一种我想要表达的方式,而不是过分的曲解。

再比如取名:

在这个案例看来,这篇年终总结所花费的时间比往年的更长,故事叙述大概花了 4 个多小时,然后是结构整理,改一些过度承接,取名等等。

感谢你能看到这里,此篇文章为手作。

看完了?说点什么呢

2025 · 仍在路上,半径之外

2025-12-27 23:38:02

该渲染由 Shiro API 生成,可能存在排版问题,最佳体验请前往:https://innei.in/notes/205

车与自由

去年年底提了车。今年年初,家里人都回来了,我也正好需要磨练车技,就带着他们去了周边很多地方。

买车这件事我一直觉得离我很远。那时我已经远程工作快两年,住在小县城里也基本足不出户,实在想不出它能带来什么改变。无非是带来更大的花销,生活又多一笔隐形的负担。

有车之后,最大的变化是生活不再局限在一亩三分地:我拥有了随时出门的自由。往年我也经常往返于杭州、上海,和从前的同事、朋友见面,但这些安排总得提前几天买好车票、把行程安排妥当。现在最好的地方在于,我不必再提前计划。而现在可以只是一个临时的念头,就能立刻出发,对我这种 P 人来说实在太友好了。偶尔也可以玩到很晚,不需要因为赶不上定的高铁票而在外面借宿一晚或者被时间限制不得不在约定的时间提前结束活动。我可以在凌晨上高速,安静地把车开回去,顺便体验夜路驾驶的乐趣。

今年第一次出国旅游,目的地是新加坡。第一次出国的新鲜感很强。新加坡华人很多,基本都说华语,没有什么语言障碍;好吃的也多,好玩的也多,算是开眼看世界了。旅行本身没有太多戏剧性,更多是把自己从熟悉的坐标系里挪开一下,同样的生活节奏,在别的城市里会显得轻一点。

https://innei.in/notes/199

长焦

今年买了一台人生第一台相机,富士 XT-5,开始走上摄影的败家之路了。

由于买不到套机,我是加价买的16-50 的套头,再加一个 35 定。用了一阵子之后,很快就意识到焦距不够长。没办法拍到远处的东西,感觉还不如手机。于是又添了一支 70-300。结果买了没几天就撞上 618,价格直接掉了 1k,心在滴血。但事已至此,也只能用一句“早买早享受”给自己收尾。相机到手后的第二天就去了湖州拍照。也正是在那次出行里,我意识到长焦有多适合我这种 I 人,主要是拍妹妹更好了(不是)。

有了相机之后,也开始 push 自己多出去走一走了。背上相机,记录一些原本会被忽略的静止瞬间,也会多留意身边的事物了。七月,时隔一年再次来到南京,这次停留得更久。去了朋友即将毕业的大学校园,也去了朋友即将“毕业”的小米园区;夜里在老西门闲逛,白天去了红山森林动物园。在城市里慢慢走、慢慢看,相机也成了我重新认识一座城市的方式。

https://innei.in/notes/195

随着去的地方越来越多,我也开始意识到,需要一个属于自己的空间来存放这些被记录下来的瞬间,需要一个像照片的个人博客一样的地方。

加速度

印象很深的是,那正好是五月份,Sonnet 4 的发布,在编码能力和工具调用能力都有了明显提升,「Vibe Coding」这个说法也开始出现。第一次使用这个模型时的感觉非常惊艳:只需要非常简短的描述,就能生成一个完整、可用的简单项目。

也是在那个时候,我完成了第一个大部分代码都由 AI 完成的项目,Afilmory。

https://github.com/Afilmory/Afilmory

有了 AI 之后,很多以前不敢想的事情都变成了现实。比如在 Afilmory 这个项目里,通过多次和 AI 反复探讨实现细节,最终做出了一个支持超高像素的 WebGL 图片预览器,这在过去几乎是不太敢轻易尝试的事情。

后来随着越来越频繁地使用 AI,我也慢慢想明白了一件事:我之所以一直喜欢写代码、喜欢做 ToC 的项目,并不是因为代码本身有多迷人,而是因为我享受把一个想法打磨成产品的过程,反复调整细节,直到它真正好用。现在有了 AI,这个过程的门槛和成本被明显降低了,我可以更快地把想法做出来,让它跑起来,并且产生真实的价值。

从那之后,我也算是真正“开上了自动挡”。到现在为止,已经借助 AI 完成并上线了不少项目:比如 Torrent Vibe,还有后来重构成 SaaS 的 Afilmory 也算是一个真正意义的比较大的项目,还有一些摄影相关的小工具。这些项目都已经跑在生产环境里,并且运行得相当稳定。

今年也是见证了 AI 的飞速发展,模型厂商内卷,几乎每个月都有更好用的模型出来,还有针对编码场景的特调版本。前半年在使用 AI 的时候,还需要编写完整的流程,探讨编码计划,一步一步引导式,最后甚至要写一个 Plan;后来进化到使用上下文工程,开始写 PRP。到了后半年 CodeX 的出现,它可以长时间执行一个任务,通过探索所有相关代码一路跑下去。我第一次用它的时候,一口气跑了一个小时最后完美实现需求,那种感觉很难不被惊艳到。凭着非常优秀的上下文和压缩能力,我一度觉得它的上下文高得有点可怕,基本不需要我再刻意去感知它。

一不小心又说了太多 AI 的故事了,容我控制下情绪。

工匠

年初花了一两个月做 React Native 的移动端 App。为了打磨细节,也顺手研究了不少 iOS native 的东西,甚至手搓了一些原生模块,只是为了性能能更好一点。我还自己实现了一套 React Native 的 Navigation,底层是基于 React Native Screens。初衷很简单:大家都在用的 React Navigation 那套声明式写法会影响后面的 DX,我总觉得它应该更接近 iOS UIKit 的 Navigation,所以就自己对齐了一遍。前半年 AI 还没现在这么能打,很多东西基本都是手搓,时间和精力确实花了不少,但也挺有意思的。

https://innei.in/posts/tech/build-simple-navigation-with-react-native-screens

工作这块,一整年基本还是围着一个 RSS Reader 打转。后面有一段时间,我几乎把所有精力都在 RSS Reader 的 AI 实现部分上。其实很早就能放 Demo 出来,但效果一直不满意,就这么拖着,迟迟没有发布。前前后后折腾了半年,最后效果也并不好。

这半年倒也不算完全白忙。我学到了不少 AI 相关的知识,也认识了一些业界有名的大佬,和他们的对话里学到很多。这也是一个契机,以至于后面我加入了一个 AI 的创业公司。

后面的故事就没那么顺利了。熟悉我的人大概也知道是怎么一回事。如果不知道,也没必要刻意去吃瓜了。

https://innei.in/notes/204

总之我去年被裁了,今年又被裁了。我是什么裁神爷,一年干黄一个项目。

又回到风里

后来我又加入了一家创业公司。我还是很喜欢创业公司的,即便已经是工作三年的老登了,仍怀着创业的热情。

为什么说是“又”:短短 5 年里,我加入了 4 家创业公司,其中有一家实习了一年半;剩下两家,一家呆了一年,一家呆了一年半不到一些。如果可以的话,我也想一直干下去。

我也在中大厂待过一段时间,所以我很清楚自己喜欢什么样的氛围。至少现在,初心犹在。

我没有把“被裁”当成故事的结尾。我还不太会安稳,只能让自己不停往前走。做点事,心里才踏实。

败家

今年也买了不少设备。除了 XT-5 和一堆镜头之外,也换了手机。一年一换几乎成了常态,真的有点败家。

还买了一台投影仪,现在终于可以躺在床上追剧了。

电脑也换了:Mac Studio M4 Max,128G 顶配内存。买之前想了很久,一直犹豫。后面因为要跑大项目,咬咬牙直接上了顶配。现在开发同时起四五个项目都不卡,太值了。

我人生里一共拥有过四台苹果电脑。这台是我人生中购入的第二台苹果电脑。

第一台是 MacBook Pro 16 的一款 Intel 芯片机型,那是我刚进入社会实习时老板发的 bonus,也算是开启了我后面的开发之路,我很感激他。

第一台 Mac Studio 是我在第二家创业公司时送的,一直陪我开发到现在,也很感谢它。

而我自己买的第一台是 MacBook Air M2,到现在已经三年多了。我平时在家的时间多,所以也一直没有换。

这些机器堆在一起,像是把我的几年拆成了几段:有人给过我起步的机会,也有人在路上借我一把力。我不太擅长表达,但都记得。

剧与游戏

今年看了不少剧,大部分都是韩剧。最近还在追《模范出租车》第三季。

也玩了一些 3A 大作,比如《死亡搁浅 2》。和前作一样,沉迷送快递,乐此不疲。

后面玩了《羊蹄山之魂》,画风绝美。和前作相比,可玩性更高了,有一种玩塞尔达的感觉:总是在赶路的时候,被偶遇的事件吸引走。

我喜欢随机事件把我带跑偏。现实里跑偏要付出代价,游戏里跑偏反而是奖励。


今年写得比去年少,掰着手指算也就 30 篇。可这一年并不空,只是没写出来。

以上就是全部的内容,感谢能看到这里。

其他的总结

依然是干劲满满的一年

里程碑

  • GitHub followers 2,422 -> 3,212
  • X 7,385 -> 9,673
  • 设备更新
    • iPhone 17
    • Mac Studio M4 Max 128G
    • Vidda C3S
    • Nuphy Air 75 v3

关于未来

我没指望未来会突然变好。只希望明年风小一点,我能更稳地往前走。

往年回顾

看完了?说点什么呢

在安稳中寻求生存

2025-12-14 13:32:01

该渲染由 Shiro API 生成,可能存在排版问题,最佳体验请前往:https://innei.in/notes/204

这段时间,事情一件接一件。先从日常说起吧。

日常片段

最近看了新的 IP:疯狂动物城,前作看了一半之后忍不住直接去电影院先去把 2 看了。

https://www.themoviedb.org/movie/1084242-zootopia-2

复仇爽剧:模范出租车出第三季了,速看。

https://www.themoviedb.org/tv/119769-taxi-driver

上个月说的羊蹄山之魂通关了,正在二周目新游戏+。最近有点忙,还没有时间玩。

然后再来说说一系列的难受的事情。

体面告别

裁员那天,HR 找我聊。我以为会是一次体面的告别,甚至在某个瞬间,我还真的把它当作“和平结束”。

我接受得很平静。没有赔偿,只有十天的通知费,设备还要折旧回收。

这和老板之前的说法不一样——他说会有一笔金额,最后并没有兑现。说到底,给不给,很多时候更像是取决于公司怎么选择,我也很难指望一定会有结果。我当时理解香港这边的规则和大陆不太一样,很多保障并没有那么到位,至少对我来说是这样。相比之下,大陆的劳动法在这件事上,确实更能保障一点。

删文回声

后来我写了一篇文章,是对这段时间的总结、复盘和经验整理。我写到了一些我认为真实发生过的事情,于是被要求删掉。他们似乎不希望外界因此去质疑产品的未来。但在我看来,这个项目对外的更新与动作已经停滞了一段时间,我很难再用更乐观的词去形容它。

我还是删了。调整内容,重新发布。

删掉的那几个小时,反而让吃瓜的人传得更开:更多人开始围观一个可能从来没用过的产品,开始猜测、开始补全故事。

我没想过会这样。我只是把文字放在自己的博客里,没有在任何社交媒体发链接。可它还是自己长出了腿,走得比我想象的远。

总之,事情已经发生了。只是我没想到,最安静的地方,也会有回声。

气愤起点

后来的事情,让我开始感到气愤。

我从一些同样被裁的人那里,听到了很多过去从未得知的细节。起初我也知道:所谓「事实」,往往只是当事人愿意倾述的版本;人们更愿意相信自己想相信的那一套。可再往后,一些细节在不同人口中反复出现,我很难再把它当成纯粹的「版本」。

而这些信息里,我感受到的是一种让我很不舒服的做法。我很难把它当成善意。

我甚至有种恍惚:我正在经历的流程,像是他们已经很熟悉的流程。很多表达让我感觉更像是在引导情绪,让人先心软,再一点点降低预期。

有些话当时听起来很重,像是在替你争取,像是站在你这边。可很快我又看到各种说法开始变化,解释一层层叠上来,最后结论还是往下走。有人情绪激动,场面也变得很难堪,我也在那种尴尬和同情里破防。

我就是在那一刻意识到:这不是沟通,也不是协商。我当时的感受是,这一切太熟练了,像是事情最终会走向哪里,大家心里早已有数。

我当时接受得很平静,而此刻回想起来,更像一个小丑。

我其实并不在意有没有赔偿。入职之前我就查过资料,也问过 GPT,知道香港的法律大抵如此:很多东西写不写进合同、给不给到位,最后都落在“愿不愿意”上。换句话说,我对结果并不意外。

让我气愤的不是钱。是前后说法的落差,是那种被轻轻带过的感觉。

是他们先把话说得像承诺,让你以为自己会被认真对待;又在最后一刻轻描淡写地收回,仿佛从来没有说过。你甚至很难抓住一个明确的“反悔瞬间”,它更像是一种温柔的推诿:说过,但不算数;答应过,但没保证;本来想给,但现在不行。

公关开场

直到那天的晚上,老板出面发布了第一个公关帖子。

在发帖前后,我注意到他之前转发过的一些内容不见了。这种变化让我很难受,像是我们被悄悄从叙事里抹掉了:那是裁员当天,我们在 X 上发帖找工作,他还转推了,看起来像是"好聚好散"。

可现在,那些转推都不见了。仿佛他从未表态过,仿佛我们从未存在过。舆论开始发酵,像一把火终于找到了引线。

我很气愤那些让我觉得不真诚的说法,但我还是忍着。

很多认识我的朋友,在我把事情的全貌讲清楚之后,都站在我这边。有人在评论区替我打抱不平,也有一些从未谋面的、一直默默关注我的人给我留言。谢谢你们。那一刻我才意识到,原来我并不是一个人在扛。

招人疑云

可真正让我彻底破防的,是这篇公关帖之后发生的另一件事:后来我看到有人公开说“还在招人”。这和我当时的处境放在一起,让我非常困惑。

我后来从别人转述里听到,确实有人收到了类似的招聘信息。真假我不想再追究,但它带来的观感已经足够刺痛。

一边是把所有开发裁掉,一边又在招人。即使我已经大概能猜到这个项目的结局,我仍然会本能地怀疑:他们到底在招什么?他们到底在维护什么?他们到底在演给谁看?而更可怕的是,我已经不会再相信他们说的任何一句话了。

这件事荒诞到像段子,可它真的在发生。

于是开始出现各种猜测。我不想替任何猜测背书,但我能理解大家为什么会这么想,因为许多细节拼在一起,确实让人很难安心。

我那一刻是真的崩溃了。回想过去一年多,自己亲手从零开始打磨那个项目,很多夜晚、很多细节、很多你以为“会留下些什么”的东西,最后把我推到这个下场。突然就显得很可笑。

反击爆点

后面我开始反击。

我把我知道的、我听到的、那些彼此矛盾的说辞一条条摆出来,把不满也摆出来。不是为了吵赢谁,而是因为我突然明白:如果我继续沉默,他们就会替我把故事写完——写成一个“大家都理解、都体面、都没问题”的结尾。

第二天,情绪被推到了顶点。

奇怪的是,这段时间里没有人再来找我“沟通”、也没有人再来做公关。倒像是他们自己开始上演一出反向公关。

先是招人的事。我看到那条内容挂了一会儿,后来又不见了。

然后,有一位老板在公开场合说了这样一句话:

“大意是:创业不是慈善,加入前就该知道风险。”

后面那半句更锋利:他甚至还在强调公司对我们“没有任何可以挑出问题的地方”。

我当时看到这段话,心里一下子就凉了。

原来他们最后选择的立场不是“解释”,也不是“道歉”,甚至不是“沉默”。而是把一切包装成一句轻飘飘的逻辑:你选择了创业,所以你就应该承担一切;你受伤了,也只是你没想清楚风险;至于他们许下过什么、表演过什么、推诿过什么,都可以被这句话一笔抹平。

你们的委屈不是委屈,你们的付出也不算付出——你们只是在“应该如此”。

而真正荒诞的地方在于:他们一边说“风险自担”,一边又需要你相信他们;一边裁掉所有开发,一边又试图招新人;一边要求大家体面收场,一边又在公众叙事里把责任切得很干净。

懂事道具

我从来没指望创业像童话。我只是没想到,最后会被当成一个“应该懂事”的道具。

更荒诞的是,话说到这里还没有结束。那个说出“创业不是慈善”的老板,转头又出现了一些私下的说法,像是在推动大家选边站,也像是在形成某种“口径”。于是新的版本开始流出来。而且每一个版本都比上一个更像谣言。

他说我是自愿离职的,不是被裁的。

他说公司裁员不给赔偿,是因为“你没签国内合同,这是你当时的选择”。可事实是:这根本就不是“选择”。对我来说,那份合同从一开始就没有真正的可选项。你甚至没有站在十字路口,你只是被推着往前走,走到最后再被反过来指责:你看,是你自己走的。

这两天我真的累了。我开始想结束这一切,不想再把更多人的时间和精力卷进来。朋友的,围观者的,甚至我自己的。

二次公关

可偏偏就在我想把情绪压下去的那天晚上,他发了第二篇公关小作文。很长,很“认真”。开头先说外界的讨论被片段化信息带偏了,说这次人员调整是正常的商业决策:为了长期发展、为了降低成本、为了对用户负责,并且不是突发,而是按节奏推进。接着,他把矛头对准了“某一位离职员工”,暗示情绪之所以失控,是因为有人在夸张表达、带动对立。

我看完反而更气愤。是因为他把重点换掉了。他没有正面回应那些承诺为什么会消失、那些话为什么会变成空气。他只是把故事重写成另一种更安全的版本:公司在做正确的事,而问题出在“表达方式”和“情绪”。

更让我不舒服的是,他把生活也拖进了工作里。奖金、回忆、一起旅行、一起拍照,这些东西被摆出来。我承认,加班可能是我自己的选择,奖金也是对方的选择。我只是觉得,选择不等于可以互相抵扣。钱当然重要,但它换不回生活,也换不回那些被占用的深夜和周末。

朋友迷思

至于所谓朋友,我更不愿意被拿来当论据。老板和员工之间存在强利益关系,权力和选择从来都不对等。它可以是合作,可以是尊重,甚至可以是欣赏,但很难是朋友。朋友不会在争议发生时,用私人回忆来证明自己更正确。那更像一种提醒:你应该懂事……,你应该感恩,你不该把事情说成这样。

那一刻我意识到,对我来说这更不像沟通。他们只是要一个体面的结尾——而这个体面里,不包括我。

我再也压不住情绪,于是主动联系了他。在那段漫长的沟通里,我其实只想尽快结束这个闹剧。

越聊越像陷进去的泥。一边是迫切想停下来的疲惫,一边是被情绪推着走的冲动。再加上一点点生活外的交情,哪怕只是曾经的礼貌,曾经的温和,我心软了。我以为只要把话说开,把情绪压下去,事情就能回到“到此为止”的轨道上。

但现实不是这样运作的。

我在对话里意外透露了一些来自前同事的说法。它原本只是倾诉,只是“我听说过”。可话转了几层之后,性质就变了。传到另一个老板耳朵里,它可能就不再是信息,而变成了立场。不再是叙述,而变成了证词。于是那个说过“创业不是慈善”的人,随后我也听到,有人反复联系前同事,话里话外像是在把责任重新分配。

我成了夹在中间的人。两边都不满意,两边都觉得我有问题。我既得罪了他们,也伤到了那些曾经支持我的人。

放下接受

最后,事情还是结束了。非常不愉快。

我得到了什么呢。得罪了两边的人,伤害了曾经站出来替我说话的人。然后我发现这一切都无法挽回。那一晚,我对自己的鲁莽感到深深的自责。情绪冲昏了头脑,像个小孩子一样,急着证明,急着结束,急着把痛苦从胸口掏出来给别人看。结果只换来两败俱伤。

我甚至开始反问自己,我预想的结局到底是什么。是一个道歉,一个解释,一句真话,还是一个终于可以睡觉的夜晚。可到了最后,什么都没有。只有更大的噪音,和更难收拾的残局。

那天回家的路上,我在车里坐了很久很久。车外的世界还在动,灯在走,我却像被按在原地。然后我给前同事发了一段很长的消息,更像是一封迟到的道歉。

发完那段话,我才意识到一个更刺人的事实。有些话一旦说出口,就不再属于你了。它会在别人手里变形,会被拿去站队,会被拿去证明某个叙事。你站在中间,怎么解释都像狡辩,怎么沉默都像默认。

那一晚,我第一次认真地承认,我不是输给了他们的公关。我输给了自己的冲动。

那天之后,我删掉了 X。直到一周之后,世界仍然在运转,吃瓜的人已经散伙,一切又恢复了平静。

平静来得很快,快到像是什么都没发生过。可有些东西并不会因为热度退去就被证明是误会,它只是被遗忘了。

那个曾经说还会继续迭代维护的人,直到现在也没有看到对应的变化。对我来说,那句话更像是一种当时的安抚,而不是后续会兑现的计划。

而我也慢慢确定了一点,我很难再信任他们对外的表述。不是因为我想否定什么,而是因为我已经看过太多前后不一致。

不是因为真话太复杂,也不是因为真话太难讲。我能想到的解释是,真话一旦说出口,就会让体面露出裂缝。于是更多时候,他们选择沉默,选择拖延,选择用一句句看起来正确的话,把真实的部分藏起来。

在那一周里,我无意经过一座寺庙。我进去站了一会儿,很诚心地祈祷,希望自己能在安稳中找到一块生存地。

出来的时候,看见门口有两块石头,上面分别刻着两个字。

放下 接受

像是冥冥之中在告诉我,事情已经到这里了。

回去的路上我又路过一家书店。玻璃上印着一句话,我站在那儿看了很久。

希望你能记住我,记住我曾经这样存在过。 ——村上春树

我不知道为什么那一刻忽然很想哭。也许不是因为谁,也不是因为那个项目,而是因为我突然意识到,我真正害怕的并不是失去工作。

我害怕的是,我花了那么多时间和心力,最后连“好好告别”都不被允许。连一句真话都不能留下。连我曾经那样认真地存在过,也要被改写成不合时宜。

感谢你能看到这里。

看完了?说点什么呢

Better Auth 的多租户用户鉴权的构想

2025-12-04 23:07:18

该渲染由 Shiro API 生成,可能存在排版问题,最佳体验请前往:https://innei.in/posts/tech/better-auth-multi-tenant-auth-concept

最近又把 Afilmory 捡起来做的,这次受 ChronoFrame 影响,我也决定给它加一层 CMS 能力,顺手往「做一个 CMS 的 SaaS 平台」这个方向靠一靠。

既然要做多租户 SaaS,身份验证这块就躲不过去。Better Auth 本身没有内建 multi-tenancy 的概念,所以整个用户模型、OAuth 流程、以及和 ORM 的边界都需要重新想一遍。

这篇主要是把我现在的构想和落地方案整理一下,后面如果再踩坑也方便回头翻。

每个租户一套 Better Auth?

一开始我想得比较直觉:

既然是多租户,那干脆让每个租户自己配置 OAuth / 鉴权,后端就给每个租户开一个 Better Auth 实例:

  • Tenant A → Better Auth 实例 #1,配自己的 GitHub / Google
  • Tenant B → Better Auth 实例 #2,再配一遍

这种做法的问题其实也很明显:

  • 配置地狱:租户一多,每个租户一套 OAuth 配置,维护成本会爆炸。
  • 实例地狱:Better Auth 实例越开越多,内存占用和初始化开销都上来了,极端一点甚至 OOM。
  • 逻辑重复:大部分逻辑其实是一样的,只是换了几个 client id / secret。

所以这条路基本可以确定是走不远的。

统一 Auth Provider

后来想了一圈,感觉更合理的一种模型是:

Auth Provider(Better Auth 实例)只有一个,是全局单例。但在业务层面,同一个人可以在多个租户下拥有不同的身份。

比如:Innei 在 Tenant A 里是 Admin,在 Tenant B 里只是一个普通 User;Cupchino 在 Tenant A 是 User,在 Tenant B 刚好是 Admin。

也就是说:「账号」是同一个 GitHub / Email,但落到租户里,都是不同的 user 记录,权限、数据都完全隔离。

这个关系可以简单理解成:tenant 有很多 authuser,同一个 GitHub 账号,可以在多个 tenant 下绑定多个 authuser。

Not support render this content in RSS render

即便是使用同一个账户登录后在不同的租户下都会是一个不同的用户。不同租户下的数据完全隔离,但是 auth provider 却是一个单例。

数据库层的设计

在数据库定义上,处理 better-auth 基准的字段之外,需要额外增加一个 tenantId 标识。

// Custom users table (Better Auth: user)
// Note: Multi-tenant design - same email can exist in different tenants
export const authUsers = pgTable(
  'auth_user',
  {
    // Add this
    role: userRoleEnum('role').notNull().default('user'),
    tenantId: text('tenant_id').references(() => tenants.id, {
      onDelete: 'set null',
    }),
  },
  (t) => [
    // Multi-tenant: same email can exist in different tenants
    unique('uq_auth_user_tenant_email').on(t.tenantId, t.email),
    index('idx_auth_user_tenant').on(t.tenantId),
  ],
)

// Custom sessions table (Better Auth: session)
export const authSessions = pgTable('auth_session', {
  // Add this
  tenantId: text('tenant_id').references(() => tenants.id, {
    onDelete: 'set null',
  }),
})

// Custom accounts table (Better Auth: account)
// Note: Multi-tenant design - same social account can exist in different tenants
export const authAccounts = pgTable(
  'auth_account',
  {
    tenantId: text('tenant_id').references(() => tenants.id, {
      onDelete: 'set null',
    }),
  },
  (t) => [
    // Multi-tenant: same social account can exist in different tenants
    unique('uq_auth_account_tenant_provider').on(
      t.tenantId,
      t.providerId,
      t.accountId,
    ),
    index('idx_auth_account_tenant').on(t.tenantId),
  ],
)

export const tenants = pgTable(
  'tenant',
  {
    id: snowflakeId,
    slug: text('slug').notNull(),
    name: text('name').notNull(),
  },
  (t) => [unique('uq_tenant_slug').on(t.slug)],
)

Not support render this content in RSS render

Better Auth 初始化

在 better-auth 的实例初始化中,需要额外定义扩展字段:

betterAuth({
  session: {
    freshAge: 0,
    additionalFields: {
      tenantId: { type: 'string', input: false },
    },
  },
  account: {
    additionalFields: {
      tenantId: { type: 'string', input: false },
    },
  },

  user: {
    additionalFields: {
      tenantId: { type: 'string', input: false },
      role: { type: 'string', input: false },
      creemCustomerId: { type: 'string', input: false },
    },
  },
})

这里所有 tenantId 都标成 input: false,意思是:外部请求不能直接写这些字段;只能通过我们自己的 hooks / adapter 在服务端填充,避免被前端篡改。

只定义字段还不够,还需要在「创建 user / session / account」的时候,把租户信息真正写进去。

核心就是:在这些 before 钩子里,通过 ensureTenantId() 拿到当前请求上下文对应的租户,然后写到数据里。

betterAuth({
  databaseHooks: {
    user: {
      create: {
        before: async (user) => {
          const tenantId = await ensureTenantId()
          if (!tenantId) {
            throw new APIError('BAD_REQUEST', {
              message: 'Missing tenant context during account creation.',
            })
          }

          return {
            data: {
              ...user,
              tenantId, // 设置租户 ID
              role: user.role ?? 'user',
            },
          }
        },
      },
    },
    session: {
      create: {
        before: async (session) => {
          const tenantId = this.resolveTenantIdFromContext()
          const fallbackTenantId =
            tenantId ?? session.tenantId ?? (await ensureTenantId())
          return {
            data: {
              ...session,
              tenantId: fallbackTenantId ?? null, // 设置租户 ID
            },
          }
        },
      },
    },
    account: {
      create: {
        before: async (account) => {
          const tenantId = this.resolveTenantIdFromContext()
          const resolvedTenantId = tenantId ?? (await ensureTenantId())
          if (!resolvedTenantId) {
            return { data: account }
          }

          return {
            data: {
              ...account,
              tenantId: resolvedTenantId, // 设置租户 ID
            },
          }
        },
      },
    },
  },
})

做到这里,写入这条链路基本是多租户感知的了。同一个 GitHub 登录到不同子域名,就会在各自的租户下创建独立的 user / account / session。

Better Auth 查用户时根本不知道 tenantId

真正比较坑的是 的这部分。

Better Auth 在 OAuth 回调时,会做类似这样的事情:

  1. 拿到 Provider 传回来的 codestate
  2. 根据 state 找到之前那次登录请求;
  3. 拿出 email / providerId / accountId 后,去 DB 里查用户。

问题就在第 3 步: Better Auth 默认只会按 email / provider 查用户,并不会自动加上 tenantId。

这会导致一个很危险的情况:

  • 用户在 Tenant A 用 GitHub 登录了一次 → 创建了 user@tenantA
  • 同一个用户后来打开 Tenant B,用同一个 GitHub 登录
  • 回调的时候,只按 email + provider
  • DB 里第一条匹配的记录是 tenantA 的那条
  • 结果就是:Tenant B 的登录错绑到了 Tenant A 的用户记录上 → 跨租户越权 / 数据串租。

也就是说,上游业务层明明已经区分了租户,但到了 Better Auth 内部这层,它是看不到 tenant 的。 只要 ORM 层不管租户,框架就没法帮你保证隔离。

Not support render this content in RSS render

既然 Better Auth 本身不知道 tenantId,那就只能从适配器这层把它「强行带进去」。

思路是这样:写一个 tenantAwareDrizzleAdapter,把 multi-tenant 的边界下沉到 ORM / Adapter 层。

这个适配器的职责:

  1. 在每次查询前,调用 ensureTenantId() 拿到当前租户;

  2. 对于 user / account 相关的查询,自动追加:

    where ... AND tenant_id = currentTenantId
    
  3. 对于写入,自动把 tenantId 补到数据里(如果上层没写的话)。

这样一来,在 Better Auth 看来:

  • 它仍然是在做「按 email / provider 找用户」这种看起来很单租户的事情;
  • 但实际发出去的 SQL 已经被 adapter 自动加上了 tenant_id = ... 条件;
  • 也就是说:「多租户感知」这件事对框架是透明的,被我们藏在 ORM 这一层。

实现细节就不展开了,大致就是在 Drizzle 的 query builder 一层做 wrap,把所有跟用户相关的查询 /写入都套上 tenant 条件。

对应的代码在这里:

https://github.com/Afilmory/afilmory/blob/ae21438eb766fb944b37ca5949d2f25185bccccb/be/apps/core/src/modules/platform/auth/tenant-aware-adapter.ts

Not support render this content in RSS render

多租户多域名下,怎么优雅地统一配置 OAuth?

在多租户、多子域名的 SaaS 里,一个很常见的诉求是:

  • a.example.com
  • b.example.com

这两个租户都想复用同一套 GitHub(或其他)OAuth 应用,而不是每个子域名各配一套。

问题在于,大多数 OAuth Provider(比如 GitHub)在配置回调地址(redirect_uri)时,都要求是精确匹配,不能写成通配符,比如:

  • https://*.example.com/api/auth/callback/github
  • https://auth.example.com/api/auth/callback/github

也就是说,在 Provider 那边,你只能填一个固定 URL。但在我们这边,又希望最终的回调是落到各个租户自己的域名上:

  • https://a.example.com/api/auth/callback/github
  • https://b.example.com/api/auth/callback/github

所以这里需要引入一个专门做 OAuth 回调分发的网关,比如:

  • 统一对外暴露:https://auth.example.com/api/auth/callback/github
  • 真正创建 Session 的逻辑,仍然在各租户自己的后端里,只是由网关把请求转发(302 跳转)过去。

这样,Provider 侧只认一个「入口」,网关负责把这个入口再按租户「分流」出去。

网关怎么知道这次登录属于哪个租户?

当 GitHub 把用户重定向回:

https://auth.example.com/api/auth/callback/github?code=...&state=...

的时候,请求里看不到诸如 tenant=a 这种显式信息。我们又不想在网关上维护什么 session 或额外的状态。

这里可以利用 OAuth 协议里本来就存在的 state 参数来解决:让上游的认证服务负责把「租户信息」塞进 state 里,网关只负责解包并转发。

一个典型流程大概是这样(对应 @oauth-gateway 这个服务的设计):

  1. 用户在 a.example.com 点击「使用 GitHub 登录」。
  2. 后端(比如 be/apps/core 里的 Better Auth)在构造 GitHub 授权 URL 的时候,不是直接生成一个 state,而是:
    • 先生成内部真正用的 innerState(给 Better Auth 自己用)
    • 再包一层:{ tenant: "a", innerState: "<better-auth-state>" }
    • 用网关共享的密钥做一层加密 / 签名,变成一个 wrappedState
  3. 浏览器被重定向到 GitHub 授权页面,之后 GitHub 回调回统一地址:

    https://auth.example.com/api/auth/callback/github?code=...&state=<wrappedState>
    
  4. 这时 OAuth Gateway 做两件事:
    • 用自己的密钥解开 state,拿到:
      • tenant(比如 "a"
      • innerState(要还给 Better Auth 的那份)
    • 根据 tenant 和基础域名(比如 example.com)拼出目标地址:
      • https://a.example.com/api/auth/callback/github?code=...&state=<innerState>
  5. 网关直接返回一个 302

    Location: https://a.example.com/api/auth/callback/github?code=...&state=<innerState>
    

a.example.com 自己的后端视角看,只是收到了一个完全正常的 GitHub OAuth 回调;它只关心 codestate=<innerState>,根本不需要知道中间还经过了一个网关。

Not support render this content in RSS render

总结

目前这套设计,大概是把多租户问题拆成了三层:

  1. 数据模型层 每个租户有自己的 user / account / session, 唯一键全部变成 (tenantId, …)

  2. ORM / Adapter 层tenantAwareDrizzleAdaptertenantId 自动拼进所有查询 / 写入, 对 Better Auth 这种上层框架来说是透明的。

  3. OAuth 流程层 借助 state + OAuth Gateway,在多域名、多租户的场景下共享一套 Provider 配置, 同时又能把回调正确落回对应租户的后端。

感谢你看到这里。如有不足欢迎在评论区指出。

看完了?说点什么呢