Przejdź bezpośrednio do treści

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:

ABC
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 i A1 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:

  1. Śledzenie kiedy zmienna jest odczytywana. Np. podczas oceny wyrażenia A0 + A1, zarówno A0 jak i A1 są odczytywane.

  2. Jeśli zmienna jest odczytywana gdy istnieje aktualnie uruchomiony efekt, uczyń ten efekt subskrybentem tej zmiennej. Np. ponieważ A0 i A1 są odczytywane gdy update() jest wykonywane, update() staje się subskrybentem zarówno A0 jak i A1 po pierwszym wywołaniu.

  3. 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.

API ref(), computed() i watchEffect() są częścią Composition API. Jeśli do tej pory używałeś tylko Options API z Vue, zauważysz, że Composition API jest bliższe temu, jak system reaktywności Vue działa pod maską. W rzeczywistości w Vue 3 Options API jest zaimplementowane na bazie Composition API. Każdy dostęp do właściwości instancji komponentu (this) wywołuje gettery / settery do śledzenia reaktywności, a opcje takie jak watch i computed wewnętrznie wywołują swoje odpowiedniki z Composition API.

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 renderTrackedonRenderTracked i renderTriggeredonRenderTriggered. 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>
js
export default {
  renderTracked(event) {
    debugger
  },
  renderTriggered(event) {
    debugger
  }
}

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

Wypróbuj to w Playground

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

Wypróbuj to w Playground

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

Wypróbuj to w Playground

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
}

Wypróbuj to w Playground

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.

Reaktywność w szczegółachJest załadowany