跳轉到內容

響應性深度解析

Vue 最顯著的特徵之一是其無侵入的響應性系統。元件狀態由響應式 JavaScript 物件組成。當您修改它們時,檢視會更新。這使得狀態管理變得簡單直觀,但瞭解其工作原理也很重要,以避免一些常見的問題。在本節中,我們將深入研究 Vue 響應性系統的底層細節。

什麼是響應性?

這個詞在程式設計中經常出現,但人們說這個詞時到底意味著什麼?響應性是一種程式設計正規化,它允許我們以宣告式的方式適應變化。人們通常展示的典範例子是一個 Excel 電子表格,因為它是一個很好的例子

ABC
0
1
1
2
2
3

在這裡,單元格 A2 透過公式 = A0 + A1 定義(您可以透過點選 A2 來檢視或編輯公式),因此電子表格給出了 3,這並不令人驚訝。但是,如果您更新 A0 或 A1,您會注意到 A2 會自動更新。

JavaScript 通常不這樣做。如果我們用類似的東西在 JavaScript 中編寫

js
let A0 = 1
let A1 = 2
let A2 = A0 + A1

console.log(A2) // 3

A0 = 2
console.log(A2) // Still 3

當我們修改 A0 時,A2 不會自動更改。

那麼我們如何在 JavaScript 中這樣做?首先,為了重新執行更新 A2 的程式碼,讓我們將其包裹在一個函式中

js
let A2

function update() {
  A2 = A0 + A1
}

然後,我們需要定義一些術語

  • update() 函式產生一個 副作用 或簡稱為 效果,因為它修改了程式的狀態。

  • A0A1 被認為是效果 依賴項,因為它們的值被用來執行效果。效果被稱為其依賴項的 訂閱者

我們需要一個魔法函式,它可以在A0A1依賴項)發生變化時呼叫update()效果)。

js
whenDepsChange(update)

這個whenDepsChange()函式有以下任務:

  1. 跟蹤何時讀取一個變數。例如,在評估表示式A0 + A1時,會讀取A0A1

  2. 如果變數在當前有執行的效果時被讀取,將那個效果變成該變數的訂閱者。例如,因為A0A1在執行update()時被讀取,所以第一次呼叫後update()變成了A0A1的訂閱者。

  3. 檢測變數何時被修改。例如,當A0被分配一個新值時,通知所有訂閱其效果的重新執行。

Vue中的響應式是如何工作的

我們實際上無法跟蹤像示例中那樣的本地變數的讀取和寫入。在純JavaScript中根本沒有任何機制可以做到這一點。但我們可以攔截物件屬性的讀取和寫入。

在JavaScript中有兩種攔截屬性訪問的方式:getter / setter代理。Vue 2由於瀏覽器支援限制,僅使用getter / setter。在Vue 3中,代理用於響應式物件,getter / setter用於refs。以下是一些虛擬碼,說明了它們是如何工作的

js
function reactive(obj) {
  return new Proxy(obj, {
    get(target, key) {
      track(target, key)
      return target[key]
    },
    set(target, key, value) {
      target[key] = value
      trigger(target, key)
    }
  })
}

function ref(value) {
  const refObject = {
    get value() {
      track(refObject, 'value')
      return value
    },
    set value(newValue) {
      value = newValue
      trigger(refObject, 'value')
    }
  }
  return refObject
}

提示

此處和下面的程式碼片段旨在以最簡單的方式解釋核心概念,因此省略了許多細節,並忽略了邊緣情況。

這解釋了我們已在基礎知識部分討論的響應式物件的一些限制

  • 當你將響應式物件的屬性分配或解構到本地變數中時,訪問或分配到該變數是非響應式的,因為它不再觸發源物件上的get / set代理陷阱。注意這種“斷開”僅影響變數繫結 - 如果該變數指向非原始值(如物件),則修改物件仍將是響應式的。

  • reactive()返回的代理雖然行為與原始物件相同,但如果我們使用===運算子與原始物件進行比較,它們具有不同的身份。

track()內部,我們檢查是否有一個正在執行的效果。如果有,我們查詢被跟蹤屬性的訂閱者效果(儲存在Set中),並將效果新增到Set中。

js
// This will be set right before an effect is about
// to be run. We'll deal with this later.
let activeEffect

function track(target, key) {
  if (activeEffect) {
    const effects = getSubscribersForProperty(target, key)
    effects.add(activeEffect)
  }
}

效果訂閱儲存在全域性的WeakMap<target, Map<key, Set<effect>>>資料結構中。如果找不到屬性(首次跟蹤)的訂閱效果集,它將被建立。簡而言之,這就是getSubscribersForProperty()函式所做的工作。為了簡單起見,我們將跳過其細節。

trigger()內部,我們再次查詢屬性的訂閱者效果。但這次我們呼叫它們。

js
function trigger(target, key) {
  const effects = getSubscribersForProperty(target, key)
  effects.forEach((effect) => effect())
}

現在讓我們回到whenDepsChange()函式。

js
function whenDepsChange(update) {
  const effect = () => {
    activeEffect = effect
    update()
    activeEffect = null
  }
  effect()
}

它將原始的update函式包裹在一個效果中,在執行實際更新之前將其自身設定為當前活動效果。這使得在更新期間可以呼叫track()來定位當前活動效果。

到此為止,我們已經建立了一個可以自動跟蹤其依賴關係,並在依賴關係更改時重新執行的效果。我們稱之為響應式效果

Vue提供了一個API,允許您建立響應式效果:watchEffect()。實際上,您可能已經注意到它的工作方式與示例中的神奇函式whenDepsChange()非常相似。現在我們可以使用實際的Vue API重新編寫原始示例

js
import { ref, watchEffect } from 'vue'

const A0 = ref(0)
const A1 = ref(1)
const A2 = ref()

watchEffect(() => {
  // tracks A0 and A1
  A2.value = A0.value + A1.value
})

// triggers the effect
A0.value = 2

使用響應式效果來修改ref並不是最有意思的使用案例——實際上,使用計算屬性會使它更加宣告性

js
import { ref, computed } from 'vue'

const A0 = ref(0)
const A1 = ref(1)
const A2 = computed(() => A0.value + A1.value)

A0.value = 2

內部,computed使用響應式效果來管理其無效化和重新計算。

那麼一個常見且有用的響應式效果示例是什麼呢?嗯,更新DOM!我們可以這樣實現簡單的“響應式渲染”

js
import { ref, watchEffect } from 'vue'

const count = ref(0)

watchEffect(() => {
  document.body.innerHTML = `Count is: ${count.value}`
})

// updates the DOM
count.value++

事實上,這與Vue元件保持狀態和DOM同步的方式非常相似——每個元件例項都建立一個響應式效果來渲染和更新DOM。當然,Vue元件使用比innerHTML更有效的方法來更新DOM。這將在渲染機制中進行討論。

ref()computed()watchEffect() API都是組合式API的一部分。如果您迄今為止只使用過Vue的Options API,您會注意到組合式API更接近Vue的底層響應式系統。實際上,在Vue 3中,Options API是在組合式API之上實現的。元件例項上所有屬性訪問(this)都會觸發響應式跟蹤的getter/setter,而像watchcomputed這樣的選項會內部呼叫它們的組合式API等價物。

執行時與編譯時響應性

Vue的響應式系統主要是基於執行時的:跟蹤和觸發都在程式碼直接在瀏覽器中執行時執行。執行時響應式的優點是可以不經過構建步驟工作,並且邊緣情況較少。另一方面,這使得它受到JavaScript語法限制的限制,從而導致需要像Vue refs這樣的值容器。

一些框架,如Svelte,選擇在編譯時實現響應性來克服這些限制。它分析並轉換程式碼以模擬響應性。編譯步驟允許框架改變JavaScript本身的語義——例如,隱式注入程式碼,在訪問本地定義的變數周圍執行依賴分析和效果觸發。缺點是這種轉換需要一個構建步驟,而改變JavaScript語義本質上是在建立一種看起來像JavaScript但實際上編譯成其他東西的語言。

Vue團隊透過一個名為響應式轉換的實驗性功能探索了這一方向,但最終我們決定由於這裡的原因,這不會適合專案。

反應性除錯

Vue的反應性系統能夠自動跟蹤依賴關係是非常棒的,但在某些情況下,我們可能想要確定正在跟蹤的內容,或者是什麼導致了元件的重新渲染。

元件除錯鉤子

我們可以使用renderTrackedonRenderTrackedrenderTriggeredonRenderTriggered生命週期鉤子來除錯元件渲染期間使用的依賴關係以及觸發更新的依賴關係。這兩個鉤子都將接收到一個包含有關依賴關係資訊的除錯事件。建議在回撥中放置一個debugger語句以互動式檢查依賴關係。

vue
<script setup>
import { onRenderTracked, onRenderTriggered } from 'vue'

onRenderTracked((event) => {
  debugger
})

onRenderTriggered((event) => {
  debugger
})
</script>
js
export default {
  renderTracked(event) {
    debugger
  },
  renderTriggered(event) {
    debugger
  }
}

提示

元件除錯鉤子僅在開發模式下有效。

除錯事件物件具有以下型別

ts
type DebuggerEvent = {
  effect: ReactiveEffect
  target: object
  type:
    | TrackOpTypes /* 'get' | 'has' | 'iterate' */
    | TriggerOpTypes /* 'set' | 'add' | 'delete' | 'clear' */
  key: any
  newValue?: any
  oldValue?: any
  oldTarget?: Map<any, any> | Set<any>
}

計算屬性除錯

我們可以透過將帶有onTrackonTrigger回撥的第二個選項物件傳遞給computed()來除錯計算屬性。

  • onTrack將在響應式屬性或ref被跟蹤為依賴關係時被呼叫。
  • onTrigger將在依賴關係的突變觸發觀察者回調時被呼叫。

這兩個回撥都將接收到與元件除錯鉤子相同的格式的除錯事件。

js
const plusOne = computed(() => count.value + 1, {
  onTrack(e) {
    // triggered when count.value is tracked as a dependency
    debugger
  },
  onTrigger(e) {
    // triggered when count.value is mutated
    debugger
  }
})

// access plusOne, should trigger onTrack
console.log(plusOne.value)

// mutate count.value, should trigger onTrigger
count.value++

提示

onTrackonTrigger計算選項僅在開發模式下有效。

觀察者除錯

類似於computed(),觀察者也支援onTrackonTrigger選項。

js
watch(source, callback, {
  onTrack(e) {
    debugger
  },
  onTrigger(e) {
    debugger
  }
})

watchEffect(callback, {
  onTrack(e) {
    debugger
  },
  onTrigger(e) {
    debugger
  }
})

提示

onTrackonTrigger觀察者選項僅在開發模式下有效。

與外部狀態系統的整合

Vue的反應性系統透過深度轉換普通JavaScript物件為響應式代理來工作。在整合外部狀態管理系統(例如,如果外部解決方案也使用代理)時,深度轉換可能是多餘的,有時是不想要的。

將Vue的反應性系統與外部狀態管理解決方案整合的總體思路是將外部狀態儲存在一個shallowRef中。淺引用僅在訪問其.value屬性時是響應式的 - 內部值保持不變。當外部狀態改變時,替換引用值以觸發更新。

不可變資料

如果您正在實現撤銷/重做功能,您可能希望在每個使用者編輯時對應用程式的狀態進行快照。然而,如果狀態樹很大,Vue的可變反應性系統並不適合此目的,因為每次更新時序列化整個狀態物件可能會在CPU和記憶體成本上非常昂貴。

不可變資料結構透過從不修改狀態物件來解決這個問題 - 相反,它建立新的物件,這些新物件與舊物件共享相同的、未更改的部分。在JavaScript中,使用不可變資料有多種方式,但我們建議使用與Vue配合的Immer,因為它允許您在使用不可變資料的同時,保留更符合人體工程學的可變語法。

我們可以透過簡單的組合式函式將Immer與Vue整合

js
import { produce } from 'immer'
import { shallowRef } from 'vue'

export function useImmer(baseState) {
  const state = shallowRef(baseState)
  const update = (updater) => {
    state.value = produce(state.value, updater)
  }

  return [state, update]
}

在遊樂場中嘗試

狀態機

狀態機 是一種描述應用可能處於的所有狀態以及所有可能的從一個狀態轉換到另一個狀態的方法的模型。雖然對於簡單的元件來說可能有些過度,但它可以幫助使複雜的狀態流轉更加健壯和易於管理。

JavaScript中最受歡迎的狀態機實現之一是 XState。這裡有一個與之整合的組合式函式

js
import { createMachine, interpret } from 'xstate'
import { shallowRef } from 'vue'

export function useMachine(options) {
  const machine = createMachine(options)
  const state = shallowRef(machine.initialState)
  const service = interpret(machine)
    .onTransition((newState) => (state.value = newState))
    .start()
  const send = (event) => service.send(event)

  return [state, send]
}

在遊樂場中嘗試

RxJS

RxJS 是一個用於處理非同步事件流的庫。VueUse庫提供了@vueuse/rxjs外掛,用於將RxJS流與Vue的反應性系統連線。

與訊號的聯絡

相當多的其他框架已經引入了類似於Vue的Composition API中的refs的反應性原語,被稱為“訊號”。

從根本上說,訊號與Vue的refs一樣,是一種反應性原語。它是一個值容器,在訪問時提供依賴跟蹤,在變更時觸發副作用。這種基於反應性原語的正規化在前端世界中並不是一個特別新的概念:它可以追溯到十多年前如 Knockout的可觀察物件Meteor Tracker 這樣的實現。Vue的Options API和React的狀態管理庫 MobX 也基於相同的原理,但將原語隱藏在物件屬性後面。

儘管這不是成為訊號所必需的特徵,但今天這個概念經常與透過細粒度訂閱執行更新的渲染模型一起討論。由於使用了Virtual DOM,Vue目前 依賴於編譯器以實現類似的最佳化。然而,我們也在探索一種新的、受Solid啟發的編譯策略,稱為 Vapor Mode,它不依賴於Virtual DOM,並更多地利用Vue內建的反應性系統。

API設計權衡

Preact和Qwik的訊號設計非常類似於Vue的 shallowRef:三者都透過 .value 屬性提供了一個可變的介面。我們將重點討論Solid和Angular的訊號。

Solid 訊號

Solid的 createSignal() API設計強調讀寫分離。訊號作為只讀獲取器和單獨的設定器公開。

js
const [count, setCount] = createSignal(0)

count() // access the value
setCount(1) // update the value

注意如何透過setter之外的方式傳遞count訊號。這確保了只有在setter也被顯式暴露的情況下,狀態才可能被更改。這種安全保證是否足以證明這種更復雜的語法是有必要的,這取決於專案的需求和個人的喜好——但如果你更喜歡這種API風格,你可以在Vue中輕鬆地複製它。

js
import { shallowRef, triggerRef } from 'vue'

export function createSignal(value, options) {
  const r = shallowRef(value)
  const get = () => r.value
  const set = (v) => {
    r.value = typeof v === 'function' ? v(r.value) : v
    if (options?.equals === false) triggerRef(r)
  }
  return [get, set]
}

在遊樂場中嘗試

Angular Signals

Angular透過放棄髒檢查並引入其自己的響應性原語實現來經歷一些基本變化。Angular訊號API看起來是這樣的

js
const count = signal(0)

count() // access the value
count.set(1) // set new value
count.update((v) => v + 1) // update based on previous value

同樣,我們可以在Vue中輕鬆複製此API

js
import { shallowRef } from 'vue'

export function signal(initialValue) {
  const r = shallowRef(initialValue)
  const s = () => r.value
  s.set = (value) => {
    r.value = value
  }
  s.update = (updater) => {
    r.value = updater(r.value)
  }
  return s
}

在遊樂場中嘗試

與Vue的refs相比,Solid和Angular基於getter的API風格在Vue元件中使用時提供了一些有趣的權衡

  • ().value略微簡潔,但更新值時更冗長。
  • 沒有ref-unwrapping:訪問值始終需要()。這使得值訪問在所有地方都保持一致。這也意味著你可以將原始訊號作為元件屬性向下傳遞。

這些API風格是否適合你,在某種程度上是主觀的。我們的目標是在這裡展示這些不同API設計之間的潛在相似性和權衡。我們還想展示Vue的靈活性:你並不是真正被鎖定在現有的API中。如果有必要,你可以建立自己的響應性原語API來滿足更具體的需求。

響應性深度載入完成