Custom elements

La più grande evoluzione che i framework frontend post AngularJS hanno portato ai developer JavaScript è stato l’introduzione del concetto di componente. Concetto già presente in AngularJS stesso, anche nelle versioni con qualche annetto sulle spalle. Sicuramente però l’avvento di Angular, Vue e soprattutto React hanno fatto diventare questo concetto mainstream.

L’idea di componente basa molta della sua forza sul concetto di riutilizzo. Se ad esempio create un componente <Calendar/> ci aspettiamo di utilizzarlo in tutti i punti del nostro progetto in cui serve un calendario. Non è il solo vantaggio dell’approccio ai componenti, e forse neanche il più importante, ma di certo è il più immediato da cogliere.

Il concetto si può estendere non solo ad un singolo progetto, ma anche ad una suite di progetti, magari legati allo stesso brand. Ognuno dei progetti potrebbe utilizzare lo stesso <Calendar/>, portando ad un enorme riusabilità del codice.

I custom elements

Una nuova Web API permette di creare componenti in applicazioni frontend senza bisogno di nessun framework: i custom elements. Questa API fa parte del gruppo di API che formano i web components. Il supporto per ora è ancora limitato come potete vedere da questa immagine presa da caniuse.com.

Il supporto è garantito ad oggi da Chrome e Safari. In Firefox è attivabile tramite un flag e sarà disponibile dalla prossima versione. Per fortuna è possibile utlizzare questa tecnica già da subito grazie ad un polyfill che la rende compatibile anche con IE11.

Un custom element è composto da una classe che estende HTMLElement. Qui di seguito trovate un semplice esempio di custom element:

export default class MyComponent extends HTMLElement {
  connectedCallback () {
    this.innerHTML = '<div>Hello World!</div>'
  }
}

Il metodo connectedCallback viene invocato appena il nostro elemento viene connesso al DOM. Quello che facciamo è semplicemente aggiungere un div con al suo interno il famoso saluto per sviluppatori.
Una volta definito il nostro componente, questo va registrato nel registro dei custom elements

window.customElements.define('my-component', MyComponent)

Unica cosa da considerare quando si registra un nuovo custom element è che il nome deve essere composto da almeno due parole separate da un trattino. Questo perché i nomi a singola parola sono riservati. In questo modo non si rischia che i componenti custom vadano in conflitto con eventuali nuovi tag standard.

A questo punto potete inserirlo nella vostra applicazione semplicemente utilizzando il tag appena registrato:

<my-component></my-component>

Attributi

Come tutti i componenti HTML anche i custom element posso essere configurati tramite attributi. Nel prossimo esempio, il componente accetta un parametro name per personalizzare il messaggio.

export default class MyComponent extends HTMLElement {
  
  get name () {
    return this.getAttribute('name') 
  }

  set name (value) {
    this.setAttribute('name', value)
  }

  connectedCallback () {
    const name = this.name || 'World'
    this.innerHTML = `<div>Hello ${name}!</div>`
  }
}

Ed ecco l’utilizzo del nostro nuovo componente.

<div>
   <my-component name="Francesco"></my-component>
   <my-component></my-component>
</div>

Una Todo list

Lo scopo di questa parte del post è quella di creare una piccola todo list usando i custom elements. Il risultato è visibile in questa live demo, mentre il codice completo dell’applicazione è disponibile su GitHub.

Questa è la nostra applicazione d’esempio

I componenti che formano questa applicazione sono quattro: app-form che contiene l’input; app-list e app-list-row che formano la lista dei todo, ed infine my-app che fa da container e gestisce le comunicazione tra le parti. Il codice di app-form è il seguente:

import template from './Form.template.html'

export const EVENTS = {
  ADD: 'app/todo/add'
}

export default class Form extends HTMLElement {
  connectedCallback () {
    this.innerHTML = template

    this
        .querySelector('button')
        .addEventListener('click', () => this.add())

    this.input = this.querySelector('input')
  }

  focus () {
    this.input.focus()
  }

  add () {
    this.dispatchEvent(new window.CustomEvent(EVENTS.ADD, {
      detail: {
        value: this.value
      }
    }))
  }

  get value () {
    return this.input.value
  }

  set value (val) {
    this.input.value = val
  }
}

Per mantenere il markup in un file esterno, ho utilizzato un loader di Webpack che permette di importare un file html come stringa chiamato html-loader. La form genera un CustomEvent ogni volta che l’utente clicca sul pulsante “aggiungi”. Questo evento viene catturato dal componente my-app, come si può notare dal prossimo snippet:

import template from './App.template.html'
import { EVENTS as FORM_EVENTS } from '../Form/Form'
import { EVENTS as LIST_EVENTS } from '../List/List'
import todos from '../../model/todos'

export default class App extends HTMLElement {
  connectedCallback () {
    this.innerHTML = template

    this.form = this.querySelector('app-form')
    this.list = this.querySelector('app-list')

    this.form.addEventListener(FORM_EVENTS.ADD, event => {
      todos.add(event.detail.value)
      this.form.value = ''
      this.form.focus()
    })

    this.list.addEventListener(LIST_EVENTS.DELETE, event => {
      todos.delete(event.detail.index)
    })

    window.customElements.whenDefined('app-list').then(() => {
      todos.connect((todos) => {
        this.list.todos = todos
      })
    })
  }
}

Come detto in precedenza, my-app si occupa della comunicazione tra le parti. All’evento FORM_EVENTS.ADD infatti viene invocato il metodo add del nostro modello todos. Infine quando il componente app-list è pronto l’app si occupa di sincronizzare lo stato della lista con quello del nostro modello. Il modello todos è un’Observable di cui potete leggere il codice nel repository. Infine questo è il codice di app-list:

import htmlToDomElement from '../../utils/htmlToDomElement'

const TEMPLATE = `
  <div>
      <ul>
      </ul>
  </div>
`

const NO_ROW_TEMPLATE = '<div class="text-gray">Nothing to do</div>'

export const EVENTS = {
  DELETE: 'app/todo/delete'
}

export default class List extends HTMLElement {
  constructor () {
    super()
    this.todoList = []
  }

  connectedCallback () {
    this.render()
  }

  renderList () {
    this.innerHTML = TEMPLATE

    const ul = this.querySelector('ul')

    this.todoList.forEach((todo, index) => {
      const row = htmlToDomElement(`<app-list-row value="${todo.text}"></app-list-row>`)
      ul.appendChild(row)
      row
        .querySelector('button')
        .addEventListener('click', () => this.onDeleteClick(index))
    })
  }

  render () {
    if (!this.todoList || !this.todoList.length) {
      this.innerHTML = NO_ROW_TEMPLATE
      return
    }

    this.renderList()
  }

  onDeleteClick (index) {
    this.dispatchEvent(new window.CustomEvent(EVENTS.DELETE, {
      detail: {
        index
      }
    }))
  }

  get todos () {
    return Object.freeze(this.todoList)
  }

  set todos (val) {
    this.todoList = [...val]
    this.render()
  }
}

Per completare il flusso dell’applicazione, notiamo come ogni volta che viene invocato il setter todos rifacciamo il render della nostra lista. Per quanto rigurada la cancellazione abbiamo un flusso esattamente identico a quello dell’inserimento: CustomEvent su app-list che viene intercettato su my-app, dopodiché si sincronizza di nuovo il nostro modello con il componente app-list.

Conclusioni

Personalemente credo che i custom elements, e più in generale i web components, saranno la prossima big thing dello sviluppo frontend. Possono portare il concetto di portabilità a livelli molto più alti che in passato. Ragionate sul fatto che se vi siete fatti la vostra bellissima suite di componenti in Angular, avete legato tutti i vostri progetti futuri ad un framework. Con questa tecnica invece potete costruirla basandovi su uno standard. Facendo quindi il possibile per garantire la longevità della vostra codebase, che non è legata ad un framework che potrebbe semplicemente “sparire” ed essere quindi un peso negli anni a venire.

Per concludere vorrei condividere con voi la nascita del Frameworkless Movement: un gruppo di persone interessate a studiare come sviluppare senza framework e su come migliorare nel prendere decisioni tecniche in maniera consapevole. Questo post è il risultato di ragionamenti ed esperimenti che io ed il mio team abbiamo fatto negli ultimi tempi. Alla prossima!

Francesco Strazzullo

Faccio il Front-end engineer per extrategy dove mi occupo di applicazioni Angular, React e applicazioni mobile. Da sempre sono appassionato di Architetture software e cerco di applicarle in maniera innovativa allo sviluppo frontend in generale.