Come costruiamo componenti dell'interfaccia utente in Rails

Pubblicato: 2024-06-28

Mantenere la coerenza visiva in un'applicazione Web di grandi dimensioni è un problema condiviso in molte organizzazioni. L'applicazione web principale dietro il nostro prodotto Flywheel è costruita con Ruby on Rails e abbiamo circa più sviluppatori Rails e tre sviluppatori front-end che ci dedicano il codice in un dato giorno. Siamo molto attenti anche al design (è uno dei nostri valori fondamentali come azienda) e abbiamo tre designer che lavorano a stretto contatto con gli sviluppatori nei nostri team Scrum.

due persone collaborano alla progettazione di un sito web

Uno dei nostri obiettivi principali è garantire che qualsiasi sviluppatore possa creare una pagina reattiva senza ostacoli. Gli ostacoli generalmente includono il non sapere quali componenti esistenti utilizzare per creare un modello (che porta a gonfiare la base di codice con componenti molto simili e ridondanti) e il non sapere quando discutere la riusabilità con i progettisti. Ciò contribuisce a creare esperienze cliente incoerenti, frustrazione per gli sviluppatori e un linguaggio di progettazione disparato tra sviluppatori e progettisti.

Abbiamo seguito diverse iterazioni di guide di stile e metodi di creazione/mantenimento di modelli e componenti dell'interfaccia utente e ogni iterazione ha contribuito a risolvere i problemi che stavamo affrontando in quel momento. Siamo fiduciosi che il nostro nuovo approccio ci preparerà per molto tempo a venire. Se riscontri problemi simili nella tua applicazione Rails e desideri avvicinarti ai componenti dal lato server, spero che questo articolo possa darti alcune idee.

un uomo barbuto sorride alla telecamera mentre è seduto davanti al monitor di un computer che visualizza righe di codice

In questo articolo approfondirò:

  • Per cosa stiamo risolvendo
  • Componenti vincolanti
  • Componenti di rendering sul lato server
  • Dove non possiamo utilizzare componenti lato server

Ciò per cui stiamo risolvendo

Volevamo limitare completamente i nostri componenti dell'interfaccia utente ed eliminare la possibilità che la stessa interfaccia utente venga creata in più di un modo. Anche se un cliente potrebbe non essere in grado di dirlo (in un primo momento), la mancanza di vincoli sui componenti porta a un'esperienza di sviluppo confusa, rende le cose molto difficili da mantenere e rende difficile apportare modifiche alla progettazione globale.

Il modo tradizionale in cui affrontavamo i componenti era attraverso la nostra guida di stile, che elencava l'intero markup richiesto per creare un determinato componente. Ad esempio, ecco come appariva la pagina della guida di stile per il nostro componente slat:

pagina della guida di stile per il componente lamellare

Ha funzionato bene per diversi anni, ma i problemi hanno iniziato a insinuarsi quando abbiamo aggiunto varianti, stati o modi alternativi di utilizzare il componente. Con un'interfaccia utente complessa, è diventato complicato fare riferimento alla guida di stile per sapere quali classi utilizzare e quali evitare e in quale ordine deve essere il markup per ottenere la variazione desiderata.

Spesso, i progettisti apportano piccole aggiunte o modifiche a un determinato componente. Dal momento che la guida di stile non lo supportava del tutto, hack alternativi per far sì che quel tweak venga visualizzato correttamente (come cannibalizzare in modo inappropriato parte di un altro componente) sono diventati fastidiosamente comuni.

Esempio di componente non vincolato

Per illustrare come emergono le incoerenze nel tempo, utilizzerò un esempio semplice (e artificioso) ma molto comune di uno dei nostri componenti nell'app Flywheel: le intestazioni delle carte.

Partendo da un modello di progettazione, ecco come appariva l'intestazione di una carta. Era piuttosto semplice con un titolo, un pulsante e un bordo inferiore.

 .carta__intestazione
  .card__header-sinistra
    %h2 Backup
  .card__header-destra
    = link_a "#" fai
      = icona("più_piccolo")

Dopo che è stato codificato, immagina un designer che voglia aggiungere un'icona a sinistra del titolo. Fuori dagli schemi, non ci sarà alcun margine tra l'icona e il titolo.

 ...
  .card__header-sinistra
    = icona("arrow_backup", colore: "gray25")
    %h2 Backup
...

Idealmente risolveremmo questo problema nel CSS per le intestazioni delle carte, ma per questo esempio, supponiamo che un altro sviluppatore abbia pensato "Oh, lo so! Abbiamo alcuni aiutanti di margine. Mi limiterò a dare uno schiaffo a una classe di supporto sul titolo.

 ...
  .card__header-sinistra
    = icona("arrow_backup", colore: "gray25")
    %h2.--ml-10 Backup
...

Beh, tecnicamente sembra che il mockup lo abbia fatto, giusto?! Certo, ma diciamo che un mese dopo, un altro sviluppatore ha bisogno di un'intestazione della carta, ma senza l'icona. Trovano l'ultimo esempio, lo copiano/incollano e rimuovono semplicemente l'icona.

Ancora una volta sembra corretto, giusto? Fuori contesto, per qualcuno senza un occhio attento al design, certo! Ma guardalo accanto all'originale. Quel margine sinistro sul titolo è ancora lì perché non si erano resi conto che il margine sinistro dell'aiutante doveva essere rimosso!

Facendo un ulteriore passo avanti in questo esempio, diciamo che un altro mockup richiedeva un'intestazione della carta senza bordo inferiore. Si potrebbe trovare uno stato che abbiamo nella guida di stile chiamato “senza confini” e applicarlo. Perfetto!

Un altro sviluppatore potrebbe quindi provare a riutilizzare quel codice, ma in questo caso ha effettivamente bisogno di un bordo. Diciamo ipoteticamente che ignorino l'uso corretto documentato nella guida di stile e non si rendano conto che la rimozione della classe senza confini darà loro il confine. Invece, aggiungono una regola orizzontale. Alla fine c'è dell'imbottitura extra tra il titolo e il bordo, quindi applicano una classe di supporto alle ore e voilà!

Con tutte queste modifiche all'intestazione della carta originale, ora abbiamo tra le mani un pasticcio nel codice.

 .card__header.--senza bordi
  .card__header-sinistra
    %h2.--ml-10 Backup
  .card__header-destra
    = link_a "#" fai
      = icona("più_piccolo")
  %hr.--mt-0.--mb-0

Tieni presente che l'esempio sopra è solo per illustrare il modo in cui i componenti non vincolati possono diventare disordinati nel tempo. Se qualcuno nel nostro team tentasse di spedire una variazione dell'intestazione di una scheda, dovrebbe essere sottoposto a una revisione del progetto o una revisione del codice. Ma cose come queste a volte passano inosservate, da qui la nostra necessità di rendere le cose a prova di proiettile!


Componenti vincolanti

Potresti pensare che i problemi sopra elencati siano già stati chiaramente risolti con i componenti. Questo è un presupposto corretto! I framework front-end come React e Vue sono molto popolari proprio per questo scopo; sono strumenti straordinari per incapsulare l'interfaccia utente. Tuttavia, c'è un inconveniente che non sempre ci piace: richiedono che l'interfaccia utente venga renderizzata tramite JavaScript.

La nostra applicazione Flywheel è molto pesante in termini di back-end, con HTML principalmente reso dal server, ma fortunatamente per noi, i componenti possono presentarsi in molte forme. In fin dei conti, un componente dell'interfaccia utente è un incapsulamento di stili e regole di progettazione che invia markup a un browser. Con questa consapevolezza, possiamo adottare lo stesso approccio ai componenti, ma senza il sovraccarico di un framework JavaScript.

Di seguito parleremo del modo in cui creiamo i componenti vincolati, ma ecco alcuni dei vantaggi che abbiamo riscontrato utilizzandoli:

  • Non esiste mai un modo veramente sbagliato di assemblare un componente.
  • Il componente pensa a tutto il design per te. (Basta passare le opzioni!)
  • La sintassi per creare un componente è molto coerente e facile da ragionare.
  • Se è necessaria una modifica alla progettazione di un componente, possiamo modificarla una volta nel componente ed essere sicuri che venga aggiornata ovunque.

Componenti di rendering sul lato server

Allora di cosa stiamo parlando vincolando i componenti? Approfondiamo!

Come accennato in precedenza, vogliamo che qualsiasi sviluppatore che lavora nell'applicazione possa guardare un modello di progettazione di una pagina ed essere in grado di creare immediatamente quella pagina senza impedimenti. Ciò significa che il metodo di creazione dell'interfaccia utente deve essere A) documentato molto bene e B) molto dichiarativo e privo di congetture.

Partials to the Rescue (o almeno così pensavamo)

Un primo tentativo che abbiamo provato in passato è stato quello di utilizzare i parziali di Rails. I partial sono l'unico strumento che Rails ti offre per la riusabilità nei template. Naturalmente, sono la prima cosa a cui tutti aspirano. Ma ci sono notevoli svantaggi nel fare affidamento su di essi perché se è necessario combinare la logica con un modello riutilizzabile si hanno due scelte: duplicare la logica su ogni controller che utilizza il partial o incorporare la logica nel partial stesso.

I parziali impediscono errori di duplicazione di copia/incolla e funzionano bene per le prime due volte in cui è necessario riutilizzare qualcosa. Ma dalla nostra esperienza, i parziali presto si riempiono di supporto per sempre più funzionalità e logica. Ma la logica non dovrebbe vivere in modelli!

Introduzione alle cellule

Fortunatamente, esiste un'alternativa migliore ai partial che ci consente sia di riutilizzare il codice sia di mantenere la logica fuori dalla vista. Si chiama Cells, una gemma di rubino sviluppata da Trailblazer. Le celle esistono ben prima dell'aumento di popolarità dei framework front-end come React e Vue e consentono di scrivere modelli di visualizzazione incapsulati che gestiscono sia la logica che i modelli. Forniscono un'astrazione del modello di visualizzazione, che Rails semplicemente non ha immediatamente. In realtà utilizziamo Cells nell'app Flywheel ormai da un po', ma non su scala globale e super riutilizzabile.

Al livello più semplice, le celle ci consentono di astrarre una parte di markup come questa (usiamo Haml per il nostro linguaggio di template):

 %div
  %h1 Ciao mondo!

In un modello di visualizzazione riutilizzabile (molto simile ai partial a questo punto) e trasformalo in questo:

 = cella("ciao_mondo")

Questo in definitiva ci aiuta a vincolare il componente al punto in cui non è possibile aggiungere classi helper o componenti secondari errati senza modificare la cella stessa.

Costruire cellule

Inseriamo tutte le nostre celle dell'interfaccia utente in una directory app/cells/ui. Ogni cella deve contenere un solo file Ruby, con il suffisso _cell.rb. Tecnicamente puoi scrivere i modelli direttamente in Ruby con l'helper content_tag, ma la maggior parte delle nostre celle contiene anche un modello Haml corrispondente che risiede in una cartella denominata dal componente.

Una cella super semplice senza logica assomiglia a questa:

 //cellule/ui/slat_cell.rb
interfaccia utente del modulo
  classe SlatCell <ViewModel
    sicuramente spettacolo
    FINE
  FINE
FINE

Il metodo show è ciò che viene visualizzato quando si crea un'istanza della cella e cercherà automaticamente un file show.haml corrispondente nella cartella con lo stesso nome della cella. In questo caso, si tratta di app/cells/ui/slat (ambito di tutte le celle dell'interfaccia utente nel modulo dell'interfaccia utente).

Nel modello puoi accedere alle opzioni passate alla cella. Ad esempio, se la cella viene istanziata in una vista come = cell(“ui/slat”, title: “Title”, subtitle: “Subtitle”, label: “Label”), possiamo accedere a tali opzioni tramite l'oggetto options.

 //cells/ui/slat/show.haml
.stecca
  .stecca__interno
    .slat__content
      %h4= opzioni[:titolo]
      %p= opzioni[:sottotitolo]
      = icona(opzioni[:icona], colore: "blu")

Molte volte sposteremo elementi semplici e i loro valori in un metodo nella cella per impedire il rendering di elementi vuoti se un'opzione non è presente.

 //cellule/ui/slat_cell.rb
titolo def
  return a meno che opzioni[:titolo]
  content_tag:h4, opzioni[:titolo]
FINE
sottotitolo def
  ritorna a meno che opzioni[:sottotitolo]
  content_tag :p, opzioni[:sottotitolo]
FINE
 //cells/ui/slat/show.haml
.stecca
  .stecca__interno
    .slat__content
      = titolo
      = sottotitolo

Avvolgimento di celle con un'utilità dell'interfaccia utente

Dopo aver dimostrato il concetto che ciò potrebbe funzionare su larga scala, ho voluto affrontare il markup estraneo richiesto per chiamare una cella. Semplicemente non scorre nel modo giusto ed è difficile da ricordare. Quindi abbiamo creato un piccolo aiuto per questo! Ora possiamo semplicemente chiamare = ui “name_of_component” e passare le opzioni in linea.

 = ui "slat", titolo: "Titolo", sottotitolo: "Sottotitolo", etichetta: "Label"

Passaggio di opzioni come blocco anziché in linea

Portando l'utilità dell'interfaccia utente un po' oltre, divenne subito evidente che una cella con un sacco di opzioni tutte su un'unica riga sarebbe stata estremamente difficile da seguire e semplicemente brutta. Ecco un esempio di una cella con molte opzioni definite in linea:

 = ui “slat”, titolo: “Titolo”, sottotitolo: “Sottotitolo”, etichetta: “Label”, collegamento: “#”, terziary_title: “Terziario”, disabilitato: true, checklist: [“Elemento 1”, “Elemento 2”, “Elemento 3”]

È molto macchinoso, il che ci ha portato a creare una classe chiamata OptionProxy che intercetta i metodi setter di Cells e li traduce in valori hash, che vengono poi uniti in opzioni. Se ti sembra complicato, non preoccuparti: lo è anche per me. Ecco un riassunto della classe OptionProxy scritta da Adam, uno dei nostri ingegneri software senior.

Ecco un esempio di utilizzo della classe OptionProxy all'interno della nostra cella:

 interfaccia utente del modulo
  classe SlatCell <ViewModel
    sicuramente spettacolo
      OptionProxy.new(self).yield!(opzioni, &blocco)
      super()
    FINE
  FINE
FINE

Ora che abbiamo installato tutto ciò, possiamo trasformare le nostre ingombranti opzioni in linea in un blocco più piacevole!

 = ui "slat" fa |slat|
  - slat.title = "Titolo"
  - slat.subtitle = "Sottotitolo"
  - stecca.label = "Etichetta"
  - stecca.link = "#"
  - slat.tertiary_title = "Terziario"
  - slat.disabled = vero
  - slat.checklist = ["Articolo 1", "Articolo 2", "Articolo 3"]

Introduzione alla logica

Fino a questo punto, gli esempi non hanno incluso alcuna logica riguardo a ciò che viene visualizzato nella vista. Questa è una delle cose migliori offerte da Cells, quindi parliamone!

Attenendoci al nostro componente slat, abbiamo la necessità a volte di renderizzare l'intera cosa come un collegamento e talvolta di renderla come un div, in base alla presenza o meno di un'opzione di collegamento. Credo che questo sia l'unico componente che abbiamo che può essere renderizzato come div o collegamento, ma è un esempio abbastanza chiaro della potenza di Cells.

Il metodo seguente chiama un helper link_to o content_tag a seconda della presenza delle opzioni [:link] .

 def contenitore(&blocco)
  etichetta =
    se opzioni[:link]
      [:link_to, opzioni[:link]]
    altro
      [:tag_contenuto, :div]
    FINE
  send(*tag, classe: “slat__inner”, &block)
fine

Ciò ci consente di sostituire l'elemento .slat__inner nel modello con un blocco contenitore:

 .stecca
  = contenitore fare
  ...

Un altro esempio di logica in Cells che usiamo molto è quello dell'output condizionale delle classi. Diciamo di aggiungere un'opzione disabilitata alla cella. Nient'altro nell'invocazione della cella cambia, a parte il fatto che ora puoi passare un'opzione disattivata: true e osservare come il tutto si trasforma in uno stato disattivato (in grigio con collegamenti non selezionabili).

 = ui "slat" fa |slat|
  ...
  - slat.disabled = vero

Quando l'opzione disattivata è vera, possiamo impostare le classi sugli elementi nel modello necessari per ottenere l'aspetto disattivato desiderato.

 .slat{ classe: possibile_classes("--disabled": opzioni[:disabled]) }
  .stecca__interno
    .slat__content
      %h4{ classe: possibile_classi("--alt": opzioni[:disabilitato]) }= opzioni[:titolo]
      %p{ class: possible_classes("--alt": options[:disabled]) }=
      opzioni[:sottotitolo]
      = icona(opzioni[:icona], colore: "grigio")

Tradizionalmente, avremmo dovuto ricordare (o fare riferimento alla guida di stile) quali singoli elementi necessitavano di classi aggiuntive per far funzionare correttamente il tutto nello stato disabilitato. Le celle ci consentono di dichiarare un'opzione e quindi di svolgere il lavoro pesante per noi.

Nota: possible_classes è un metodo che abbiamo creato per consentire l'applicazione condizionale delle classi in Haml in un modo carino.


Dove non possiamo utilizzare i componenti lato server

Anche se l'approccio cellulare è estremamente utile per la nostra particolare applicazione e per il modo in cui lavoriamo, sarei negligente se dicessi che ha risolto il 100% dei nostri problemi. Scriviamo ancora JavaScript (molto) e creiamo parecchie esperienze in Vue attraverso la nostra app. Il 75% delle volte, il nostro modello Vue risiede ancora in Haml e leghiamo le nostre istanze Vue all'elemento contenitore, il che ci consente di sfruttare comunque l'approccio della cella.

Tuttavia, nei luoghi in cui ha più senso vincolare completamente un componente come istanza Vue a file singolo, non possiamo utilizzare Cells. Le nostre liste selezionate, ad esempio, sono tutte Vue. Ma penso che vada bene! Non abbiamo realmente riscontrato la necessità di avere versioni duplicate dei componenti sia nei componenti Cells che in quelli Vue, quindi va bene che alcuni componenti siano realizzati al 100% con Vue e altri con Cells.

Se un componente è costruito con Vue, significa che è necessario JavaScript per costruirlo nel DOM e noi sfruttiamo il framework Vue per farlo. Per la maggior parte degli altri nostri componenti, tuttavia, non richiedono JavaScript e, se lo richiedono, richiedono che il DOM sia già creato e noi ci colleghiamo e aggiungiamo semplicemente ascoltatori di eventi.

Man mano che continuiamo a progredire con l'approccio cellulare, sperimenteremo sicuramente la combinazione di componenti cellulari e componenti Vue in modo da avere uno e un solo modo di creare e utilizzare i componenti. Non so ancora che aspetto abbia, quindi attraverseremo quel ponte quando arriveremo lì!


La nostra conclusione

Finora abbiamo convertito in Cells circa trenta dei nostri componenti visivi più utilizzati. Ci ha dato un enorme aumento di produttività e dà agli sviluppatori un senso di conferma che le esperienze che stanno costruendo sono corrette e non modificate insieme.

Il nostro team di progettazione è più sicuro che mai che i componenti e le esperienze della nostra app siano 1:1 con ciò che hanno progettato in Adobe XD. Le modifiche o le aggiunte ai componenti vengono ora gestite esclusivamente attraverso l'interazione con un designer e uno sviluppatore front-end, che mantiene il resto del team concentrato e senza preoccupazioni su come modificare un componente per adattarlo a un modello di progettazione.

Ripetiamo costantemente il nostro approccio alla limitazione dei componenti dell'interfaccia utente, ma spero che le tecniche illustrate in questo articolo ti diano un'idea di ciò che funziona bene per noi!


Vieni a lavorare con noi!

Ogni reparto che lavora sui nostri prodotti ha un impatto significativo sui nostri clienti e sui nostri profitti. Che si tratti di assistenza clienti, sviluppo software, marketing o qualsiasi altra via di mezzo, stiamo tutti lavorando insieme per raggiungere la nostra missione di costruire una società di hosting di cui le persone possano davvero innamorarsi.

Pronto per unirti al nostro team? Stiamo assumendo! Candidati qui.