Tworzenie własnej biblioteki walidacji React: podstawy (część 1)
Opublikowany: 2022-03-10Zawsze uważałem, że biblioteki walidacji formularzy są całkiem fajne. Wiem, to niszowe zainteresowanie — ale tak często ich używamy! Przynajmniej w mojej pracy — większość tego, co robię, to konstruowanie mniej lub bardziej złożonych formularzy z regułami walidacji, które zależą od wcześniejszych wyborów i ścieżek. Zrozumienie, jak działa biblioteka walidacji formularzy, jest najważniejsze.
W zeszłym roku napisałem jedną taką bibliotekę walidacji formularzy. Nazwałem go „Calidation” i możesz przeczytać wprowadzający wpis na blogu tutaj. To dobra biblioteka, która oferuje dużą elastyczność i wykorzystuje nieco inne podejście niż inne dostępne na rynku. Istnieje jednak mnóstwo innych świetnych bibliotek — moja po prostu działała dobrze dla naszych wymagań.
Dzisiaj pokażę Ci, jak napisać własną bibliotekę walidacji dla Reacta. Przejdziemy przez ten proces krok po kroku, a przykłady CodeSandbox znajdziesz w miarę postępów. Pod koniec tego artykułu będziesz wiedział, jak napisać własną bibliotekę walidacji, lub przynajmniej będziesz miał głębsze zrozumienie tego, jak inne biblioteki wdrażają „magię walidacji”.
- Część 1: Podstawy
- Część 2: Funkcje
- Część 3: Doświadczenie
Krok 1: Projektowanie API
Pierwszym krokiem tworzenia dowolnej biblioteki jest zaprojektowanie sposobu jej wykorzystania. Stanowi podstawę dla wielu przyszłych prac i moim zdaniem jest to najważniejsza decyzja, jaką podejmiesz w swojej bibliotece.
Ważne jest, aby utworzyć interfejs API, który jest „łatwy w użyciu”, a jednocześnie wystarczająco elastyczny, aby umożliwić przyszłe ulepszenia i zaawansowane przypadki użycia. Postaramy się osiągnąć oba te cele.
Stworzymy niestandardowy hook, który zaakceptuje pojedynczy obiekt konfiguracyjny. Pozwoli to na przekazywanie przyszłych opcji bez wprowadzania istotnych zmian.
Uwaga o hakach
Hooki to całkiem nowy sposób pisania Reacta. Jeśli pisałeś React w przeszłości, możesz nie rozpoznać kilku z tych pojęć. W takim przypadku prosimy o zapoznanie się z oficjalną dokumentacją. Jest niesamowicie dobrze napisany i zawiera podstawowe informacje, które musisz znać.
Na razie wywołamy nasz niestandardowy hook useValidation
. Jego użycie może wyglądać mniej więcej tak:
const config = { fields: { username: { isRequired: { message: 'Please fill out a username' }, }, password: { isRequired: { message: 'Please fill out a password' }, isMinLength: { value: 6, message: 'Please make it more secure' } } }, onSubmit: e => { /* handle submit */ } }; const { getFieldProps, getFormProps, errors } = useValidation(config);
Obiekt config
akceptuje właściwości fields
, które ustalają reguły walidacji dla każdego pola. Ponadto akceptuje wywołanie zwrotne w momencie przesłania formularza.
Obiekt fields
zawiera klucz dla każdego pola, które chcemy zweryfikować. Każde pole ma swoją własną konfigurację, gdzie każdy klucz jest nazwą walidatora, a każda wartość jest właściwością konfiguracyjną tego walidatora. Innym sposobem napisania tego samego byłoby:
{ fields: { fieldName: { oneValidator: { validatorRule: 'validator value' }, anotherValidator: { errorMessage: 'something is not as it should' } } } }
Nasz hook useValidation
zwróci obiekt z kilkoma właściwościami — getFieldProps
, getFormProps
i errors
. Dwie pierwsze funkcje są tym, co Kent C. Dodds nazywa „pobieraczami właściwości” (zobacz tutaj świetny artykuł na ich temat) i służą do pobierania odpowiednich właściwości dla danego pola formularza lub tagu formularza. Właściwość errors
to obiekt z dowolnymi komunikatami o błędach, kluczowany według pola.
To użycie wyglądałoby tak:
const config = { ... }; // like above const LoginForm = props => { const { getFieldProps, getFormProps, errors } = useValidation(config); return ( <form {...getFormProps()}> <label> Username<br/> <input {...getFieldProps('username')} /> {errors.username && <div className="error">{errors.username}</div>} </label> <label> Password<br/> <input {...getFieldProps('password')} /> {errors.password && <div className="error">{errors.password}</div>} </label> <button type="submit">Submit my form</button> </form> ); };
Wporządku! Więc dopracowaliśmy API.
- Zobacz demo CodeSandbox
Zauważ, że stworzyliśmy również symulowaną implementację haka useValidation
. Na razie jest to po prostu zwracanie obiektu z obiektami i funkcjami, których potrzebujemy, aby się tam znajdowały, więc nie psujemy naszej przykładowej implementacji.
Przechowywanie stanu formularza
Pierwszą rzeczą, którą musimy zrobić, jest przechowywanie całego stanu formularza w naszym niestandardowym haczyku. Musimy pamiętać wartości każdego pola, wszelkie komunikaty o błędach i czy formularz został przesłany. W tym celu użyjemy haka useReducer
, ponieważ zapewnia on największą elastyczność (i mniej schematu). Jeśli kiedykolwiek korzystałeś z Redux, zobaczysz kilka znajomych koncepcji — a jeśli nie, wyjaśnimy to w dalszej części! Zaczniemy od napisania reduktora, który jest przekazywany do haka useReducer
:
const initialState = { values: {}, errors: {}, submitted: false, }; function validationReducer(state, action) { switch(action.type) { case 'change': const values = { ...state.values, ...action.payload }; return { ...state, values, }; case 'submit': return { ...state, submitted: true }; default: throw new Error('Unknown action type'); } }
Co to jest reduktor?
Reduktor to funkcja, która akceptuje obiekt wartości i „akcję” i zwraca rozszerzoną wersję obiektu wartości.
Akcje są zwykłymi obiektami JavaScript z właściwością type
. Używamy instrukcji switch
do obsługi każdego możliwego typu akcji.
„Obiekt wartości” jest często określany jako stan , aw naszym przypadku jest to stan naszej logiki walidacji.
Nasz stan składa się z trzech części danych — values
(bieżących wartości naszych pól formularza), errors
(bieżącego zestawu komunikatów o błędach) oraz flagi isSubmitted
wskazującej, czy nasz formularz został przesłany przynajmniej raz.
Aby przechowywać stan formularza, musimy zaimplementować kilka części naszego haka useValidation
. Kiedy wywołujemy naszą metodę getFieldProps
, musimy zwrócić obiekt z wartością tego pola, procedurę obsługi zmian dla zmiany oraz nazwę właściwości do śledzenia, które pole jest które.
function validationReducer(state, action) { // Like above } const initialState = { /* like above */ }; const useValidation = config => { const [state, dispatch] = useReducer(validationReducer, initialState); return { errors: state.errors, getFormProps: e => {}, getFieldProps: fieldName => ({ onChange: e => { if (!config.fields[fieldName]) { return; } dispatch({ type: 'change', payload: { [fieldName]: e.target.value } }); }, name: fieldName, value: state.values[fieldName], }), }; };
Metoda getFieldProps
zwraca teraz właściwości wymagane dla każdego pola. Po uruchomieniu zdarzenia zmiany upewniamy się, że pole znajduje się w naszej konfiguracji walidacji, a następnie informujemy naszego reduktora, że nastąpiła akcja change
. Reduktor obsłuży zmiany stanu walidacji.
- Zobacz demo CodeSandbox
Walidacja naszego formularza
Nasza biblioteka do walidacji formularzy wygląda dobrze, ale nie robi wiele, jeśli chodzi o walidację naszych wartości formularzy! Naprawmy to.
Będziemy sprawdzać wszystkie pola przy każdym zdarzeniu zmiany. Może to nie brzmieć zbyt wydajnie, ale w rzeczywistych aplikacjach, z którymi się zetknąłem, nie stanowi to problemu.
Uwaga, nie mówimy, że musisz pokazywać każdy błąd przy każdej zmianie. W dalszej części tego artykułu omówimy ponownie, jak wyświetlać błędy tylko wtedy, gdy prześlesz lub opuścisz pole.
Jak wybrać funkcje walidatora
Jeśli chodzi o walidatory, istnieje mnóstwo bibliotek, które implementują wszystkie metody walidacji, jakich kiedykolwiek potrzebujesz. Możesz też napisać własny, jeśli chcesz. To zabawne ćwiczenie!
W tym projekcie użyjemy zestawu walidatorów, które napisałem jakiś czas temu — calidators
. Te walidatory mają następujący interfejs API:
function isRequired(config) { return function(value) { if (value === '') { return config.message; } else { return null; } }; } // or the same, but terser const isRequired = config => value => value === '' ? config.message : null;
Innymi słowy, każdy walidator akceptuje obiekt konfiguracji i zwraca w pełni skonfigurowany walidator. Gdy ta funkcja jest wywoływana z wartością, zwraca właściwość message
, jeśli wartość jest nieprawidłowa, lub null
, jeśli jest poprawna. Możesz zobaczyć, jak niektóre z tych walidatorów są zaimplementowane, patrząc na kod źródłowy.
Aby uzyskać dostęp do tych walidatorów, zainstaluj pakiet calidators
za pomocą npm install calidators
.
Sprawdź poprawność jednego pola
Pamiętasz konfigurację, którą przekazujemy do naszego obiektu useValidation
? To wygląda tak:
{ fields: { username: { isRequired: { message: 'Please fill out a username' }, }, password: { isRequired: { message: 'Please fill out a password' }, isMinLength: { value: 6, message: 'Please make it more secure' } } }, // more stuff }
Aby uprościć naszą implementację, załóżmy, że mamy do sprawdzenia tylko jedno pole. Przejrzymy każdy klucz obiektu konfiguracyjnego pola i uruchomimy walidatory jeden po drugim, aż znajdziemy błąd lub zakończymy walidację.
import * as validators from 'calidators'; function validateField(fieldValue = '', fieldConfig) { for (let validatorName in fieldConfig) { const validatorConfig = fieldConfig[validatorName]; const validator = validators[validatorName]; const configuredValidator = validator(validatorConfig); const errorMessage = configuredValidator(fieldValue); if (errorMessage) { return errorMessage; } } return null; }
Tutaj napisaliśmy funkcję validateField
, która akceptuje wartość do sprawdzenia i konfigurację walidatora dla tego pola. Przechodzimy przez wszystkie walidatory, przekazujemy im konfigurację tego walidatora i uruchamiamy go. Jeśli otrzymamy komunikat o błędzie, pomijamy resztę walidatorów i wracamy. Jeśli nie, spróbujemy następnego walidatora.
Uwaga: w przypadku interfejsów API walidatora
Jeśli wybierzesz różne walidatory z różnymi interfejsami API (jak bardzo popularny validator.js
), ta część kodu może wyglądać nieco inaczej. Jednak ze względu na zwięzłość pozostawiamy tę część ćwiczeniem pozostawionym czytelnikowi.
Uwaga: włączone dla…w pętlach
Nigdy wcześniej nie używany for...in
pętlach? W porządku, to też był mój pierwszy raz! Zasadniczo iteruje po kluczach w obiekcie. Więcej o nich przeczytasz na MDN.
Sprawdź poprawność wszystkich pól
Teraz, gdy sprawdziliśmy już jedno pole, powinniśmy być w stanie sprawdzić poprawność wszystkich pól bez większych problemów.
function validateField(fieldValue = '', fieldConfig) { // as before } function validateFields(fieldValues, fieldConfigs) { const errors = {}; for (let fieldName in fieldConfigs) { const fieldConfig = fieldConfigs[fieldName]; const fieldValue = fieldValues[fieldName]; errors[fieldName] = validateField(fieldValue, fieldConfig); } return errors; }
Napisaliśmy funkcję validateFields
, która akceptuje wszystkie wartości pól i całą konfigurację pola. Przechodzimy w pętli przez każdą nazwę pola w konfiguracji i sprawdzamy poprawność tego pola za pomocą jego obiektu konfiguracji i wartości.
Dalej: Powiedz naszemu reduktorowi
W porządku, więc teraz mamy tę funkcję, która sprawdza wszystkie nasze rzeczy. Wciągnijmy to do reszty naszego kodu!
Najpierw dodamy procedurę obsługi akcji validate
do naszego validationReducer
.
function validationReducer(state, action) { switch (action.type) { case 'change': // as before case 'submit': // as before case 'validate': return { ...state, errors: action.payload }; default: throw new Error('Unknown action type'); } }
Za każdym razem, gdy uruchamiamy akcję validate
, zastępujemy błędy w naszym stanie tym, co zostało przekazane wraz z akcją.
Następnie uruchomimy naszą logikę walidacji z haka useEffect
:
const useValidation = config => { const [state, dispatch] = useReducer(validationReducer, initialState); useEffect(() => { const errors = validateFields(state.fields, config.fields); dispatch({ type: 'validate', payload: errors }); }, [state.fields, config.fields]); return { // as before }; };
Ten haczyk useEffect
działa zawsze, gdy zmieni się nasz state.fields
lub config.fields
, oprócz pierwszego montowania.
Uważaj na błąd
W powyższym kodzie jest bardzo subtelny błąd. Określiliśmy, że nasz hook useEffect
powinien być uruchamiany ponownie tylko wtedy, gdy zmienią się state.fields
lub config.fields
. Okazuje się, że „zmiana” niekoniecznie oznacza zmianę wartości! useEffect
używa Object.is
do zapewnienia równości między obiektami, co z kolei wykorzystuje równość referencji. To znaczy — jeśli przekażesz nowy obiekt o tej samej treści, nie będzie on taki sam (ponieważ sam obiekt jest nowy).
state.fields
są zwracane z useReducer
, co gwarantuje nam tę równość referencji, ale nasza config
jest określona inline w naszym komponencie funkcyjnym. Oznacza to, że obiekt jest tworzony ponownie przy każdym renderowaniu, co z kolei wyzwala powyższy useEffect
!
Aby rozwiązać ten problem, musimy skorzystać z biblioteki use-deep-compare-effect
autorstwa Kent C. Dodds. Instalujesz go za pomocą npm install use-deep-compare-effect
i zastępujesz wywołanie useEffect
tym. Daje to pewność, że wykonujemy głębokie sprawdzenie równości zamiast sprawdzania równości odwołań.
Twój kod będzie teraz wyglądał tak:
import useDeepCompareEffect from 'use-deep-compare-effect'; const useValidation = config => { const [state, dispatch] = useReducer(validationReducer, initialState); useDeepCompareEffect(() => { const errors = validateFields(state.fields, config.fields); dispatch({ type: 'validate', payload: errors }); }, [state.fields, config.fields]); return { // as before }; };
Uwaga dotycząca efektu użytkowania
Okazuje się, useEffect
to całkiem ciekawa funkcja. Dan Abramov napisał naprawdę ładny, długi artykuł o zawiłościach useEffect
, jeśli chcesz dowiedzieć się wszystkiego o tym haczyku.
Teraz wszystko zaczyna wyglądać jak biblioteka walidacyjna!
- Zobacz demo CodeSandbox
Obsługa przesyłania formularza
Ostatnim elementem naszej podstawowej biblioteki walidacji formularzy jest obsługa tego, co dzieje się po przesłaniu formularza. W tej chwili ponownie ładuje stronę i nic się nie dzieje. To nie jest optymalne. Chcemy zapobiec domyślnemu zachowaniu przeglądarki, jeśli chodzi o formularze, i zamiast tego obsługiwać je samodzielnie. Umieszczamy tę logikę wewnątrz funkcji pobierającej prop getFormProps
:
const useValidation = config => { const [state, dispatch] = useReducer(validationReducer, initialState); // as before return { getFormProps: () => ({ onSubmit: e => { e.preventDefault(); dispatch({ type: 'submit' }); if (config.onSubmit) { config.onSubmit(state); } }, }), // as before }; };
Zmieniamy naszą funkcję getFormProps
, aby zwracała funkcję onSubmit
, która jest wyzwalana za każdym razem, gdy wyzwalane jest zdarzenie submit
DOM. Zapobiegamy domyślnemu zachowaniu przeglądarki, wysyłamy akcję informującą nasz reduktor, że przesłaliśmy, i wywołujemy podane wywołanie zwrotne onSubmit
z całym stanem — jeśli jest podany.
Streszczenie
Byli tam! Stworzyliśmy prostą, użyteczną i całkiem fajną bibliotekę walidacji. Jednak wciąż jest mnóstwo pracy do wykonania, zanim będziemy mogli zdominować interweby.
- Część 1: Podstawy
- Część 2: Funkcje
- Część 3: Doświadczenie