关掉页面以后:追问一个模拟系统为什么这样设计

约 9 分钟另有英文版

页面关了,模拟已经停了,再打开为什么还能看到中间那段历史?沿着这个追问,用三个可操作的虚构例子,梳理共享状态、时钟、历史重建、随机流和稳定 ID 怎样让假数据前后对得上。

目录
  1. 先把“前后对得上”说具体
  2. 视图之间:三个数字凭什么一致
  3. 时间:关掉以后,过去从哪里来
  4. 种子:固定了一串数,还没有固定结果
  5. 换成真实服务,还有哪些责任
  6. 每个选择,到底保护了什么

那次请 AI 帮我梳理一套 mock 数据的实现,我觉得很有意思。页面里的记录可以操作,数字和图表也会跟着变化。我想知道,这些东西到底是怎么连起来的。

我接着问:它跑在服务器上,还是我的电脑上?会不会只是我上次启动的网页服务一直没关?如果把浏览器页面关掉,数据还会继续生成吗?再打开,是接着上次,还是从头来?另一台部署网站的服务器,又跟这些数据是什么关系?

后面的问题又往前追了一层:做的人是怎么想到要这样搭的?是不是所有模拟都可以用这套思路?

下面的会议室和三个可操作示意,是为这篇文章新做的虚构教学例子,不是原项目,也不是我的练习记录。

先把“前后对得上”说具体

我不知道原作者实际的思考顺序。这里换一个小例子,从它要满足的需求往回推,看看每个技术选择在保护什么。

假设有三间会议室。页面要显示日程、预约清单和今天的占用时长;用户可以预约、取消;以后还希望把内存里的数据来源换成真实服务。为了看一段历史里的变化,也需要生成不止几条记录。

这些要求放在一起,才开始产生约束。日程上多了一小时,清单和统计要能对上;时钟往后走,尚未开始的会议才可能变成进行中;隔一天重建历史,不能因为窗口前面少了一天,就把所有记录的身份都换掉。“像一个系统”,在这个例子里有了这些可以检查的含义。

三个示意共用下面这套小模型。时间用虚构的“第几天”计算,不对应真实日期,也没有夏令时。

模型里的东西为什么需要
会议室 A、B、C预约必须属于明确的房间,冲突也在同一房间里判断
ID、开始和结束时间同一条预约要能被日程、清单和取消操作共同引用
bookedAt、cancelledAt区分“生成器计划了这件事”和“此刻已经发生了这件事”
注入的 now所有地方用同一个“现在”,也能重现某一时刻
历史记录与会话内追加区分可以重新算出的数据和只在这次打开期间存在的变化

视图之间:三个数字凭什么一致

如果日程随机画几个块,清单独立生成几条记录,占用数字再随机抽一次,每个部分都可能挺像那么回事,放在一起却没有理由一致。

换成一份权威记录,三个地方分别查询它,关系才有了依据。这里的“今日占用”被明确规定为:A 房当天 09:00–18:00 之间,已经可见、尚未取消的预约时长;分母是九小时。它再按 now 拆成已过去和未到的两部分。所有数都来自这些记录,不另外抽一个数字。

这只是本例的统计口径。真实系统里的指标可能有另外的数据来源、分母和筛选范围,仍要逐项定义,不能看到数字就默认它是当前列表的行数。

有了一份数据,还没解决已经显示出来的旧结果。查询返回的是副本;取消成功,只代表内部状态改了,其他视图手里那份副本不会自己变新。因此还要有一条完整的更新路径:校验操作、修改状态、通知受影响的房间、让相应视图重新查询。

第一个示意可以把这几层拆开。先看三个地方各自生成的结果,再换成共享记录,试着只让发起操作的视图重查。最后打开订阅,比较写入之后三处的版本。还可以先在清单里取消,再点过期日程中的同一条预约:画面仍有那个块,内部却已经不允许再取消了。

示意 1 / 3 · 视图之间

三个视图,凭什么对得上?

× 记录或版本对不上:日程 2 小时 · 清单 3 小时 · 指标 5 小时;版本 0 / 0 / 0,store 0

第8天 10:20 · 三个视图读取此刻可见的预约;切换来源会重置数据。

数据来源
数据来源

A 房日程第 0 版

竖线:现在 10:20 · 斜纹:已过去

今日清单第 0 版

A-d8-9 · 09:00–10:00已结束
A-d8-12 · 12:00–13:00即将开始
A-d8-17 · 17:00–18:00即将开始

占用第 0 版

5 / 9 小时已占用

已过去 0.33 · 未到 4.67

为本文新做的示意:虚构数据,只在本页内存里计算,刷新即重置。

数据关系也得由写操作保护。同一房间的两个有效时间段,重叠条件是 a.start < b.end && b.start < a.end。严格小于允许前一场结束的那一刻,后一场恰好开始。已经开始的会议不能再取消,过去的空格也不能被重新预约。失败时返回明确结果,不改变记录,也不发送成功通知。

页面面对的接口可以很小:

type Result<T> = { ok: true; value: T } | { ok: false; error: string };
type Booking = { id: string; roomId: string; start: number; end: number };
interface BookingSource {
  list(room: string, from: number, to: number): Promise<Result<Booking[]>>;
  book(room: string, start: number, end: number): Promise<Result<Booking>>;
  cancel(id: string): Promise<Result<Booking>>;
  subscribe(onChange: (room: string) => void): () => void;
}

这是一份接口草图,约定能力和返回形状,不代表一定会发 HTTP 请求。当前实现可以在工厂函数的闭包里读写数组,再用 structuredClone 返回副本。所有视图拿到同一个实例,才会面对同一份状态;各调用一次工厂,得到的就是几份独立数据。

接口中的等待、失败和通知,也是在为数据来源划边界。页面需要处理结果,却不必直接伸进生成器的数组里。这个边界有代价:要维护查询、缓存失效和订阅的清理,不能把“用了共享状态”当成联动已经完成。

时间:关掉以后,过去从哪里来

回到我当时最具体的疑问:页面关了,模拟也停了,为什么重新打开,还可能看见中间那几个小时的数据?

先区分两件事:网页文件由哪台服务器提供,以及模拟程序在哪里执行。一个网站可以部署在远处的服务器上,浏览器下载代码以后,仍然在本地生成数据。只看部署地址,回答不了模拟住在哪里。

在这个例子里,关掉页面以后,没有代码继续替会话攒数据;再打开时,过去是按种子和时钟重新算出来的。

具体做法是把历史生成和“此刻能看到什么”拆开。生成器先按房间、日期、时段产生预约的假设事实,包括什么时候预订、什么时候开始、是否会取消;再用同一个 now 投影出可见记录与状态。预订时间还没到的记录不显示;已经取消的显示取消;其余按开始、结束时间区分即将开始、进行中和已结束。

例如,假设一条预约在第 8 天 11:30 才被预订,14:00 开始。10:20 打开时,它还不可见。13:20 再打开,把同一套规则计算到 13:20,就能得到这条即将开始的预约。这并不需要有程序在关闭期间每分钟写一条日志。

const facts = generate(seed, window, rules);
const visible = project(facts, now);

这里的时钟从外面传入,各个函数不另外读取系统时间。这样才能固定在同一时刻比较结果,再明确地推进到另一个时刻。时间过去本身不会执行代码,仍要由调用方触发重算或状态推进。浏览器后台标签页的定时器还可能被节流,也不能简单把定时器触发次数当成经过的时间。

第二个示意保留外层的文章与时钟,真正卸载、重新挂载里面的模拟会话。先推进一段时间,可以看到会话临时追加预约;也可以取消一条即将开始的预约。关闭后再推进三小时,观察这段时间的生成器调用和追加计数,然后重新打开对账。

示意 2 / 3 · 时间与生命周期

关掉再打开,过去从哪里来?

模拟会话已挂载 · 第8天 10:20

关闭会话只卸载文中的模拟组件;这页文章和浏览器标签页仍开着。真正关掉标签页时,连这份对照快照也不会留下。

第8天 10:20
打开第8天 10:20

历史 65 · 可见 65 · 生成器调用 1 · 临时加订 0 · 你的取消 0

A-d8-15即将开始
A-d8-17即将开始
为本文新做的示意:虚构数据,只在本页内存里计算,刷新即重置。

这里故意把两类数据分开:历史底层可以用种子和新的 now 重建;临时加订和你的取消,只是这次会话的覆盖层。重新打开后,历史可能多出记录、改变状态,旧的操作却没有一起回来。

因此,“重新看到了过去”和“保存了过去”是两个不同的承诺。要让取消记录留下来,就得保存当前结果,或者保存操作并在重建后回放;后者又要求那条取消指向的 ID 仍然可靠。文章里的对照快照也只由外层组件暂存,真正关闭整个标签页时,它不会替这个例子保留证据。

种子:固定了一串数,还没有固定结果

那次解释一开始把固定种子与数据一致联系在一起,后来补上了限定:种子固定的是伪随机数序列。数据最后长什么样,还取决于这串数怎样被取走。

这个差别值得具体展开。示例生成器按“日 → 房间 → 时段”遍历:先取一个数判断有没有预约;有预约,再取时长、提前预订多久、是否取消;如果取消,还要多取一个数决定取消时间。两小时的预约会占住下一格,那一格就不用再抽。

这一格走到的分支消耗的随机数
空闲1 个
有预约,不取消4 个
有预约,会取消5 个
被前一场占住,或按维修规则跳过0 个

如果所有房间共用一条流,A 房少走几次分支,B 房取到的就可能是序列中另一个位置。B 的规则没有改,它的结果仍可能改变。窗口往后移一天也有类似影响:前面少生成一天,后面的抽取位置就变了。

第三个示意比较同一批时段的生成事实,不把“时钟变了,会议自然结束”算成随机结果变化。可以分别试窗口后移与 A 房维修,再切换全局流和分流,点一个格子查看改动前后具体用了第几个随机数。

示意 3 / 3 · 种子与身份

固定了种子,为什么还会变?

A 房以外 72 段里,变了 59 段。

随机流
随机流
改动
改动
变:生成事实变化斜纹:按维修规则移除点选或方向键移动,Enter 看详情
房 / 日91011121314151617
A · d5
A · d6
A · d7
A · d8
B · d5
B · d6
B · d7
B · d8
C · d5
C · d6
C · d7
C · d8

全局序号 ID:48 个现在指向另一场会 · 业务键:0 个换了指向,28 个随记录消失,27 个新出现

改前 · 无预约
mock-world-4 第 165 个 → [0.88]

改后 · 无预约
mock-world-4 第 107 个 → [0.60]

这里只比较完整生成集里的事实(含未来安排),不比较截至当前时刻可见的数据或随时钟推导的状态。业务键定位时段,不保证属性不变。分流隔开随机数的抽取位置;两小时预约等业务约束仍会影响本房相邻时段。换随机算法、改抽取顺序或升级生成器版本,同一个种子也可能给出不同数据;需要长期保持的示例,应保存生成结果或给生成器标版本。

为本文新做的示意:虚构数据,只在本页内存里计算,刷新即重置。

按键分流,是把依赖缩小的一种办法:

const streamKey = `${seed}/${roomId}/${day}/${hour}`;
const random = createRandom(streamKey);

这样,一个房间的分支不再顺手挪走另一个房间要用的数。但它不是“所有地方从此互不影响”:两小时预约仍会占掉本房下一格,这是业务规则本来就有的关联。换随机算法、改字段的抽取顺序,或者升级生成器版本,也可能改变同一种子的结果。需要长期固定的示例,可以保存生成结果,或连生成器版本与配置一起记录。

ID 还要单独看。按“第几条”编号,窗口开头移走几条记录,后面相同的序号就可能指向另一场会议。即使分流后重叠范围的数据完全没变,序号仍可能换指向。示意里同时保留两套编号,正是为了把这两个问题拆开。

业务键 A-d8-11 则指向 A 房、第 8 天、11 点这一格。它稳定的是身份的定位方式,不保证时长、预订时间等属性永远不变。对于允许同一格多条记录、改期或合并的真实业务,这个键还不够,得重新设计身份规则。

换成真实服务,还有哪些责任

接口一致,能帮助页面继续按相同方式调用;它不会把内存模型的保证自动搬到服务器。

单个 JavaScript 环境里,一段不等待的“检查冲突 → 写入”可以连续完成。多人请求到了服务器,则可能一起通过检查,需要事务配合合适的锁或数据库约束保护预约不重叠。权限要从真实会话重新检查,记录要真的保存,网络失败要结束加载并允许重试。其他用户的变化,也需要轮询或推送才能被这一页知道。

第一个示意只在固定时钟的可见快照上允许新建预约;会移动时间的第二个示意只做历史投影、临时追加和取消。它们分别解释边界,没有把两个小实验拼成一套已经解决所有并发问题的预约服务。

每个选择,到底保护了什么

我还问过,是不是所有模拟都可以这样做。现在整理这段讨论时,更适合先把需求问清楚。一张固定截图,用几条固定数据就够了;需要操作联动、历史趋势、可复现的问题状态,才会逐渐需要这些机制。

需要保护的条件这里采用的选择拿掉以后,会看到什么
日程、清单、指标对得上共享记录,按明确口径查询各部分看着合理,合起来却不一致
写入后能看到新结果成功后通知,相关视图重查数据已经改了,画面还停在旧版
能比较某个确定时刻时钟从外部传入“现在”各算各的,问题难以复现
重开时能解释历史来源生成事实,再按时刻投影分不清重新计算与持续运行
无关数据少受牵连按键拆分随机流一处少抽几个数,别处跟着变
操作能指回同一条记录稳定身份,单独考虑 ID序号还在,指向却换了

这也更接近我当时想追问的“怎么想到这样设计”:给出一个需求之后,怎样找到必须保持的关系,再把它落实到接口、状态、时钟和规则里。这里能展示一种具体推法;原作者真正怎样做选择,仍需要原作者自己的回答。