單向數據流的真正甜蜜

語言: 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 推廣開了,這套打法一定會被搬進瀏覽器的。