单向数据流的真正甜蜜

语言: CN / TW / HK

单向数据流和事件驱动开发,微服务拆分一样,具有极其形象,易于模仿的特点。但不是说你可以在白板上捋出一根单向的数据流就能单方面宣告胜利了。只要忽略足够多的细节,任何数据流都可以自称自己是单向数据流,只要把不在行进方向上的数据访问称之为“细节”就可以了。

那么前端开发为什么如此迷恋单向数据流呢?单向数据流的真正甜蜜是什么?

单向数据流最让人迷恋的地方在于减少需要程序员手工管理的状态数量。也就是所有 mutable state 的个数。这个是建立在程序员可以用10个Model上的变量,轻松管理上千个DOM的可变节点属性。只要我们把那10个Model上的变量管理好了,这个往 Browser DOM 映射过程是不需要过度投入精力。

  • 改了之后 render 会自动重新执行,跑一个完整的 VDOM 出来。你不用管 diff 是什么
  • 改了之后计算属性会自动重新执行,你也不用管是谁触发修改的,也不用管之前算出来的结果是什么
  • 改了之后effect会自动重新执行调用外部 API 把最新的值同步出去。你不用管是从1改到了3,还是从2改到了3。只管读到了是什么,就往外同步什么

如果每一个 VDOM 的 apply 过程是一个手写的 diff,如果每一个计算属性都是一堆前提条件的增量计算,如果每个effect都要考虑其他 effect 对自己的组合影响。那么就达不到从管理上千个DOM可变属性降低到管理10个Model上变量的目的。预想的降低管理状态的复杂度目标不但没有达到,可能还因为引入了 Model 上的10个变量使得总的要管理的状态数目变多了。在这种状态下无论自称单向数据流,还是自称八通阀数据流,都没有用。

数据绑定的第四种模式:标脏

vdom,计算属性和effect 有的时候还是重算太多了。使得我们无法实现“真正的”单向数据流。这里有第四种方案,标脏。

这里是一条计算销售员奖励列表的 SQL。可能会有新的销售员加入或者退出,可能会有新的销售分成记录,可能会有新的顾客绑定。如果要在任何一个改动的时候,都触发持久化之后的查询结果的刷新重算那将是非常昂贵的。

标脏就是一种平衡的策略。如果完全重算,写起来很简单,但是执行起来很慢。如果完全精确算diff,执行起来很快,但是要写对就很困难。标脏取的是一个执行效率和开发效率的平衡点。这里我们用了三条 SQL

一个是从 binlog 记录的 salesman 里取改动了的销售员。一个是从 SalesmanCommission 的 binlog 记录里取受影响的销售员。另外一个是从 SalesmanCommissionRelation 的 binlog 记录里取受影响的销售员。这三个集合(impactSet)取并集就是脏数据。

这里的 binlog 是依赖了 mysql 的 change detection 能力,我们的服务器作为从库挂在 mysql 上监听数据的改动。如果实时根据每一条改动的 binlog 记录去刷新查询持久化记录则太难了。所以就想了这么一个标脏的折衷做法。

然后有了脏数据的范围, 要做的重算就仅仅是把 impactSet Join 到前面那条正向计算销售员奖励的 SQL 里就可以求出这些销售员最新的统计结果。

我们可以看到在其他领域 Dirty Flag · Optimization Patterns · Game Programming Patterns . 也有类似的标脏的实践。这是在 vdom,计算属性和effect三种常见模式之外的第四种实现数据绑定刷新的模式。 也许你会问,这和计算属性不一样吗?计算属性也可以优化为标记一个 dirty flag。

区别在于需要标记的 dirty flag 的数量,以及重算的时机。对于重算我们有两种主要的策略:

  • 写的时候 eager 更新
  • 读的时候 lazy 刷新

标脏的做法是在写的 eager 标脏,但是刷新的时机是在你选定的时刻做一次性的批量刷新。也就是读的时候可以不做 dirty 的检查然后触发重算。也就是放弃了“联动数据”的实时性。也就是你在界面上看到的统计数据可能不是每一秒都是最新的,而是每一分钟级别同步过来的。

因为我们是批量刷新,所以 dirty flag 就不需要保持在每一个受影响的变量上。只需要 dirty flag 能够让重新刷新结果跑正确就行了。而不需要担心每一个可能被读到的数据 dirty flag 检查不到从而读不到“最新”的值。比如说一个parent对应10个children。可能脏标记就标在parent上就可以了。刷新的时候看到了就把“所有”children也刷了。有没有可能只有7个children真正需要刷新呢?可能,但是做到这一点太难了,所以就不要管那么细。而且从现代计算机的特点来说,计算有的时候是比读存储还要更快的事情。如果能用 simd 或者 GPU 来批量算一个 Array 的函数计算结果,可能比check每个元素的dirty和缓存再合并结果是耗时更少的,因为Array的内存更连续。

数据绑定的第五种模式:暴力重算

标脏是建立在重算太慢的前提下的。OLAP 早期我们需要很多 cube 数据做为中间计算结果存储起来。但是 clickhouse 之类的大力出奇迹,快速扫全表的方案弄出来之后,很多公司已经可以把即席查询直接从原始数据开始重算了。这依赖于硬件的更新,对新型硬件的更高效的利用。

The whole web at maximum FPS: How WebRender gets rid of jank – Mozilla Hacks - the Web developer blog Mozilla 做过一个实验。如果浏览器对矢量绘制指令和光栅化之后的纹理不做精细化的缓存,每帧都用 GPU 暴力重算会怎么样?

之前做法是 CPU 做一堆精细化的分层分Tile 的缓存。然后自己算好了之后,让 GPU 画三角形

暴力重算的方式就是去掉 CPU 先 Paint,GPU 再 Composite 的假设。把两步合并为一步

这个时候 CPU 给 GPU 的绘制指令就从三角形的级别,上升到了带有一定业务属性的“对象”了。

然后利用 GPU 的并行能力,在 GPU 的 shader 里把这些大对象分解为三角形来绘制。因为 GPU 的并行能力远高于 CPU,我们在一个 tick 内可以绘制的三角形个数极大的上升了。即便有一些三角形没有变,不需要重新绘制的,也没有关系了。这种对新硬件的利用,使得暴力重算变成了可能。硬件带来的范式的改变,精细化的缓存的 overhead 可能比每次暴力重算还要高。

基于这个想法,Github 的 ATOM 团队在解散之后自己搞了个公司开始做基于 GPU 的文本编辑器Zed 代码量从以 JavaScript 为主,直接变成了以 WebGL 的 Shader 语言为主了。 表面上看起来是一个基于Web的东西,实际上Web只是一个GPU软件的 Installer,你的软件是大部分跑在GPU里的了。

听起来很新鲜吗?一点也不新鲜。这就是3d游戏渲染的工作方式。JavaScript 前端工程师们也早就掌握了这门技术,叫做 pixi.js。 pixi.js mesh-and-shaders uniforms example - CodeSandbox 用 pixi.js 我们可以创建自己的 Mesh,加入到 stage 里进行渲染。Mesh 对应的就是一段自己的 WebGL 的 vertexShader 和 fragmentShader 代码。这两段代码会由 pixi.js 上传到 GPU 里执行。甚至 pixi.js 用这个模式都可以实现 SVG 的渲染 http:// medium.com/javascript-i n-plain-english/vector-rendering-of-svg-content-with-pixijs-6f26c91f09ee

在渲染管线的更前一个阶段是 occlusion & culling。传统上这一步也是 CPU 完成的。写游戏的大神们已经一步步地往前推进,CPU 干的事情越来越少。他们已经打出了 GPU Driven 的旗号了。 安柏霖:《天涯明月刀》手游中用GPU Driven优化渲染效果

现在是 WebGPU 标准还没有推广开来。用 WebGL 缺少 ComputeShader 的残血版搞不了。只要 WebGPU API 推广开了,这套打法一定会被搬进浏览器的。