Strona główna / Artykuły

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.

Wydawca: REACTAPP.TOP10 min czytania2405 słów

Ź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

  • 1 i "1": operacja == zwraca true; === oraz Object.is() zwracają false.
  • 0 i false: operacja == zwraca true; === oraz Object.is() zwracają false.
  • null i undefined: operacja == zwraca true; === oraz Object.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:

    1. Zawsze używaj domyślnie === i !==.
    2. Unikaj == i !=, z wyjątkiem celowego skrótu value == null, jeśli to pozwalają zasady lintingu.
    3. Wykrywaj NaN za pomocą Number.isNaN(), nigdy za pomocą operatora równości.
    4. Pamiętaj, że porównania obiektów sprawdzają tożsamość, a nie zawartość.
    5. Przed użyciem < lub > konwertuj łańcuchy liczbowe za pomocą Number(), a funkcji sort() podaj komparator dla liczb.
    6. Używaj Object.is(), gdy trzeba dokładnie obsłużyć wartości NaN lub zeru ze znakiem.

    Umiarkowane konwersje w JavaScript nie znikną, ale stosowanie ścisłych porównań zapobiega ich przenikaniu do zachowań w produkcji.

    Literatura pokrewna

  • Operatory TypeScript w praktyce: matematyka, porównania, logika i operatory ternarne — Praktyczny przegląd operatorów arytmetycznych, przydzielania, porównań, logicznych, inkrementacji oraz ternarnych w TypeScript, w tym sprawdzania równości, którego kompilator odrzuca.