返回文章

状态层:同一份数据,只能有一个家

这是五层约束系列的第三篇。第一篇语言层 管代码的形状对不对,第二篇样式层 管页面用的是不是同一套设计变量,这一篇管数据准不准。母篇《从能跑到可交付:一套前端 AI 协作系统的设计笔记 》把五层一起讲了,这个系列逐层拆开。我用的系统是开源的 frontend-harness-cli ,文中所有规则都能在仓库里找到对应实现。

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

先看一个具体的场景。

你让 AI 给页头加一个购物车角标,显示购物车里有几件商品。它写了一个角标组件,自己维护一个计数:在商品页点"加入购物车",计数加一。购物车页那边管着自己的商品列表,增删改都在列表里操作。两边单独看都没毛病。

然后用户在购物车页把数量从 1 改成 2。角标不知道这件事,还写着原来的数。用户删掉一件商品,角标也不知道。最后角标写着 3,购物车里是 2——两个数字都来自"购物车里有什么"这一件事,却对不上了。

这个 bug 藏在哪一行代码里?哪一行都不藏。每个文件的类型都对,每个函数都做了作者想让它做的事,编译通过,Lint 干净。它藏在文件之间:从来没有人决定过"购物车里有什么"这份数据到底住在哪。角标存了一份,购物车页存了另一份,两份各自更新,对不上只是时间问题。

状态层的技能 state-schema-first 开头就是这么写的:状态 bug 是发现得太晚的设计 bug——两个组件各存一份同一个事实、一个持久化键和另一个功能撞了车、没人说得清初始值应该是什么。先写结构定义,是把这些问题变成在纸上做一次的决定,而不是在生产环境里找的 bug。

为什么这一层问的是“准不准”

前两层看不见这类问题,这不是它们不努力,是问题不在它们的辖区里。

语言层看形状:计数的类型是 number,列表的类型是数组,全对。它不知道这两个东西说的是同一件事。样式层看外观:角标的间距和颜色都走了变量,全对。它不关心角标上的数字是真是假。

所以状态层的问题只有一个:准不准——页面上显示的数据,是不是真实的那份数据。

母篇里有个比喻:这一层像一座城市只有一条公路从水厂到每家每户。你不需要猜水从哪来,打开龙头就有;出了问题,也知道沿哪条路查。前提是水厂只有一个。要是每家自己挖一口井,每口井的水位还都不一样,“我家的水怎么是浑的"这种问题就永远查不清。

落到规则上,就是四件事:每条事实先写清结构,一个事实只有一个出处,服务端数据和客户端状态分开住,落盘的键必须写明是谁的。

设计一:先列事实,再写结构

state-schema-first 的干活顺序分两步,顺序不许颠倒。

第一步,列事实。 把这个功能涉及的每一份状态都枚举出来,一条事实一行。这一步不写任何类型——先回答"到底有哪些数据”,别急着回答"它们长什么样"。购物车这个功能列出来大概是:商品条目、已选优惠券、结算进行到哪一步、收货地址草稿。写代码的人(或 AI)平时是边写边"发现"状态的:写到角标才想起要计数,写到结算才想起要步骤。列事实是把这个发现过程提前到写代码之前,一次列全。

第二步,写结构。 每条事实写清五样东西:

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

这五列里,“初始值"和"可否缺省"是最容易被跳过、也最容易出事的。购物车还没加载完的时候,items 是"空数组"还是"还没有值”?如果角标组件认为 undefined 表示"没加载完",显示一个转圈;购物车页认为它表示"购物车是空的",显示一句空状态——同一条事实,两个组件各自假设了一个初始语义,页面就会一半转圈一半说空。这不是谁写错了,是这个决定从来没人做过。写进结构定义,它就只做一次,所有人照着读。

“事实源"这一列是下一条设计的入口:每一行都必须能填出来。填不出来,说明这条事实的家还没定,别往下写代码。

设计二:一个事实,只许有一个事实源

结构列完,规则跟着来:同一条事实,只能有一个地方说了算;其他地方要么读它,要么从它算出来,不许另存一份。

角标的计数就是典型。计数不是一条独立事实,它是条目的导出值:有几条条目,计数就是几。它不应该被任何人"维护”,只应该被计算。谁把计数存成一份独立状态,谁就埋下了角标写 3、购物车是 2 的那颗雷——因为更新条目的代码不会自动知道还要去更新另一份计数,两份数据的同步靠的是"每个修改入口都记得改两处",而总有一天会多出一个新入口。

这条规则对 AI 尤其要紧。AI 写一个新组件时,看不到也不关心已有的组件在存什么,它最自然的做法是就地存一份自己需要的。人的代码评审能偶尔拦住这种事,但 AI 的产出量下评审拦不住。把"一个事实一个源"写在结构定义和规范里,让生成的时候就没得选:新组件需要某条事实,先查它住在哪,然后去读,不许在本地再立一份。

设计三:服务端数据和客户端状态,分开住

结构表里"事实源"那一列还会暴露一个区分:有些事实的家在服务端,有些在客户端。这两类不能混住。栈包(harness init 装进项目的 standards 文件)第六节写得很具体:

  • 服务端状态留在服务端,或者放在查询缓存里。用户信息、订单、购物车条目,权威版本在服务器上。客户端要显示,就去查、去缓存,但不许把它们抄进一个客户端 store 里当成自己的数据——抄出来的那一刻,它就开始过期,而没人知道它过期了。“用户已经退出登录,页头还挂着他的头像"这类 bug,源头就是抄了一份之后没人负责同步。
  • 客户端状态本地优先:弹窗开没开、输入框里打了什么、当前选中的标签页,这类事实服务端根本没有,也不需要有。它们默认放在组件本地,确实要共享了才往上提,只有跨页面还要用的才进全局 store。顺序反过来——什么都往全局 store 里塞——store 就会变成一个谁都能改、谁都不负责的大杂烩。
  • store 必须在一处声明自己的状态类型和初始值,就是设计一那张表在代码里的落点。不许十个组件各自定义一遍同一个 store 的形状。

分开的判据就一条:这条事实,服务端有没有权威版本? 有,它就是服务端状态,客户端只有缓存的资格;没有,它才是客户端状态,按本地优先的顺序安家。

设计四:持久化的键,必须带命名空间

有一类状态要落盘:刷新页面不能丢的草稿、偏好设置。它们写进 localStorage,而 localStorage 是个全局的、扁平的键值空间——这个应用所有功能、所有版本写过的键,全挤在同一个抽屉里。

不带命名空间的键就是在抽屉里裸奔。设想两个功能都用了 draft 这个键:一个存的是写了一半的文章(一个对象),另一个存的是结算表单(另一组字段)。用户先写文章存了草稿,再去结算,结算页读出 draft,JSON.parse 成功——键对、JSON 也合法——字段却全是另一功能的,页面拿着一堆对不上的数据往下跑。报错都不会报,因为形状"看起来"没问题。

所以栈包要求:持久化的键必须是 <app>.<feature>.<field> 的形状——哪个应用、哪个功能、哪个字段,写在键里。比如地址草稿的键是 shop.checkout.draftAddress,不是 draft 也不是 address。键本身就是所有权声明:撞车在命名时就被排除,删数据时也知道能删谁、不能碰谁。

机器怎么验这一层

这一层的校验主要在 harness scan 的数据维度里,有三件具体的活:

一是键命名空间:有一条就叫 storage-key-namespace 的规则——代码里出现的每个存储键,不符合 {page}.{entity}.{field} 形状的,记一个违规,扣 5 分。裸键在扫描报告里会被逐个点名,不是靠人评审时想起来才说。

二是和数据规格逐字段对差:项目给了数据规格(data spec)之后,扫描把代码里实际用到的字段和规格逐字段比对——代码里多出一个规格上没有的字段,或者规格上有的字段代码里从来没写过,都会作为差异报出来。结构定义不是写完就供着的文件,它是拿来对账的底稿。

三是状态结构闭合检查:代码里读写的每个状态字段,都要在结构定义里有出处;结构定义里声明的字段,代码里也要真的在用。一边多出来,都说明"纸上的决定"和"代码里的现实"脱节了——而状态层全部的价值,就建立在两者不脱节上。

这一层管不了什么

边界说清。状态层保证的是:每条事实只有一个家、一份形状、一个写定的初始值,页面上读到的就是那份真数据——准,仅此而已。

它管不了代码本身的组织。事实是唯一的,但读它写它的代码可以散在四十个文件里互相乱引;结构定义全对,但一个文件可以长到三千行没人敢动。这些是代码"通不通、好不好维护"的问题,不在状态层的辖区。下一篇讲工程层:谁许引用谁、文件长到什么程度必须拆、这些规矩怎么串进提交和流水线里,让人想绕都绕不过去。