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

前端小智發表於2023-04-13
微信搜尋 【大遷世界】, 我會第一時間和你分享前端行業趨勢,學習途徑等等。
本文 GitHub https://github.com/qq449245884/xiaozhi 已收錄,有一線大廠面試完整考點、資料以及我的系列文章。

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

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可以建立基礎訊號的訂閱,即使對開發人員來說似乎是傳遞了一個值。

好處有:

  • 清晰的語法
  • 自動訂閱和取消訂閱
  • 元件介面不必選擇原始型別或Accessor。
  • 響應性即使開發人員將Accessor轉換為原始型別也能正常工作。

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

響應性和渲染

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

image.png

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

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

image.png

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

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

image.png

請注意,只有目標 Cart 需要執行。無需檢視狀態是在哪裡宣告的或共同祖先是什麼。也不必擔心資料記憶化以修剪樹。精細的反應式系統的好處在於,開發人員無需任何努力,執行時只執行最少量的程式碼!

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

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

image.png

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

Qwik

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

總結

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

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

程式碼部署後可能存在的BUG沒法實時知道,事後為了解決這些BUG,花了大量的時間進行log 除錯,這邊順便給大家推薦一個好用的BUG監控工具 Fundebug

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

交流

有夢想,有乾貨,微信搜尋 【大遷世界】 關注這個在凌晨還在刷碗的刷碗智。

本文 GitHub https://github.com/qq449245884/xiaozhi 已收錄,有一線大廠面試完整考點、資料以及我的系列文章。

相關文章