35行基础设施代码,0行业务逻辑。这是Angular开发者Marko Stanimirović在维护第20个Signal Store时的真实处境——每个Store都像从同一个模子刻出来的残次品,加载状态、错误处理、分页计算、搜索过滤,复制粘贴后微调,最后演变成20份逐渐分叉的代码尸体。
他在monorepo里数了数:超过50行的仪式性代码,才能让一行业务逻辑落地。这不是开发,是体力劳动。
Marko的解法很产品经理:把重复模式抽象成可组合的功能模块。他提取了5个signalStoreFeature工具,打包成开源库signalstore-toolkit。结果:样板代码砍掉40%,Store从60%脚手架变成纯业务容器。
withRequestStatus:一刀砍掉15行重复
每个Store都有的三重奏——isLoading、isError、errorMessage,加上setPending、setFulfilled、setError三件套。Marko说这是他最大的胜利:「以前每个Store手写15行,现在一行withRequestStatus()全包。」
注入后自动获得requestStatus状态,以及isPending、isFulfilled、error三个派生信号。方法层面:setPending、setFulfilled、setError、resetStatus,全部类型安全,全部跨Store一致。
「类型推导是隐式的,」Marko在代码注释里写,「你不用手动声明任何东西。」这对TypeScript重度用户意味着:重构时编译器会抓住所有不一致,而不是留给运行时爆炸。
withEntitySync:实时数据的 reconciliation 战场
WebSocket推送、轮询、SSE(服务器发送事件)——任何实时场景都需要一个核心能力:把新来的实体和Store里的存量对齐。withEntitySync提供syncAll(全量覆盖)、upsertOne(单条更新或插入)、removeOne(单条删除)。
还有个liveUpdate方法,直接解析JSON然后upsert。Marko的注释很克制:「对WebSocket消息特别有用。」没有「革命性」,没有「颠覆」,就是一个刚好解决痛点的工具。
实体同步的脏活被封装后,开发者不用再写patchState(setAllEntities(products))这种重复咒语。
withPagination:分页计算的数学坟墓
currentPage、pageSize、total三个信号,派生出totalPages、hasNextPage、hasPreviousPage、pageOffset四个计算值。setPage、nextPage、previousPage三个方法。这是每个列表页Store的标配,也是复制粘贴错误的重灾区。
withPagination一次性解决。注入后自动获得所有派生计算和方法,数学公式藏在库里,不再出现在业务代码中。Marko提到一个细节:页码计算用Math.ceil(total / pageSize),很多手写实现会漏掉这个边界,导致最后一页数据消失。
withSearchFilter:搜索管道的标准化
searchQuery信号,filteredEntities计算值,大小写不敏感匹配,可选的自定义过滤函数。withSearchFilter把这些打包成一行配置。
Marko的设计很务实:默认行为覆盖80%场景,剩下20%通过选项对象扩展。比如你想搜name字段,标准配置;想搜name+description组合,传个自定义函数。不追求万能,追求可预测。
withUndoRedo:状态时间旅行的工程化
这是第五个工具,也是最有争议的一个。Marko承认:「不是每个Store都需要。」但当你需要时,手写undo/redo栈是噩梦——状态快照、栈深度限制、内存泄漏防护。
withUndoRedo提供undo、redo、canUndo、canRedo四个方法,以及maxHistorySize配置。Marko的实现用了Immer的不可变数据模式,保证快照不会意外共享引用。
五个工具的组合方式很灵活:你可以只拿withRequestStatus处理加载态,也可以五件套全上打造一个功能完整的CRUD Store。
Marko在GitHub仓库放了一个对比示例。改造前:50行基础设施,业务逻辑挤在缝隙里。改造后:5行feature注入,剩下全是领域代码。他算了笔账:20个Store,每个省15-20行,整个monorepo砍掉400行重复——这还没算测试代码的减少。
更隐蔽的收益是一致性。以前20个Store有20种错误处理方式,有的叫setError,有的叫handleError,有的直接patchState。现在全叫setError,行为完全一致。新人 onboarding 时不用猜:这个Store的加载态怎么判断?看isPending,永远是isPending。
signalstore-toolkit的API设计暴露了Marko的产品背景。每个feature都返回一个SignalStoreFeature对象,符合NgRx的插件规范。命名用withXxx前缀,一眼看出是增强器。选项对象都是可选的,零配置能跑,细调也能满足。
「组合优于继承」——这句老生常谈在Signal Store语境下有了新含义。Marko没有造一个万能BaseStore让人继承,而是提供原子化feature,按需组装。继承是静态的,组合是动态的;继承耦合父子关系,组合保持Store扁平。
TypeScript的类型推导是另一个隐藏卖点。withRequestStatus注入后,Store的类型自动扩展,不需要手动声明requestStatus字段。这在大型monorepo里意味着:重构时编译器会告诉你哪些Store漏了升级,而不是等到运行时才发现某个角落的Store还在用旧API。
Marko在README里写了一个警告:「这些工具假设你的Store结构符合一定约定。」比如withEntitySync要求Store里有entities信号,withPagination要求currentPage和pageSize。这不是缺陷,是设计选择——约定优于配置,但约定必须显式。
社区反馈分化明显。Reddit上有人欢呼:「终于不用写第21个分页Store了。」也有人质疑:「抽象层太多,调试时栈深增加。」Marko的回应很直接:「源码就几百行,看不懂可以fork改。」
一个细节值得玩味:signalstore-toolkit没有依赖NgRx以外的任何库,除了withUndoRedo用的Immer。Marko说这是刻意为之——工具库的依赖应该比业务代码更少,而不是更多。
性能方面,Marko做了基准测试。feature注入后的Store,内存占用和原生Signal Store持平,计算属性缓存行为一致。undo/redo的栈快照在maxHistorySize限制下可控,默认50步历史,大概占用几KB到几十KB,取决于实体大小。
文档风格也带着产品经理的克制。没有「最佳实践」章节,只有「常见模式」;没有「你必须这样」,只有「我们推荐」。每个feature的README都有Before/After代码对比,差异一目了然。
Marko在Issue里回复一个功能请求时说了句话,可以当这个项目的注脚:「我不反对增加feature,但我反对增加假设。每个新工具必须解决一个真实痛点,而不是'可能有人需要'。」
signalstore-toolkit的GitHub星数增长曲线很典型:发布首周300星,主要来自NgRx核心团队的转发;第二周放缓到日均10星;第三周一个Hacker News热帖带来第二波增长。现在稳定在2.3k星,周下载量约4000次——对Angular生态的细分工具来说,算是健康水平。
Marko没有全职维护这个库。他的主业是某金融科技公司的前端架构师,signalstore-toolkit是周末项目。版本发布节奏很佛系:有breaking change时发major,新feature发minor,bug fix发patch。没有路线图,没有里程碑。
这种「够用就好」的态度反而降低了使用门槛。企业开发者最怕依赖一个「野心过大」的开源项目——今天帮你分页,明天想接管你的整个状态管理。Marko的五件套只做五件事,边界清晰,升级风险可控。
一个使用案例来自某电商后台系统。他们的订单Store原本120行,用了withRequestStatus、withPagination、withEntitySync后压缩到45行。测试代码从80行降到30行——因为不用测分页计算的正确性了,那是库的责任。
Marko在最近的commit里加了一个实验性功能:withDevtoolsIntegration,自动连接Redux DevTools。这个功能还在canary分支,文档里写着「API可能变动」。他的谨慎和对核心五件套的稳定形成对比。
回到最初的问题:为什么Signal Store需要这些工具?Marko的答案是:「因为Angular团队给了我们强大的原语,但没给常见模式的实现。」signalStore和signalStoreFeature是乐高积木,signalstore-toolkit是预制件。你可以只用积木搭房子,但预制件省时间。
这种分层思路在工具链设计中很常见。React有useState/useEffect,也有React Query;Vue有Composition API,也有VueUse。NgRx Signal Store的生态位正在形成,signalstore-toolkit是早期定居者之一。
竞争对手不是没有。@ngrx/signals/entities已经提供了withEntities,和withEntitySync有功能重叠。Marko的定位很微妙:官方库提供基础实体管理,他的库提供「业务场景化」的封装——实时同步、搜索过滤、分页计算,这些都是withEntities之上的层。
一个潜在风险是官方库的进化。如果NgRx团队决定把pagination或search纳入核心,signalstore-toolkit的部分价值会被稀释。Marko的应对是保持轻量:核心代码不到500行,即使被官方替代,迁移成本也低。
他的README里有个「哲学」章节,很短:「代码被阅读的次数远多于被编写的次数。我们优化前者。」这解释了为什么withRequestStatus的方法叫setPending而不是startLoading——和其他NgRx约定一致,减少认知负担。
命名一致性是signalstore-toolkit的隐藏质量。所有feature用with前缀,所有状态用名词,所有方法用动词,所有派生信号用形容词或短语。这种可预测性在大型团队里价值巨大:你不用打开源码就知道canUndo是布尔值,setFulfilled是方法。
Marko拒绝了一个PR:有人想把withPagination改成支持游标分页。他的理由很产品经理:「这会让API表面翻倍,而需求来自一个人。」他建议fork实现,保持主库精简。
这种「不讨好所有人」的态度,反而建立了信任。企业技术选型时,一个边界清晰的工具比一个野心勃勃的工具更安全——你知道它会做什么,更知道它不会做什么。
signalstore-toolkit的测试覆盖率是94%,主要缺口在undo/redo的边缘情况。Marko的注释很诚实:「这些场景理论上存在,实际中未触发。」他没有为了数字而写无意义测试。
一个有趣的对比是社区里的另一个项目:ng-signal-store-utils。功能高度重叠,但设计理念相反——提供更多配置选项,更少的约定。两个项目共存,说明开发者偏好分化:有人喜欢开箱即用,有人喜欢精细控制。
Marko在Twitter上被问到:「为什么不用Akita或Elf?它们也有实体管理。」他的回复很直接:「它们很好,但我们的团队已经选了NgRx。迁移成本高于抽象成本。」这是真实的企业决策逻辑,不是技术优劣的抽象辩论。
signalstore-toolkit的最后一个隐藏价值:它是学习Signal Store模式的教材。源码很短,每个feature的实现都展示了如何正确使用patchState、computed、effect。新手读一遍,比看十篇博客更懂原理。
Marko在最近的技术分享里放了一张图:改造前后的Store代码行数对比。40%的削减是平均值,极端案例能达到60%。但他说更重要的是:「剩下的代码全是业务逻辑,读一遍就知道这个Store是干什么的。」
这种「代码即文档」的理想状态,正是工具库追求的终极价值。不是写更少的代码,是写更诚实的代码——每行都在说业务,而不是说「我在处理分页」。signalstore-toolkit把后者赶进了库的黑盒,让开发者专注于前者。
现在的问题是:你的Store里,有多少百分比在说实话?
热门跟贴