Componenti ed eventi dinamici in Vue
Come usare <component> per caricare dei componenti da una lista e intercettare eventi diversi partendo da un file di configurazione.
Come insegnante ho dedicato diverso tempo a pensare quale fosse il metodo migliore per spiegare a cosa possono servire Array e Object.
Nella maggior parte dei libri si svolgono esercizi molto teorici e astratti che, a dire il vero, mi hanno sempre mandato in tilt 🤯
Non è infatti facile pensare in schemi, soprattutto se non è chiaro cosa stiamo cercando di riprodurre.
Ancora di più se si parla di numeri! 🆘 ⚠️
Il metodo migliore per capire qualcosa è sempre vederla in un contesto caso reale e poiché non sviluppo software per la Nasa ma mi interesso di web, vedremo come sfruttare queste due strutture in questo ambito.
Scopo del progetto sarà creare dei form dinamicamente partendo da un file di configurazione.
In parte questo argomento l’ho già trattato nella serie di articoli Creiamo una app con Vue ed Electron, nel caso lo abbiate perso.
ATTENZIONE:
Per questo articolo è richiesta una conoscenza base di Vue 3 (creazione dei componenti, gestione emit ed eventi, reattività) e di Pinia.
Cosa useremo:
Vite
Vue 3
Router
Pinia
Questo sarà il risultato finale della nostra pagina.

Sulla sinistra abbiamo la possibilità trascinare i componenti di un form modificando il loro ordine, nonché il numero di colonne occupate in una griglia.
Sulla destra avremo una preview del form modificato, aggiornato solamente al click su Save.
Step 1: Creiamo i componenti del form
Partiamo dalla base: nel nostro form avremo 4 tipi di tag html:
- Label,
- Input,
- Textarea,
- Button
Per ognuno di questi creeremo un componente

Utilizziamo la Composition API e la sintassi Object per le props in modo da poter specificare anche i tipi di dati.
All’interno di questi componenti inseriremo solo ciò che riguarda il layout e invieremo gli emit.
ButtonComponent.vue
<script setup lang="ts">
const emit = defineEmits(["click"]);defineProps({
id: String,
value: {
type: String,
default: "",
},
disabled: {
type: Boolean,
default: false,
},
});
</script><template>
<button
:id="id"
:disabled="disabled"
@click="emit('click')"
>
<slot>
{{ value }}
</slot>
</button>
</template>
InputComponent.vue
<script setup>
const emit = defineEmits(["update:value"]);defineProps({
id: String,
name: String,
type: {
//questa validazione serve a noi sviluppatori :)
validator(value) {
return ["text", "email", "password"].includes(value);
},
},
placeholder: String,
ariaLabelledBy: String,
required: {
type: Boolean,
default: false,
},
disabled: {
type: Boolean,
default: false,
},
value: {
type: String,
default: "",
},
});const updateValue = (evt) => {
emit("update:value", evt.target.value);
};
</script><template>
<input
:id="id"
:name="name"
:type="type"
:placeholder="placeholder"
:aria-labelledby="ariaLabelledBy"
:required="required"
:disabled="disabled"
:value="value"
@input="updateValue"
/>
</template><style lang="scss" scoped>
input {
width: 100%;
}
</style>
LabelComponent.vue
<script setup>
defineProps({
id: String,
value: {
type: String,
default: "",
},
});
</script><template>
<label :id="id">{{ value }}</label>
</template>
TextareaComponent.vue
<script setup>
const emit = defineEmits(["update:value"]);defineProps({
id: String,
name: String,
placeholder: String,
ariaLabelledBy: String,
required: {
type: Boolean,
default: false,
},
disabled: {
type: Boolean,
default: false,
},
value: {
type: String,
default: "",
},
cols: {
type: Number,
default: 30,
},
rows: {
type: Number,
default: 10,
},
});const updateValue = (evt) => {
emit("update:value", evt.target.value);
};
</script><template>
<textarea
:cols="cols"
:rows="rows"
:id="id"
:name="name"
:placeholder="placeholder"
:aria-labelledby="ariaLabelledBy"
:required="required"
:disabled="disabled"
:value="value"
@input="updateValue"
/>
</template><style lang="scss" scoped>
textarea {
width: 100%;
}
</style>
Step 2: Creiamo il file di configurazione!

I componenti ci sono, ora abbiamo bisogno di un Object nel quale conservare le informazioni che ci serviranno per caricarli nell’ordine giusto.
Partiamo dall’import e poi li inseriamo all’interno di una mappa.
configLayouts.ts
// importiamo i componenti
import LabelComponent from "../components/LabelComponent.vue";
import InputComponent from "../components/InputComponent.vue";
import TextareaComponent from "../components/TextareaComponent.vue";
import ButtonComponent from "../components/ButtonComponent.vue";//creiamo una mappa
export const componentsMap = {
Label: {
component: LabelComponent,
},
InputName: {
component: InputComponent,
},
InputEmail: {
component: InputComponent,
},
InputEmailRepeat: {
component: InputComponent,
},
InputPasswordRepeat: {
component: InputComponent,
},
Textarea: {
component: TextareaComponent,
},
Button: {
component: ButtonComponent,
},
};
Ad ogni chiave corrisponde il name di un elemento che inseriremo in un array components all’interno dello store.
Ogni elemento sarà quindi un object con la seguente struttura:
{
//il name corrisponde alla chiave in ComponentsMap
name: string,
//order ci servirà per il drag&drop
order: number,
//in data avremo tutte le prop del componente
data: {
id: string,
name: string,
type: string,
placeholder: string,
ariaLabelledBy: string,
value: string,
required: boolean,
disabled: boolean,
},
}
Ecco lo Store aggiornato con l’array components
import { defineStore } from "pinia";export const useComponents = defineStore("components", {
state: () => {
return {
components: [
{
name: "Label",
order: 0,
data: {
id: "user-config",
value: "User Configuration",
},
},
{
name: "InputName",
order: 1,
data: {
id: "name",
name: "name",
type: "text",
placeholder: "Name",
ariaLabelledBy: "user-data",
value: "",
required: true,
},
},
{
name: "InputEmail",
order: 2,
data: {
id: "email",
name: "email",
type: "email",
placeholder: "email",
ariaLabelledBy: "user-data",
value: "",
required: true,
},
},
{
name: "InputEmailRepeat",
order: 3,
data: {
id: "repeatEmail",
name: "repeat-email",
type: "email",
placeholder: "Repeat Email",
ariaLabelledBy: "user-data",
value: "",
required: true,
},
},
{
name: "InputPassword",
order: 4,
data: {
id: "password",
name: "password",
type: "password",
placeholder: "password",
ariaLabelledBy: "user-data",
value: "",
required: true,
},
},
{
name: "InputPasswordRepeat",
order: 5,
data: {
id: "repeatPassword",
name: "repeat-password",
type: "password",
placeholder: "Repeat Password",
ariaLabelledBy: "user-data",
value: "",
required: true,
},
},
{
name: "Textarea",
order: 6,
data: {
id: "bio",
name: "bio",
placeholder: "Name",
ariaLabelledBy: "user-data",
value: "",
},
},
{
name: "Button",
order: 7,
data: {
id: "submit",
value: "invia",
disabled: true,
},
},
],
};
},
actions: {
// questa action ci consentirà di ordinare i componenti in base alla chiave order
getItems() {
return this.components.sort((a, b) => a.order - b.order);
},
},
});
Nel nostro componente UseForm a questo punto importeremo il file di config e lo store.
<script setup>
import { componentsMap } from "@/config/configLayout.ts";
import { useComponents } from "@/stores/components.ts";const storeComponents = useComponents();
</script>
Perfetto! 😎
Ora nel template useremo i Dynamic Components che ci consentiranno, come ci dice il nome, di caricare dei componenti in maniera dinamica.
Ecco la reference:
https://vuejs.org/guide/essentials/component-basics.html#dynamic-components
<component>
ha una speciale prop is
che consisterà nell’import del componente.
Nel nostro caso, questa informazione si trova in componentsMap
Facciamo dunque un ciclo sui nostri componenti nello Store e per ogni elemento rintracciamo il giusto componente nella mappa tramite il suo nome, che, come abbiamo detto, corrisponde alla chiave.
componentsMap[component.name].component

<template>
<div class="container">
<h2>Preview Form</h2>
<form @submit.prevent>
<div
v-for="(component, index) in storeComponents.components"
class="column"
:key="index"
>
<component
:is="componentsMap[component.name].component"
v-bind="component.data"
></component>
</div>
</form>
</div>
</template>
Cosa abbiamo visto fin qui
Mentre gli Array sono usati per conservare liste di elementi, gli Object ci servono per mappare informazioni.
Le due strutture possono essere usate assieme, come nel nostro caso, per recuperare dati in contesti diversi.
Da una parte quindi abbiamo una mappa che ci dice
{
"nomeElemento": ComponenteDaCaricare
}
Dall’altra abbiamo un array che contiene un’altra mappa nella quale inseriamo tutti i dati necessari al componente caricato.
{
name: "nomeElemento",
order: 1,
data: {
...
},
}
Come uniamo le due informazioni?
Tramite un elemento in comune: il value di name
è uguale alla chiave nella mappa
Well Done!
Step 2: Come possiamo intercettare un emit diverso a seconda del componente utilizzato?
La documentazione come sempre ci viene in aiuto, basta saper cercare!
https://vuejs.org/guide/essentials/template-syntax.html#dynamic-arguments
Useremo gli eventi dinamici!
<a v-on:[eventName]="doSomething"> ... </a><!-- shorthand -->
<a @[eventName]="doSomething">
La sintassi è molto simile a quella che usiamo per richiamare una proprietà di un object usando le quadre.
nameObject[key]
Aggiungiamo al nostro file di config una nuova chiave event
e al suo interno il nome dell’emit da intercettare e il nome della callback che vogliamo chiamare.
InputName: {
component: InputComponent,
event: {
name: "update:value",
callback: "updateValue",
},
}
Chiameremo all’emit update:value
una action dello Store che si chiamerà updateValue
Ecco il nostro Store aggiornato
import { defineStore } from "pinia";export const useComponents = defineStore("components", {
state: () => {
return {
components: []
},
actions: {
getItems() {
return this.components.sort((a, b) => a.order - b.order);
},
findByName(name) {
return this.components.find((item) => item.name === name);
},
updateValue({ name, value }) {
const component = this.findByName(name);
component.data.value = value;
},
},
});
Ora possiamo inserire nel template l’evento dinamico passando il nome dell’evento, nel nostro caso:
componentsMap[name].event.name
🤨 Ma purtroppo @[qui dentro non possiamo mettere delle quadre]
!!!
Possiamo però creare un metodo per estrarre la stringa che ci serve.
Molto semplicemente passeremo alla funzione una stringa corrispondente alla chiave in componentsMap
e restituiremo il valore di event.name
const getEventNameFromMap = (name) => componentsMap[name].event.name;
Questa stringa poi la useremo nel template
<component
:is="componentsMap[component.name].component"
v-bind="component.data"
@[getEventNameFromMap(component.name)]=""
></component>
Ora manca la callback! 🤓
Creiamo una funzione simile a quella precedente.
const getEventCallbackFromMap = (name) => componentsMap[name].event.callback;
E infine creiamo una funzione che useremo per eseguire la action, passiamo a questa funzione il componente e il valore che è stato emesso.
const callComponentEvent = ({ component, value }) => {
storeComponents[getEventCallbackFromMap(component.name)]({
name: component.name,
value: value,
});
};
Possiamo adesso completare l’evento dinamico inserendo anche una funzione da chiamare all’emit del componente.
<component
:is="componentsMap[component.name].component"
v-bind="component.data"
@[getEventNameFromMap(component.name)]="
callComponentEvent({
component: component,
value: $event,
})
"
></component>
Tutto questo tradotto in codice statico sarebbe così:
<component
:is="InputName"
v-bind="storeComponents.components[1].data"
@update:value="
storeComponents.updateValue({
component: component,
value: $event,
})
"
></component>
Ricapitolando
Ecco uno schema di quello che abbiamo visto fino ad ora.

🤪 L’utilizzo di diversi file e di funzioni che chiamano funzioni può confonderci.
Scriviamo sempre uno schema prima di partire senza avere fretta! 😉
Per ora è tutto, nel prossimo articolo lavoreremo al file che ci consentirà di gestire l’ordine e la dimensione dei nostri componenti nel Form.