跳转到内容

一次更新的完整追踪

一次更新从状态写入开始,以缓冲区里出现不同文本结束。前文各章分别讲过这条链上的部件——响应式图、Runtime、Ebox 的 flush 流水线——本章把它们接成一次完整的走查:哪一步在什么顺序运行、每一步能看见什么、失败从哪里截断。本章不定义自己的接口;每一步都用别处定义好的公共接口来点名。

这条链从 etaf-set-value 或 etaf-update-value 开始:前者替换 ref 的值并返回它,后者把函数在当前值上应用一次。两者都让新值立刻可以通过 etaf-value 读到——批处理之内也一样,而且早于任何缓冲区变化。etaf-update-value 在函数运行之前拒绝渲染期与只读源;函数报错则 ref 保持不变。

ref 用它的浅比较规则:列表、向量、哈希表、记录、函数与编辑器句柄按同一性,其余值用 equal。设置相等的值不通知依赖者,也不做深拷贝,所以替换对象后,调用方应把旧对象视为已分离。写入本身不改任何缓冲区,它只让新值成为当前值,并标记读过旧值的观察者。

批处理之外,写入在返回之前同步排空排队的工作,所以一次 etaf-set-value 返回时已经可见。在 etaf-batch 或 etaf-call-with-batch 之内,投递延后到最外层批处理;嵌套批处理并入它,多次写入一起发布。ref 读取不延后——延后的只有观察者与 Runtime 更新。

事件回调与 Action 处理函数运行在同一个边界里,所以在事件回调外再包一层显式批处理,不会产生第二次发布。这个边界是同步的:更晚的异步回调必须建立自己的批处理。批次体报错或退出时,排队的工作仍会先排空,再重抛原来的条件;写过的 ref 保持写入,批处理既不提供回滚,也不提供跨多个 Runtime 的原子发布。

写入把直接观察者变为 dirty、把它们传递到下游的观察者变为 check,于是排空只运行「真正读过的值发生了变化」的副作用。这里有两类副作用相关。以 etaf-watch 或 etaf-watch-effect 默认的 pre 阶段注册的侦听器,在已挂载 Runtime 写缓冲区之前运行:它们看到新值与旧缓冲区。渲染绑定在同一阶段运行,把新值变成保留编辑:属性绑定补丁自己 Host 的属性集,文本绑定补丁自己的文本载荷,结构区域补丁自己的子节点列表。每个绑定恰好拥有一个目标;它可以读许多响应式源,但只能补丁那个目标。

这种所有权正是更新保持很小的原因:保留树是唯一权威,没有候选树,也没有整树 diff;没有重跑的绑定不会碰它的节点。渲染与绑定求值不得写应用状态;写入属于事件、Action、侦听器、副作用、异步完成与显式批处理。

pre 与 render 副作用安定之后,Runtime 提交。若任何保留节点发生了变化,提交恰好做一次 Ebox flush;增量更新与发布一章讲那条重新样式、重排、重绘并算出受损行范围的流水线。flush 只写那些行范围,未被碰到的行原样保留,ebox-mount-flush-count 只在 flush 真的写了东西时前进。etaf-runtime-revision 为这次提交前进一次,且只在缓冲区变化时前进;发现无内容可写的提交不动计数器,也不动修订号。

提交是这次更新唯一可见的写边界。它之前的一切都在准备保留状态,之后一切读缓冲区的代码看到的是新文本。应用通过公共接口观察 flush:ebox-buffer-update-report 返回最近一次 flush 的报告 plist,ebox-buffer-set-observer 在每次 flush 后收到一份报告,ebox-after-flush-functions 在每次写了东西的 flush 之后运行。报告给出策略、行替换的数量与位置、flush 计数器,以及——当那次 flush 有观察者计时时——耗时毫秒数。当一次变化看起来没生效时,把报告和 etaf-runtime-revision 一起看:修订号与报告都没变,说明没有东西到达缓冲区;而修订号前进却带空编辑列表的情况不会出现。

固定有三种后续工作在可见提交之后运行。第一是组件生命周期钩子:etaf-on-updated 在一个 Component 参与更新之后运行,etaf-on-mounted 在它第一次发布之后运行;那时缓冲区已经显示新文本。第二是 post 侦听器——带 :flush 'post 的 etaf-watch 与 etaf-watch-effect——在缓冲区写完之后运行,所以它们能读到已发布的结果。第三是用 etaf-next-tick 排队的函数,它们在排队的工作发布之后运行,位置因此在 post 侦听器之后;批处理之外它们立即运行,排空失败时排队的函数也按顺序运行。

这个顺序可以观察而且稳定:批处理之外的一次 etaf-set-value,pre 侦听器看到旧缓冲区,提交写入新文本,etaf-on-updated 看到新缓冲区,随后 post 侦听器也看到它。在组件 setup 期间创建的侦听器每次运行都重入该 Component 的环境;在组件之外创建的侦听器不隶属于某个 Runtime,而是通过它捕获的 Runtime 与 Host 引用来读,而不是通过当前环境。

普通的响应式绑定是进入这条链的常见方式,但不是唯一方式。etaf-runtime-update-host-content 与 etaf-runtime-update-host-contents 直接替换 Host 内容——视频帧这类高频显示面的保留边界——先校验每个目标,再开始第一次替换,每次调用发布一次、修订号前进一次。etaf-runtime-update-host-geometry 与 etaf-runtime-update-host-geometries 对位置、尺寸与图层事实做同样的事;调用方仍然负责让自己的状态与显示保持一致。etaf-runtime-refit 与 etaf-refit 通过同一个修订号重新发布变化后的视口。

事件与 Action 经批处理边界进入,所以它们的回调发布起来与状态写入完全相同。动画帧是值随时间移动的普通 ref,所以一次 tick 就是一次提交。Resource 与 Data 的完成写一个响应式状态单元,然后走同一条链。无论哪种入口,更新都结束于同一个提交:一次 flush、一组行替换、一个修订号。

pre 或 render 副作用可能在同一轮排空中、更早的副作用已经编辑过保留节点之后报错。提交随即丢弃整个事务:记录下的改动被反向回放,这一轮创建的副作用、Scope 与安装被释放,运行过的副作用被搁置(parked)。缓冲区、修订号与 post 侦听器都保持更新之前的样子,而应用 ref 保留写入给它们的值。没有任何半写状态被发布。

被搁置的副作用在下一次有工作的排空时、或显式调用 etaf-runtime-flush 时,与新工作一起重试。因此持续失败会把最后一次提交的 UI 留在屏上,直到起因被修好;etaf-runtime-flush 会重抛失败,而不是发布。起因消失后,下一次写入把新工作与搁置的副作用放进同一次提交发布,于是缓冲区一步追上,而不是经过一连串局部更新。生命周期钩子的失败不一样:它们在提交之后报告,绝不回退可见状态。

对一个已挂载的 Runtime,批处理之外的一次写入经过这些步骤:

  1. etaf-set-value(或 etaf-update-value)替换 ref 的值并标记它的观察者;新值立刻可读。
  2. 最外层批处理边界排空排队的副作用——这里是同步的,或者在外层 etaf-batch / etaf-call-with-batch 退出时。
  3. pre 侦听器运行,看到新值与旧缓冲区。
  4. 渲染绑定补丁它们拥有的保留 Ebox 节点:属性、文本载荷、子区域。
  5. Runtime 提交并做一次 Ebox flush;只写受损的行范围。
  6. etaf-runtime-revision 前进一次,ebox-mount-flush-count 记下这次写入;报告可由读者与观察者取得。
  7. etaf-on-updated 等 Component 生命周期钩子运行,随后是 post 侦听器。
  8. 用 etaf-next-tick 排队的函数运行。

第 3 或第 4 步失败会撤销第 3、4 步并停在第 5 步之前;下一次写入或 etaf-runtime-flush 从被搁置的副作用起重试。