Ostatnie kilka lat pokazało nam, że tworzenie stron internetowych się zmienia. Ponieważ do przeglądarek dodawano wiele funkcji i interfejsów API, musieliśmy używać ich we właściwy sposób. Językiem, któremu zawdzięczamy ten zaszczyt był JavaScript.
Początkowo deweloperzy nie byli przekonani do tego, jak został on zaprojektowany i mieli w większości negatywne wrażenia podczas korzystania z tego skryptu. Z czasem okazało się, że język ten ma ogromny potencjał, a kolejne standardy ECMAScript sprawiają, że niektóre mechaniki stają się bardziej ludzkie i, po prostu, lepsze. W tym artykule przyjrzymy się niektórym z nich.
Typy wartości w JS
Dobrze znana prawda o JavaScript jest to, że wszystko tutaj jest obiektem. Naprawdę wszystko: tablice, funkcje, ciągi znaków, liczby, a nawet wartości logiczne. Wszystkie typy wartości są reprezentowane przez obiekty i mają swoje własne metody i pola. Możemy jednak podzielić je na dwie kategorie: prymitywy i struktury. Wartości z pierwszej kategorii są niezmienne, co oznacza, że możemy ponownie przypisać zmiennej nową wartość, ale nie możemy modyfikować istniejącej wartości. Druga reprezentuje wartości, które mogą być zmieniane, więc powinny być interpretowane jako kolekcje właściwości, które możemy zastąpić lub po prostu wywołać metody, które są do tego przeznaczone.
Zakres zadeklarowanych zmiennych
Zanim przejdziemy głębiej, wyjaśnijmy, co oznacza zakres. Można powiedzieć, że zakres jest jedynym obszarem, w którym możemy używać zadeklarowanych zmiennych. Przed standardem ES6 mogliśmy deklarować zmienne za pomocą instrukcji var i nadawać im zakres globalny lub lokalny. Pierwszy z nich to sfera, która pozwala nam na dostęp do niektórych zmiennych w dowolnym miejscu aplikacji, drugi jest po prostu dedykowany konkretnemu obszarowi - głównie funkcji.
Od czasu standardu ES2015, JavaScript ma trzy sposoby deklarowania zmiennych, które różnią się słowem kluczowym. Pierwszy z nich został opisany wcześniej: zmienne zadeklarowane za pomocą słowa kluczowego var są przypisane do bieżącego ciała funkcji. Standard ES6 pozwolił nam zadeklarować zmienne na bardziej ludzkie sposoby - w przeciwieństwie do instrukcji var, zmienne zadeklarowane za pomocą instrukcji const i let są ograniczone tylko do bloku. Jednak JS traktuje instrukcję const dość nietypowo w porównaniu z innymi instrukcjami. języki programowania - zamiast trwałej wartości, przechowuje trwałą referencję do wartości. Krótko mówiąc, możemy modyfikować właściwości obiektu zadeklarowanego za pomocą instrukcji const, ale nie możemy nadpisać referencji do tej zmiennej. Niektórzy twierdzą, że alternatywa var w ES6 jest w rzeczywistości instrukcją let. Nie, nie jest, a instrukcja var nie jest i prawdopodobnie nigdy nie zostanie wycofana. Dobrą praktyką jest unikanie używania instrukcji var, ponieważ w większości przypadków przysparzają nam one więcej kłopotów. Z kolei należy nadużywać instrukcji const, dopóki nie musimy zmodyfikować jej referencji - wtedy powinniśmy użyć let.
Przykład nieoczekiwanego zachowania zakresu
Zacznijmy od następujących rzeczy kod:
(() => {
for (var i = 0; i {
console.log(`Wartość "i": ${i}`);
}, 1000);
}
})();
Kiedy na to spojrzymy, wygląda na to, że pętla for iteruje wartość i, a po jednej sekundzie rejestruje wartości iteratora: 1, 2, 3, 4, 5. Ale tak nie jest. Jak wspomnieliśmy powyżej, instrukcja var polega na przechowywaniu wartości zmiennej przez całe ciało funkcji; oznacza to, że w drugiej, trzeciej i tak dalej iteracji wartość zmiennej i zostanie zastąpiona kolejną wartością. W końcu pętla kończy się, a tyknięcia limitu czasu pokazują nam następującą rzecz: 5, 5, 5, 5, 5. Najlepszym sposobem na zachowanie aktualnej wartości iteratora jest użycie instrukcji let:
(() => {
for (let i = 0; i {
console.log(`Wartość "i": ${i}`);
}, 1000);
}
})();
W powyższym przykładzie utrzymujemy zakres wartości i w bieżącym bloku iteracji, jest to jedyny obszar, w którym możemy użyć tej zmiennej i nic nie może jej nadpisać spoza tego obszaru. Wynik w tym przypadku jest zgodny z oczekiwaniami: 1 2 3 4 5. Przyjrzyjmy się, jak poradzić sobie z tą sytuacją za pomocą instrukcji var:
(() => {
for (var i = 0; i {
setTimeout(() => {
console.log(`Wartość "j": ${j}`);
}, 1000);
})(i);
}
})();
Ponieważ instrukcja var dotyczy przechowywania wartości wewnątrz bloku funkcji, musimy wywołać zdefiniowaną funkcję, która przyjmuje argument - wartość bieżącego stanu iteratora - a następnie po prostu coś zrobić. Nic poza zadeklarowaną funkcją nie zastąpi wartości j.
Przykłady błędnych oczekiwań dotyczących wartości obiektów
Najczęściej popełnianym przestępstwem, które zauważyłem, jest ignorowanie mocy struktur i zmienianie ich właściwości, które są również modyfikowane w innych fragmentach kodu. Wystarczy spojrzeć:
const DEFAULT_VALUE = {
favoriteBand: 'The Weeknd'
};
const currentValue = DEFAULT_VALUE;
const bandInput = document.querySelector('#favorite-band');
const restoreDefaultButton = document.querySelector('#restore-button');
bandInput.addEventListener('input', () => {
currentValue.favoriteBand = bandInput.value;
}, false);
restoreDefaultButton.addEventListener('click', () => {
currentValue = DEFAULT_VALUE;
}, false);
Od początku: załóżmy, że mamy model z domyślnymi właściwościami, przechowywany jako obiekt. Chcemy mieć przycisk, który przywraca wartości wejściowe do wartości domyślnych. Po wypełnieniu danych wejściowych pewnymi wartościami, aktualizujemy model. Po chwili uznajemy, że domyślny wybór był po prostu lepszy, więc chcemy go przywrócić. Klikamy przycisk... i nic się nie dzieje. Dlaczego? Z powodu ignorowania mocy wartości referencyjnych.
Ta część: const currentValue = DEFAULTVALUE mówi JS, co następuje: weź odniesienie do DEFAULTVALUE i przypisać do niej zmienną currentValue. Rzeczywista wartość jest przechowywana w pamięci tylko raz i obie zmienne na nią wskazują. Modyfikowanie niektórych właściwości w jednym miejscu oznacza modyfikowanie ich w innym. Mamy kilka sposobów na uniknięcie takich sytuacji. Jednym z nich jest operator rozrzutu. Poprawmy nasz kod:
const DEFAULT_VALUE = {
favoriteBand: 'The Weeknd'
};
const currentValue = { ...DEFAULT_VALUE };
const bandInput = document.querySelector('#favorite-band');
const restoreDefaultButton = document.querySelector('#restore-button');
bandInput.addEventListener('input', () => {
currentValue.favoriteBand = bandInput.value;
}, false);
restoreDefaultButton.addEventListener('click', () => {
currentValue = { ...DEFAULT_VALUE };
}, false);
W tym przypadku operator spread działa w następujący sposób: pobiera wszystkie właściwości z obiektu i tworzy nowy obiekt wypełniony nimi. Dzięki temu wartości w currentValue i DEFAULT_VALUE nie wskazują już na to samo miejsce w pamięci i wszelkie zmiany zastosowane do jednej z nich nie będą miały wpływu na pozostałe.
Ok, więc pytanie brzmi: czy chodzi tylko o użycie magicznego operatora spreadu? W tym przypadku - tak, ale nasze modele mogą wymagać większej złożoności niż ten przykład. W przypadku, gdy używamy zagnieżdżonych obiektów, tablic lub innych struktur, operator rozprzestrzeniania wartości odniesienia najwyższego poziomu wpłynie tylko na najwyższy poziom, a właściwości, do których się odwołujemy, nadal będą współdzielić to samo miejsce w pamięci. Istnieje wiele rozwiązań tego problemu, wszystko zależy od potrzeb użytkownika. Możemy klonować obiekty na każdym poziomie głębokości lub, w bardziej złożonych operacjach, korzystać z narzędzi takich jak immer, które pozwalają nam pisać niezmienny kod niemal bezboleśnie.
Wymieszaj wszystko razem
Czy połączenie wiedzy o zakresach i typach wartości jest użyteczne? Oczywiście, że tak! Zbudujmy coś, co wykorzystuje oba te typy:
const useValue = (defaultValue) => {
const value = [...defaultValue];
const setValue = (newValue) => {
value.length = 0; // trudny sposób na wyczyszczenie tablicy
newValue.forEach((item, index) => {
value[index] = item;
});
// zrób kilka innych rzeczy
};
return [value, setValue];
};
const [animals, setAnimals] = useValue(['cat', 'dog']);
console.log(animals); // ['kot', 'pies']
setAnimals(['koń', 'krowa']);
console.log(animals); // ['koń', 'krowa']);
Wyjaśnijmy, jak działa ten kod linijka po linijce. Cóż, funkcja useValue tworzy tablicę na podstawie argumentu defaultValue; tworzy zmienną i inną funkcję, jej modyfikator. Ten modyfikator przyjmuje nową wartość, która jest w podstępny sposób stosowana do istniejącej. Na końcu funkcji zwracamy wartość i jej modyfikator jako wartości tablicowe. Następnie używamy utworzonej funkcji - deklarujemy animals i setAnimals jako zwracane wartości. Używamy ich modyfikatora, aby sprawdzić, czy funkcja wpływa na zmienną animal - tak, to działa!
Ale zaraz, co dokładnie jest takiego wymyślnego w tym kodzie? Referencja przechowuje wszystkie nowe wartości i można wprowadzić własną logikę do tego modyfikatora, np. niektóre interfejsy API lub część ekosystemu który zasila przepływ danych bez żadnego wysiłku. Ten trudny wzorzec jest często używany w bardziej nowoczesnych bibliotekach JS, gdzie funkcjonalny paradygmat programowania pozwala nam utrzymać kod mniej skomplikowany i łatwiejszy do odczytania przez innych programistów.
Podsumowanie
Zrozumienie tego, jak mechanika języka działa pod maską, pozwala nam pisać bardziej świadomy i lekki kod. Nawet jeśli JS nie jest językiem niskopoziomowym i zmusza nas do posiadania pewnej wiedzy na temat przydzielania i przechowywania pamięci, nadal musimy uważać na nieoczekiwane zachowania podczas modyfikowania obiektów. Z drugiej strony, nadużywanie klonów wartości nie zawsze jest właściwą drogą, a nieprawidłowe użycie ma więcej wad niż zalet. Właściwym sposobem planowania przepływu danych jest zastanowienie się, czego potrzebujemy i jakie możliwe przeszkody możemy napotkać podczas wdrażania logiki aplikacji.
Czytaj więcej: