2024-11-06|閱讀時間 ‧ 約 0 分鐘

EP60 - 深入探討反應性原理

Reactivity in Depth,我也蠻好奇反應性原理?
希望別太難懂 www

Vue 最具特色的功能之一是其無侵入性的反應性系統。組件的狀態由反應性的 JavaScript 物件組成。當你修改這些物件時,視圖會自動更新。這使得狀態管理變得簡單且直觀,但同時也需要了解其運作原理以避免一些常見的陷阱。在本節中,我們將深入探討 Vue 反應性系統的一些底層細節。

什麼是反應性? - What is Reactivity?

這個術語在現在的程式設計中經常出現,但當人們提到它時,他們到底是什麼意思呢?反應性是一種程式設計範式,允許我們以宣告的方式調整變更。通常人們會展示的典型範例是一個 Excel 試算表:

A B C 0 1 1 2 2 3 這裡的 A2 是通過公式 = A0 + A1 定義的(你可以點擊 A2 來查看或編輯公式),所以試算表會給出 3。這裡沒有什麼意外。但如果你更新 A0 或 A1,你會發現 A2 也會自動更新。

JavaScript 通常不會這樣運作。如果我們用 JavaScript 寫一些類似的東西:

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

console.log(A2) // 3

A0 = 2
console.log(A2) // 仍然是 3

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

那麼我們該如何在 JavaScript 中做到這一點呢?首先,為了重新執行更新 A2 的代碼,我們將其包裝在一個函式中:

let A2

function update() {
A2 = A0 + A1
}

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

  • update() 函式產生了一個副作用,或簡稱為效果,因為它修改了程式的狀態。
  • A0 和 A1 被認為是這個效果的依賴項,因為它們的值被用來執行這個效果。效果被認為是它們依賴項的訂閱者。

我們需要的是一個魔法函式,可以在 A0 或 A1(依賴項)變更時調用 update()(效果):

whenDepsChange(update)

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

  1. 追踪變數何時被讀取。例如,在評估表達式 A0 + A1 時,A0 和 A1 都被讀取。
  2. 如果在有當前執行的效果時讀取變數,使該效果成為該變數的訂閱者。例如,因為在執行 update() 時讀取了 A0 和 A1,update() 在第一次調用後成為 A0 和 A1 的訂閱者。
  3. 檢測變數何時被修改。例如,當 A0 被賦予新值時,通知所有訂閱它的效果重新執行。

Vue 中的反應性運作方式 - How Reactivity Works in Vue

我們無法像範例中那樣追蹤局部變數的讀寫。純 JavaScript 中沒有這樣的機制。不過,我們可以攔截物件屬性的讀取和寫入。

在 JavaScript 中有兩種方式可以攔截屬性訪問:getter/setterProxy。由於瀏覽器支援的限制,Vue 2 專門使用 getter/setter。在 Vue 3 中,Proxy 用於反應性物件,而 getter/setter 則用於 refs。這裡有一些展示它們工作原理的偽代碼:

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 中:

let activeEffect

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

效果訂閱存儲在一個全局 WeakMap<target, Map<key, Set<effect>>> 數據結構中。如果沒有找到屬性的訂閱效果 Set(首次被追蹤),則會創建它。這就是 getSubscribersForProperty() 函數的作用,簡而言之。為了簡化,我們將跳過其細節。

在 trigger() 中,我們再次查找屬性的訂閱效果,但這次我們調用它們:

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

現在讓我們回到 whenDepsChange() 函數:

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

它將原始更新函數包裝在一個效果中,在運行實際更新之前將自己設置為當前活動效果。這使得在更新期間的 track() 調用可以找到當前活動效果。

此時,我們已經創建了一個自動追蹤其依賴項的效果,並在依賴項變更時重新運行。我們稱這為反應效果。

Vue 提供了一個 API 讓你可以創建反應效果:watchEffect()。事實上,你可能已經注意到它的工作方式與示例中的魔法函數 whenDepsChange() 相當相似。我們現在可以使用實際的 Vue API 重寫原始示例:

import { ref, watchEffect } from 'vue'

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

watchEffect(() => {
// 追蹤 A0 和 A1
A2.value = A0.value + A1.value
})

// 觸發效果
A0.value = 2

使用反應效果來修改 ref 並不是最有趣的用例 - 事實上,使用計算屬性會使其更具宣告性:

import { ref, computed } from 'vue'

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

A0.value = 2

內部,計算屬性使用反應效果管理其失效和重新計算。

那麼,有什麼常見且有用的反應效果範例呢?嗯,更新 DOM!我們可以這樣實現簡單的「反應性渲染」:

js
複製程式碼import { ref, watchEffect } from 'vue'

const count = ref(0)

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

// 更新 DOM
count.value++

事實上,這非常接近 Vue 組件保持狀態和 DOM 同步的方式 - 每個組件實例創建一個反應效果來渲染和更新 DOM。當然,Vue 組件使用更有效的方式來更新 DOM,而不是 innerHTML。這在渲染機制中有所討論。

運行時與編譯時的反應性 - Runtime vs. Compile-time Reactivity

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

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

Vue 團隊曾經通過一個稱為 Reactivity Transform 的實驗性功能探索這一方向,但最終我們認為這不適合這個項目,原因如下所述

反應性除錯 - Reactivity Debugging

Vue 的反應性系統自動追蹤依賴關係非常方便,但有時我們需要確定具體追蹤了什麼或是什麼原因導致組件重新渲染。

組件除錯鉤子 - Component Debugging Hooks

我們可以使用 onRenderTrackedonRenderTriggered 這兩個生命週期鉤子來除錯組件在渲染時使用了哪些依賴關係以及是哪個依賴關係觸發了更新。這兩個鉤子會收到一個調試事件,該事件包含相關依賴的信息。建議在回調函數中放置 debugger 語句以互動檢查依賴關係:

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

onRenderTracked((event) => {
debugger
})

onRenderTriggered((event) => {
debugger
})
</script>
提示: 組件除錯鉤子僅在開發模式下有效。

除錯事件對象的類型如下:

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>
}

計算屬性除錯 - Computed Debugging

我們可以通過給 computed() 傳遞第二個選項對象,其中包含 onTrackonTrigger 回調來除錯計算屬性:

  • onTrack 會在追蹤反應性屬性或引用作為依賴時被調用。
  • onTrigger 會在依賴項發生變更觸發觀察者回調時被調用。

兩個回調都會收到與組件除錯鉤子相同格式的調試事件:

const plusOne = computed(() => count.value + 1, {
onTrack(e) {
// 當 count.value 被追蹤為依賴項時觸發
debugger
},
onTrigger(e) {
// 當 count.value 被更改時觸發
debugger
}
})

// 訪問 plusOne,應該觸發 onTrack
console.log(plusOne.value)

// 修改 count.value,應該觸發 onTrigger
count.value++
提示:onTrackonTrigger 計算選項僅在開發模式下有效。

觀察者除錯 - Watcher Debugging

computed() 類似,觀察者也支持 onTrackonTrigger 選項:

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

watchEffect(callback, {
onTrack(e) {
debugger
},
onTrigger(e) {
debugger
}
})
提示:onTrackonTrigger 觀察者選項僅在開發模式下有效。

與外部狀態系統的整合 - Integration with External State Systems

Vue 的反應性系統通過將普通 JavaScript 物件深度轉換為反應性代理來運作。在與外部狀態管理系統整合時(例如,如果外部解決方案也使用 Proxies),這種深度轉換可能是不必要的,甚至是不希望的。

將 Vue 的反應性系統與外部狀態管理解決方案整合的一般思路是將外部狀態存放在 shallowRef 中。shallowRef 只有在訪問其 .value 屬性時才是反應性的——內部值保持不變。當外部狀態改變時,替換引用的值以觸發更新。

不可變數據 - Immutable Data

如果您正在實現撤銷/重做功能,您可能希望在每次用戶編輯時拍攝應用程序狀態的快照。然而,Vue 的可變反應性系統不太適合這種情況,特別是當狀態樹很大時,因為在每次更新時序列化整個狀態物件在 CPU 和內存成本方面都可能非常昂貴。

不可變數據結構通過永遠不改變狀態物件來解決這個問題,取而代之的是,它創建新物件,這些新物件與舊物件共享相同且未改變的部分。在 JavaScript 中有多種使用不可變數據的方法,但我們建議在 Vue 中使用 Immer,因為它允許您使用不可變數據,同時保持更符合習慣的可變語法。

我們可以通過一個簡單的可組合函數將 Immer 與 Vue 整合:

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]
}

Try it in the playground

狀態機 - State Machines

狀態機是一種描述應用程序所有可能狀態及其轉換方式的模型。雖然對於簡單的組件來說可能有些過頭,但它可以幫助使複雜的狀態流程更加健全和易於管理。

JavaScript 中最流行的狀態機實現之一是 XState。這裡是一個與其整合的可組合函數:

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]
}

Try it in the playground

RxJS

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

與 Signals 的連接 - Connection to Signals

許多其他框架引入了與 Vue 的 Composition API 中的 refs 類似的反應性原語,稱為「signals」:

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

儘管信號的必要特徵並不是如此,但今天這個概念通常與通過細粒度訂閱進行更新的渲染模型一起討論。由於使用虛擬 DOM,Vue 目前依賴於編譯器來實現類似的優化。然而,我們也在探索一種受 Solid 啟發的新編譯策略,稱為 Vapor 模式,它不依賴於虛擬 DOM,並且更充分利用 Vue 的內建反應性系統。

API 設計權衡 - API Design Trade-Offs

Preact 和 Qwik 的 signals 設計與 Vue 的 shallowRef 非常相似:三者都通過 .value 屬性提供一個可變接口。我們將重點討論 Solid 和 Angular 的 signals。

Solid Signals

Solid 的 createSignal() API 設計強調讀/寫分離。信號被公開為一個只讀的 getter 和一個單獨的 setter:

const [count, setCount] = createSignal(0)

count() // 獲取值
setCount(1) // 更新值

注意,count 信號可以在不傳遞 setter 的情況下傳遞下去。這確保了狀態除非顯式暴露 setter,否則永遠不會被修改。這種安全保證是否值得更冗長的語法可能取決於項目的需求和個人口味——但如果您喜歡這種 API 風格,您可以輕鬆地在 Vue 中模仿它:

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]
}

Try it in the playground

Angular Signals

Angular 正在進行一些基本變化,放棄 dirty-checking,引入其自己的反應性原語實現。Angular Signal API 如下所示:

const count = signal(0)

count() // 獲取值
count.set(1) // 設置新值
count.update((v) => v + 1) // 基於先前的值更新

同樣,我們可以在 Vue 中輕鬆模仿這個 API:

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
}

Try it in the playground

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

  • ().value 稍微簡潔一些,但更新值更冗長。
  • 沒有引用解包:訪問值始終需要 ()。這使得值訪問在任何地方都一致。這也意味著您可以將原始信號作為組件道具傳遞下去。

這些 API 風格是否適合您在某種程度上是主觀的。我們的目標是展示這些不同 API 設計之間的基本相似性和權衡。我們還希望展示 Vue 的靈活性:您並不被現有的 API 鎖定。如果需要,您可以創建自己的反應性原語 API,以滿足更具體的需求。

依舊覺得蠻深奧地~~
可能對這個觀念實在太淺了www
分享至
成為作者繼續創作的動力吧!
© 2024 vocus All rights reserved.