Reaktywność w szczegółach
Jedną z najbardziej charakterystycznych cech Vue jest nienarzucający się system reaktywności. Stan komponentu składa się z reaktywnych obiektów JavaScript. Kiedy je modyfikujesz, widok się aktualizuje. Sprawia to, że zarządzanie stanem jest proste i intuicyjne, ale ważne jest również zrozumienie jak to działa, aby uniknąć typowych pułapek. W tej sekcji zagłębimy się w niektóre szczegóły niskopoziomowe systemu reaktywności Vue.
Czym jest reaktywność?
Ten termin pojawia się ostatnio dość często w programowaniu, ale co ludzie mają na myśli, kiedy o nim mówią? Reaktywność jest paradygmatem programowania, który pozwala nam dostosowywać się do zmian w sposób deklaratywny. Kanonicznym przykładem, który ludzie zwykle pokazują, ponieważ jest świetny, jest arkusz kalkulacyjny Excel:
A | B | C | |
---|---|---|---|
0 | 1 | ||
1 | 2 | ||
2 | 3 |
Tutaj komórka A2 jest zdefiniowana formułą = A0 + A1
(możesz kliknąć na A2, aby wyświetlić lub edytować formułę), więc arkusz kalkulacyjny daje nam 3. Nic zaskakującego. Ale jeśli zaktualizujesz A0 lub A1, zauważysz, że A2 również automatycznie się aktualizuje.
JavaScript zwykle nie działa w ten sposób. Gdybyśmy mieli napisać coś podobnego w JavaScript:
js
let A0 = 1
let A1 = 2
let A2 = A0 + A1
console.log(A2) // 3
A0 = 2
console.log(A2) // wciąż 3
Kiedy zmieniamy A0
, A2
nie zmienia się automatycznie.
Więc jak moglibyśmy to zrobić w JavaScript? Po pierwsze, aby ponownie uruchomić kod, który aktualizuje A2
, opakujmy go w funkcję:
js
let A2
function update() {
A2 = A0 + A1
}
Następnie musimy zdefiniować kilka terminów:
Funkcja
update()
tworzy efekt uboczny, lub w skrócie efekt, ponieważ modyfikuje stan programu.A0
iA1
są uznawane za zależności efektu, ponieważ ich wartości są używane do wykonania efektu. Mówi się, że efekt jest subskrybentem swoich zależności.
Potrzebujemy magicznej funkcji, która może wywołać update()
(efekt) za każdym razem, gdy A0
lub A1
(zależności) się zmieniają:
js
whenDepsChange(update)
Ta funkcja whenDepsChange()
ma następujące zadania:
Śledzenie kiedy zmienna jest odczytywana. Np. podczas oceny wyrażenia
A0 + A1
, zarównoA0
jak iA1
są odczytywane.Jeśli zmienna jest odczytywana gdy istnieje aktualnie uruchomiony efekt, uczyń ten efekt subskrybentem tej zmiennej. Np. ponieważ
A0
iA1
są odczytywane gdyupdate()
jest wykonywane,update()
staje się subskrybentem zarównoA0
jak iA1
po pierwszym wywołaniu.Wykrywanie kiedy zmienna jest mutowana. Np. gdy
A0
jest przypisana nowa wartość, powiadom wszystkie jej efekty-subskrybentów aby wykonały się ponownie.
Jak działa reaktywność w Vue
Nie możemy tak naprawdę śledzić odczytywania i zapisywania lokalnych zmiennych jak w przykładzie. Po prostu nie ma mechanizmu do tego w czystym JavaScript. Co możemy zrobić, to przechwycić odczytywanie i zapisywanie właściwości obiektów.
Istnieją dwa sposoby przechwytywania dostępu do właściwości w JavaScript: gettery / settery oraz Proxy. Vue 2 używało wyłącznie getterów / setterów ze względu na ograniczenia wsparcia przeglądarek. W Vue 3, Proxy są używane dla obiektów reaktywnych, a gettery / settery są używane dla refs. Oto pseudo-kod, który ilustruje jak to działa:
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
}
TIP
Fragmenty kodu tutaj i poniżej mają na celu wyjaśnienie podstawowych koncepcji w najprostszej możliwej formie, więc wiele szczegółów jest pominiętych, a przypadki brzegowe zignorowane.
To wyjaśnia kilka ograniczeń obiektów reaktywnych, które omówiliśmy w sekcji podstaw:
Kiedy przypisujesz lub destrukturyzujesz właściwość obiektu reaktywnego do zmiennej lokalnej, dostęp lub przypisanie do tej zmiennej nie jest reaktywne, ponieważ nie wywołuje już pułapek get / set proxy na obiekcie źródłowym. Zauważ, że to "rozłączenie" wpływa tylko na wiązanie zmiennej - jeśli zmienna wskazuje na wartość nie-prymitywną, taką jak obiekt, mutowanie obiektu pozostanie reaktywne.
Zwrócone proxy z
reactive()
, mimo że zachowuje się tak samo jak oryginał, ma inną tożsamość, jeśli porównamy je z oryginałem za pomocą operatora===
.
Wewnątrz track()
sprawdzamy, czy aktualnie działa jakiś efekt. Jeśli tak, wyszukujemy efekty subskrybentów (przechowywane w Set) dla śledzonej właściwości i dodajemy efekt do Set:
js
// To zostanie ustawione tuż przed tym, jak efekt
// ma zostać uruchomiony. Zajmiemy się tym później.
let activeEffect
function track(target, key) {
if (activeEffect) {
const effects = getSubscribersForProperty(target, key)
effects.add(activeEffect)
}
}
Subskrypcje efektów są przechowywane w globalnej strukturze danych WeakMap<target, Map<key, Set<effect>>>
. Jeśli nie znaleziono zestawu efektów subskrybujących dla właściwości (śledzonej po raz pierwszy), zostanie on utworzony. To właśnie robi funkcja getSubscribersForProperty()
w skrócie. Dla uproszczenia pominiemy jej szczegóły.
Wewnątrz trigger()
ponownie wyszukujemy efekty subskrybentów dla właściwości. Ale tym razem zamiast tego je wywołujemy:
js
function trigger(target, key) {
const effects = getSubscribersForProperty(target, key)
effects.forEach((effect) => effect())
}
Teraz wróćmy do funkcji whenDepsChange()
:
js
function whenDepsChange(update) {
const effect = () => {
activeEffect = effect
update()
activeEffect = null
}
effect()
}
Opakowuje surową funkcję update
w efekt, który ustawia siebie jako aktualnie aktywny efekt przed uruchomieniem właściwej aktualizacji. Umożliwia to wywołaniom track()
podczas aktualizacji zlokalizowanie aktualnie aktywnego efektu.
W tym momencie stworzyliśmy efekt, który automatycznie śledzi swoje zależności i uruchamia się ponownie, gdy zależność ulega zmianie. Nazywamy to Efektem Reaktywnym.
Vue udostępnia API, które pozwala na tworzenie efektów reaktywnych: watchEffect()
. W rzeczywistości być może zauważyłeś, że działa bardzo podobnie do magicznego whenDepsChange()
w przykładzie. Możemy teraz przepracować oryginalny przykład używając faktycznych API Vue:
js
import { ref, watchEffect } from 'vue'
const A0 = ref(0)
const A1 = ref(1)
const A2 = ref()
watchEffect(() => {
// śledzi A0 i A1
A2.value = A0.value + A1.value
})
// wyzwala effect
A0.value = 2
Używanie efektu reaktywnego do mutowania ref-a nie jest najbardziej interesującym przypadkiem użycia - w rzeczywistości użycie właściwości obliczanej czyni to bardziej deklaratywnym:
js
import { ref, computed } from 'vue'
const A0 = ref(0)
const A1 = ref(1)
const A2 = computed(() => A0.value + A1.value)
A0.value = 2
Wewnętrznie computed
zarządza swoją nieważnością i ponownym obliczaniem za pomocą efektu reaktywnego.
Więc jaki jest przykład powszechnego i użytecznego efektu reaktywnego? Cóż, aktualizacja DOM-u! Możemy zaimplementować prostą "reaktywną renderowanie" w ten sposób:
js
import { ref, watchEffect } from 'vue'
const count = ref(0)
watchEffect(() => {
document.body.innerHTML = `Licznik: ${count.value}`
})
// aktualizuje DOM
count.value++
W rzeczywistości jest to dość zbliżone do tego, jak komponent Vue utrzymuje synchronizację stanu i DOM-u - każda instancja komponentu tworzy efekt reaktywny do renderowania i aktualizacji DOM-u. Oczywiście, komponenty Vue używają znacznie wydajniejszych sposobów aktualizacji DOM-u niż innerHTML
. Jest to omówione w Mechanizm Renderowania.
Reaktywność w czasie wykonania vs. w czasie kompilacji
System reaktywności Vue jest przede wszystkim oparty na czasie wykonania: śledzenie i wyzwalanie są wykonywane podczas bezpośredniego działania kodu w przeglądarce. Zaletami reaktywności w czasie wykonania są możliwość działania bez etapu budowania oraz mniejsza liczba przypadków brzegowych. Z drugiej strony, sprawia to, że jest ograniczona przez składniowe ograniczenia JavaScript, prowadząc do potrzeby stosowania kontenerów wartości takich jak Vue refs.
Niektóre frameworki, takie jak Svelte, wybierają pokonanie tych ograniczeń poprzez implementację reaktywności podczas kompilacji. Analizują i transformują kod w celu symulowania reaktywności. Etap kompilacji pozwala frameworkowi na zmianę semantyki samego JavaScriptu - na przykład, niejawne wstrzykiwanie kodu, który wykonuje analizę zależności i wyzwalanie efektów wokół dostępu do lokalnie zdefiniowanych zmiennych. Wadą jest to, że takie transformacje wymagają etapu budowania, a zmiana semantyki JavaScriptu to w zasadzie tworzenie języka, który wygląda jak JavaScript, ale kompiluje się w coś innego.
Zespół Vue zbadał ten kierunek za pomocą eksperymentalnej funkcji zwanej Transformacją Reaktywności, ale ostatecznie zdecydowaliśmy, że nie będzie to dobre rozwiązanie dla projektu z powodu uzasadnienia tutaj.
Debugowanie reaktywności
To świetnie, że system reaktywności Vue automatycznie śledzi zależności, ale w niektórych przypadkach możemy chcieć dokładnie ustalić, co jest śledzone lub co powoduje ponowne renderowanie komponentu.
Hooki debugowania komponentów
Możemy debugować, jakie zależności są używane podczas renderowania komponentu i która zależność wywołuje aktualizację, używając hooków cyklu życia onRenderTracked
i onRenderTriggered
. Oba hooki otrzymają zdarzenie debugowania, które zawiera informacje o danej zależności. Zaleca się umieszczenie instrukcji debugger
w callbackach, aby interaktywnie sprawdzać zależność:
vue
<script setup>
import { onRenderTracked, onRenderTriggered } from 'vue'
onRenderTracked((event) => {
debugger
})
onRenderTriggered((event) => {
debugger
})
</script>
TIP
Hooki do debugowania komponentów działają wyłącznie w trybie deweloperskim.
Obiekty zdarzeń debugowania mają następujący typ:
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>
}
Debugowanie computed
Możemy debugować właściwości computed poprzez przekazanie do computed()
drugiego obiektu opcji z callbackami onTrack
i onTrigger
:
onTrack
zostanie wywołany, gdy właściwość reaktywna lub ref jest śledzona jako zależność.onTrigger
zostanie wywołany, gdy callback watchera jest wyzwalany przez mutację zależności.
Oba callbacki otrzymają zdarzenia debugowania w tym samym formacie co hooki debugowania komponentów:
js
const plusOne = computed(() => count.value + 1, {
onTrack(e) {
// wyzwalane gdy count.value jest śledzone jako zależność
debugger
},
onTrigger(e) {
// wyzwalane gdy count.value jest mutowane
debugger
}
})
// dostęp do plusOne, powinno wyzwolić onTrack
console.log(plusOne.value)
// mutacja count.value, powinno wyzwolić onTrigger
count.value++
TIP
Opcje onTrack
i onTrigger
działają wyłącznie w trybie deweloperskim.
Debugowanie watcher
Podobnie do computed()
, watchers wspierają opcje onTrack
i onTrigger
:
js
watch(source, callback, {
onTrack(e) {
debugger
},
onTrigger(e) {
debugger
}
})
watchEffect(callback, {
onTrack(e) {
debugger
},
onTrigger(e) {
debugger
}
})
TIP
Opcje onTrack
i onTrigger
działają wyłącznie w trybie deweloperskim.
Integracja z zewnętrznymi systemami stanu
System reaktywności Vue działa poprzez głęboką konwersję zwykłych obiektów JavaScript na reaktywne proxy. Głęboka konwersja może być niepotrzebna lub czasami niepożądana podczas integracji z zewnętrznymi systemami zarządzania stanem (np. jeśli zewnętrzne rozwiązanie również używa Proxy).
Ogólna idea integracji systemu reaktywności Vue z zewnętrznym rozwiązaniem zarządzania stanem polega na przechowywaniu zewnętrznego stanu w shallowRef
. Płytki ref jest reaktywny tylko wtedy, gdy następuje dostęp do jego właściwości .value
- wewnętrzna wartość pozostaje nietknięta. Gdy zmienia się stan zewnętrzny, zastępujemy wartość ref-a, aby wywołać aktualizacje.
Dane niezmienne
Jeśli implementujesz funkcję cofnij / ponów, prawdopodobnie chcesz wykonywać snapshot stanu aplikacji przy każdej edycji użytkownika. Jednak mutowalny system reaktywności Vue nie jest najlepiej dostosowany do tego, jeśli drzewo stanu jest duże, ponieważ serializacja całego obiektu stanu przy każdej aktualizacji może być kosztowna zarówno pod względem CPU, jak i pamięci.
Niezmienne struktury danych rozwiązują to poprzez nigdy niemutowanie obiektów stanu - zamiast tego tworzą nowe obiekty, które współdzielą te same, niezmienione części ze starymi. Istnieją różne sposoby używania niezmiennych danych w JavaScript, ale zalecamy używanie Immer z Vue, ponieważ pozwala on na używanie niezmiennych danych, zachowując bardziej ergonomiczną, mutowalną składnię.
Możemy zintegrować Immer z Vue za pomocą prostego composable:
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]
}
Maszyny stanu
Maszyna stanów to model opisujący wszystkie możliwe stany, w których może znajdować się aplikacja, oraz wszystkie możliwe sposoby przejścia z jednego stanu do drugiego. Chociaż może być to przesadą dla prostych komponentów, może pomóc uczynić złożone przepływy stanów bardziej solidnymi i łatwiejszymi w zarządzaniu.
Jedną z najpopularniejszych implementacji maszyny stanów w JavaScript jest XState. Oto composable, który się z nią integruje:
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 jest biblioteką do pracy ze strumieniami zdarzeń asynchronicznych. Biblioteka VueUse udostępnia dodatek @vueuse/rxjs
do łączenia strumieni RxJS z systemem reaktywności Vue.
Powiązanie z Sygnałami
Wiele innych frameworków wprowadziło prymitywy reaktywności podobne do refs z Vue Composition API, pod pojęciem "sygnałów":
Fundamentalnie, sygnały są tym samym rodzajem prymitywu reaktywności co Vue refs. Jest to kontener wartości, który zapewnia śledzenie zależności podczas dostępu i wyzwalanie efektów ubocznych podczas mutacji. Ten paradygmat oparty na prymitywach reaktywności nie jest szczególnie nowym konceptem w świecie frontendu: sięga implementacji takich jak Knockout observables i Meteor Tracker sprzed ponad dekady. Vue Options API i biblioteka zarządzania stanem React MobX również bazują na tych samych zasadach, ale ukrywają prymitywy za właściwościami obiektów.
Choć nie jest to cecha niezbędna, aby coś kwalifikowało się jako sygnały, obecnie koncepcja ta jest często omawiana wraz z modelem renderowania, w którym aktualizacje są wykonywane poprzez precyzyjne subskrypcje. Ze względu na użycie Virtual DOM, Vue obecnie polega na kompilatorach, aby osiągnąć podobne optymalizacje. Jednak badamy również nową strategię kompilacji inspirowaną Solid, zwaną Vapor Mode, która nie opiera się na Virtual DOM i lepiej wykorzystuje wbudowany system reaktywności Vue.
Kompromisy w Projektowaniu API
Projekt sygnałów Preact i Qwik jest bardzo podobny do Vue shallowRef: wszystkie trzy zapewniają mutowalny interfejs poprzez właściwość .value
. Skupimy się na omówieniu sygnałów Solid i Angular.
Solid Signals
Projekt API createSignal()
w Solid kładzie nacisk na rozdzielenie odczytu/zapisu. Sygnały są eksponowane jako gettery tylko do odczytu i oddzielny setter:
js
const [count, setCount] = createSignal(0)
count() // dostęp do wartości
setCount(1) // aktualizacja wartości
Zauważ, że sygnał count
może być przekazywany bez settera. Zapewnia to, że stan nigdy nie może być zmutowany, chyba że setter jest również jawnie wyeksponowany. To, czy ta gwarancja bezpieczeństwa uzasadnia bardziej rozwlekłą składnię, może zależeć od wymagań projektu i osobistych preferencji - ale jeśli preferujesz ten styl API, możesz łatwo go odtworzyć w 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 przechodzi fundamentalne zmiany, rezygnując z dirty-checkingu i wprowadzając własną implementację prymitywu reaktywności. API Angular Signal wygląda następująco:
js
const count = signal(0)
count() // dostęp do wartości
count.set(1) // ustawienie nowej wartości
count.update((v) => v + 1) // aktualizacja bazująca na poprzedniej wartości
Ponownie, możemy bardzo łatwo zreplikować to API we Vue:
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
}
W porównaniu do Vue refs, styl API oparty na getterach w Solid i Angular oferuje kilka interesujących kompromisów podczas użycia w komponentach Vue:
()
jest nieco mniej rozwlekłe niż.value
, ale aktualizacja wartości jest bardziej rozwlekła.- Nie ma odpakowywania ref: dostęp do wartości zawsze wymaga
()
. Sprawia to, że dostęp do wartości jest spójny wszędzie. Oznacza to również, że możesz przekazywać surowe sygnały jako właściwości komponentów.
To, czy te style API ci odpowiadają, jest do pewnego stopnia subiektywne. Naszym celem jest zademonstrowanie podstawowego podobieństwa i kompromisów między tymi różnymi projektami API. Chcemy również pokazać, że Vue jest elastyczne: nie jesteś tak naprawdę ograniczony do istniejących API. W razie potrzeby możesz stworzyć własne API prymitywów reaktywności, aby spełnić bardziej specyficzne potrzeby.