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.
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.
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// Sposob 1: literal - wzorzec wpisany wprost w kod
2const pattern = /ERROR/gi;
3
4console.log(pattern.test("Error: brama sektora 7")); // true
5
6// Sposob 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 sie, gdy wzorzec zalezy od danych
13function findSpecies(logLine, name) {
14 return new RegExp(name, "i").test(logLine);
15}
16
17console.log(findSpecies("ALERT: T-Rex opuscil 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.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 wartosc boolean
4console.log(/ALERT/.test(log)); // true
5console.log(/BRAK DANYCH/.test(log)); // false
6
7// match() - zwraca tablice dopasowan
8console.log(log.match(/ERR-\d{3}/)); // ["ERR-503", index: 37, ...]
9
10// replace() - zwraca NOWY napis, oryginal zostaje nietkniety
11console.log(log.replace(/ALERT/, "OSTRZEZENIE"));
12console.log(log); // wciaz "... ALERT: ..." - napisy sa 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.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 wiecej powtorzen
4console.log(record.match(/\d+/g)); // ["042", "7", "37"]
5
6// \w - znak slowny (litera, cyfra, podkreslenie)
7console.log(record.match(/\w+/g)); // ["DINO_042_REX", "sektor", "7", "temperatura", "37"]
8
9// [A-Z] - wlasna klasa znakow, {3} - dokladnie trzy powtorzenia
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 wiecej razy
13console.log(/sektor ?7/.test("sektor7")); // true - spacja nieobowiazkowa
14console.log(/RAPTOR-0*42/.test("RAPTOR-42")); // true - zera wiodace nieobowiazkoweKwantyfikator 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.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 sie fragmentem
2console.log(/[A-Z]{3}-\d{3}/.test("smieci REX-001 smieci")); // true
3
4// ^ oraz $ wymuszaja dopasowanie calego napisu
5const dinoId = /^[A-Z]{3}-\d{3}$/;
6
7console.log(dinoId.test("REX-001")); // true
8console.log(dinoId.test("smieci REX-001 smieci")); // false
9console.log(dinoId.test("REX-01")); // false - za malo cyfr
10console.log(dinoId.test("rex-001")); // false - male litery
11
12// \b - granica slowa
13console.log(/\bRex\b/.test("T-Rex opuscil 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.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 przechwytujace - 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" - cale dopasowanie
8console.log(parts[1]); // "2024-06-15"
9console.log(parts[2]); // "ERR-503"
10console.log(parts[3]); // "7"
11
12// Grupy nazwane - czytelniejsze niz 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.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 ktorych stoi ALERT
4console.log(raport.match(/SEKTOR-\d(?= ALERT)/g)); // ["SEKTOR-7"]
5
6// Lookahead negatywny - wszystkie oprocz tych z ALERT
7console.log(raport.match(/SEKTOR-\d(?! ALERT)/g)); // ["SEKTOR-3", "SEKTOR-9"]
8
9// Kilka warunkow naraz - kod dostepu 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 krotkiW 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.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 - juz zwyklymi 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.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:
/ERROR/gi albo konstruktor new RegExp("ERROR", "gi"). W literale kolejność jest stała: nazwa, = /, treść wzorca, zamykający ukośnik z flagami.test() oddaje true albo false, match() tablicę dopasowań, replace() nowy napis, search() indeks pierwszego dopasowania.\d, \w, \s i klasy własne [A-Z] opisują, co ma pasować; kwantyfikatory +, *, ?, {n,m} mówią, ile razy; kotwice ^, $, \b wskazują miejsce.(...) wycina fragment, (?:...) tylko grupuje, a (?<nazwa>...) daje dostęp przez match.groups.nazwa.(?=...) 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ść.