從 Lisp 到 Vue、React 再到 Qwit:響應式編程的發展歷程

本文介紹了響應式編程的歷史和發展,響應式編程是一種編程範式,它強調了數據流和變化的傳遞。文章從早期的編程語言開始講述,比如 Lisp 和 Smalltalk,它們的數據結構和函數式編程的特性促進了響應式編程的發展。然後,文章提到了響應式編程框架的出現,如 React 和 Vue.js 等。這些框架使用虛擬 DOM(Virtual DOM)技術來跟蹤數據變化,並更新界面。文章還討論了響應式編程的優點和缺點,如可讀性和性能等。最後,文章預測了未來響應式編程的發展方向。

總的來說,本文很好地介紹了響應式編程的歷史和發展,深入淺出地講述了它的優點和缺點。文章提到了很多實際應用和框架的例子,讓讀者更好地理解響應式編程的概念和實踐。文章還預測了未來響應式編程的發展方向,這對讀者和開發者有很大的啓示作用。

下面是正文。。。

這篇文章並不是關於響應式的權威歷史,而是關於我個人在這方面的經歷和觀點。

Flex

我的旅程始於 Macromedia Flex,後來被 Adobe 收購。Flex 是基於 Flash 上的 ActionScript 的一個框架。ActionScript 與 JavaScript 非常相似,但它具有註解功能,允許編譯器爲訂閱包裝字段。我不記得確切的語法了,也在網上找不到太多信息,但它看起來是這樣的:

class MyComponent {
[Bindable] public var name: String;
}

[Bindable] 註解會創建一個 getter/setter,當屬性發生變化時,它會觸發事件。然後你可以監聽屬性的變化。Flex 附帶了用於渲染 UI 的 .mxml 文件模板。如果屬性發生變化,.mxml 中的任何數據綁定都是細粒度的響應式,因爲它通過監聽屬性的變化。

<mx:Application xmlns:mx="http://www.adobe.com/2006/mxml">
  <mx:MyComponent>
    <mx:Label text="{name}"/></mx:Label>
  </mx:MyComponent>
</mx:Applicatio>

我懷疑 Flex 並不是響應式最早出現的地方,但它是我第一次接觸到響應式。

在 Flex 中,響應式有點麻煩,因爲它容易創建更新風暴。更新風暴是指當單個屬性變化觸發許多其他屬性(或模板)變化,從而觸發更多屬性變化,依此類推。有時,這會陷入無限循環。Flex 沒有區分更新屬性和更新 UI,導致大量的 UI 抖動(渲染中間值)。

事後看來,我可以看到哪些架構決策導致了這種次優結果,但當時我並不清楚,我對響應式系統有點不信任。

AngularJS

AngularJS 的最初目標是擴展 HTML 詞彙,以便設計師(非開發人員)可以構建簡單的 Web 應用程序。這就是爲什麼 AngularJS 最終採用了 HTML 標記的原因。由於 AngularJS 擴展了 HTML,它需要綁定到任何 JavaScript 對象。那時候既沒有 Proxy、getter/setters,也沒有 Object.observe() 這些選項可供選擇。所以唯一可用的解決方案就是使用髒檢查。

髒檢查通過在瀏覽器執行任何異步工作時讀取模板中綁定的所有屬性來工作。

<!doctype html>
<html ng-app>
  <head>
    <script src="https://ajax.googleapis.com/ajax/libs/angularjs/1.8.2/angular.min.js"></script>
  </head>
  <body>
    <div>
      <label>Name:</label>
      <input type="text" ng-model="yourName" placeholder="Enter a name here">
      <hr>
      <h1>Hello {{yourName}}!</h1>
    </div>
  </body>
</html>

這種方法的好處是,任何 JavaScript 對象都可以在模板中用作數據綁定源,更新也能正常工作。

缺點是每次更新都要執行大量的 JavaScript。而且,因爲 AngularJS 不知道何時可能發生變化,所以它運行髒檢查的頻率遠遠超過理論上所需。

因爲 AngularJS 可以與任何對象一起工作,而且它本身是 HTML 語法的擴展,所以 AngularJS 從未將任何狀態管理形式固化。

React

React 在 AngularJS(Angular 之前)之後推出,並進行了幾項改進。

首先,React 引入了 setState()。這使得 React 知道何時應該對 vDOM 進行髒檢查。這樣做的好處是,與每個異步任務都運行髒檢查的 AngularJS 不同,React 只有在開發人員告訴它要運行時纔會執行。因此,儘管 React vDOM 的髒檢查比 AngularJS 更耗費計算資源,但它會更少地運行。

function Counter() {
  const [count, setCount] = useState();
  return <button onClick={() => setCount(count+1)}>{count}</button>
}

其次,React 引入了從父組件到子組件的嚴格數據流。這是朝着框架認可的狀態管理邁出的第一步,而 AngularJS 則沒有這樣做。

粗粒度響應性

React 和 AngularJS 都是粗粒度響應式的。這意味着數據的變化會觸發大量的 JavaScript 執行。框架最終會將所有的更改合併到 UI 中。這意味着快速變化的屬性,如動畫,可能會導致性能問題。

細粒度響應性

解決上述問題的方法是細粒度響應性,狀態改變只更新與狀態綁定的 UI 部分。

難點在於如何以良好的開發體驗(DX)來監聽屬性變化。

Backbone.js

Backbone 早於 AngularJS,它具有細粒度的響應性,但語法非常冗長。

var MyModel = Backbone.Model.extend({
  initialize: function() {
    // Listen to changes on itself.
    this.on('change:name', this.onAsdChange);
  },
  onNameChange: function(model, value) {
    console.log('Model: Name was changed to:', value);
  }
});
var myModel = new MyModel();
myModel.set('name''something');

我認爲冗長的語法是像 AngularJS 和後來的 React 這樣的框架取而代之的原因之一,因爲開發者可以簡單地使用點符號來訪問和設置狀態,而不是一組複雜的函數回調。在這些較新的框架中開發應用程序更容易,也更快。

Knockout

Knockout 和 AngularJS 出現在同一時期。我從未使用過它,但我的理解是它也受到了更新風暴問題的困擾。雖然它在 Backbone.js 的基礎上有所改進,但與可觀察屬性一起使用仍然很笨拙,這也是我認爲開發者更喜歡像 AngularJS 和 React 這樣的點符號框架的原因。

但是 Knockout 有一個有趣的創新 —— 計算屬性,它可能已經存在過,但這是我第一次聽說。它們會自動在輸入上創建訂閱。

var ViewModel = function(first, last) {
  this.firstName = ko.observable(first);
  this.lastName = ko.observable(last);
  this.fullName = ko.pureComputed(function() {
    // Knockout tracks dependencies automatically.
    // It knows that fullName depends on firstName and lastName,
    // because these get called when evaluating fullName.
    return this.firstName() + " " + this.lastName();
  }, this);
};

請注意,當 ko.pureComputed() 調用 this.firstName() 時,值的調用會隱式地創建一個訂閱。這是通過 ko.pureComputed() 設置一個全局變量來實現的,這個全局變量允許 this.firstName()ko.pureComputed() 通信,並將訂閱信息傳遞給它,而無需開發者進行任何額外的工作。

Svelte

Svelte 使用編譯器實現了響應式。這裏的優勢在於,有了編譯器,語法可以是任何你想要的。你不受 JavaScript 的限制。對於組件,Svelte 具有非常自然的響應式語法。但是,Svelte 並不會編譯所有文件,只會編譯以.svelte結尾的文件。如果你希望在未經過編譯的文件中獲得響應性,則 Svelte 提供了一個存儲 API,它缺少已編譯響應性所具有的魔力,並需要更明確地註冊使用subscribeunsubscribe

const count = writable(0);
const unsubscribe = count.subscribe(value ={
  countValue = value;
});

我認爲擁有兩種不同的方法來實現同樣的事情並不理想,因爲你必須在腦海中保持兩種不同的思維模式並在它們之間做出選擇。一種統一的方法會更受歡迎。

RxJS

RxJS 是一個不依賴於任何底層渲染系統的響應式庫。這似乎是一個優勢,但它也有一個缺點。導航到新頁面需要拆除現有的 UI 並構建新的 UI。對於 RxJS,這意味着需要進行很多取消訂閱和訂閱操作。這些額外的工作意味着在這種情況下,粗粒度響應式系統會更快,因爲拆除只是丟棄 UI(垃圾回收),而構建不需要註冊 / 分配監聽器。我們需要的是一種批量取消訂閱 / 訂閱的方法。

const observable1 = interval(400);
const observable2 = interval(300);
const subscription = observable1.subscribe(x => console.log('[first](https://rxjs.dev/api/index/function/first): ' + x));
const childSubscription = observable2.subscribe(x => console.log('second: ' + x));
subscription.add(childSubscription);
setTimeout(() ={
  // Unsubscribes BOTH subscription and childSubscription
  subscription.unsubscribe();
}, 1000);

Vue 和 MobX

大約在同一時間,Vue 和 MobX 都開始嘗試基於代理的響應式。代理的優勢在於,你可以使用開發者喜歡的乾淨的點表示法語法,同時可以像 Knockout 一樣使用相同的技巧來創建自動訂閱 —— 這是一個巨大的勝利!

<template>
  <button @click="count = count + 1">{{ count }}</button>
</template>

<script setup>
import { ref } from "vue";

const count = ref(1);
</script>

在上面的示例中,模板在渲染期間通過讀取 count 值自動創建了一個對 count 的訂閱。開發者無需進行任何額外的工作。

SolidJS

SolidJS 的缺點是無法將引用傳遞給 getter/setter。你要麼傳遞整個代理,要麼傳遞屬性的值,但是你無法從存儲中剝離一個 getter 並傳遞它。以此爲例來說明這個問題。

function App() {
  const state = createStateProxy({count: 1});
  return (
    <>
      <button onClick={() => state.count++}>+1</button>\
      <Wrapper value={state.count}/>
    </>
  );
}

function Wrapper(props) {
  return <Display value={state.value}/>
}
function Display(props) {
  return <span>Count: {props.value}</span>
}

當我們讀取 state.count 時,得到的數字是原始的,不再是可觀察的。這意味着 Middle 和 Child 都需要在 state.count 改變時重新渲染。我們失去了細粒度的響應性。理想情況下,只有 Count: 應該被更新。我們需要的是一種傳遞值引用而不是值本身的方法。

signals

signals 允許你不僅引用值,還可以引用該值的 getter/setter。因此,你可以使用信號解決上述問題:

function App() {
  const [count, setCount] = createSignal(1);
  return (
    <>
      <button onClick={() => setCount(count() + 1)}>+1</button>
      <Wrapper value={count}/>
    </>
  );
}
function Wrapper(props: {value: Accessor<number>}) {
  return <Display value={props.value}/>
}
function Display(props: {value: Accessor<number>}) {
  return <span>Count: {props.value}</span>
}

這種解決方案的好處在於,我們不是傳遞值,而是傳遞一個 Accessor(一個 getter)。這意味着當 count 的值發生更改時,我們不必經過 WrapperDisplay,可以直接到達 DOM 進行更新。它的工作方式非常類似於 Knockout,但在語法上類似於 Vue/MobX。

假設我們想要綁定到一個常量作爲組件的用戶,則會出現 DX 問題。

<Display value={10}/>

這樣做不會起作用,因爲 Display 被定義爲 Accessor

function Display(props: {value: Accessor<number>});

這是令人遺憾的,因爲組件的作者現在定義了使用者是否可以發送gettervalue。無論作者選擇什麼,總會有未涵蓋的用例。這兩者都是合理的事情。

<Display value={10}/>
<Display value={createSignal(10)}/>

以上是使用 Display 的兩種有效方式,但它們都不能同時成立!我們需要一種方法來將類型聲明爲基本類型,但可以同時與基本類型和 Accessor 一起使用。這時編譯器就出場了。

function App() {
  const [count, setCount] = createSignal(1);
  return (
    <>
      <button onClick={() => setCount(count() + 1)}>+1</button>
      <Wrapper value={count()}/>
    </>
  );
}
function Wrapper(props: {value: number}) {
  return <Display value={props.value}/>
}
function Display(props: {value: number}) {
  return <span>Count: {props.value}</span>
}

請注意,現在我們聲明的是 number,而不是 Accessor。這意味着這段代碼將正常工作

<Display value={10}/>
<Display value={createSignal(10)()}/> // Notice the extra ()

但這是否意味着我們現在已經破壞了響應性?答案是肯定的,除非我們可以讓編譯器執行一個技巧來恢復我們的響應性。問題就出在這行代碼上:

<Wrapper value={count()}/>

count()的調用會將訪問器轉換爲原始值並創建一個訂閱。因此編譯器會執行這個技巧。

Wrapper({
  get value() { return count(); }
})

通過在將count()作爲屬性傳遞給子組件時,在getter中包裝它,編譯器成功地延遲了對count()的執行,直到 DOM 實際需要它。這使得 DOM 可以創建基礎信號的訂閱,即使對開發人員來說似乎是傳遞了一個值。

好處有:

我們還能在此基礎上做出什麼改進嗎?

響應性和渲染

讓我們想象一個產品頁面,有一個購買按鈕和一個購物車。

在上面的示例中,我們有一個樹形結構中的組件集合。用戶可能採取的一種可能的操作是點擊購買按鈕,這需要更新購物車。對於需要執行的代碼,有兩種不同的結果。

在粗粒度響應式系統中,它是這樣的:

我們必須找到 Buy  和 Cart 組件之間的共同根,因爲狀態很可能附加在那裏。然後,在更改狀態時,與該狀態相關聯的樹必須重新渲染。使用 memoization 技術,可以將樹剪枝成僅包含上述兩個最小路徑。尤其是隨着應用程序變得越來越複雜,需要執行大量代碼。

在細粒度反應式系統中,它看起來像這樣:

請注意,只有目標 Cart 需要執行。無需查看狀態是在哪裏聲明的或共同祖先是什麼。也不必擔心數據記憶化以修剪樹。精細的反應式系統的好處在於,開發人員無需任何努力,運行時只執行最少量的代碼!

精細的反應式系統的手術精度使它們非常適合懶惰執行代碼,因爲系統只需要執行狀態的偵聽器(在我們的例子中是 Cart)。

但是,精細的反應式系統有一個意外的角落案例。爲了建立反應圖,系統必須至少執行所有組件以瞭解它們之間的關係!一旦建立起來,系統就可以進行手術。這是初始執行的樣子:

你看出問題了嗎?我們想懶惰地下載和執行,但反應圖的初始化強制執行應用程序的完整下載。

Qwik

這就是 Qwik 發揮作用的地方。Qwik 是精細的反應式,類似於 SolidJS,意味着狀態的變化直接更新 DOM。(在某些角落情況下,Qwik 可能需要執行整個組件。)但是 Qwik 有一個詭計。記得精細的反應性要求所有組件至少執行一次以創建反應圖嗎?好吧,Qwik 利用了組件在 SSR/SSG 期間已經在服務器上執行的事實。Qwik 可以將這個圖形序列化爲 HTML。這使得客戶端完全可以跳過最初的 “執行世界以瞭解反應圖” 的步驟。我們稱這種能力爲可恢復性。由於組件在客戶端上不會執行或下載,因此 Qwik 的好處是應用程序的即時啓動。一旦應用程序正在運行,反應就像 SolidJS 一樣精確。

原文:https://www.builder.io/blog/history-of-reactivity

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/s_zDepb8QiHl_F4HfDgY3Q