從Lisp到Vue、React再到 Qwit:響應式編程的發展歷程
本文介紹了響應式編程的歷史和發展,響應式編程是一種編程范式,它強調了數據流和變化的傳遞。文章從早期的編程語言開始講述,比如Lisp和Smalltalk,它們的數據結構和函數式編程的特性促進了響應式編程的發展。然后,文章提到了響應式編程框架的出現,如React和Vue.js等。這些框架使用虛擬DOM(Virtual DOM)技術來跟蹤數據變化,并更新界面。文章還討論了響應式編程的優點和缺點,如可讀性和性能等。最后,文章預測了未來響應式編程的發展方向。
總的來說,本文很好地介紹了響應式編程的歷史和發展,深入淺出地講述了它的優點和缺點。文章提到了很多實際應用和框架的例子,讓讀者更好地理解響應式編程的概念和實踐。文章還預測了未來響應式編程的發展方向,這對讀者和開發者有很大的啟示作用。
下面是正文。。。
這篇文章并不是關于響應式的權威歷史,而是關于我個人在這方面的經歷和觀點。
Flex
我的旅程始于 Macromedia Flex,后來被 Adobe 收購。Flex 是基于 Flash 上的 ActionScript 的一個框架。ActionScript 與 JavaScript 非常相似,但它具有注解功能,允許編譯器為訂閱包裝字段。我不記得確切的語法了,也在網上找不到太多信息,但它看起來是這樣的:
[Bindable] 注解會創建一個 getter/setter,當屬性發生變化時,它會觸發事件。然后你可以監聽屬性的變化。Flex 附帶了用于渲染 UI 的 .mxml 文件模板。如果屬性發生變化,.mxml 中的任何數據綁定都是細粒度的響應式,因為它通過監聽屬性的變化。
我懷疑 Flex 并不是響應式最早出現的地方,但它是我第一次接觸到響應式。
在 Flex 中,響應式有點麻煩,因為它容易創建更新風暴。更新風暴是指當單個屬性變化觸發許多其他屬性(或模板)變化,從而觸發更多屬性變化,依此類推。有時,這會陷入無限循環。Flex 沒有區分更新屬性和更新 UI,導致大量的 UI 抖動(渲染中間值)。
事后看來,我可以看到哪些架構決策導致了這種次優結果,但當時我并不清楚,我對響應式系統有點不信任。
AngularJS
AngularJS 的最初目標是擴展 HTML 詞匯,以便設計師(非開發人員)可以構建簡單的 Web 應用程序。這就是為什么 AngularJS 最終采用了 HTML 標記的原因。由于 AngularJS 擴展了 HTML,它需要綁定到任何 JavaScript 對象。那時候既沒有 Proxy、getter/setters,也沒有 Object.observe() 這些選項可供選擇。所以唯一可用的解決方案就是使用臟檢查。
臟檢查通過在瀏覽器執行任何異步工作時讀取模板中綁定的所有屬性來工作。
這種方法的好處是,任何 JavaScript 對象都可以在模板中用作數據綁定源,更新也能正常工作。
缺點是每次更新都要執行大量的 JavaScript。而且,因為 AngularJS 不知道何時可能發生變化,所以它運行臟檢查的頻率遠遠超過理論上所需。
因為 AngularJS 可以與任何對象一起工作,而且它本身是 HTML 語法的擴展,所以 AngularJS 從未將任何狀態管理形式固化。
React
React在AngularJS(Angular之前)之后推出,并進行了幾項改進。
首先,React引入了setState()。這使得React知道何時應該對vDOM進行臟檢查。這樣做的好處是,與每個異步任務都運行臟檢查的AngularJS不同,React只有在開發人員告訴它要運行時才會執行。因此,盡管React vDOM的臟檢查比AngularJS更耗費計算資源,但它會更少地運行。
其次,React引入了從父組件到子組件的嚴格數據流。這是朝著框架認可的狀態管理邁出的第一步,而AngularJS則沒有這樣做。
粗粒度響應性
React 和 AngularJS 都是粗粒度響應式的。這意味著數據的變化會觸發大量的 JavaScript 執行。框架最終會將所有的更改合并到 UI 中。這意味著快速變化的屬性,如動畫,可能會導致性能問題。
細粒度響應性
解決上述問題的方法是細粒度響應性,狀態改變只更新與狀態綁定的 UI 部分。
難點在于如何以良好的開發體驗(DX)來監聽屬性變化。
Backbone.js
Backbone 早于 AngularJS,它具有細粒度的響應性,但語法非常冗長。
我認為冗長的語法是像 AngularJS 和后來的 React 這樣的框架取而代之的原因之一,因為開發者可以簡單地使用點符號來訪問和設置狀態,而不是一組復雜的函數回調。在這些較新的框架中開發應用程序更容易,也更快。
Knockout
Knockout 和 AngularJS 出現在同一時期。我從未使用過它,但我的理解是它也受到了更新風暴問題的困擾。雖然它在 Backbone.js 的基礎上有所改進,但與可觀察屬性一起使用仍然很笨拙,這也是我認為開發者更喜歡像 AngularJS 和 React 這樣的點符號框架的原因。
但是 Knockout 有一個有趣的創新 —— 計算屬性,它可能已經存在過,但這是我第一次聽說。它們會自動在輸入上創建訂閱。
請注意,當 ko.pureComputed() 調用 this.firstName() 時,值的調用會隱式地創建一個訂閱。這是通過 ko.pureComputed() 設置一個全局變量來實現的,這個全局變量允許 this.firstName() 與 ko.pureComputed() 通信,并將訂閱信息傳遞給它,而無需開發者進行任何額外的工作。
Svelte
Svelte使用編譯器實現了響應式。這里的優勢在于,有了編譯器,語法可以是任何你想要的。你不受JavaScript的限制。對于組件,Svelte具有非常自然的響應式語法。但是,Svelte并不會編譯所有文件,只會編譯以.svelte結尾的文件。如果你希望在未經過編譯的文件中獲得響應性,則Svelte提供了一個存儲API,它缺少已編譯響應性所具有的魔力,并需要更明確地注冊使用subscribe和unsubscribe。
我認為擁有兩種不同的方法來實現同樣的事情并不理想,因為你必須在腦海中保持兩種不同的思維模式并在它們之間做出選擇。一種統一的方法會更受歡迎。
RxJS
RxJS 是一個不依賴于任何底層渲染系統的響應式庫。這似乎是一個優勢,但它也有一個缺點。導航到新頁面需要拆除現有的 UI 并構建新的 UI。對于 RxJS,這意味著需要進行很多取消訂閱和訂閱操作。這些額外的工作意味著在這種情況下,粗粒度響應式系統會更快,因為拆除只是丟棄 UI(垃圾回收),而構建不需要注冊/分配監聽器。我們需要的是一種批量取消訂閱/訂閱的方法。
Vue 和 MobX
大約在同一時間,Vue 和 MobX 都開始嘗試基于代理的響應式。代理的優勢在于,你可以使用開發者喜歡的干凈的點表示法語法,同時可以像 Knockout 一樣使用相同的技巧來創建自動訂閱 —— 這是一個巨大的勝利!
在上面的示例中,模板在渲染期間通過讀取 count 值自動創建了一個對 count 的訂閱。開發者無需進行任何額外的工作。
SolidJS
SolidJS 的缺點是無法將引用傳遞給 getter/setter。你要么傳遞整個代理,要么傳遞屬性的值,但是你無法從存儲中剝離一個 getter 并傳遞它。以此為例來說明這個問題。
當我們讀取 state.count 時,得到的數字是原始的,不再是可觀察的。這意味著 Middle 和 Child 都需要在 state.count 改變時重新渲染。我們失去了細粒度的響應性。理想情況下,只有 Count: 應該被更新。我們需要的是一種傳遞值引用而不是值本身的方法。
signals
signals 允許你不僅引用值,還可以引用該值的 getter/setter。因此,你可以使用信號解決上述問題:
這種解決方案的好處在于,我們不是傳遞值,而是傳遞一個 Accessor(一個 getter)。這意味著當 count 的值發生更改時,我們不必經過 Wrapper 和 Display,可以直接到達 DOM 進行更新。它的工作方式非常類似于 Knockout,但在語法上類似于 Vue/MobX。
假設我們想要綁定到一個常量作為組件的用戶,則會出現 DX 問題。
這樣做不會起作用,因為 Display 被定義為 Accessor:
這是令人遺憾的,因為組件的作者現在定義了使用者是否可以發送getter或 value。無論作者選擇什么,總會有未涵蓋的用例。這兩者都是合理的事情。
以上是使用 Display 的兩種有效方式,但它們都不能同時成立!我們需要一種方法來將類型聲明為基本類型,但可以同時與基本類型和 Accessor 一起使用。這時編譯器就出場了。
請注意,現在我們聲明的是 number,而不是 Accessor。這意味著這段代碼將正常工作
但這是否意味著我們現在已經破壞了響應性?答案是肯定的,除非我們可以讓編譯器執行一個技巧來恢復我們的響應性。問題就出在這行代碼上:
count()的調用會將訪問器轉換為原始值并創建一個訂閱。因此編譯器會執行這個技巧。
通過在將count()作為屬性傳遞給子組件時,在getter中包裝它,編譯器成功地延遲了對count()的執行,直到DOM實際需要它。這使得DOM可以創建基礎信號的訂閱,即使對開發人員來說似乎是傳遞了一個值。
好處有:
- 清晰的語法
- 自動訂閱和取消訂閱
- 組件接口不必選擇原始類型或Accessor。
- 響應性即使開發人員將Accessor轉換為原始類型也能正常工作。
我們還能在此基礎上做出什么改進嗎?
響應性和渲染
讓我們想象一個產品頁面,有一個購買按鈕和一個購物車。
在上面的示例中,我們有一個樹形結構中的組件集合。用戶可能采取的一種可能的操作是點擊購買按鈕,這需要更新購物車。對于需要執行的代碼,有兩種不同的結果。
在粗粒度響應式系統中,它是這樣的:
我們必須找到 Buy 和 Cart 組件之間的共同根,因為狀態很可能附加在那里。然后,在更改狀態時,與該狀態相關聯的樹必須重新渲染。使用 memoization 技術,可以將樹剪枝成僅包含上述兩個最小路徑。尤其是隨著應用程序變得越來越復雜,需要執行大量代碼。
在細粒度反應式系統中,它看起來像這樣:
請注意,只有目標 Cart 需要執行。無需查看狀態是在哪里聲明的或共同祖先是什么。也不必擔心數據記憶化以修剪樹。精細的反應式系統的好處在于,開發人員無需任何努力,運行時只執行最少量的代碼!
精細的反應式系統的手術精度使它們非常適合懶惰執行代碼,因為系統只需要執行狀態的偵聽器(在我們的例子中是 Cart)。
但是,精細的反應式系統有一個意外的角落案例。為了建立反應圖,系統必須至少執行所有組件以了解它們之間的關系!一旦建立起來,系統就可以進行手術。這是初始執行的樣子:
你看出問題了嗎?我們想懶惰地下載和執行,但反應圖的初始化強制執行應用程序的完整下載。
Qwik
這就是 Qwik 發揮作用的地方。Qwik 是精細的反應式,類似于 SolidJS,意味著狀態的變化直接更新 DOM。(在某些角落情況下,Qwik 可能需要執行整個組件。)但是 Qwik 有一個詭計。記得精細的反應性要求所有組件至少執行一次以創建反應圖嗎?好吧,Qwik 利用了組件在 SSG 期間已經在服務器上執行的事實。Qwik 可以將這個圖形序列化為 HTML。這使得客戶端完全可以跳過最初的“執行世界以了解反應圖”的步驟。我們稱這種能力為可恢復性。由于組件在客戶端上不會執行或下載,因此 Qwik 的好處是應用程序的即時啟動。一旦應用程序正在運行,反應就像 SolidJS 一樣精確。
原文:https://www.builder.io/blog/history-of-reactivity