如何看待 snabbdom 的作者開發的前端框架 Turbine 拋棄了虛擬DOM?

snabbdom (vue.js / cycle.js 的 virtual dom 都用了 snabbdom) 的作者自己寫了個 FRP 的前端框架,該框架不僅不用虛擬DOM (以及snabbdom),還稱當前的大多數 FRP以及基於觀察者模式的框架在虛擬DOM的使用上存在問題。

參考作者的說明:Why Turbine doesn"t use virtual DOM · Issue #32 · funkia/turbine


在一個月之前的北京QCon,我的演講主題《單頁應用的數據流方案探索》(https://zhuanlan.zhihu.com/p/26426054)中,有這麼一段:

所以,我們發現如下事實:

  • 在觸發reducer的時候,我們是精確知道要修改state的什麼位置的
  • 合併完reducer之後,輸出結果是個完整state對象,已經不知道state的什麼位置被修改過了
  • 視圖組件必須精確地拿到變更的部分,才能排除無效的渲染

整個過程,是經歷了變更信息的擁有——丟失——重新擁有過程的。如果我們的數據流是按照業務模型去分別建立的,我們可以不需要去做這個全合併的操作,而是根據需要,選擇合併其中一部分去進行運算。

這樣的話,整個變更過程都是精確的,減少了不必要的diff和緩存。

作者的這篇文章說的就是這個問題,很高興能碰到也這麼想的人。

在React + Redux體系中,數據變更與視圖變更之間的過程,就是經過了「精確——不精確——精確」這樣的步驟。前一步是簡單合併,而且是要改變數據引用的合併,後一步是diff。

問題的關鍵在於:為什麼我已經很精確知道了某個數據產生了變更,還必須先假裝不知道,整體提交上去,然後由另外一個環節再次通過「大家來找茬」,發現剛才變化的部分呢?

究其原因如下:

  • 因為Redux是只有一個全局狀態的,所以,能描述整體V=f(M)的東西只有這個全局狀態
  • 各視圖組件需要分別setState,這些state是全局狀態的部分,問題是,它們之間是有重疊的,很多時候還是有變換的,全局狀態並不是簡單等於這堆子狀態之和,重點是這裡。

所以我們就發現,因為它描述不出全局狀態與子狀態之間的關係,所以不得不用這種辦法實現。那這種對於全局狀態的diff算不算臟檢查?而且,還必須強行修改引用,造成在使用Immutable數據的假象,話說,如果你願意在Angular體系中每次操作都這樣,生成新的引用,它臟檢查的效率也會大大提高的呢。說好的鄙視Angular體系的臟檢查的呢,AngularJS和Angular可都是沿用的臟檢查策略。

所以,為什麼React-Redux體系希望你每次修改了數據之後,把引用也改掉?為什麼期望你盡量把全局狀態的結構扁平化?因為這能提高臟檢查效率啊。考慮一下,如果真的使用那種展示組件純化的方案,必然就會導致全局狀態對象極度膨脹,這個時候,是否需要擔心這個diff的性能問題呢?

在AngularJS中,除了scope繼承這個地方之外,其實可以採用跟React-Redux相同的開發規範。多簡單啊,把所有數據全掛在$rootScope上,讓它全局化,唯一化,然後其他的各類controller,只充當action dispatcher和reducer的職責,不就完事了嗎,大家都一起快樂地臟檢查……

那麼,這個問題有沒有解呢?我們還是回頭看一看那個最關鍵的問題:全局狀態跟組件狀態之間的關係,到底有沒有辦法描述出來。

目前,各類主流前端框架的本質差異就是:做不做精確更新。

我們先考慮一下數據的變化過程,以這樣一個東西為例:

某個表單對象myForm上,一個欄位的值foo更新了。

我們應該抽象出怎樣的信息來?

大粒度的更新策略就是:我告訴你,myForm變了,至於變了哪裡,你自己看著辦。

小粒度的更新策略就是:我告訴你,myForm下面的foo變了。

採用哪種策略,有幾個方面的權衡。

  1. 變更批量更新的策略。

    這什麼意思呢?如果我點一個按鈕,不只是改了myForm的foo屬性,而是改了好幾個,你讓視圖更新幾次?如果是採用大粒度更新的框架,這裡可以整體提交,比如說,你把一次外部交互所產生的變更一起提交,然後diff之後一次更新即可。但是在小粒度更新的框架里,內部一定會把這些拆解到一個隊列中,在一次微任務中一起做掉,不然就會導致視圖多次刷新,產生浪費。

  2. 數據的變更策略

    採用哪種機制去更新視圖,直接決定了業務代碼應該怎麼操作數據。

    大粒度更新機制決定了:一切變更都應該儘可能地發起更大粒度的變更,直到全局,所以最後大家確實都直接在修改全局狀態對象了。

    小粒度更新機制決定了:一切變更都應該儘可能最小,直到原子化。

    舉例來說,如果你要修改a.b.c這個表達式裡面c的值:

    大粒度模式下,直接改c對你並沒有什麼好處,你會傾向於改b,甚至連a都改了,這樣說不定還快些。
    小粒度模式下,直接改c比改a或者b都要划算,因為你改了上層的東西,就得自動重建下級的這些索引關係,哪個數據的變更被另外哪些東西訂閱了,都得補起來。

  3. 拿什麼才能組合出全局狀態來。

    在大粒度更新策略的框架中,它們根本就不會去嘗試做這件事,因為做不到。所以,大家都不約而同地偷懶,先合併出一個整體的,然後,上vdom這類強行diff的東西。本質上講,不管是AngularJS,React,Cycle,都是一個思路。

    這個時候再來看那些小粒度更新策略的框架,我們會發現一個問題,他們好像很大程度上迴避了「描述全局狀態」這件事。為什麼呢,因為每個狀態對象的每個屬性,到底變化的時候影響到視圖的什麼地方,他們全部都知道。每個變動都生成了一個特定的函數去做視圖變更的事情,甚至是做其他一些數據的變更,從而間接變更視圖。所以,雖然他們是有機會描述全局狀態的,但很大程度上並不需要做,通過精心設計的computed property,或者getter,可以把組件所需的數據來源定義得很清晰。

那麼,「描述全局狀態」這件事,到底有沒有什麼好處呢?

需要說明一下,這件事情是有好處的,尤其是調試的時候。設想有若干組件共享了某一個數據的不同形態,當這個數據產生了改變的時候,如果你只有組件調試器,很難對整個應用有一個全局視野。

但是,即使這些採用小粒度更新策略的框架們,他們在已有精確的依賴關係的情況下,想要完整表達出全局狀態來,也不是一件容易的事。原因是:

  • 框架不知道每個細粒度數據的真實含義,數據類型過於簡單,業務無法加入補充的描述信息
  • 框架雖然知道每個細粒度數據的完整變化過程,但很難描述時序這個事情

解決這兩個問題的最終方向,還是要採用一種具有更強描述性的通用數據結構,那就是一種對普通數據類型的包裝,一種Monad。

現在我們回頭來看文章中提到的觀點,它其實是要跟Cycle作對比的。同樣做F RP,兩者的差異就在我剛才說的地方:要做大粒度更新策略,還是小粒度?要不要把所有細節變更匯總?

基於Observable/Stream這樣的機制,在這類事情上其實有非常大的優勢,究其本質:

  1. 一個Observable實際上就足以描述一個數據的完整變化過程。那麼,如果我們把一個應用的整體狀態視為一個大的波形圖,它實際上就是可以用類似傅立葉變換這種視角去看待,被視為若干個小波形的疊加,而這些小波形是什麼?就是描述整個應用全狀態數據結構中,每一個小塊的變化情況。最令我們心動的是:這個疊加關係,就是一個簡單加法,數據之間互相不重疊。
  2. 一個Observable足以描述一個特定數據的完整生命周期。你拿到這些Observable之後,任意一個都能展開成剛才提到的一個小波形。
  3. Observable自身是可以描述時間的,時序關係理得出來。
  4. Observable的合併順序是可交換,可組合的,意味著你可以從組成整個應用狀態的Observable們裡面,選擇一些先合併起來,然後拿結果再跟其他的合併,最終結果不變。這讓我們能夠有機會應對不同視圖對相同數據產生不同使用形態這件事,只要源頭的一些東西足夠原子化,這些都好辦。

所以,這篇文章對Cycle產生的質疑是可以理解的:強迫合併成最大粒度更新這件事,別人那麼做是因為他們做不到更好的,你是F RP,明明能做到更好,為什麼你還要合併?

雖然理論上是可以做,但實際開發的時候,應該還是有不少地方值得深思的,比如:

  • 粒度的控制。把每個最小粒度的更新都包裝成Observable,是否划算,如果不划算,這個粒度如何把控?
  • 框架應當如何替業務開發人員把一些事情做起來,做哪些事情?
  • 如何處理「組件」這個概念?
  • 可視化調試工具要做成什麼樣子?有幾個維度?

帶著好奇心,期待它的發展。

======= 以下為5.20補充 ========

在相關的一些回復中,仍然看到一些同學沒有理解合併和diff這兩個東西所表達的意思,寫了一段簡單代碼來說明:

OmabOO

上面這個鏈接是執行結果,可以看看我注釋掉的那兩段代碼,它們究竟在幹什麼。

當然,這些框架並不是這麼實現的,我只是用來打個比方。

我們可以注意到,如果你的框架是基於小粒度更新的理念構建的,那麼,完全可以自動得到最初的那幾個change表達式,不但如此,你還知道最後與之相關的那句訂閱表達式:

const changeTitle$ = Rx.Observable
.fromEvent(document.getElementById("changeTitle"), "click")
.map(e =&> `title${Math.random()}`)
changeTitle$.startWith(initTodo.title)
.subscribe(title =&> todoDiv.querySelector(".title").innerHTML = title)

所以,每一個變動事件到底影響了誰,是很精確知道的,我用Rx寫的demo,像Vue內部不是這麼做的,但它原理是一樣的,它完全就知道什麼變更會導致什麼界面的更新,根本不需要virtual dom,就能夠做到精確更新。之所以它還是引入了vd,是因為它要考慮服務端渲染,而且還適當放棄了一些精確性,根據 @尤雨溪 的描述,應該是推薦做組件粒度的整體數據設置,這個我認為是比較合理的取捨。

我注釋掉的那段代碼:

// 為什麼不用這段,是因為這段會有不必要更新,比如說,content沒變,但是title變了,它也得跟著更新視圖
// todo$.subscribe(todo =&> {
// todoDiv.querySelector(".title").innerHTML = todo.title
// todoDiv.querySelector(".content").innerHTML = todo.content
// todoDiv.querySelector(".completed").innerHTML = todo.completed
// })

理由在注釋里說了,如果不做diff,每次就是全量更新,因為你不知道到底誰變了,誰沒變。

但是下面這段:

const changeTitle1$ = todo$.pluck("title").distinct()
const changeContent$ = todo$.pluck("content").distinct()
const changeChange$ = todo$.pluck("change").distinct()

這個就相當於把變動的部分diff出來了,不變的就不會改變對應的視圖。這麼兩段就分別約等於Redux和React-Redux所做的事情。

然而,這麼一大片東西,實際上最後注釋掉的那幾句就能代替了,因為最初的精確變更的部分一直沒有丟掉,所以是可以利用的。在複雜一些的場景下,那個地方的代碼會複雜一些,但仍然會是精確高效的,這個不會變。

我從去年到現在,寫了好幾篇東西,但有些人就是不看,我也很為難啊,我反對什麼,是會寫出理由的,並不是什麼信仰啊之類的,為什麼我一直這麼不認同Redux,繁瑣的那部分我先不說了,引入這麼繁瑣的機制,抽象程度也較高,卻還遺留著重大問題,仿elm形實兼失,很難讓人信服。在底層一直保持著精確變更的數據管道的時候,非要我先合併到一個無類型的巨大對象中,再diff出來,很令人質疑啊。


其實說明了幾個事:

1. Virtual DOM不快,只是避免了重大性能損失,Model和View的分離導致Diff本身都不能達到最高效率,Virtual DOM的根本目的只是希望以聲明式設計簡化亂七八糟的瀏覽器環境,簡化開發。

這個就等於破除迷信了,Virtual DOM只是讓性能更穩定,從原理上來說其實更慢,不會像Angular 1那樣Scope爆炸之後出現性能嚴重下滑的情況。因為Virtual DOM和State是解耦的,如果要真追求性能也要二者合起來才能快。

React的設計並不是以性能為主的,早期只是希望以聲明式設計封裝DOM,只是被om吊打了一次之後才抄了些長處過來做賣點。這些API設計是FB考慮了很多社區接受程度才出現的。

Sebastian發過一個twitter:

React tried and abandoned the Observable tree approach that a href="https://twitter.com/andrestaltz">@andrestaltz is using but but try out http://cycle.js.org/ incase we"re wrong.

React以前還嘗試過,並且放棄了andre正在用的Observable樹的實現方式,但是如果我們錯了的話,可以看看Cycle.js。

不過Cycle.js也是Virtual DOM,Model和View分離就註定了Diff達不到最高效率。但是核心在於Model和View分離更容易測試,更容易嘗試更好的方式,激發更多的工具出現。

Virtual DOM的意義在於有個庫可以幫你把你的數據結構Render成DOM,你就可以無視瀏覽器中最複雜最不穩定又不容易兼容的一堆API。因此基於React的各種庫才爆炸式的湧現,如果你去Egghead上看Redux的視頻,發現和React一起用,幾行代碼就能當個框架了了。

2. 響應式的核心是響應Model,所以Model(數據源)更新應該精確更新View。

這種優化其實現代解決方案都有了,比如Redux的Connect機制,Vue和Mobx的機制等等,但是依賴Virtual DOM還是會變慢,即使React有了fiber,也只是在解決自己創造出來的問題。

3. 以副作用精確更新DOM理論上更快。

上面說了其實View Model一起Diff更快,其實根本沒必要Diff這些數據。哪些東西變了直接改DOM就行了。

說白了其實我們根本不需要前端框架,因為和後端常用的Http一次對話不同,前端處理的是連續的事件,所以前端唯一真需要的東西就是一個比較好的綁定機制,數據源變了精準通知所有受影響的點就好。只用數據綁定意味著更大的自由度,也意味著這個工具不容易隨著時間過時。

所以其實我司大神@楊博 的Binding.scala就是出於這種最簡單最有效的方式設計的(比起之前硬廣,這問題多適合打廣告……快來安利一波)


大致的理解:在整體使用 FRP 的前提下,Cycle.js 是這樣的:

Multiple state streams =&> merged into single state stream =&> vdom stream =&> diff

經過了一個從分散的狀態流,合併成一個狀態流,映射到 vdom 流,最後 diff 的過程。

這裡的問題是在狀態流合併的過程中,丟棄了各個狀態流和 DOM 元素之間的關係。這個關係在 diff 的時候又被重新暴力計算了一遍,所以有浪費。Turbine 的策略是不合併狀態流,直接映射到 DOM 副作用。

這和 Vue 1 的針對每個綁定單獨觀察的策略其實有些類似。需要顧慮的是在可能變動的 DOM 節點比較多的時候,大量對應的 watcher / observer 的常駐內存佔用。另外這樣的策略必須是基於 fine-grained + push-based 的響應機制,框架不是基於這樣的前提去設計的話,還是得用 vdom,除非像 svelte 那樣直接把副作用在編譯的時候就編譯好。

作者的那段話並不是在說『vdom 是錯誤的方向』,而是說『在 FRP 的前提下,vdom 不是性能最優的選擇』。

我對這個策略的顧慮:

- 拋棄狀態流的合併這一步,會不會導致 debug 方面的難度,因為這樣就沒有一個統一的入口去獲得某一時間點的狀態快照。

- vdom 的一個好處就是它把最終的副作用和應用的『視圖狀態』隔離了。這個策略等於從『數據狀態』直接映射到『副作用』,完全丟掉了『視圖狀態』這個中間步驟。這樣的話對於做非 DOM 的渲染可能會增加難度,但是如果把副作用層設計成抽象的介面,應該也可以做。

- 開發體驗上,vdom 的渲染函數是一個同步的純函數,Turbine 的 view 函數貌似還帶各種 yield,感覺這個一般開發者還是挺難適應的...

總的來說這還是一個比較底層的實現/性能細節。與其關注 vdom,還不如關注 Turbine 和 Cycle 之間的異同。這個問題其實應該找 Andre Staltz 來答...


如果真的要提升性能,有什麼理由添加一層 virtual dom 並且把 diff 的工作交給別的 library 比自己直接操作 DOM 效果更好呢?

舉個栗子吧。14 年的時候,Atom 團隊為了提升性能,決定使用 React Moving Atom To React 。他們非常清楚有兩個切入點,第一是減少 reflow,方法是儘可能減少 DOM 的讀寫(當然讀寫順序也很重要),第二是利用 GPU。但是他們研究後決定使用 React。15 年的時候,他們又發現把 DOM 操作交給 React 帶來的開銷其實更大,於是他們重新回退到了手動操作 DOM Implement text editor DOM updates manually instead of via React by nathansobo · Pull Request #5624 · atom/atom

而與此同時 Visual Studio Code - Code Editing. Redefined 一直是手動操作 DOM ,不過團隊核心阿萊士閑暇時光讀了 Chromium 的 spec https://www.chromium.org/developers/design-documents/gpu-accelerated-compositing-in-chrome 和 chromium 的源碼。

對於一個編輯器而言,你是容易知道頁面上有哪些 component/element 的。比如用戶打了一個 a,你只需要更改一個 DOM node (常常是那個 token),如果產生了換行,把受影響的 line 使用 translate3D 移動。試想這個事情到 virtual dom 里走一遭,並不能帶來一丁點的性能提升。

當然手動操作 DOM 從來都不是普世的,阿萊士一個人負責 Monaco 六年,頭上頂了七八百個 issue,我們得想辦法把他的腦子 dump 出來。

最後,如果大家感興趣,還可以去了解一下 Atom 從 adopt Shadow DOM 到棄用的過程,也很精彩。


請先看一下 民工(如何看待 snabbdom 的作者開發的前端框架 Turbine 拋棄了虛擬DOM?)和尤老師(如何看待 snabbdom 的作者開發的前端框架 Turbine 拋棄了虛擬DOM?)的回答,我在這裡要說的是更加學院派的一些補充信息

Turbine不使用VDOM的最基礎的原因是,它是一個基於FRP的框架FRP中的R為工具,其實並非一定要Reactive,我們可以有很多的手段去做到變更的追蹤並通過綁定等方式同步到DOM的更新上,如我作為主要設計人員參與的 ecomfe/san-store 就是使用了一個庫來在更新的同時生成diff而FRP中的F是其基礎,只有Functional才能滿足使用變更追蹤(我不管你叫Stream還是Watcher還是Observable)來進行視圖的開發,如果沒有F這個前提,VDOM和變更追蹤根本不是一個層面上的可供比較的方案

為了解釋這一問題,我們將基於VDOM的邏輯進行一下拆解:

let newState = payloadToState(payload, previousState);
let newVirtualNode = stateToVirtualNode(newState);
let diff = virtualNodeToDiff(previousVirtualNode, newVirtualNode);
let modifications = diffToModifications(diff);
let newView = applyModifications(previousView, modifications);

而基於變更追蹤時,拆解後則是這樣的:

let [newState, diff] = payloadToStateWithDiff(payload, previousState);
let modifications = diffToModifications(diff);
let newView = applyModifications(previousView, modifications);

可以看出來少了2步(stateToVirtualNode和virtualNodeToDiff),但其實因為第1步里直接生成了diff,真正少的是stateToVirtualNode這一步

那麼差了這一步有什麼問題呢?我們可以首先看看這幾步都是由什麼東西來完成的:

  • payloadToState - 例如react的setState或者redux的reducer,由用戶編寫
  • stateToVirtualNode - 例如react的render方法,由用戶編寫
  • virtualNodeToDiff - 框架內部實現
  • diffToModifications - 框架內部實現
  • applyModifications - 框架內部實現

通過將基於VDOM的和基於變更追蹤的實現進行比對,並結合上面的列表,我們不難得出一個結論:基於VDOM的實現多了一步由用戶實現的過程

假設這一個過程是純粹的輸入到輸出的映射,那麼這一個過程確實是多餘的,先從一個無上下文的狀態變成一個無上下文的VDOM,再拿兩次VDOM去做diff並生成操作是一種浪費。但問題在於,這一個過程並非必定是純函數

為此我們再來拿一個真實的代碼看下這個情況,首先假設這是一個普通的網頁,伺服器記錄當前的訪問次數,並輸出當前的時間(代碼瞎寫的,不保證能運行):

import http from "http";
import moment from "moment";

let visits = (() =&> {
let current = 0;
return () =&> ++current;
})();
let greeting = () =&> `&

Hello, by ${moment().format("YYYY-MM-DD HH:mm:ss")} you visit here ${visits()} times&`;

http.createServer((req, res) =&> res.end(greeting()));

然後試著訪問這個網頁,並刷新一下界面,我們一定能看到時間和訪問次數都發生了變化。

那麼如果將它帶到富客戶端的系統中去呢?我們知道富客戶端系統就是要在一個頁內模擬瀏覽器本身的原生行為,因此我們這麼去寫代碼(隨手以React為例子):

import React, {Component} from "react";
import {render} from "react-dom";
import moment from "moment";

class App extends Component {
constructor(props) {
super(props);
this.state = {
count: 1
};
}

refresh() {
this.setState({count: this.state.count + 1});
}

render() {
return (
&
&

如果Vue.js作者不上知乎,vue還會在中國這麼火嗎?
如今es8都出了 ,還有必要用ts嗎?
v-on 綁定事件時,函數名加括弧和不加括弧有什麼區別?
閱讀vue.js源碼可以從哪幾方面入手?

TAG:前端框架 | React | Cyclejs | 虛擬DOM |