Uno degli aspetti più importanti dello sviluppo front-end è quello di mostrare agli utenti dei dati utili a svolgere l’attività per la quale la nostra web application è stata creata. Se volessimo portare questa definizione su un piano più tecnico potremmo dire che il nostro scopo è trasformare i dati in una serie di elementi del DOM: in altre parole fare quello che si chiama rendering. La parte di codice che, a basso livello, si occupa di creare, manipolare ed eliminare elementi del DOM viene di solito chiamata Rendering Engine. Questa parte di applicazione viene di solito delegata ai Framework come Angular, Vue o a librerie come React.

Lo scopo di questo post è quello di spiegare quali sono i passi per poter creare un rendering engine from scratch, non facendo leva su nessun tipo di dipendenza.

Frameworkless Movement

Perché per uno sviluppatore front-end dovrebbe essere utile saper sviluppare da zero un rendering engine? Perchè, anche quando si decide di affidare una parte importante delle nostre applicazioni ad un framework, bisogna conoscere i principi e le architetture che sono dietro i tool che utilizziamo. Questo è uno dei motivi che hanno portato alla creazione del Frameworkless Movement: un gruppo di persone interessate allo sviluppo senza framework. Per altre informazioni sul movimento e sui suoi principi fondanti potete consultare il Manifesto presente su GitHub.

Il Logo del Frameworkless Movement

Applicazione di Esempio

Per sviluppare il nostro engine utilizzeremo come base TodoMVC: una todo-list abbastanza basilare che trovate già sviluppata con i maggiori framework front-end esistenti. Qui trovate una live demo di un’implementazione di TodoMVC. Per motivi di spazio la nostra versione di TodoMVC sarà solo parziale, non comprendendo la gestione degli eventi invocati dagli utenti, una versione completa è consultabile su GitHub. L’Engine che andremo a creare sarà basato su una semplice astrazione: sarà una funzione pura dello stato dell’applicazione.

La struttura del nostro engine

Iniziamo creando la struttura vuota di un’applicazione TodoMVC all’interno della index.html.

<body>
    <section class="todoapp">
        <header class="header">
            <h1>todos</h1>
            <input class="new-todo" placeholder="What needs to be done?" autofocus>
        </header>
        <section class="main">
            <input id="toggle-all" class="toggle-all" type="checkbox">
            <label for="toggle-all">Mark all as complete</label>
            <ul class="todo-list">
            </ul>
        </section>
        <footer class="footer">
            <span class="todo-count">1 Item Left</span>
            <ul class="filters">
                <li>
                    <a href="#/">All</a>
                </li>
                <li>
                    <a href="#/active">Active</a>
                </li>
                <li>
                    <a href="#/completed">Completed</a>
                </li>
            </ul>
            <button class="clear-completed">Clear completed</button>
        </footer>
    </section>
</body>

Per capire bene la struttura della nostra applicazione, iniziamo dalla index.js dove avviene il boot della nostra applicazione.

import getTodos from './getTodos.js'
import view from './view.js'

const state = {
  todos: getTodos(),
  currentFilter: 'All'
}

const main = document.querySelector('.todoapp')

window.requestAnimationFrame(() => {
  const newMain = view(main, state)
  main.replaceWith(newMain)
})

Il meccanismo alla base del nostro rendering engine è il seguente: si utilizza l’HTML esistente come base. Questa base insieme allo stato dell’applicazione vengono utilizzati dalla funzione view (il cuore del nostro engine) per creare un nuovo albero di DOM che viene poi sostituito al precedente tramite replaceWith. Per i più curiosi, il nostro stato è creato tramite la funzione getTodos che è una piccola utility basata su Faker.js di cui potete leggere il codice su GitHub.

La prima versione dell’Engine

Quella che vedete qui è la prima versione dell’engine che andremo a creare insieme.

const getTodoElement = todo => {
  const {
    text,
    completed
  } = todo

  return `
  <li ${completed ? 'class="completed"' : ''}>
    <div class="view">
      <input 
        ${completed ? 'checked' : ''}
        class="toggle" 
        type="checkbox">
      <label>${text}</label>
      <button class="destroy"></button>
    </div>
    <input class="edit" value="${text}">
  </li>`
}

const getTodoCount = todos => {
  const notCompleted = todos
    .filter(todo => !todo.completed)

  const { length } = notCompleted
  if (length === 1) {
    return '1 Item left'
  }

  return `${length} Items left`
}

export default (targetElement, state) => {
  const {
    currentFilter,
    todos
  } = state

  const element = targetElement.cloneNode(true)

  const list = element.querySelector('.todo-list')
  const counter = element.querySelector('.todo-count')
  const filters = element.querySelector('.filters')

  list.innerHTML = todos.map(getTodoElement).join('')
  counter.textContent = getTodoCount(todos)

  Array
    .from(filters.querySelectorAll('li a'))
    .forEach(a => {
      if (a.textContent === currentFilter) {
        a.classList.add('selected')
      } else {
        a.classList.remove('selected')
      }
    })

  return element
}

Guardando il codice, diventa ancora più chiaro il meccanismo alla base di questo semplice engine. L’elemento passato come parametro viene clonato (tramite il metodo cloneNode), modificato in modo da rappresentare lo stato dell’applicazione e infine restituito. La purezza di questo engine ne garantisce un’estrema testabilità, data proprio dal fatto che dipende esclusivamente dai parametri di ingresso.

Un Engine a “Componenti”

Questa prima versione dell’engine non è per nulla leggibile. Tutte le modifiche al DOM sono in un unico file che già da adesso risulta confusionario e complicato. Una soluzione del genere non è per nulla accettabile in un sistema reale. I framework front-end (ma anche le API native) risolvono questo problema tramite la possibilità di creare componenti. In questo prossimo esempio non arriveremo a creare un registro di componenti (cosa che probabilmente farà parte di un post a parte in futuro) ma semplicemente la view viene spezzettata in una serie di funzioni pure, questo comporta un aumento della leggibilità e della manutenibilità del codice.

import todosView from './todos.js'
import counterView from './counter.js'
import filtersView from './filters.js'

export default (targetElement, state) => {
  const element = targetElement.cloneNode(true)

  const list = element
    .querySelector('.todo-list')
  const counter = element
    .querySelector('.todo-count')
  const filters = element
    .querySelector('.filters')

  list.replaceWith(todosView(list, state))
  counter.replaceWith(counterView(counter, state))
  filters.replaceWith(filtersView(filters, state))

  return element
}

Come vedete, sia questa funzione che tutte le funzioni al suo interno hanno la stessa forma: prendono in ingresso una base e lo stato e restituiscono una copia degli elementi stessi. Questa caratteristica è la base per la creazione di un sistema a componenti come quelli presenti in Angular, React e così via. Su GitHub trovate il codice di tutti i “componenti” della nostra piccola applicazione. Qui nel post riporto esclusivamente il codice del componente list e la relativa suite di test unitari.

const getTodoElement = todo => {
  const {
    text,
    completed
  } = todo

  return `
      <li ${completed ? 'class="completed"' : ''}>
        <div class="view">
          <input 
            ${completed ? 'checked' : ''}
            class="toggle" 
            type="checkbox">
          <label>${text}</label>
          <button class="destroy"></button>
        </div>
        <input class="edit" value="${text}">
      </li>`
}

export default (targetElement, { todos }) => {
  const newTodoList = targetElement.cloneNode(true)
  const todosElements = todos
    .map(getTodoElement)
    .join('')
  newTodoList.innerHTML = todosElements
  return newTodoList
}
import todosView from './todos.js'

let targetElement

describe('filtersView', () => {
  beforeEach(() => {
    targetElement = document.createElement('ul')
  })

  test('should create an li for every todo element', () => {
    const newCounter = todosView(targetElement, {
      todos: [
        {
          text: 'First',
          completed: true
        },
        {
          text: 'Second',
          completed: false
        },
        {
          text: 'Third',
          completed: false
        }
      ]
    })

    const items = newCounter.querySelectorAll('li')
    expect(items.length).toBe(3)
  })

  test('should set the right attributes to every li according to the todos', () => {
    const newCounter = todosView(targetElement, {
      todos: [
        {
          text: 'First',
          completed: true
        },
        {
          text: 'Second',
          completed: false
        }
      ]
    })

    const [firstItem, secondItem] = newCounter.querySelectorAll('li')

    expect(firstItem.classList.contains('completed')).toBe(true)
    expect(firstItem.querySelector('.toggle').checked).toBe(true)
    expect(firstItem.querySelector('label').textContent).toBe('First')
    expect(firstItem.querySelector('.edit').value).toBe('First')

    expect(secondItem.classList.contains('completed')).toBe(false)
    expect(secondItem.querySelector('.toggle').checked).toBe(false)
    expect(secondItem.querySelector('label').textContent).toBe('Second')
    expect(secondItem.querySelector('.edit').value).toBe('Second')
  })
})

Conclusioni

Il Rendering è un argomento molto complesso e difficile da distillare in un unico post. Ci sono altri aspetti da considerare che saranno probabilmente parte di post successivi quali: creazione di un registro di componenti, misurazione performance e creazione di un algoritmo performante in stile virtual DOM. Se volete approfondire l’argomento potete acquistare il mio libro “Frameworkless Front-end Development” che raccoglie una serie di esempi su vari aspetti importanti di applicazioni web quali Rendering, gestione eventi e Routing. Tutto il sorgente, sia di questo post che di tutto il libro, è disponibile su GitHub.

Frameworkless Front-end Development

Inoltre questo argomento è stato anche il tema centrale di un mio talk al JSDay 2019 tenutasi a Verona, di cui trovate il video qui sotto. Alla Prossima!

30 Posts

Sono uno sviluppatore front-end, speaker e trainer per Flowing. Oltre a scrivere su vari blog (tra cui codingjam!) sono l'autore de libro "Frameworkless Front-end Development". Nel tempo libero mi piace cucinare cibo etnico e giocare ai videogame.