- Intro
- Definizione problema
- Ipotesi sulla soluzione
- implementazione di una soluzione
- Conclusioni e credits
Intro
Cos'è Vue.js?
Vediamo cosa dice la documentazione:
Vue (pronounced /vjuː/, like view) is a progressive framework for building user interfaces. Unlike other monolithic frameworks, Vue is designed from the ground up to be incrementally adoptable. The core library is focused on the view layer only, and is easy to pick up and integrate with other libraries or existing projects. On the other hand, Vue is also perfectly capable of powering sophisticated Single-Page Applications when used in combination with modern tooling and supporting libraries.
[...]
In atre parole, Vue è framework javascript da utilizzarsi nella realizzazione di frontend. Dalla sua ha la semplicità d'uso e di setup, il template code richiesto è minimo ed è comunque performante, tanto da essere riuscito a ritagliarsi nel tempo un proprio rispettabilissimo spazio accanto a framework decisamente molto più noti ed utilizzati (sì ovviamente sto parlando di Angular e React). Niente di più, niente di meno.
Cosa sono i Web Components?
Se ne è scritto e tutt'ora se ne scrive (e spero se ne continuerà a scrivere) tantissimo, mi limiterò a fornire una piccola sintesi: i web components, in breve, non sono altro che componenti frontend i quali, una volta registrati dal browser e quindi da questo riconosciuti, possono essere usati come normali tag html con i propri attributi, parametri e comportamento peculiare.
Possono essere definiti tramite classi in js vanilla o usando un framework che li supporti, nello specifico, come è facile intuire, in questo articolo si parlerà di web component definiti utilizzando Vue.js
Cos'è Storybook?
Storybook è un eccellente tool per il testing visuale di componenti UI, compatibile con tutti i maggiori framework js ed utilizzabile anche con js vanilla. Tutto quello che si deve fare è specificare quale componente renderizzare, fornire dei mock data e lasciare che storybook istanzi il nostro componente in un proprio iframe e il gioco è fatto. La criticità con vue nasce dalla difficoltà di poter istanziare semplici web components senza utilizzare altre dipendenze.
Definizione del problema
Creazione progetto di test
Creare web components con Vue non è un problema, tanto che la sua cli permette di specificare un target apposito per questo compito e, con alcuni accorgimenti, è possibile testarli anche con il server di sviluppo.
Cerchiamo ora di andare un po' più nel dettaglio, la procedura per definire un web component in Vue è decisamente banale, partiamo da un normale progetto Vue:
vue create vue-webcomponent-storybook-test
la mia configurazione custom è stata typescript, babel, scss (dart-sass) e basic linter on save.
Ciò che si otterrà sarà un'alberatura di questo tipo:
├── dist
├── node_modules
├── public
│ ├── favicon.ico
│ └── index.html
├── src
│ ├── assets
│ │ └── logo.png
│ ├── components
│ │ └── HelloWorld.vue
│ ├── App.vue
│ ├── main.ts
│ ├── shims-tsx.d.ts
│ └── shims-vue.d.ts
├── .gitignore
├── babel.config.js
├── package.json
├── README.md
├── tsconfig.json
├── vue.config.js
└── yarn.lock
Se tutto è andato liscio, da terminale, lanciando yarn serve
, potremo vedere la nostra app con il componente HelloWorld.vue
di test, fare bella mostra di se su http://localhost:8080/
.
Aggiunta di Storybook
Il secondo passo consiste nell'installare Storybook attraverso il plugin manager di Vue, anche qui l'operazione non è particolarmente impegnativa:
vue add storybook
Storybook aggiungerà alcuni file e cartelle:
├── config
│ └── storybook
│ └── storybook.js
├── dist
├── node_modules
├── public
│ ├── favicon.ico
│ └── index.html
├── src
│ ├── assets
│ │ └── logo.png
│ ├── components
│ │ ├── Helloworld.vue
│ │ └── MyButton.vue
│ ├── stories
│ │ ├── index.stories.js
│ │ └── index.stories.mdx
│ ├── App.vue
│ ├── main.ts
│ ├── shims-tsx.d.ts
│ └── shims-vue.d.ts
├── .gitignore
├── babel.config.js
├── package.json
├── README.md
├── tsconfig.json
├── vue.config.js
└── yarn.lock
Possiamo eliminare tranquillamente il componente src/components/MyButton.vue
e la story src/stories/index.stories.mdx
, non saranno necessari al nostro progetto.
All'interno di src/stories/index.stories.js
creiamo una story per il nostro componente App.vue
:
Ora lanciando il task storybook:serve
, si avvierà un server di test che permetterà di eseguire storybook e testare il nostor componente:
npm run storybook:serve
(Al momento della scrittura sembra che avviare storybook con yarn non sia possibile).
Creazione di un Web Component
Il secondo passo consiste nel wrappare il nostro componente (lavoreremo con il componente root di default, App.vue
, questo ci permetterà di vedere l'inclusione di altri componenti e come si comportano i loro stili, tutto è ovviamente replicabile per qualunque componente) all'interno di una classe che estende HTMLElement
(link approfondimento), questo non verrà fatto direttamente da noi, ma attraverso una api fornita da Vue. Alla fine di questo step il file main.ts
avrà questo aspetto:
customElements.define
fa parte dell'api js che materialmente permette di registrare il nostro componente al browser con il tag name my-web-component
.
Una piccola nota, se usate typescript come il sottoscritto, potreste dover aggiungere al file shim-vue.d.ts
la definizione del modulo per @vue/web-component-wrapper
:
declare module '@vue/web-component-wrapper';
Questo per evitare l'errore Could not find a declaration file for module '@vue/web-component-wrapper'.
che su ide come IntelliJ e simili potrebbe essere apparso, strano non sia presente un d.ts già preinstallato che risolve il problema.
A questo punto nell'index.html
del nostro progetto (in public/index.html
) dovremo liberarci del root component predefinito (il div con id="app"
) e sostituirlo con il nostro componente appena registrato. Il nostro index, quindi, sarà:
Problema con gli stili
Lanciando ora il comando yarn serve
vedremo in nostro componente funzionare alla grande, no?
Bhè no...
Cioè sì... ma in verità non proprio... dove diavolo sono finiti gli stili??
Il guaio è che Vue ha incluso gli stili nel tag <head>
della pagina come farebbe normalmente, ma il nostro componente è rinchiuso dentro ad uno shadow dom (https://w3c.github.io/webcomponents/spec/shadow/), una sorta di orizzonte degli eventi attraverso cui è difficile (non impossibile, qualcosa passa tutto sommato) far passare informazione.
Con Storybook invece? bhe le cose non migliorano di molto, anzi il problema si ripropone. Modificando il nostro index.stories.js
Registrando il componente prima di utilizzarlo (storybook attualmente sembra non utilizzare quanto definito nel main.ts
), è possibile renderizzarlo, ma non sono applicati gli stili:
Ipotesi sulla soluzione
Una possibile soluzione è descritta qui, a quanto pare l'opzione shodowMode
di vue-loader è settata a false
di default, da qui il cursioso comportamente riscontrato. A questo punto mettere a true
quella proprietà dovrebbe risolvere il problema.
vue_config.js
Tutto ciò di cui abbiamo bisogno ora è il file vue.config.js nella root del nostro progetto; se ancora non esiste, creiamolo.
Per sapere con cosa riempire il nostro file è necessario ispezionare la configurazione webpack del nostro progetto con il comando:
vue inspect
Il risultato assomiglierà a questo:
Se guardiamo attentamente questo output, possiamo notare alcuni commenti interessanti, per esempio:
/* config.module.rule('css').oneOf('vue').use('vue-style-loader') */
che illustrano l'api necessaria a generare quel determinato pezzetto di configurazione, questa api, infatti, è parte di webpack-chain
(https://github.com/neutrinojs/webpack-chain) tool utilizzato per facilitare la stesura di file di configurazione per webpack. Visto che è già installato nel nostro progetto, lo possiamo usare a nostro favore.
Ovviamente le parti della configurazione che interessano a noi sono quelle in cui appare la proprietà shadowmode: false
, qui sotto l'estratto delle parti interessate:
Ora, ciò che mettiamo nel vue_config.js
verrà intercettato da webpack e integrato nel processo di compilazione, e alla fine dovrebbe essere una cosa di questo genere:
questo script aggiunge shadowMode=false
ovunque sia necessario e permette a webpack di procedere con la compilazione, finalmente quello che si avrà sarà un web component correttamente renderizzato che incapsula tutti i suoi stili:
Includere il web component nella story ()
Se lanciamo storybook ora, vedremo che anche lì il nostro componente sarà corretamente renderizzato, tuttavia l'api di storybook in questo caso non ci aiuta: come facciamo a passare dati al nostro componente in modo efficiente? Se questi dati sono oggetti complessi? Come si può interfacciare il nostro web component con l'api esposta dall'addon knobs?
Ok andiamo con ordine:
Registrare il componente
Questa è facile, ogni componente deve essere registrato come abbbiamo detto, un a possibilità è implementare una funzione che controlli se già il componetne non sia stato registrato e in caso contrario proceda di conseguenza, qualcosa del genere:
Davvero molto semplice, gli elementi non registrati hanno come constructor HTMLElement()
, è sufficente fare un check e il gioco è fatto.
Successivamente, il componente va registrato:
anche qui nulla di nuovo, la procedura è quella vista poco sopra, soltanto chiusa dentro una funzione.
Integrare l'interfaccia delle stories
Ora dobbiamo fare in modo di poter usare l'addon-knobs
per poter passare dati al nostro componente e renderli reattivi ai cambiamenti che possiamo fare durante i test, la mia soluzione è stata costruire una funzione che restituisse un componente e succesivamente ne recuperasse il riferimento per potergli passare eventuali dati:
Cerchiamo di capire cosa questo script effettivamente fa:
export const webComponentWrapper = ({props, template}) => {
...
In ingresso ci si aspetta un oggetto, per esempio:
props: {
test: [
['test', true, 'GROUP-ID1'],
boolean
],
},
template: '<test-component></test-component>'
formato dalla proprietà props
che sarà un altro oggetto, i suoi elementi avranno come chiave il nome della proprietà del nostro componente e per valore un array dove il primo elemento sarà un ulteriore array formato da
- nome proprietà (sì c'è un po' di ridondanza di cui è possibile liberarsi),
- valore che si dovrà considerare
- e l'etichetta che vogliamo dare al gruppo di dati di quello specifico knob.
Il secondo valore, invece, la funzione dell'addon knobs che verrà utilizzata per trattare quello specifico tipo di dato (in questo caso boolean
).
template
invece è una stringa che rappresenta il nostro componente e quello che contiene.
...
const id = generateRandomNumber(0, 10 ** 16);
...
Qui generiamo un id casuale che verrà utilizzato poi per applicarlo al componente e recuperarne il riferimento, io ho creato una funzione apposta, ma in effetti può essere benissimo un timestamp qualunque.
...
for (const key in props) {
if (Object.hasOwnProperty.call(props, key)) {
const old = key + 'Old' + id;
const value = key + 'Value' + id;
props[old] = null;
props[value] = () => (props[old] !== null) ? props[old] : props[key][0][1];
}
}
...
Ora cominciamo a lavorare sui dati da passare al componente: prima di tutto prendiamo la proprietà props
e ne scorriamo il contenuto, per ogni elemento preso in considerazione, lo arricchiamo di altre due proprietà (le variabili old
e value
), alla prima diamo null
alla seconda una funzione che ritornerà il vecchio valore (old
) o quello di 'default' passato assieme alle proprietà (per capirci, il valore true
nel ['test', true, 'GROUP-ID1']
di cui abbiamo parlato più su) a seconda che il vecchio valore esista o meno.
Ogni volta che in Storybook selezioniamo un certo componente questo viene reinizializzato, con questo sistema riusciamo a passare sempre l'ultimo valore usato nei knobs, diversamente ritornando su un componente perderemmo le modifiche fatte durante i nostri test e vedremmo sempre il primo valore passato.
return () => {
setTimeout(() => {
const root = document.getElementById(id.toString());
const old = 'Old' + id;
const value = 'Value' + id;
for (const key in props) {
if (Object.prototype.hasOwnProperty.call(props, key) && !key.includes(old) && !key.includes(value)) {
const knobsParams = props[key][0];
const knobsFunction = props[key][1];
const tagElem = props[key][2];
knobsParams[1] = props[key + value]();
props[key + old] = props[key][1](...knobsParams);
if (tagElem) {
const elems = root.getElementsByTagName(tagElem)
elems.forEach((item) => {
item[key] = props[key + old];
})
}
else {
root[key] = props[key + old];
}
}
}
});
return newTemplate;
}
la funzione ritornata è quella che verrà eseguita da Storybook ogni volta che si selezionerà quel determinato componente.
Prima che questa ritorni il template (nulla più che una stringa del tipo <my-web-component></my-web-component>
), si esegue un timeout privo dei milliscondi di durata, questo permette all'handler di rientare nella coda del loop event appena sarà possibile (più informazioni qui), in questo caso appena il template diventa un elemento della pagina.
Viene recuperato il riferimento del componente tramite l'id calcolato prima, dopo di che vengono recuperati i dati dall'oggetto passato alla funzione e passati al componente. Come detto prima, il dato viene salvato nella proprietà aggiunta prima (qui props[key + old] = props[key][1](...knobsParams);
).
Conclusioni e credits
E questo è quanto, mettendo tutto assieme, si riesce ad avere un progetto Vue per testare web components (e non solo normali classi Vue) con Storybook e il dev server incluso. Qui trovate un repository con un progetto di test completo e funzionante.
Fonti:
Top comments (0)