Kurs JavaScript i TypeScript · Moduł 5: Zaawansowany JavaScript
Zaawansowane wyrażenia regularne
W tej lekcji8
Dr Rex stoi przed ścianą monitorów w laboratorium genetycznym Parku Jurajskiego. Na jednym z ekranów przewija się dziennik systemowy: dwadzieścia tysięcy linii z czujników ogrodzeń, inkubatorów i bram sektorowych. Gdzieś w tym strumieniu ukryła się jedna awaria zasilania w siódmym sektorze. Przewijanie tego wzrokiem zajęłoby całą noc.
- Nie szukamy konkretnego zdania - mówi dr Rex, nie odrywając wzroku od ekranu. - Szukamy kształtu zdania. Kod błędu zawsze wygląda tak samo: trzy wielkie litery, myślnik, trzy cyfry. Nieważne, które to litery.
Dokładnie tym są wyrażenia regularne (RegExp): opisem kształtu tekstu. Tak jak sekwencja DNA opisuje wzór, według którego składa się organizm, tak wyrażenie regularne opisuje wzór, według którego składa się fragment tekstu. Ty podajesz wzór, a silnik JavaScriptu przegląda za ciebie każdy znak w próbce.
Dwa sposoby zapisania wzorca
Zanim zaczniesz czegokolwiek szukać, musisz mieć obiekt wzorca. W JavaScripcie prowadzą do niego dokładnie dwie drogi - i tylko dwie. Pierwsza to literał, czyli wzorzec wpisany wprost do kodu pomiędzy dwa ukośniki. Druga to konstruktor new RegExp("wzorzec", "flagi"), który buduje ten sam obiekt ze zwykłych napisów.
Zapamiętaj ten podział, bo wokół niego krąży sporo mitów. JSON.parse() i JSON.stringify() przekładają dane pomiędzy tekstem a obiektem i z wyszukiwaniem wzorców nie mają nic wspólnego. document.regex() oraz window.match() w ogóle nie istnieją - document to drzewo strony, window to obiekt globalny przeglądarki i żaden z nich nie tworzy wyrażeń regularnych. Nie istnieją też String.regex() ani Array.match(): napis owszem, ma metodę match(), ale ona wzorca nie tworzy, tylko go przyjmuje jako argument, a tablice metody match() nie mają wcale.
1// Sposób 1: literał - wzorzec wpisany wprost w kod
2const pattern = /ERROR/gi;
3
4console.log(pattern.test("Error: brama sektora 7")); // true
5
6// Sposób 2: konstruktor - wzorzec zbudowany z napisu
7const species = "Velociraptor";
8const dynamicPattern = new RegExp(species, "i");
9
10console.log(dynamicPattern.test("velociraptor w sektorze B")); // true
11
12// Konstruktor przydaje się, gdy wzorzec zależy od danych
13function findSpecies(logLine, name) {
14 return new RegExp(name, "i").test(logLine);
15}
16
17console.log(findSpecies("ALERT: T-Rex opuścił wybieg", "t-rex")); // truePrzyjrzyj się pierwszej linii, bo jej kolejność jest sztywna. Najpierw deklarujesz nazwę: const pattern. Potem otwierasz literał: = /. Dalej wpisujesz treść wzorca: ERROR. Na końcu zamykasz go ukośnikiem i doklejasz flagi: /gi. Całość czyta się jako const pattern = /ERROR/gi. Flagi stoją zawsze za zamykającym ukośnikiem, nigdy przed wzorcem - zapis w rodzaju /gi ERROR/ nie jest niczym szczególnym, to zwykły wzorzec szukający dosłownego ciągu gi ERROR.
Flaga g (global) każe silnikowi zebrać wszystkie wystąpienia zamiast zatrzymać się na pierwszym. Flaga i (ignore case) wyłącza rozróżnianie wielkości liter, dzięki czemu jeden wzorzec złapie ERROR, Error i error. Literał wybieraj zawsze wtedy, gdy wzorzec jest stały - jest krótszy i czytelniejszy. Po konstruktor sięgaj, gdy wzorzec powstaje dopiero w trakcie działania programu, na przykład z nazwy gatunku wpisanej przez operatora w centrum kontroli.
Co która metoda zwraca
Mając wzorzec, trzeba go do czegoś użyć - i tu zaczyna się najczęstsza pomyłka. Cztery podstawowe metody wyglądają w kodzie podobnie, ale każda oddaje coś zupełnie innego. Dr Rex traktuje je jak cztery różne przyrządy laboratoryjne: pierwszy mówi, czy próbka zawiera szukaną sekwencję, drugi wyciąga, jaką, trzeci podmienia ją na inną, a czwarty podaje, gdzie dokładnie leży.
1const log = "2024-06-15 ALERT: awaria zasilania - ERR-503 - sektor 7";
2
3// test() - zwraca wartość boolean
4console.log(/ALERT/.test(log)); // true
5console.log(/BRAK DANYCH/.test(log)); // false
6
7// match() - zwraca tablicę dopasowań
8console.log(log.match(/ERR-\d{3}/)); // ["ERR-503", index: 37, ...]
9
10// replace() - zwraca NOWY napis, oryginał zostaje nietknięty
11console.log(log.replace(/ALERT/, "OSTRZEŻENIE"));
12console.log(log); // wciąż "... ALERT: ..." - napisy są niezmienne
13
14// search() - zwraca indeks pierwszego dopasowania (albo -1)
15console.log(log.search(/ERR-/)); // 37
16console.log(log.search(/RAPTOR/)); // -1Zatrzymaj się przy test(), bo to o nią najczęściej pytają. test() zwraca wartość boolean: true, gdy wzorzec pasuje do tekstu, i false, gdy nie pasuje. Nic więcej. Nie zwraca nowego napisu z podmienionymi dopasowaniami - to robota replace(). Nie zwraca tablicy dopasowanych fragmentów - tym zajmuje się match(). Nie zwraca też indeksu pierwszego dopasowania - od tego jest search(), która przy braku dopasowania oddaje -1. Dlatego test() stawiaj wszędzie tam, gdzie interesuje cię wyłącznie odpowiedź tak albo nie, na przykład w warunku if.
Warto znać jeszcze dwie metody. matchAll() z flagą g zwraca iterator wszystkich dopasowań razem z grupami, a split() potrafi rozciąć napis według wzorca - split(/[\s,]+/) podzieli tekst po dowolnej liczbie spacji i przecinków. Uważaj przy tym na jedną pułapkę: wzorzec z flagą g zapamiętuje pozycję w polu lastIndex, więc powtarzane wywołania test() na tym samym obiekcie potrafią raz zwrócić true, a raz false.
Alfabet wzorca - klasy znaków i kwantyfikatory
Wzorzec /ERROR/ szuka dosłownego napisu i przez to jest bezużyteczny przy kodach błędów, bo każdy z nich ma inne cyfry. Prawdziwa siła wyrażeń regularnych bierze się z klas znaków, czyli symboli opisujących całe kategorie znaków, oraz z kwantyfikatorów mówiących, ile razy dany element ma się powtórzyć.
Trzy klasy zapamiętasz od razu: \d to dowolna cyfra, \w to znak słowny (litera, cyfra albo podkreślenie), a \s to biały znak - spacja, tabulator lub złamanie linii. Ich wielkie odpowiedniki \D, \W i \S znaczą dokładnie odwrotnie. Własną kategorię ułożysz w nawiasach kwadratowych: [A-Z] to wielka litera, [0-9] cyfra, a [^0-9] wszystko oprócz cyfry. Kropka pasuje do dowolnego znaku poza złamaniem linii.
1const record = "DINO_042_REX sektor 7 temperatura 37";
2
3// \d - cyfra, + - jeden lub więcej powtórzeń
4console.log(record.match(/\d+/g)); // ["042", "7", "37"]
5
6// \w - znak słowny (litera, cyfra, podkreślenie)
7console.log(record.match(/\w+/g)); // ["DINO_042_REX", "sektor", "7", "temperatura", "37"]
8
9// [A-Z] - własna klasa znaków, {3} - dokładnie trzy powtórzenia
10console.log("ERR-503 oraz ERR-404".match(/[A-Z]{3}-\d{3}/g)); // ["ERR-503", "ERR-404"]
11
12// ? - zero lub jeden raz, * - zero lub więcej razy
13console.log(/sektor ?7/.test("sektor7")); // true - spacja nieobowiązkowa
14console.log(/RAPTOR-0*42/.test("RAPTOR-42")); // true - zera wiodące nieobowiązkoweKwantyfikator odnosi się zawsze do elementu stojącego bezpośrednio przed nim. + żąda co najmniej jednego powtórzenia, * dopuszcza również zero, ? oznacza element opcjonalny, a klamry podają liczbę wprost: {3} to dokładnie trzy razy, {2,4} od dwóch do czterech, {3,} co najmniej trzy. Dzięki temu /[A-Z]{3}-\d{3}/ czyta się jak zdanie: trzy wielkie litery, myślnik, trzy cyfry - czyli dokładnie ten kształt kodu błędu, którego szukał dr Rex.
Zwróć uwagę na różnicę między \d+ a \d{3}. Pierwszy zapis złapie ciąg cyfr dowolnej długości, drugi upiera się przy trzech. W dzienniku parku ta drobna różnica decyduje o tym, czy do wyników wpadnie wyłącznie trzycyfrowy kod awarii, czy również numer sektora i numer wersji oprogramowania.
Kotwice - w którym miejscu tekstu
Klasy i kwantyfikatory mówią, co ma pasować. Kotwice mówią, gdzie. Daszek ^ oznacza początek tekstu, dolar $ jego koniec, a \b granicę słowa, czyli miejsce styku znaku słownego z niesłownym. Bez kotwic wzorzec zadowoli się dopasowaniem gdziekolwiek w środku, a przy sprawdzaniu formatu to zwykle za mało: identyfikator okazu ma być całą wartością, a nie jej przypadkowym fragmentem.
1// Bez kotwic wzorzec zadowoli się fragmentem
2console.log(/[A-Z]{3}-\d{3}/.test("śmieci REX-001 śmieci")); // true
3
4// ^ oraz $ wymuszają dopasowanie całego napisu
5const dinoId = /^[A-Z]{3}-\d{3}$/;
6
7console.log(dinoId.test("REX-001")); // true
8console.log(dinoId.test("śmieci REX-001 śmieci")); // false
9console.log(dinoId.test("REX-01")); // false - za mało cyfr
10console.log(dinoId.test("rex-001")); // false - małe litery
11
12// \b - granica słowa
13console.log(/\bRex\b/.test("T-Rex opuścił wybieg")); // true
14console.log(/\bRex\b/.test("Rexona w laboratorium")); // false^[A-Z]{3}-\d{3}$ to gotowy walidator identyfikatora: cała wartość musi składać się z trzech wielkich liter, myślnika i trzech cyfr, bo daszek i dolar nie zostawiają miejsca na nic więcej. Usuń kotwice, a ten sam wzorzec przepuści każdy śmieciowy napis, w którym gdzieś w środku siedzi poprawny kod.
Przy \b pamiętaj, że jest to granica, a nie znak - nie zajmuje w tekście żadnego miejsca. Właśnie dlatego \bRex\b odnajduje Rex w T-Rex (myślnik jest znakiem niesłownym), ale odrzuca Rexona, gdzie zaraz po Rex idzie kolejna litera. Gdy pracujesz z dziennikiem złożonym z wielu linii, dołóż flagę m (multiline): wtedy ^ i $ zaczną oznaczać początek i koniec każdej linii z osobna, a nie całego tekstu.
Grupy - wycinanie fragmentów
Sprawdzenie, czy linia dziennika pasuje do wzorca, to dopiero połowa roboty. Dr Rex potrzebuje z niej danych: osobno daty, osobno kodu błędu, osobno numeru sektora. Służą do tego grupy przechwytujące, czyli fragmenty wzorca ujęte w nawiasy okrągłe. Każda taka grupa trafia do wyniku match() jako kolejny element tablicy - pod indeksem zero leży zawsze całe dopasowanie, a od jedynki idą grupy w kolejności otwierających nawiasów.
Nawiasy przydają się też do alternatywy. Pionowa kreska | znaczy "albo", więc (ALERT|ERROR|FATAL) dopasuje dowolne z trzech słów. Jeżeli nawias jest ci potrzebny wyłącznie do zgrupowania, a nie do zapamiętania wyniku, użyj grupy nieprzechwytującej (?:...) - nie zaśmieca ona tablicy wyników.
1const line = "2024-06-15 ERROR ERR-503 sektor 7";
2
3// Grupy przechwytujące - numerowane od 1
4const numbered = /(\d{4}-\d{2}-\d{2}) (?:ALERT|ERROR|FATAL) (ERR-\d{3}) sektor (\d+)/;
5const parts = line.match(numbered);
6
7console.log(parts[0]); // "2024-06-15 ERROR ERR-503 sektor 7" - całe dopasowanie
8console.log(parts[1]); // "2024-06-15"
9console.log(parts[2]); // "ERR-503"
10console.log(parts[3]); // "7"
11
12// Grupy nazwane - czytelniejsze niż indeksy
13const named = /(?<data>\d{4}-\d{2}-\d{2}) (?:ALERT|ERROR|FATAL) (?<kod>ERR-\d{3}) sektor (?<sektor>\d+)/;
14const fields = line.match(named).groups;
15
16console.log(fields.data); // "2024-06-15"
17console.log(fields.kod); // "ERR-503"
18console.log(fields.sektor); // "7"Zapis (?<nazwa>...) tworzy grupę nazwaną. Zamiast pamiętać, że numer sektora siedzi pod indeksem trzy, sięgasz po match.groups.sektor. Gdy wzorzec urośnie do sześciu czy siedmiu grup, ta zmiana ratuje czytelność kodu, bo dorzucenie jednego nawiasu w środku wzorca nie przesuwa już numeracji wszystkich pozostałych.
Zwróć uwagę, że (?:ALERT|ERROR|FATAL) mimo nawiasu nie zajął żadnego numeru - dlatego kod błędu wylądował pod indeksem drugim, a nie trzecim. Właśnie po to istnieje grupa nieprzechwytująca: grupuje alternatywę, ale nie udaje danych. Jeżeli chcesz przejść po wszystkich liniach dziennika naraz, dodaj flagę g i użyj matchAll() - dostaniesz iterator, w którym każdy element ma własną tablicę i własne pole groups.
Lookahead - warunek, który nic nie zjada
Czasem chcemy dopasować fragment tekstu pod warunkiem, że dalej stoi (albo właśnie nie stoi) coś konkretnego - ale samego warunku nie chcemy wciągać do wyniku. Do tego służy lookahead, czyli podgląd w przód. Zapis (?=...) to lookahead pozytywny: dopasowanie uda się tylko wtedy, gdy zaraz za nim da się dopasować podany wzorzec. Zapis (?!...) to lookahead negatywny: dopasowanie uda się tylko wtedy, gdy podanego wzorca w tym miejscu nie ma.
Kluczowa różnica wobec zwykłej grupy jest taka, że lookahead nie konsumuje znaków. Sprawdza teren przed sobą i cofa się na to samo miejsce, więc sprawdzony fragment nie wchodzi do wyniku, a kilka takich warunków można nałożyć jeden po drugim.
1const raport = "SEKTOR-3 OK SEKTOR-7 ALERT SEKTOR-9 OK";
2
3// Lookahead pozytywny - tylko sektory, po których stoi ALERT
4console.log(raport.match(/SEKTOR-\d(?= ALERT)/g)); // ["SEKTOR-7"]
5
6// Lookahead negatywny - wszystkie oprócz tych z ALERT
7console.log(raport.match(/SEKTOR-\d(?! ALERT)/g)); // ["SEKTOR-3", "SEKTOR-9"]
8
9// Kilka warunków naraz - kod dostępu do laboratorium
10const accessCode = /^(?=.*[A-Z])(?=.*\d).{8,}$/;
11
12console.log(accessCode.test("Raptor42Lab")); // true
13console.log(accessCode.test("raptor42lab")); // false - brak wielkiej litery
14console.log(accessCode.test("RaptorLab")); // false - brak cyfry
15console.log(accessCode.test("Rap42")); // false - za krótkiW pierwszym przykładzie wynik zawiera wyłącznie SEKTOR-7, choć wzorzec sprawdzał również słowo ALERT - to właśnie znaczy, że lookahead nie wchodzi do dopasowania. Drugi wzorzec odwraca warunek i zwraca dwa pozostałe sektory.
Najciekawszy jest przykład trzeci. ^(?=.*[A-Z])(?=.*\d).{8,}$ czyta się jako trzy niezależne wymagania nałożone na ten sam napis: gdzieś musi pojawić się wielka litera, gdzieś cyfra, a całość ma mieć co najmniej osiem znaków. Bez lookaheadu trzeba by wypisać wszystkie kolejności, w jakich te elementy mogą wystąpić, a jest ich mnóstwo. Każdy (?=...) startuje od tej samej pozycji, sprawdza swój warunek i oddaje sterowanie następnemu; dopiero .{8,} faktycznie przechodzi przez tekst i zjada znaki.
Praktyka - parser dziennika parku
Czas złożyć wszystko w jedno narzędzie. Dziennik systemowy parku ma stały format: data, poziom zdarzenia, kod błędu i numer sektora. Zbudujemy funkcję, która z surowego tekstu wyciągnie te cztery informacje i zwróci gotową listę obiektów - dokładnie taką, jaką da się posortować, policzyć albo wyświetlić w centrum kontroli. Wzorzec deklarujemy raz, poza funkcją, i korzystamy z flagi g razem z matchAll(), żeby przejść przez wszystkie linie za jednym zamachem.
1const LOG_PATTERN = /(?<data>\d{4}-\d{2}-\d{2}) (?<poziom>ALERT|ERROR|INFO) (?<kod>[A-Z]{3}-\d{3}) sektor (?<sektor>\d+)/g;
2
3function parseLog(text) {
4 return [...text.matchAll(LOG_PATTERN)].map(match => ({
5 data: match.groups.data,
6 poziom: match.groups.poziom,
7 kod: match.groups.kod,
8 sektor: Number(match.groups.sektor)
9 }));
10}
11
12const dziennik = "2024-06-15 INFO SYS-100 sektor 3\n2024-06-15 ALERT PWR-503 sektor 7\n2024-06-16 ERROR FNC-404 sektor 7";
13
14const zdarzenia = parseLog(dziennik);
15
16console.log(zdarzenia.length); // 3
17console.log(zdarzenia[1]);
18// { data: "2024-06-15", poziom: "ALERT", kod: "PWR-503", sektor: 7 }
19
20// Awarie z sektora 7 - już zwykłymi metodami tablicy
21console.log(zdarzenia.filter(e => e.sektor === 7 && e.poziom !== "INFO").length); // 2Wzorzec został tu zapisany jako stała LOG_PATTERN poza funkcją i nie jest to przypadek. Wyrażenie regularne trzeba skompilować, więc budowanie go wewnątrz pętli albo przy każdym wywołaniu funkcji to czysta strata pracy - raz utworzony obiekt spokojnie obsłuży cały dziennik.
Zauważ też, jak grupy nazwane zamieniły surowy tekst w strukturę danych. Po map() mamy zwykłe obiekty i od tej chwili pracujemy narzędziami, które znasz od dawna: filter(), sort(), reduce(). Wyrażenie regularne zrobiło swoją część, czyli przetłumaczyło nieuporządkowany tekst na dane, i dalej nie jest już potrzebne. To dobra reguła na co dzień: regex ma być mostem do danych, a nie miejscem, w którym mieszka cała logika programu.
Podsumowanie
Dr Rex gasi ekran z dziennikiem. Dwadzieścia tysięcy linii, jedna awaria zasilania w siódmym sektorze, kilka sekund pracy silnika wyrażeń regularnych. Oto, co warto zabrać z tego laboratorium:
- Dwa sposoby tworzenia wzorca - literał
/ERROR/gialbo konstruktornew RegExp("ERROR", "gi"). W literale kolejność jest stała: nazwa,= /, treść wzorca, zamykający ukośnik z flagami. - Zwracane wartości -
test()oddajetruealbofalse,match()tablicę dopasowań,replace()nowy napis,search()indeks pierwszego dopasowania. - Budulec wzorca - klasy
\d,\w,\si klasy własne[A-Z]opisują, co ma pasować; kwantyfikatory+,*,?,{n,m}mówią, ile razy; kotwice^,$,\bwskazują miejsce. - Grupy -
(...)wycina fragment,(?:...)tylko grupuje, a(?<nazwa>...)daje dostęp przezmatch.groups.nazwa. - Lookahead -
(?=...)i(?!...)stawiają warunek na dalszy ciąg tekstu, nie zabierając go do wyniku.
Na koniec rada, którą dr Rex powtarza każdemu praktykantowi: wyrażenia regularne są potężne, ale nie są obowiązkowe. Jeżeli szukasz zwykłego, stałego fragmentu tekstu, includes() powie to samo prościej i szybciej. Po regex sięgaj wtedy, gdy naprawdę opisujesz kształt danych, a nie ich treść.
Widzisz błąd w tej lekcji?
Sprawdź się
Odpowiedz na pytania z tej lekcji. Wybierz odpowiedź, a od razu zobaczysz, czy jest poprawna.
1. Jakie są dwa sposoby tworzenia wyrażeń regularnych w JavaScript?
2. Co zwraca metoda test() wyrażenia regularnego?
Zadania praktyczne w grze
- Edytor kodu
Wyciągnij daty, kody błędów i numery sektorów z logów za pomocą RegExp.
- Układanie w poziomie
Ułóż elementy wyrażenia regularnego z flagami: