Wyjaśnienie równości w JavaScript: ===, ==, operatorzy relacyjne i Object.is
Dowiedz się, w jaki sposób ścisła i luźna równość, porównania relacyjne oraz Object.is faktycznie określają tożsamość w JavaScript, a także jakie pułapki przymusowej konwersji każdy z nich ukrywa.
Źródła: Day 4 of Learning JavaScript: The Three Ways JavaScript Asks “Are These the Same Thing?”
Załóżmy formularz, w którym użytkownik wpisuje swój wiek jako tekst "18", a sprawdzenie z liczbą 18 przechodzi bez problemu, by później obliczenia z tą wartością poszły nie tak. Albo sprawdzenie dostępu, które udaje się wtedy, gdy nie powinno, ponieważ dwa różne wartości przypadkowo przekształciły się w to samo. Takie błędy wynikają z jednego faktu: JavaScript oferuje kilka sposobów na sprawdzenie, czy dwie wartości są takie same, a każdy z nich definiuje pojęcie „takiej samej” inaczej. Po przeczytaniu tego dowiesz się, co tak naprawdę robią ===, ==, operatorzy relacyjne oraz Object.is(), i który z nich należy użyć w danej sytuacji.
Dlaczego język ma więcej niż jedną koncepcję równości
Wczesny JavaScript działał w przeglądarkach, gdzie niemal każda informacja wprowadzana przez użytkownika przychodziła jako tekst z formularza. Zamiast rzucać błąd za każdym razem, gdy liczba pojawiała się jako ciąg znaków, język został zaprojektowany tak, by być wyrozumiały: sam domyślał się intencji użytkownika i automatycznie konwertował typy. Ta wygoda stała się jednym z najbardziej niepewnych źródeł błędów w tym języku, a bardziej rygorystyczne operatory istnieją głównie po to, by uniknąć takich problemów.
Rygorystyczna równość (===) jako standard
Kontrola typu, a następnie kontrola wartości
Rygorystyczna równość przeprowadza dwuetapową weryfikację bez żadnych ukrytych konwersji. Najpierw porównuje typy obu operandów. Jeśli się różnią, wynikiem jest false, bez konieczności sprawdzania samych wartości. Dopiero gdy typy się pokrywają, porównuje się same wartości.
console.log(1 === 1);
// true (both are numbers, both are 1)
console.log(1 === "1");
// false (the types fail immediately: number versus string)
console.log(0 === false);
// false (number versus boolean)
console.log("" === false);
// false (string versus boolean)
console.log(null === null);
// true
console.log(null === undefined);
// false (different types)
Każde false w tym fragmencie pochodzi od kroku typowania: liczba kontra ciąg znaków, liczba kontra wartość logiczna, ciąg znaków kontra wartość logiczna, null kontra undefined.
Porównania ciągów znaków są dokładne na poziomie poszczególnych liter. Ma znaczenie wielkość liter, a także niewidoczne spacje, które są bardzo częstym problemem w praktyce, gdy ktoś naciska klawisz spacju po wpisaniu adresu e-mail.
console.log("hello" === "Hello");
// false (different capitalization)
console.log("john@email.com" === "john@email.com ");
// false (the second string has a hidden space at the end)
W praktyce oznacza to normalizację danych wprowadzonych przez użytkownika, zwykle za pomocą trim(), a często także toLowerCase() dla pól niewrażliwych na wielkość liter, zanim zostaną one porównane.
Dwie celowe wyjątki od standardu IEEE 754
Ścisła równość jest wiarygodna niemal wszędzie, z dwoma wyjątkami numerycznymi. Żaden z nich nie jest błędem; oba wynikają ze standardu liczb zmiennoprzecinkowych IEEE 754, na którym opierają się liczby w JavaScript. Pierwszym wyjątkiem jest to, że NaN, wartość „nie jest liczbą”, nie jest równa niczemu, włączając w to samemu sobie.
const result = 0 / 0;
// result is NaN
console.log(result === NaN);
// false
console.log(NaN === NaN);
// false
Ponieważ sprawdzenie równości nigdy nie może tego wykryć, należy zamiast tego użyć specjalnej funkcji Number.isNaN().
console.log(Number.isNaN(NaN));
// true
Należy preferować tę funkcję nad starszą globalną funkcją isNaN(), która najpierw przekształca swój argument na liczbę, dlatego zwraca true dla ciągów znaków takich jak "abc". Drugi wyjątek dotyczy zera. JavaScript reprezentuje dodatnie i ujemne zero oddzielnie, co ma znaczenie w niektórym kodzie numerycznym, jednak ścisła równość traktuje je jako równe.
console.log(+0 === -0);
// true
Obiekty porównuje się pod kątem tożsamości
Prymitywy takie jak liczby i łańcuchy porównuje się pod względem wartości. Obiekty są inne: zmienna przechowująca obiekt w rzeczywistości przechowuje odwołanie do miejsca, gdzie ten obiekt znajduje się w pamięci. Wyobraź sobie dwa domy zbudowane według tych samych planów i pomalowane na ten sam kolor, ale znajdujące się przy różnych adresach. Wyglądają identycznie, ale to nie są ten sam dom, ponieważ ich adresy się różnią.
Gdy porównujesz obiekty za pomocą ===, JavaScript sprawdza, czy obie strony odwołują się do tego samego obiektu, ignorując jego zawartość. Tablice podlegają tej samej zasadzie, ponieważ one również są obiektami.
const userA = { name: "Alex" };
const userB = { name: "Alex" };
console.log(userA === userB);
// false (two different objects sitting in different memory slots)
console.log([] === []);
// false (two different arrays sitting in different memory slots)
Jeśli jedna zmienna jest przypisywana z innej, obie odwołują się do tego samego obiektu, a porównanie kończy się sukcesem.
const original = { name: "Alex" };
const pointer = original;
console.log(original === pointer);
// true (both point to the exact same memory location)
Aby sprawdzić, czy dwa różne obiekty zawierają te same dane, sam porównaj ich prymitywne właściwości.
console.log(userA.name === userB.name);
// true (now we are comparing two strings, not two object addresses)
W przypadku zagnieżdżonych struktur dochodzi do rekurencyjnego porównywania, dlatego zespoły zazwyczaj polegają na sprawdzonej funkcji pomocniczej do głębokiego porównywania równości z biblioteki narzędziowej lub ich frameworku testowego, zamiast pisania takiej funkcji od podstaw.
Strażna nierówność (!==)
Pроверka różnic między wartościami jest równie powszechna jak sprawdzanie ich zgodności. Strażna nierówność jest dokładną negacją ===: zwraca true, gdy różnią się zarówno typy, jak i wartości.
console.log(1 !== 2);
// true (they are different numbers)
console.log(1 !== "1");
// true (one is a number, one is a string)
console.log("apple" !== "orange");
// true
Jest to naturalne rozwiązanie, gdy kod powinien być wykonywany tylko wtedy, gdy nie jest spełniona określona warunek, np. przy sprawdzaniu uprawnień lub filtrowaniu niepożądanych elementów.
const currentRole = "guest";
if (currentRole !== "admin") {
console.log("Access denied. Admins only.");
}
// "Access denied. Admins only."
Luźna równość (==) i jej konwersje
Jak przymus powoduje zaskakujące wyniki
Zanim ścisła równość stała się normą, w większości kodu używano ==. Gdy typy operandów się różnią, luźna równość przekształca jeden lub oba wartości na wspólny typ, a następnie je porównuje. Wyniki szybko przestają odpowiadać intuicji.
console.log(1 == "1");
// true (the string "1" becomes the number 1)
console.log(0 == false);
// true (false becomes 0)
console.log("" == false);
// true (empty string and false both become 0)
console.log(0 == "");
// true (empty string becomes 0)
console.log([] == "");
// true (empty array becomes empty string)
console.log([1] == 1);
// true (array becomes "1", which becomes 1)
Słynny przykład pokazuje, ile ukrytych kroków może być w tym zaangażowanych.
console.log([] == 0);
// true
Aby uzyskać wartość true, silnik najpierw przekształca pusty tablicę na wartość prymitywną, co daje pustą ciąg znaków "". Typy nadal się różnią, ponieważ ciąg znaków jest porównywany z liczbą, więc ciąg znaków zostaje przekształcony na liczbę, co daje 0. Ostateczne porównanie to 0 == 0, co jest równoznaczne z true.
Liczby logiczne są równie skomplikowane, ponieważ przy porównaniu z wartością niebędącą liczbą logiczną są one najpierw przekształcane na liczby. true staje się wtedy 1.
console.log(true == "true");
// false
Ciąg "true" nie staje się 1; po przekształceniu na liczbę staje się NaN, a 1 nie jest równe NaN. Dlatego value == true prawie nigdy nie jest tym, czego chcemy.
null i undefined podlegają specjalnej zasadzie: przy luźnej równości są równe sobie i niczemu innemu, nawet nie 0, false ani pustemu ciągowi znaków.
console.log(null == undefined);
// true
console.log(null == 0);
// false
console.log(null == false);
// false
console.log(null == "");
// false
console.log(undefined == 0);
// false
Sprawdzanie wartości null, gdzie == jest uzasadnione
Luźną równość najlepiej unikać w nowoczesnym kodzie, z jedną powszechnie używaną wyjątkiem. Przy obsłudze danych z API często chcemy wiedzieć, czy wartość jest null lub undefined, a == null odpowiada na to za pomocą jednego porównania.
function checkStatus(status) {
if (status == null) {
console.log("Status is missing entirely");
} else {
console.log("Status is:", status);
}
}
checkStatus(null);
// "Status is missing entirely"
checkStatus(undefined);
// "Status is missing entirely"
checkStatus(0);
// "Status is: 0"
To działa dokładnie dzięki powyższej zasadzie: null jest luźno równe tylko null i undefined, więc wartości fałszywe, ale sensowne, takie jak 0 czy "", przechodzą bez zmian. Zastępuje on dłuższy wyrażenie status === null || status === undefined. Należy pamiętać, że wiele zespołów konfiguruje narzędzia do sprawdzania kodu tak, aby całkowicie zabronić użycia ==, w takim przypadku trzeba użyć wyraźnej, ścisłej wersji. Na przykład reguła eqeqeq w ESLint posiada opcję, która umożliwia dokładnie takie porównanie z null, jednocześnie zabraniając innych zastosowań. W wielu przypadkach operator łączenia wartości nullowych ?? lub łańcuchowanie opcjonalne ?. wyrażają intencję jeszcze bardziej bezpośrednio.
Luźne nierówność (!=)
!= stosuje te same konwersje co ==, a następnie odwraca wynik, więc przechodzi na niego wszystkie „niespodzianki”.
console.log(1 != "1");
// false (it converts "1" to a number, decides they are the same, so they are not different)
Tutaj "1" jest konwertowane na liczbę 1, wartości są uznawane za równe, więc sprawdzenie „nierówności” zwraca false. Traktuj != z taką samą ostrożnością jak == i preferuj !==.
Porównania relacyjne: <, >, <= i >=
Liczby i mieszane typy
Przy dwóch liczbach operatorzy relacyjne zachowują się dokładnie tak samo jak w arytmetyce.
console.log(5 > 3);
// true
console.log(10 <= 10);
// true
Gdy znak tekstowy spotyka się z liczbą, najpierw znak tekstowy jest konwertowany na liczbę. Znak tekstowy, który nie reprezentuje liczby, staje się NaN, a każde porównanie relacyjne z udziałem NaN zwraca false.
console.log("10" > 5);
// true
console.log("abc" > 3);
// false ("abc" becomes NaN, and NaN comparisons always return false)
Gdzie null i undefined różnią się od siebie
Operatory relacyjne ujawniają niezgodność z luźną równością. == odmawia konwersji null na liczbę, natomiast <, >, <= i >= dokonują takiej konwersji, przekształcając null na 0.
console.log(null == 0);
// false
console.log(null > 0);
// false (0 is not greater than 0)
console.log(null >= 0);
// true (0 is greater than or equal to 0)
Zatem null nie jest ani równe 0, ani większe od 0, a mimo to jest większe lub równe 0. Ostatni wynik ma sens, gdy wiadomo, że a >= b jest interpretowane jako „a < b nie jest prawdą”, a 0 < 0 jest fałszywe.
undefined zachowuje się ponownie inaczej. Po konwersji na liczbę staje się NaN, więc każde porównanie relacyjne z udziałem undefined skutkuje wartością false.
console.log(undefined > 0);
// false
console.log(undefined < 0);
// false
Strony porównuje się według jednostek kodu, a nie według kolejności słownika
Gdy oba operandy to strony, nie następuje żadna konwersja liczbowa. Strony porównuje się znak po znaku od lewej, używając liczbowej jednostki kodu UTF-16 każdego znaku.
console.log("apple" < "banana");
// true (a comes before b)
console.log("b" > "a");
// true (b comes after a)
Litera wielka ma mniejsze jednostki kodu niż litera mała, co tłumaczy, dlaczego proste sortowanie może umieścić „Zebra” przed „apple”.
console.log("Z" < "a");
// true (Z=90, a=97 in Unicode)
Dla sortowania przeznaczonego dla ludzi należy użyć localeCompare() lub Intl.Collator, które kierują się zasadami językowymi zamiast surowymi jednostkami kodu.
Bardziej niebezpieczną pułapką są strony zawierające cyfry. Porównywane jako strony, „10” jest mniejsze od „9”.
console.log("10" < "9");
// true (the character "1" is smaller than "9")
JavaScript nie wie, że te łańcuchy reprezentują wielkości liczbowe. Sprawdza pierwszy znak każdego z nich – "1" w porównaniu z "9" – stwierdza, że "1" występuje pierwszy, i na tym poprzestaje, zwracając true. Ten sam efekt występuje, gdy tablica liczb jest sortowana za pomocą sort() bez funkcji porównywania, ponieważ domyślny sort przekształca elementy na łańcuchy przed ich porównaniem; aby dokonać sortowania liczbowego, należy przekazać funkcję (a, b) => a - b.
Rozwiązaniem jest wyraźne przekształcenie łańcuchów liczbowych na liczby przed porównywaniem ich wartości.
console.log(Number("10") < Number("9"));
// false (correct: 10 is not less than 9)
Object.is() do dokładnego sprawdzania tożsamości
Zamknięcie luk w operatorze ===
Object.is() to najdokładniejsza metoda sprawdzania równości dostępna w JavaScript. Dla zwykłych wartości daje te same wyniki co operator strict equality.
console.log(Object.is(1, 1));
// true
console.log(Object.is(1, "1"));
// false
Różnica polega dokładnie na tych dwóch przypadkach zstandaryzowanych przez IEEE 754. Funkcja Object.is() traktuje NaN tak samo jak samą siębie:
console.log(Object.is(NaN, NaN));
// true
a także rozróżnia zerę dodatnią od zera ujemnej:
console.log(Object.is(0, -0));
// false
Trzy sprawdzenia obok siebie
1i"1": operacja==zwracatrue;===orazObject.is()zwracająfalse.0ifalse: operacja==zwracatrue;===orazObject.is()zwracająfalse.nulliundefined: operacja==zwracatrue;===orazObject.is()zwracająfalse.
NaN i NaN: operatory == oraz === zwracają false; Object.is() zwraca true.+0 i -0: operatory == oraz === zwracają true; Object.is() zwraca false.[] i [], lub {} i {}: wszystkie trzy operatory zwracają false, ponieważ są to odrębne obiekty.5 i 5: wszystkie trzy operatory zwracają true.Rzadko korzystasz bezpośrednio z Object.is(), ale ma on znaczenie w narzędziach, których używasz codziennie. React polega na Object.is(), aby określić, czy stan lub zależność uległy zmianie i czy konieczna jest ponowna renderizacja – to także powód, dla którego modyfikacja obiektu „na miejscu” nie wywołuje aktualizacji: referencja pozostaje identyczna. Biblioteki do zarządzania stanem stosują tę samą zasadę; aby dowiedzieć się więcej, sprawdź jak React-Redux wykorzystuje sprawdzanie równości do decydowania o ponownych renderizacjach.
Główne wnioski
W pierwszym przykładzie, gdy sprawdzenie udzieliło dostępu, którego powinno było odrzucić, nie potrzeba niczego bardziej skomplikowanego niż porównanie == między łańcuchem a liczbą: cicha konwersja sprawia, że dwa różne wartości wyglądają tak, jakby były równe. Zasady zapobiegające temu są proste:
- Zawsze używaj domyślnie
===i!==. - Unikaj
==i!=, z wyjątkiem celowego skrótuvalue == null, jeśli to pozwalają zasady lintingu. - Wykrywaj
NaNza pomocąNumber.isNaN(), nigdy za pomocą operatora równości. - Pamiętaj, że porównania obiektów sprawdzają tożsamość, a nie zawartość.
- Przed użyciem
<lub>konwertuj łańcuchy liczbowe za pomocąNumber(), a funkcjisort()podaj komparator dla liczb. - Używaj
Object.is(), gdy trzeba dokładnie obsłużyć wartościNaNlub zeru ze znakiem.
Umiarkowane konwersje w JavaScript nie znikną, ale stosowanie ścisłych porównań zapobiega ich przenikaniu do zachowań w produkcji.
Literatura pokrewna
- Zasady TypeScriptu: od pierwszej adnotacji do generyków i reżimu sztywnego — Strukturalny przewodnik po systemie typów TypeScriptu, od wartości prymitywnych i inferencji po zespoły rozróżniające, generyki, typy pomocnicze oraz sztywną konfigurację tsconfig.
- Zasady pętli w TypeScriptie: while, do-while, for, break i continue — Dowiedz się, jak pętle while, do-while i for w TypeScriptie oceniają swoje warunki, dlaczego zmiana zakresu zmiennych pętli wpływa na wyniki oraz w jaki sposób break i continue zmieniają bieg wykonywania kodu.