返回文章

state-schema-first:写有状态的代码之前,先把事实列成一张表

这是 skill 深挖系列的一篇,讲通用组里的 state-schema-first。它有一个现成的搭档:《状态层:同一份数据,只能有一个家 》——那一篇讲这一层的规矩:一个事实一个家、服务端数据和客户端状态分开住、落盘的键带命名空间。这一篇不重复规矩,讲执行规矩的顺序:干活的人(或 AI)具体先写什么、后写什么、写成什么样才算写完。母篇是《从能跑到可交付:一套前端 AI 协作系统的设计笔记 》。我用的系统是开源的 frontend-harness-cli ,文中所有规则都能在仓库里找到对应实现。

角标写着 3,购物车里是 2

这个场景在状态层那篇讲过,这里只用一句话复述:页头角标自己维护一个计数,购物车页管着自己的列表,用户改了数量、删了商品,角标都不知道——最后角标写着 3,购物车里是 2。每个文件都没写错,编译和 Lint 全过;错的是从来没有人决定过"购物车里有什么"这条事实到底住在哪。

那篇引了这份 skill 的开头:状态 bug 是发现得太晚的设计 bug——两个组件各存一份同一个事实、一个持久化键和另一个功能撞了车、没人说得清初始值应该是什么。这一篇就展开这句话:一份 skill 怎么把这三个"发现得太晚",变成写代码之前在纸上做完的决定。

这份 skill 是什么:一种干活顺序

先说清它是什么。它不是一个库,不是一个框架特性,是一个写代码之前的动作顺序。触发点写得很具体:

  • 新功能需要客户端状态、store 或缓存;
  • 数据要落盘(localStorage、sessionStorage、IndexedDB、cookie);
  • 服务端数据和本地编辑要共存——草稿、乐观更新、离线队列;
  • 调试时发现几个组件对同一个值各执一词。

最后一条值得停一下:这份 skill 不只是开工前的仪式,还是止损手段。两个组件已经对同一个值吵起来了,说明这张表从来没写过;这时候的正解不是加一段同步代码把两边糊上,而是停下来补表——糊上同步,等于承认问题、然后把问题永久化。

它干活的顺序一共七步,但骨架只有两步:先列事实,再给每条事实写结构定义。 剩下的步骤——定归属、分家、命名空间、定迁移——都是围着这张表转的规矩。下面逐步拆。

第一步:先列事实,不许碰类型

第一步只有一个动作:把这个功能需要的每一份状态枚举出来,一个事实占一行,产出是一份事实清单。这一步不选任何类型。

什么叫一个"事实"?就是界面上各处必须口径一致的一件事。“购物车里有哪些条目"是一个事实;角标上的数字不是另一个事实,它是这个事实的读数。先数清"到底有哪些数据”,再管"它们长什么样"。

为什么把顺序卡得这么死?因为不列清单的时候,状态是边写边"发现"的:写到角标想起要计数,写到结算想起要步骤,写到刷新想起草稿还没落盘。每"发现"一次,就在手边随手安一个家——组件里一份、store 里一份、localStorage 里一份。角标和购物车对不上,根源不是哪一次写错了,是这些事实从来没有被完整地数过一遍:没数过,就没人见过全貌;没见过全貌,“计数和条目是同一件事"这种关系就永远不会被说出来。

列事实用的是大白话,不需要做任何技术决定,所以它便宜——一个功能通常也就十来行。便宜正是它排第一的原因:所有决定里,改口成本最低的就是这一张清单,越早列,后面越省。

第二步:每个事实写满五列

清单列完,给每条事实写结构定义(schema)。形态随项目的惯例:TypeScript 类型、JSON schema、一张表格都行。判据只有一条:评审的人不用打开组件代码,就能把这份定义读完。

每条事实要写清五样:字段名、类型、初始值、可否缺省,外加一条下一节要定的事实源。拿购物车那几条事实填出来,是这样:

事实字段类型初始值可否缺省事实源
购物车条目itemsCartItem[][]否服务端(查询缓存)
已选优惠券couponCoupon | nullnull是客户端 store
结算步骤step"cart" | "pay" | "done""cart"否客户端 store
地址草稿draftAddressAddress空地址否localStorage 持久化

逐列看它们各在决定什么:

  • 字段名和类型决定"长什么样”。这是大多数人以为的全部。
  • 初始值决定"第一帧是什么"——数据还没到的时候,界面拿什么顶着。
  • 可否缺省决定"没有的时候合不合法"——是停下来等它,还是当它不存在继续走。
  • 事实源决定"谁说了算",这是下一节的主角。

后两列(初始值、可否缺省)先按下不表,第六节专门讲它们为什么最容易出事。这里先把表填完,然后过三关。

形状定完,还要定归属、落盘和谁能改

归属:事实源那一列,每行只许填一个。 候选就五种:服务端、store、URL、表单状态,或者"由 X 派生"。角标计数必须选最后一项:它是条目的派生值,只许算、不许存。填表时如果发现一条事实有两个地方都想说了算,规矩是现在就重新设计它,不要留到以后去同步。skill 里对同步代码的定性很不客气:把同一事实散在组件状态、全局 store 和 localStorage 里,再加一个 effect 去"保持同步",同步代码本身就是在承认——这张表从来没定过。

分家:标出哪些事实是服务端数据的缓存副本,哪些只活在本地。 两者的命不一样:缓存副本会过期、要失效、要重取;本地事实——弹窗开没开、草稿打了什么字——服务端根本没有,也不需要有。这个区分必须显式写在表里,不能靠读者悟。还有一条被点名的反模式:把服务端数据建成模成本地状态,再手搓一遍重取、过期和错误标志——这些活项目的数据层早就干了,手搓的版本只会更差,还多出一份要维护的副本。

落盘:要持久化的事实,键必须带命名空间。 形状是 {app}.{feature}.{field}:哪个应用、哪个功能、哪个字段,写进键里,比如地址草稿是 shop.checkout.draftAddress。形状以后可能演化,就再加一段版本号。写完还不算完:拿这份键清单去和代码库里已有的键逐个对一遍,确认不撞车。用 draft、settings 这种裸键是明确的反模式——localStorage 是全应用共用一个扁平抽屉,今天不撞,明年也会撞,撞上时还不报错,只是读出一份形状对不上的数据往下跑。

迁移:每条事实写清谁能改它、什么时候改——写成动作和事件,不许写"任何地方"。这一列的用途是事后追责:某个值变得不对劲时,“它怎么变成这个值的"要能从代码里回答出来。谁都能改、随时能改的事实,出了问题就是无头案。

四关过完,才许写代码。而且写代码时还有一条收尾的规矩:组件消费这张表,不许随手扩它。 干活干到一半发现少了一条事实,正确动作是回到第一步把它补进表里,走完后面的列,再回来写代码——不是在组件里就地加一个字段了事。就地加的字段没有初始值决定、没有归属、没有对账,等于把"边写边发现"又放回来了。

设计一:初始值和可否缺省——最容易跳过的两列

五列里,字段名和类型永远不会被跳过,因为不写代码跑不起来。最容易被跳过的是初始值和可否缺省——跳过它们,代码照样能写、能跑,看起来什么都没耽误。代价是:这个决定被推给了每一个读这条事实的组件,让它们各自去猜。

猜的是什么?是"还没有"和"是空的"到底是不是一回事。购物车条目还没加载回来时,items 是 [] 还是 undefined?

表里如果写的是"初始值 []、不可缺省”,语义就定死了:它永远是个数组,空就表示车里没东西;“正在加载"压根不是这条事实的事——那是另一条事实(一个加载状态字段),本该在第一步就被列进清单。如果这一列空着,角标组件可以拿 undefined 当"没加载完”,显示一个转圈;购物车页拿它当"车是空的",显示一句空状态。同一个页面,一半在转圈,一半说空。没有人写错,是这个决定从来没有人做过,于是每个读者各做了一次,还做得不一样。

落到 AI 写代码上更明显:它写代码默认世界一切正常——数据永远在、字段永远有。初始值和可否缺省这两列,本质上是两个强制提问:数据还没来时显示什么?这条事实不存在时合不合法?问题都不难答,难的是没人问。这两列就是那个发问的人,而且只问一次,所有组件照着同一个答案读。

设计二:哪怕只加一个小 store,也要写这张表

最常见的抵触是:就加一个小 store、三个字段,至于列清单填表格吗?

先算成本。列事实加填五列,一个小功能是几分钟的事。而它防的那类 bug——两份副本对不上、持久化键撞车、初始语义各猜一套——都是在生产环境里才被发现的,排查以小时计,还要先复现。这笔账和语言层的逻辑是同一笔:把贵的发现,换成便宜的决定。

再说"小"本身。store 不会停在小:先是一个开关,下个月加两个字段、要落盘,再下个月第二个页面也要读它。到那时候,这张表其实已经存在了——只不过是以代码的形式,散在几个组件各自的假设里,没有任何一份能拿出来单独看。趁它还小的时候写在纸上,是唯一便宜的时机。

还有一条是给"下一个读者"留的。接手这段代码的,往往是另一个上下文全新的 AI,或者三个月后的你自己。一份不用打开组件就能读完的表,是唯一能快速核对"现在系统里有哪些事实、各自住哪"的入口。skill 的完成判据最后一条写得很直白:实现与表是否相符,要靠把两者对着读来验证,不靠记忆。没有表,连对账的另一边都不存在——你只能凭印象说"应该没问题"。

设计三:写完的表,是拿来对账的

这张表不是写完就供起来的文档,它是 harness scan 数据维度的对账底稿。状态层那篇讲过扫描干的三件活,都和它直接咬合:

一是逐字段对差:项目的数据规格(就是这张表落成的规格)给定之后,扫描把代码里实际用到的字段和它逐字段比对——代码里多出一个规格上没有的字段,或者规格上有的字段代码从没写过,都作为差异报出来。

二是点名裸键:有一条就叫 storage-key-namespace 的规则,代码里每个存储键不符合 {page}.{entity}.{field} 形状的,记一个违规、扣 5 分。第五步写的那份键清单,就是它的比对名单。

三是结构双向闭合:代码读写的每个状态字段,表里要有出处;表里声明的字段,代码里要真的在用。一边多出来,都算脱节。

所以"组件不许随手扩表"不只是纪律问题:谁绕过表在代码里加了字段,下一次扫描就会把这个字段当差异报出来。这张表的全部价值,建立在"纸上的决定和代码里的现实不一致时,会被发现"上——发现不了的表,和没写一样。

它管不了什么

边界说清。

第一,它保证每条事实只有一个家、一份形状、一个写定的初始值;它不保证你列的是对的事实。第一步漏掉的事实,不会因为表填得整齐就自己冒出来——表会完整、自洽,并且错。功能到底需要哪些数据,是需求和设计层面的问题;这张表只能把答案钉住,不能替你想出答案。

第二,它只是一套方法,本身没有强制力。真正让它绕不过去的,是规范写明、扫描扣分、评测否决那一整套配合(语言层那篇讲过这个三档逻辑)。只有方法、没有校验,赶起进度来,它就是第一个被跳过的步骤——这一点它和所有流程文档命运相同,它防不了自己被跳过。

第三,它管"数据住哪、长什么样、谁能改",不管"改完之后业务对不对"。计数只有一个家了,但金额算错了 10%,不归它管;条目的形状全对,但结算流程少了一步,也不归它管。那些是评测阶段的事。

下一篇讲 component-from-design:设计稿已经定好的组件,怎么写才不走样。