Creazione della propria libreria di convalida della reazione: le basi (parte 1)

Pubblicato: 2022-03-10
Riassunto veloce ↬ Vi siete mai chiesti come funzionano le librerie di validazione? Questo articolo ti spiegherà come creare la tua libreria di convalida per React passo dopo passo. La parte successiva aggiungerà alcune funzionalità più avanzate e la parte finale si concentrerà sul miglioramento dell'esperienza degli sviluppatori.

Ho sempre pensato che le librerie di convalida dei moduli fossero piuttosto interessanti. Lo so, è un interesse di nicchia da avere, ma li usiamo così tanto! Almeno nel mio lavoro, la maggior parte di quello che faccio è costruire moduli più o meno complessi con regole di convalida che dipendono da scelte e percorsi precedenti. Capire come funzionerebbe una libreria di convalida dei moduli è fondamentale.

L'anno scorso, ho scritto una di queste librerie di convalida dei moduli. L'ho chiamato "Calidation" e puoi leggere il post introduttivo del blog qui. È una buona libreria che offre molta flessibilità e utilizza un approccio leggermente diverso rispetto alle altre sul mercato. Ci sono anche un sacco di altre fantastiche librerie là fuori, però: la mia ha funzionato bene per le nostre esigenze.

Oggi ti mostrerò come scrivere la tua libreria di convalida per React. Analizzeremo il processo passo dopo passo e troverai esempi di CodeSandbox mentre procediamo. Entro la fine di questo articolo, saprai come scrivere la tua libreria di validazione, o almeno avere una comprensione più profonda di come altre librerie implementano “la magia della validazione”.

  • Parte 1: Le basi
  • Parte 2: Le caratteristiche
  • Parte 3: L'esperienza
Altro dopo il salto! Continua a leggere sotto ↓

Passaggio 1: progettazione dell'API

Il primo passo per creare qualsiasi libreria è progettare come verrà utilizzata. Getta le basi per gran parte del lavoro a venire e, secondo me, è la decisione più importante che prenderai nella tua libreria.

È importante creare un'API che sia "facile da usare" e tuttavia sufficientemente flessibile da consentire miglioramenti futuri e casi d'uso avanzati. Cercheremo di raggiungere entrambi questi obiettivi.

Creeremo un hook personalizzato che accetterà un singolo oggetto di configurazione. Ciò consentirà di passare opzioni future senza introdurre modifiche sostanziali.

Una nota sui ganci

Hooks è un modo piuttosto nuovo di scrivere React. Se hai scritto React in passato, potresti non riconoscere alcuni di questi concetti. In tal caso, dai un'occhiata alla documentazione ufficiale. È incredibilmente ben scritto e ti guida attraverso le basi che devi conoscere.

Per ora chiameremo il nostro hook personalizzato useValidation . Il suo utilizzo potrebbe assomigliare a questo:

 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);

L'oggetto config accetta un prop dei fields , che imposta le regole di convalida per ogni campo. Inoltre, accetta una richiamata per l'invio del modulo.

L'oggetto fields contiene una chiave per ogni campo che vogliamo convalidare. Ogni campo ha la propria configurazione, dove ogni chiave è un nome di validatore e ogni valore è una proprietà di configurazione per quel validatore. Un altro modo per scrivere lo stesso sarebbe:

 { fields: { fieldName: { oneValidator: { validatorRule: 'validator value' }, anotherValidator: { errorMessage: 'something is not as it should' } } } }

Il nostro hook useValidation restituirà un oggetto con alcune proprietà: getFieldProps , getFormProps ed errors . Le due prime funzioni sono quelle che Kent C. Dodds chiama "prop getter" (vedi qui per un ottimo articolo su questi) e viene utilizzato per ottenere gli oggetti di scena pertinenti per un dato campo modulo o tag modulo. Il prop errors è un oggetto con eventuali messaggi di errore, digitato per campo.

Questo utilizzo sarebbe simile a questo:

 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> ); };

Va bene! Quindi abbiamo inchiodato l'API.

  • Vedi la demo di CodeSandbox

Nota che abbiamo creato anche un'implementazione fittizia dell'hook useValidation . Per ora, restituisce solo un oggetto con gli oggetti e le funzioni di cui abbiamo bisogno per essere presenti, quindi non interrompiamo la nostra implementazione di esempio.

Memorizzazione dello stato del modulo

La prima cosa che dobbiamo fare è memorizzare tutto lo stato del modulo nel nostro hook personalizzato. Dobbiamo ricordare i valori di ogni campo, eventuali messaggi di errore e se il modulo è stato inviato o meno. Useremo il gancio useReducer per questo poiché consente la massima flessibilità (e meno boilerplate). Se hai mai usato Redux, vedrai alcuni concetti familiari e, in caso contrario, ti spiegheremo man mano che procediamo! Inizieremo scrivendo un riduttore, che viene passato 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'); } }

Cos'è un riduttore?

Un riduttore è una funzione che accetta un oggetto di valori e una "azione" e restituisce una versione aumentata dell'oggetto di valori.

Le azioni sono semplici oggetti JavaScript con una proprietà di type . Stiamo usando un'istruzione switch per gestire ogni possibile tipo di azione.

L'“oggetto dei valori” è spesso indicato come stato e, nel nostro caso, è lo stato della nostra logica di validazione.

Il nostro stato è costituito da tre dati: values (i valori correnti dei nostri campi modulo), errors (l'attuale serie di messaggi di errore) e un flag isSubmitted che indica se il nostro modulo è stato inviato o meno almeno una volta.

Per memorizzare il nostro stato del modulo, dobbiamo implementare alcune parti del nostro hook useValidation . Quando chiamiamo il nostro metodo getFieldProps , dobbiamo restituire un oggetto con il valore di quel campo, un gestore di modifiche per quando cambia e un nome prop per tracciare quale campo è quale.

 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], }), }; };

Il metodo getFieldProps ora restituisce gli oggetti di scena richiesti per ogni campo. Quando viene attivato un evento di modifica, ci assicuriamo che il campo sia nella nostra configurazione di convalida, quindi informiamo il nostro riduttore che è stata eseguita un'azione di change . Il riduttore gestirà le modifiche allo stato di convalida.

  • Vedi la demo di CodeSandbox

Convalida del nostro modulo

La nostra libreria di convalida dei moduli ha un bell'aspetto, ma non sta facendo molto in termini di convalida dei valori dei nostri moduli! Risolviamolo.

Convalideremo tutti i campi su ogni evento di modifica. Potrebbe non sembrare molto efficiente, ma nelle applicazioni del mondo reale in cui mi sono imbattuto, non è davvero un problema.

Nota, non stiamo dicendo che devi mostrare tutti gli errori su ogni modifica. Rivedremo come mostrare gli errori solo quando invii o esci da un campo, più avanti in questo articolo.

Come scegliere le funzioni del validatore

Quando si tratta di validatori, ci sono tonnellate di librerie là fuori che implementano tutti i metodi di convalida di cui avresti mai bisogno. Puoi anche scrivere il tuo se vuoi. È un esercizio divertente!

Per questo progetto utilizzeremo una serie di validatori che ho scritto tempo fa: calidators . Questi validatori hanno la seguente 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;

In altre parole, ogni validatore accetta un oggetto di configurazione e restituisce un validatore completamente configurato. Quando quella funzione viene chiamata con un valore, restituisce il message prop se il valore non è valido o null se è valido. Puoi vedere come vengono implementati alcuni di questi validatori osservando il codice sorgente.

Per accedere a questi validatori, installa il pacchetto calidators con npm install calidators .

Convalida un singolo campo

Ricordi la configurazione che passiamo al nostro oggetto useValidation ? Si presenta così:

 { 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 }

Per semplificare la nostra implementazione, supponiamo di avere un solo campo da convalidare. Esamineremo ogni chiave dell'oggetto di configurazione del campo ed eseguiremo i validatori uno per uno fino a quando non troviamo un errore o non avremo terminato la convalida.

 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; }

Qui, abbiamo scritto una funzione validateField , che accetta il valore da convalidare e le configurazioni del validatore per quel campo. Eseguiamo il ciclo di tutti i validatori, passiamo loro la configurazione per quel validatore e lo eseguiamo. Se riceviamo un messaggio di errore, saltiamo il resto dei validatori e ritorniamo. In caso contrario, proviamo il prossimo validatore.

Nota: sulle API di convalida

Se scegli validatori diversi con API diverse (come il molto popolare validator.js ), questa parte del tuo codice potrebbe avere un aspetto leggermente diverso. Per brevità, tuttavia, lasciamo che quella parte sia un esercizio lasciato al lettore.

Nota: On for...in loop

Mai usato for...in loop prima? Va bene, anche questa è stata la mia prima volta! Fondamentalmente, itera sulle chiavi di un oggetto. Puoi leggere di più su di loro su MDN.

Convalida tutti i campi

Ora che abbiamo convalidato un campo, dovremmo essere in grado di convalidare tutti i campi senza troppi problemi.

 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; }

Abbiamo scritto una funzione validateFields che accetta tutti i valori di campo e l'intera configurazione del campo. Eseguiamo il ciclo di ogni nome di campo nella configurazione e convalidiamo quel campo con il suo oggetto e valore di configurazione.

Successivo: Dillo al nostro riduttore

Va bene, quindi ora abbiamo questa funzione che convalida tutte le nostre cose. Inseriamolo nel resto del nostro codice!

Innanzitutto, aggiungeremo un gestore di azioni di validate al nostro 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'); } }

Ogni volta che attiviamo l'azione di validate , sostituiamo gli errori nel nostro stato con tutto ciò che è stato passato insieme all'azione.

Successivamente, attiveremo la nostra logica di convalida da un hook 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 }; };

Questo hook useEffect viene eseguito ogni volta che il nostro state.fields o config.fields cambia, oltre al primo montaggio.

Attenti ai bug

C'è un bug super sottile nel codice sopra. Abbiamo specificato che il nostro hook useEffect dovrebbe essere eseguito nuovamente solo ogni volta che state.fields o config.fields cambiano. Si scopre che "cambiamento" non significa necessariamente un cambiamento di valore! useEffect usa Object.is per garantire l'uguaglianza tra gli oggetti, che a sua volta usa l'uguaglianza di riferimento. Cioè, se passi un nuovo oggetto con lo stesso contenuto, non sarà lo stesso (poiché l'oggetto stesso è nuovo).

Gli state.fields vengono restituiti da useReducer , che ci garantisce questa uguaglianza di riferimento, ma la nostra config è specificata in linea nel nostro componente di funzione. Ciò significa che l'oggetto viene ricreato su ogni rendering, che a sua volta attiverà useEffect sopra!

Per risolvere questo problema, dobbiamo usare per la libreria use-deep-compare-effect di Kent C. Dodds. Lo installi con npm install use-deep-compare-effect e sostituisci invece la tua chiamata useEffect con questa. Questo ci assicura di eseguire un controllo di uguaglianza approfondito invece di un controllo di uguaglianza di riferimento.

Il tuo codice ora apparirà così:

 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 }; };

Una nota sull'usoEffect

Si scopre che useEffect è una funzione piuttosto interessante. Dan Abramov ha scritto un articolo molto bello e lungo sulla complessità di useEffect se sei interessato a imparare tutto quello che c'è da sapere su questo hook.

Ora le cose stanno iniziando a sembrare una libreria di convalida!

  • Vedi la demo di CodeSandbox

Gestione dell'invio del modulo

L'ultimo pezzo della nostra libreria di base per la convalida dei moduli è la gestione di ciò che accade quando inviamo il modulo. In questo momento, ricarica la pagina e non succede nulla. Non è ottimale. Vogliamo impedire il comportamento predefinito del browser quando si tratta di moduli e gestirlo noi stessi. Inseriamo questa logica all'interno della funzione getter 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 }; };

Cambiamo la nostra funzione getFormProps per restituire una funzione onSubmit , che viene attivata ogni volta che viene attivato l'evento DOM di submit . Impediamo il comportamento predefinito del browser, inviamo un'azione per dire al nostro riduttore che abbiamo inviato e chiamiamo il callback onSubmit fornito con l'intero stato, se è fornito.

Sommario

Ci siamo! Abbiamo creato una libreria di convalida semplice, utilizzabile e piuttosto interessante. C'è ancora un sacco di lavoro da fare prima di poter dominare le interwebs, però.

  • Parte 1: Le basi
  • Parte 2: Le caratteristiche
  • Parte 3: L'esperienza