Uwaga! Trwają prace nad nową wersją serwisu. Mogą występować przejściowe problemy z jego funkcjonowaniem. Przepraszam za niedogodności!

Walidacja formularza w JavaScript

Stwórz elastyczne i reużywalne rozwiązanie

W programowaniu zwykle zależy nam na optymalizacji rozwiązań. Im bardziej są one elastyczne i w im większej ilości projektów możemy użyć ich ponownie – tym lepiej! Dowiedz się, jak stworzyć uniwersalną walidację formularza z pomocą HTML-a i JavaScriptu.

Spis treści

 Czym jest walidacja formularzy w JavaScript

Walidacja formularza to sprawdzenie poprawności wprowadzonych przez użytkownika danych. Możemy wykonać ją zarówno po stronie front endu, jak i back endu, a najlepiej robić to po obu stronach – bo po co marnować czas i zasoby na przesyłanie nieprawidłowych danych na serwer tylko po to, by za chwilę prosić użytkownika o ich poprawienie?

W walidacji formularzy pomagają nam także przeglądarki. Są one coraz bardziej nowoczesne i potrafią zweryfikować chociażby poprawność adresu e-mail. Jednak póki istnieją starsze lub mniej popularne przeglądarki, lepiej nie zdawać się jedynie na ich zabezpieczenia.

Walidacja za pomocą JavaScriptu polega na sprawdzeniu wartości pobranych z pól formularza. Weryfikujemy zatem, czy na przykład:

  • wymagane pola nie są puste
  • imię i nazwisko nie zawierają cyfr i znaków specjalnych
  • format kodu pocztowego wpisuje się w konkretny wzór.

Póki wprowadzone dane nie są poprawne, nie wykonujemy akcji wysłania formularza (nie niepokoimy więc back endu).

 Stworzenie formularza HTML

Kod całego rozwiązania przed refaktoryzacją znajdziesz na repozytorium na GitHubie. Jego działanie możesz podejrzeć także na żywo dzięki GitHub Pages. Zatem jeśli chcesz, możesz od razu sforkować repozytorium i testować prezentowany poniżej kod.

Nasz formularz HTML będzie przyjmował polskie dane adresowe. Ponieważ w tym artykule chcę przedstawić przede wszystkim ideę optymalizacji pracy z formularzami, nie będę tworzył skomplikowanych rozwiązań, by nie dezorientować początkującego odbiorcy. W tym miejscu zachęcam Cię jednak do poszerzania wiedzy z wyrażeń regularnych w JavaSripcie – z pewnością Ci się ona przyda!

<form action="" method="post" novalidate>
    <ul class="messages"></ul>
    <div>
        <label>
            Imię
            <input name="firstName" required />
        </label>
    </div>
    <div>
        <label>
            Nazwisko
            <input name="lastName" required />
        </label>
    </div>
    <div>
        <label> Ulica <input name="street" required /> </label>
        <label>
            Numer budunku
            <input name="houseNumber" type="number" required />
        </label>
        <label>
            Numer mieszkania <input name="flatNumber" type="number" />
        </label>
    </div>
    <div>
        <label>
            Kod pocztowy
            <input name="zip" pattern="^[0-9]{2}-[0-9]{3}$" required />
        </label>
        <label>
            Miejscowość
            <input name="city" required />
        </label>
    </div>
    <div>
        <label>
            Województwo
            <select name="voivodeship" required>
                <option></option>
                <option>dolnośląskie</option>
                <option>kujawsko-pomorskie</option>
                <!-- (...) -->
                <option>wielkopolskie</option>
                <option>zachodniopomorskie</option>
            </select>
        </label>
    </div>
    <div><button type="submit">Wyślij</button></div>
</form>

Atrybut novalidate pozwala na zablokowanie przeglądarce możliwości walidacji pól. Dzięki temu będziemy mogli testować nasze rozwiązanie bez irytujących okienek. Jeśli formularz przekazywalibyśmy już na produkcję, powinniśmy ten atrybut usunąć (w końcu jeśli przeglądarka może nas wspomóc, to dlaczego z tego nie skorzystać?).

 Obsługa formularza w JavaScript – wersja mało elastyczna

Nic dziwnego, że na początku nauki programowania, gdy nie poruszamy się swobodnie po możliwościach, jakie daje JavaScript, korzystamy z najprostszych, choć niekoniecznie elastycznych rozwiązań.

Poniżej omówię kod, który mogłaby stworzyć osoba początkująca. Później zajmiemy się jego refaktoryzacją.

 Wyszukanie potrzebnych elementów DOM

// czekam na wczytanie kodu HTML
document.addEventListener('DOMContentLoaded', init);

function init() {
    const formEl = document.querySelector('form');
    const ulEl = document.querySelector('ul');

    // sprawdzam, czy formularz został wyszukany i dopiero przypisuję nasłuchiwanie zdarzenia submit
    if (formEl) {
        formEl.addEventListener('submit', handleSubmit);
    }
    // (...)
}

Najpierw korzystamy z wydarzenia DOMContentLoaded, aby upewnić się, że rozpoczniemy wyszukiwanie elementów DOM dopiero wtedy, gdy kod HTML zostanie w pełni wczytany.

Następnie wyszukujemy formularz form oraz listę ul, która posłuży nam do wyświetlania użytkownikowi treści błędów. Aby ustrzec się przed errorem Uncaught TypeError: Cannot read properties of null (reading 'addEventListener'), za pomocą instrukcji warunkowej sprawdzamy, czy formularz został znaleziony. Dopiero wtedy dodajemy do niego nasłuchiwanie na zdarzenie submit i funkcję handleSubmit, która to zdarzenie obsłuży.

 Zatrzymanie akcji wysyłania formularza i obsługa błędów

Przyjrzyjmy się działaniu funkcji handleSubmit jeszcze bez zagłębiania się w logikę walidacji formularza.

function handleSubmit(e) {
    // blokuję automatyczne wysłanie formularza
    e.preventDefault();

    const errors = [];
    
    //  (...)

    // czyszczę listę błędów
    ulEl.innerHTML = '';
    if (errors.length === 0) {
        alert('Dane zostały wypełnione prawidłowo!');
        
        // czyszczę pola po prawidłowym wypełnieniu formularza
        Array.from(formEl.elements).forEach(function(el) {
            el.value = ''
        })
    } else {
        errors.forEach(function (text) {
            const liEl = document.createElement('li');
            liEl.innerText = text;

            ulEl.appendChild(liEl);
        });
    }
}

Aby nasz formularz nie był wysyłany po każdym kliknięciu przycisku submit (co spowoduje u nas przeładowanie strony), blokujemy tę akcję dzięki metodzie .preventDefault(). Od razu też tworzymy sobie tablicę errors, do której wrzucimy informacje o błędach.

Na końcu, zanim treści naszych błędów trafią do ul, czyścimy jej poprzednią zawartość: ulEl.innerHTML = ''. W przeciwnym razie po każdym zatwierdzeniu formularza treści błędów nie będą znikać (nawet jeśli pola zostaną uzupełnione poprawnie). Możesz zakomentować tę linię kodu i sprawdzić, jaki będzie efekt.

Następnie mamy instrukcję warunkową if-else, która obsługuje dwa scenariusze:

  • gdy błędów nie ma, informujemy użytkownika o poprawności wypełnionych danych i czyścimy pola formularza,
  • w przeciwnym razie dla każdego błędu umieszczonego w tablicy errors tworzymy nowy element listy (li) i dodajemy go do rodzica ul, by mógł zostać wyświetlony użytkownikowi.

 Sprawdzenie poprawności wprowadzonych danych

Przed refaktoryzacją poprawność danych z pól formularza sprawdzamy za pomocą dużej liczby warunków:

if(firstNameEl.value.length === 0) {
    errors.push('Dane w polu Imię są niepoprawne!');
}

if(lastNameEl.value.length === 0) {
    errors.push('Dane w polu Nazwisko są niepoprawne!');
}

if(streetEl.value.length === 0) {
    errors.push('Dane w polu Ulica są niepoprawne!');
}

if(houseNumberEl.value.length === 0 || Number.isNaN(houseNumberEl.value)) {
    errors.push('Dane w polu Numer budynku są niepoprawne!');
}

if(Number.isNaN(Number(flatNumberEl.value))) {
    errors.push('Dane w polu Numer mieszkania są niepoprawne!');
}

if(zipEl.value.length === 0 || !/^[0-9]{2}-[0-9]{3}$/.test(zipEl.value)) {
    errors.push('Dane w polu Kod pocztowy są niepoprawne!');
}

if(cityEl.value.length === 0) {
    errors.push('Dane w polu Miasto są niepoprawne!');
}

if(voivodeshipEl.value.length === 0) {
    errors.push('Dane w polu Województwo są niepoprawne!');
}

Jeśli warunek nie zostaje spełniony (np. pole jest puste; wartość nie jest liczbą, choć powinna; wartość nie wpisuje się w podany pattern), to do tablicy errors dodajemy treść błędu, który po zatwierdzeniu formularza wyświetlamy użytkownikowi.

Powyższe rozwiązanie działa, lecz ma pewien minus: wszelkie zmiany wiążą się ze sporą ingerencją w kod JavaScript. Przykładowo:

  • Dodanie nowego pola formularza wiążę się ze stworzeniem nowej zmiennej i nowego warunku if. Może to wcale nie być takie proste, jeśli pole ma więcej „obostrzeń”, jak np. pole numeru budynku (musi ono zostać wypełnione i być liczbą).
  • Modyfikacja właściwości pola formularza zmusza nas do wyszukania odpowiedniego warunku if i dodania/usunięcia fragmentów kodu, np. gdyby województwo nie było wymagane, musielibyśmy usunąć z instrukcji if fragment sprawdzający długość stringa.

 Obsługa formularza w JavaScript – refaktoryzacja

 Informacja o polach formularza w obiektach JavaScript

Użycie tablicy pozwala zgromadzić dane w jednym miejscu i swobodnie po nich iterować (my robimy to za pomocą metody .forEach()). Jeśli dodatkowo tymi danymi są obiekty z konkretnymi właściwościami, bez problemu odwołamy się do wybranych wartości.

Wykorzystamy to, by usprawnić walidację naszego formularza. Wszystkie informacje o polach zgromadzimy w tablicy fields. W tym momencie możesz zacząć śledzić kod z repozytorium po refaktoryzacji.

const fields = [
    { name: 'firstName', label: 'Imię', required: true },
    { name: 'lastName', label: 'Nazwisko', required: true },
    { name: 'street', label: 'Ulica', required: true },
    {
        name: 'houseNumber',
        label: 'Numer budynku',
        type: 'number',
        required: true,
    },
    { name: 'flatNumber', label: 'Numer mieszkania', type: 'number' },
    {
        name: 'zip',
        label: 'Kod pocztowy',
        pattern: '^[0-9]{2}-[0-9]{3}$',
        required: true,
    },
    { name: 'city', label: 'Miasto', required: true },
    { name: 'voivodeship', label: 'Województwo', required: true }
];
  • Nazwę pola, czyli name, wykorzystamy do odwołania się do elementu formularza.
  • Etykieta label przyda się nam przy generowaniu treści błędu.
  • Właściwość required zadecyduje o tym, czy pole jest wymagane, czy nie.
  • Dzięki type będziemy mogli sprawdzić, czy wprowadzona wartość jest wartością danego typu.
  • Pod kluczem pattern przechowywać będziemy wyrażenie regularne, względem którego przetestujemy wartość pola.

 Instrukcje warunkowe w walidacji formularza – zmiana podejścia

Być może widzisz już, że część właściwości obiektów się powiela. Większość pól jest wymagana (właściwość required), dwa są typu number, a (jak na razie) jedno musi mieć wartość zgodną ze wzorem (pattern).

Zamiast więc – jak do tej pory – tworzyć instrukcje warunkowe dla każdego pola, stworzymy je dla każdej z wyżej wymienionych właściwości. Zobacz: z 8 instrukcji warunkowych zrobiły nam się 3! Nie potrzebujemy już także tych wszystkich zmiennych, które do tej pory przechowywały pola formularza (firstNameEl, lastNameEl, streetEl itd.).

fields.forEach(function (field) {
    // wykorzystuję właściwość obiektu o nazwie name, by pobrać wartość konkretnego elementu formularza
    const value = formEl.elements[field.name].value;

    if (field.required) {
        if (value.length === 0) {
            errors.push('Dane w polu ' + field.label + ' są wymagane.');
        }
    }

    if (field.type === 'number') {
        if (Number.isNaN(Number(value))) {
            errors.push(
                'Dane w polu ' + field.label + ' muszą być liczbą.'
            );
        }
    }

    if (field.pattern) {
        const reg = new RegExp(field.pattern);
        if (!reg.test(value)) {
            errors.push(
                'Dane w polu ' +
                    field.label +
                    ' zawierają niedozwolone znaki lub nie są zgodne z przyjętym w Polsce wzorem.'
            );
        }
    }
});

Co więcej, jesteśmy teraz w stanie dać użytkownikowi bardziej precyzyjny komunikat! Wcześniej wiązałoby się to z dodaniem kolejnych ifów, np. jeśli pole Numer domu nie jest wypełnione, wyświetl: „Uzupełnij pole Numer domu”; jeśli pole Numer domu nie jest liczbą, wyświetl: „Pole Numer domu musi być liczbą”.

Naszą tablicę fields możemy wykorzystać też przy czyszczeniu pól formularza po wysyłce danych. Do tej pory zastępowaliśmy pustym stringiem wartości wszystkich elementów formularza – istniało więc ryzyko, że wyczyścimy wartość elementu, która akurat wyczyszczona być nie miała. Teraz czyścimy tylko te pola, które znajdują się w tablicy fields. W razie potrzeby moglibyśmy utworzyć dodatkową właściwość w obiektach, która oznaczałaby, że pole ma zostać niewyczyszczone, np. clear: true. Możesz to zrobić jako zadanie dodatkowe 🙂

// czyszczę listę błędów
ulEl.innerHTML = '';
if (errors.length === 0) {
    alert('Dane zostały wypełnione prawidłowo!');
    
    // czyszczę pola po prawidłowym wypełnieniu formularza
    fields.forEach(function(el) {
        formEl[el.name].value = ''
    })
} else {
    errors.forEach(function (text) {
        const liEl = document.createElement('li');
        liEl.innerText = text;

        ulEl.appendChild(liEl);
    });
}

 Rozszerzenie formularza – nowe warunki i nowe pola

Czas przedstawić dowód na to, że tak skonstruowana walidacja formularza jest wygodniejszym i bardziej elastycznym rozwiązaniem!

Nowe warunki walidacji pól

Załóżmy, że postanowiliśmy lepiej zabezpieczyć pola imienia, nazwiska i miasta za pomocą wyrażenia regularnego, które akceptuje tylko litery, spacje, dywizy i półpauzy. Przy pierwotnym rozwiązaniu, musielibyśmy utworzyć/zmodyfikować aż 3 instrukcje warunkowe. Teraz wystarczy dodać właściwość pattern do odpowiednich obiektów i gotowe. Nie musieliśmy nawet modyfikować instrukcji warunkowych!

const fields = [
    { name: 'firstName', label: 'Imię', required: true, pattern: '^[a-zA-Z –-]+$'},
    { name: 'lastName', label: 'Nazwisko', required: true, pattern: '^[a-zA-Z –-]+$' },
    // (...)
    { name: 'city', label: 'Miasto', required: true, pattern: '^[a-zA-Z –-]+$' },
    // (...)
];

Nowe pole formularza

Od użytkownika chcemy pobierać numer telefonu (dla uproszczenia pomińmy telefony stacjonarne). Jest to polski numer, więc przyjmujemy, że numer kierunkowy jest znany i nie uwzględniamy go w naszych „obostrzeniach”. Będziemy sprawdzać, czy wprowadzona wartość jest typu number oraz czy numer składa się z dokładnie 9 cyfr.

Uzupełniamy formularz HTML o nowe pole, a do tablicy fields w pliku app.js dodajemy kolejny obiekt:

<form action="" method="post" novalidate>
    <ul class="messages"></ul>
    <!-- (...) -->
    <label>
        Numer telefonu komórkowego
        <input
            name="mobileNumber"
            type="number"
            pattern="^[1-9]{9}$"
            required
        />
    </label>
    <div><button type="submit">Wyślij</button></div>
</form>
const fields = [
    // (...)
    {
        name: 'mobileNumber',
        label: 'Numer telefonu komórkowego',
        type: 'number',
        pattern: '^[1-9]{9}$',
        required: true,
    },
];

Walidacja przebiega prawidłowo – a nawet nie dodaliśmy ani jednej instrukcji warunkowej!

 Propozycje dalszej optymalizacji

 Destrukturyzacja obiektu i wartości domyślne

Destrukturyzacja obiektu

Zamiast za każdym razem w metodzie .forEach() odwoływać się do wartości właściwości obiektu field za pomocą zapisu z kropką (np. field.required, field.type), możemy skorzystać z destrukturyzacji, która pojawiła się w wersji ES6 JavaScriptu.

Pozwala ona stworzyć zmienne przechowujące wartości obiektu.

fields.forEach(function (field) {
    const { name, label, required, type, pattern } = field;

    // wykorzystuję właściwość obiektu o nazwie name, by pobrać wartość konkretnego elementu formularza
    const value = formEl.elements[name].value;

    if (required) {
        if (value.length === 0) {
            errors.push('Dane w polu ' + label + ' są wymagane.');
        }
    }

    if (type === 'number') {
        if (Number.isNaN(Number(value))) {
            errors.push(
                'Dane w polu ' + label + ' muszą być liczbą.'
            );
        }
    }

    if (pattern) {
        const reg = new RegExp(pattern);
        if (!reg.test(value)) {
            errors.push(
                'Dane w polu ' +
                    label +
                    ' zawierają niedozwolone znaki lub nie są zgodne z przyjętym w Polsce wzorem.'
            );
        }
    }
});

W ten sposób otrzymaliśmy zmienne – name, label, required, type, pattern – które przechowują informacje z danego obiektu. Weźmy na przykład obiekt z numerem budynku (houseNumber). Dla niego wartość ze zmiennej name to 'houseNumber', label to 'Numer budynku', required to true, ze zmiennej type to 'number', a ze zmiennej pattern to undefined, ponieważ obiekt nie zawiera takiej właściwości.

Wartości domyślne w destrukturyzacji

Możemy jeszcze bardziej usprawnić nasz kod. Zauważ, że obiekty w tablicy fields nie zawsze mają podane wszystkie właściwości: np. imię i nazwisko nie mają typu, ulica nie ma patternu, a numer mieszkania nie jest wymagany (i nawet nie ustawiliśmy required na false).

Jeśli będziemy rozwijać projekt, możemy napotkać sytuację, w której te właściwości jednak będą nam potrzebne. Przykładowo jeśli teraz chcielibyśmy w jakiś sposób wyróżnić pola typu text, nie wystarczy prosty warunek if( type === 'text' ), ponieważ nasze obiekty nie mają właściwości z taką wartością. Czy musimy zatem wszędzie ją dopisać? Nie. Z pomocą przychodzą nam wartości domyślne:

const {name, label, required = false, type = 'text', pattern = null} = field;

Dzięki temu wszystkie nasze obiekty, które nie posiadają którejś z powyższych właściwości, otrzymają przypisaną do zmiennej wartość. Przykładowo w przypadku obiektu z numerem mieszkania będzie to wyglądać tak:

// { name: 'flatNumber', label: 'Numer mieszkania', type: 'number' }

const {name, label, required = false, type = 'text', pattern = null} = field;

console.log(name) // 'flatNumber'

console.log(label) // 'Numer mieszkania'

console.log(required) // false

console.log(type) // 'number'

console.log(pattern) // null

 Automatyczne generowanie formularza JavaScript

Być może poczułeś pewien dyskomfort, gdy przy tworzeniu nowego pola formularza z numerem telefonu, musiałeś to zrobić zarówno w pliku HTML, jak i JS. Wpisywanie identycznych danych w obu miejscach nie
tylko zabiera nasz czas, ale tworzy też pole do powstawania błędów (choćby literówek!).

Czy wiesz, że mając naszą tablicę fields, możemy na podstawie zawartych w niej obiektów wygenerować formularz HTML? Dzięki temu dane będziemy aktualizować tylko w jednym miejscu! W tym artykule nie będę już tego prezentował, lecz przygotowałem dla Ciebie zadanie dodatkowe ze wskazówkami:

// ZADANIE DODATKOWE: wygeneruj formularz HTML na podstawie kodu tablicy obiektów: fields

    // utwórz element <form>
    // ustaw na nim nasłuchiwanie na zdarzenie submit i dodaj funkcję, która to zdarzenie obsłuży (możesz wykorzystać naszą funkcję handleSubmit)
    fields.forEach(function (field) {
        // Dla każdego elementu tablicy (field):
        // utwórz element <label>
        // dodaj do <label> innerText z właściwości name
        // utwórz element <input>
        // dodaj do <input> atrybuty z odpowiednimi wartościami (reszta właściwości obiektu)
        // dodaj <input> do jego rodzica: <label>
        // nieobowiązkowo: stwórz <div> i wstaw tam <label> jako dziecko

        // dodaj <div> lub <label> (zależy, na co się zdecydowałeś) do rodzica: <form>
    })
    // dodaj element <form> do drzewa DOM

Możesz też pobawić się walidacją obecnych pól i wyrażeniami regularnymi. Przykładowo numer domu często zawiera także litery, ukośniki czy dywizy. Czy potrafisz zmienić walidację tak, by znaki te były dopuszczalne?

 Możliwość przeniesienia rozwiązania do innego projektu

Jeśli jesteś na etapie nauki klas w JavaScripcie (a już w ogóle, jeśli znane są Ci hooki w Reakcie) lub tworzyłeś już własne helpery – czyli pliki JS zawierające np. uniwersalne funkcje, które importujesz do miejsc ich wykorzystania (dzięki czemu nie powielasz kodu) – to być może w naszym rozwiązaniu dla walidacji formularza widzisz pewien potencjał.

I on tam naprawdę drzemie. Uczynienie z naszego rozwiązania samodzielnego modułu pozwoli na ponowne wykorzystanie go w innych projektach. Zresztą już teraz przeniesienie kodu nie jest dużym problemem!

Przypomnienie: kod rozwiązania po refaktoryzacji znajdziesz na repozytorium na GitHubie, a działanie na żywo podejrzysz dzięki GitHub Pages.

 

Grupowanie danych, dbanie o to, by niepotrzebnie nie powielać kodu, i tworzenie uniwersalnych rozwiązań to codzienność w pracy programisty. Jeśli zainteresował Cię temat obiektów oraz ograniczania z ich pomocą liczby instrukcji warunkowych, to zapraszam Cię do lektury artykułu „Mniej instrukcji warunkowych”, w którym przedstawiam to zagadnienie na przykładzie prostej gry dla dzieci i kalkulatora JavaScript.

Udostępnij ten artykuł:

Mentoring to efektywna nauka pod okiem doświadczonej osoby, która:

  • przekazuje Ci swoją wiedzę i nadzoruje Twoje postępy w zdobywaniu umiejętności,
  • uczy Cię dobrych praktyk i wyłapuje złe nawyki,
  • wspiera Twój rozwój i zwiększa zaangażowanie w naukę.