Introdurre Vue in un progetto esistente

Pubblicato il 19/02/2022

Vue.js viene definito come una libreria progressiva, una libreria che può essere introdotta incrementalmente in un progetto esistente.

Ho deciso di provare questa caratteristica introducendo Vue in Resting, un’estensione per browser rilasciata con licenza Open Source che permette di eseguire chiamate verso API HTTP/REST.

Resting è nato qualche anno fa ed è stato implementato usando Knockout.js e Bootstrap. L’intento di questo esperimento è introdurre Vue nel modo più conservativo possibile e valutare i problemi di convivenza con altre tecnologie.

Trovare un buon candidato

Quale componente di Resting ha la giusta complessità per provare la conversione?

Un buon pretendente è il pulsante che copia negli appunti la response di una chiamata:

  • la logica della funzionalità è isolata.
  • il codice dipende da un altro modulo, quindi permette di valutare la comunicazione con il codice esistente.

Screen 1

Ho deciso di utilizzare Vue 2.6 invece della più nuova 3.x. Non ho esperienza con la 3.x e ho preferito evitare un’ulteriore livello di incertezza nell’esperimento.

Le difficoltà

Primo scoglio: Resting utilizza require.js per gestire le dipendenze. Come si comporta Vue con questa modalità di caricamento?

Le operazioni da eseguire:

  1. Copiare il file di vue.js (nel mio caso full, compilatore + runtime) nella cartella delle dipendenze terze.

  2. Definire la dipendenza Vue nella configurazione di require.js

requirejs.config({
    baseUrl: 'js/vendor',
    paths: {
         app : '../app',
         component : '../app/components',
        'jquery': 'jquery-3.3.1.min',
        'knockout': 'knockout-3.4.2',
        'knockout-secure-binding': 'knockout-secure-binding',
        'localforage': 'localforage.nopromises.min',
        'hjls': 'highlight.pack',
  --->  'Vue': 'vue'   
    }
});
  1. Scrivere il codice del componente component/clip.js.
define(['Vue','app/clipboard'],function(Vue, clipboard) {
  Vue.component('ClipboardButton', {
      created: function() {
        clipboard.copyFrom('#highlighted-response', 'copy-n-paste');
        clipboard.onCopy(function() {
          $('.alert').removeClass('hide');
          setTimeout(function () { $('.alert').addClass('hide'); }, 2000);
        });
      },
      methods: {
        push() {
          document.execCommand('copy')
        }
      },
      template: `
        <button @click.prevent.stop="push" title="Copy to clipboard" class="copy-n-paste">
          <i class="fa fa-clipboard" aria-hidden="true"></i>
        </button>
      `
  })
})
  1. Iniettare la dipendenza a Vue e a component/clip nella parte di applicazione che utilizza il componente.
define(['knockout','jquery','hjls','app/clipboard', 'app/bacheca','Vue','component/clip'],function(ko,$,hjls,clipboard,bacheca, Vue) {
  1. Definire un’applicazione Vue che si occupi di gestire il workflow del nuovo componente.
var vueApp = new Vue({
    el: '#vue-stuff'
    })
  1. Introdurre nella view un elemento div che contenga il componente.
<div id="vue-stuff">
    <div class="btn-group" style="float: left" role="group">
        <clipboard-button></clipboard-button>
    </div>

I passaggi appena descritti sono sufficienti per introdurre Vue in un’applicazione che utilizza require.js.

Secondo scoglio: Resting è però un’estensione per browser, deve quindi sottostare ad una serie di limitazioni ulteriori, una di serie di policy CSP (Content Security Policy). Provando a caricare l’estensione nel browser non visualizzaremo ancora il pulsante creato, e nella console verrà mostrato il seguente messaggio di errore:

Content Security Policy: The page’s settings blocked the loading of a resource at eval (“script-src”).

Soluzione rapida: introdurre nel file manifest.json delle regole CSP più permissive.

"content_security_policy" : "default-src * 'unsafe-inline'; script-src 'self' 'unsafe-eval'; object-src 'self'",

Con questo permesso il componente viene visualizzato e funziona correttamente.

La soluzione appena descritta non mi soddisfa: inserire delle clausole così permissive nel manifest presuppone l’insorgere di problemi durante il processo di review sui vari marketplace.

A cosa è dovuto l’errore?

L’errore è dovuto all’utilizzo della versione full di Vue che contiene sia il compilatore dei template HTML che il runtime dell’applicazione. Il compilatore utilizza la funzione eval per creare il codice Javascript a partire dai template HTML del componente.

La descrizione di questo problema è descritta nella documentazione ufficiale di Vue, qui.

La soluzione: compilare preventivamente i template HTML presenti nei componenti Vue. Per farlo possiamo utilizzare gli strumenti presenti nell’ecosistema Vue come vue-cli.

I passi per arrivare a questa soluzione:

  1. Sostituire la libreria vue.min.js con la libreria vue.runtime.min.js fra le dipendenze di progetto e modificare la dipendenza caricata da require.js.
'Vue': 'vue.runtime.min'
  1. Installare nel progetto vue-cli.
npm install @vue/cli
  1. Scrivere un comando per la compilazione dei componenti Vue utilizzando vue-cli.
${CLI_PATH}/vue-cli-service build --target lib --formats umd --dest ${DIST_FOLDER} --no-clean --name clipboard-button  ${COMPONENTS_FOLDER}/ClipboardButton.vue

vue-cli-service compila solo i file con estensione .vue, quindi rinominare il componente clip.js in ClipboardButton.vue. Ho colto l’occasione per dare un nome più in linea con le best practise di Vue.

  1. Riscrivere il codice del componente.
<template>
  <button @click.prevent.stop="push" title="Copy to clipboard" class="copy-n-paste">
          <i class="fa fa-clipboard" aria-hidden="true"></i>
        </button>
</template>

<script>
import bacheca from 'Services/bacheca'

export default {
  name: 'ClipBoardButton',
  methods: {
    push(event) {
      bacheca.publish('copyResponse')
    }
  }
}
</script>

Ora il componente è descritto come un SFC (single file component) e non contiene più riferimenti a Vue. Verrà compilato dal plugin webpack utilizzato da vue-cli-service.

Come compilo correttamente le dipendenze al codice definire in require.js?

Introducendo un mapping nella configurazione di webpack, all’interno del file vue.config.js.

Il contenuto del file:

const path = require('path')

module.exports = {
  configureWebpack: {
    resolve: {
      alias: {
        Components: path.resolve(__dirname, 'src/js/app/components'),
        Services: path.resolve(__dirname, 'src/js/app')
      }
    },
    externals: {
      'Services/clipboard': 'app/clipboard',
      'Services/storage': 'app/storage',
      'Services/bacheca': 'app/bacheca',
      'Components/RDialog.vue': 'vuecomp/r-dialog.umd'
    }      
  }
}

Nel caso descritto grazie a externals si può dire a webpack di considerare delle dipendenze come esterne, quindi di non compilarle. Il mapping avviene tra la stringa che rappresenta la dipendenza che si vuole importare, Services/clipboard ad esempio, e il path require.js che carica tale dipendenza, nell’esempio app/clipboard. Per semplificare la gestione ho definito anche degli alias come Services per non dover scrivere path molto lunghi.

Il comando di compilazione produe un file compilato per ogni ogni componente con il nome in questa forma clipboard-button.umd.js Ho creato una nuova cartella in vendor chiamata vue-stuff per contenere i componenti Vue compilati e l’ho aggiunta alla configurazione require.js

requirejs.config({
    baseUrl: 'js/vendor',
    paths: {
         app : '../app',
         component : '../app/components',        
         vuecomp : 'vue-stuff',
        'jquery': 'jquery-3.3.1.min',
        'knockout': 'knockout-3.4.2',
        'knockout-secure-binding': 'knockout-secure-binding',
        'localforage': 'localforage.nopromises.min',
        'hjls': 'highlight.pack',
        'Vue': 'vue.runtime.min'
    }
});

Per non incappare nuovamente nel problema CSP ho modificato il codice di inizio dell’applicazione Vue andando a sostituire il template HTML con la sua rappresentazione compilata.

 new Vue({
      el: '#v-response-b-group',
      components: {
        ClipboardButton
      },
      render: function(createElement) {
        return createElement(
          'div',
          {
            class: 'btn-group',
            role: 'group'
          },
          [
            createElement('clipboard-button')
          ]
        )
      }
    })

Le modifiche apportare alla parte di applicazione che carica il componente.

define(['knockout','jquery','hjls', 'app/bacheca','Vue','app/clipboard', 'vuecomp/clipboard-button.umd'],function(ko,$,hjls, bacheca, Vue, clipboard, ClipboardButton) {

Il risultato finale.

Screen 3

Come comunicano tra loro i componenti?

Utilizzando un provider pub/sub molto grezzo che ho scritto per Resting, bacheca. I componenti scritti in Knockout.js già comunicano tra loro attraverso questo provider e lo stesso sistema è perfetto anche per la comunicazione fra componenti di diverse tecnologie dato che mantiene disaccoppiate le logiche.

Prossimi passi

L’esperimento ha avuto successo e non ho avuto problemi durante la review nel marketplace di Firefox e di Chrome: Vue convive bene nell’applicazione esistente.

Nel prossimo futuro:

  • Proverò a passare da Vue 2 a 3 (che è diventata la versione ufficiale del progetto).
  • Riscriverò i componenti esistenti in Vue.
  • Le nuove funzionalità saranno scritte in Vue.

Gli spezzoni di codice presentati in questo articolo seguono la licenze di Resting: GPLv3.

  • I contenuti di questo articolo sono rilasciati con licenza CC-BY 4.0
  • Eventuali spezzoni di codice presentati seguono, dove non dichiarato, licenza MIT