Passo 27: Costruire una SPA

5.0 version
Maintained

Costruire una SPA

La maggior parte dei commenti saranno inviati durante la conferenza, i cui partecipanti, probabilmente, avranno un telefono e non un computer. Perché non creare un’applicazione mobile per controllare rapidamente i commenti della conferenza?

Un modo per creare un’applicazione mobile di questo tipo è quello di creare un’applicazione Javascript a pagina singola (SPA). Una SPA viene eseguita localmente, può utilizzare archiviazione locale, può chiamare una API HTTP remota e può sfruttare service workers per creare un’esperienza quasi nativa.

Creare l’applicazione

Per creare l’applicazione mobile useremo Preact e Symfony Encore. Preact è una piccola ed efficiente libreria che semplificherà creazione della SPA del Guestbook.

Per rendere il sito e la SPA coerenti, utilizzeremo i fogli di stile sass del sito anche per l’applicazione mobile.

Creare l’applicazione SPA nella cartella spa e copiare i fogli di stile del sito:

1
2
3
$ mkdir -p spa/src spa/public spa/assets/css
$ cp assets/css/*.scss spa/assets/css/
$ cd spa

Nota

Abbiamo creato una cartella public in quanto interagiremo principalmente con la SPA tramite un browser. Avremmo potuto chiamarla build se avessimo voluto solo costruire un’applicazione mobile.

Inizializzare il file package.json (equivalente JavaScript del file composer.json):

1
$ yarn init -y

Ora, aggiungere alcune delle dipendenze richieste:

1
$ yarn add @symfony/webpack-encore @babel/core @babel/preset-env babel-preset-preact preact html-webpack-plugin bootstrap

Per sicurezza, aggiungere un file .gitignore:

.gitignore
1
2
3
4
5
/node_modules
/public
/yarn-error.log
# used later by Cordova
/app

L’ultimo passo è quello di creare la configurazione per Webpack Encore:

webpack.config.js
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
const Encore = require('@symfony/webpack-encore');
const HtmlWebpackPlugin = require('html-webpack-plugin');

Encore
    .setOutputPath('public/')
    .setPublicPath('/')
    .cleanupOutputBeforeBuild()
    .addEntry('app', './src/app.js')
    .enablePreactPreset()
    .enableSingleRuntimeChunk()
    .addPlugin(new HtmlWebpackPlugin({ template: 'src/index.ejs', alwaysWriteToDisk: true }))
;

module.exports = Encore.getWebpackConfig();

Creare il template principale della SPA

È ora di creare il template iniziale che Preact utilizzerà per eseguire il render dell’applicazione:

src/index.ejs
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
<!DOCTYPE html>
<html>
<head>
    <meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="msapplication-tap-highlight" content="no" />
    <meta name="viewport" content="user-scalable=no, initial-scale=1, maximum-scale=1, minimum-scale=1, width=device-width" />

    <title>Conference Guestbook application</title>
</head>
<body>
    <div id="app"></div>
</body>
</html>

Il tag <div> è il punto in cui JavaScript eseguirà il render dell’applicazione. Ecco la prima versione del codice che esegue il render della view «Hello World»:

src/app.js
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
import {h, render} from 'preact';

function App() {
    return (
        <div>
            Hello world!
        </div>
    )
}

render(<App />, document.getElementById('app'));

L’ultima riga registra la funzione App() con l’elemento #app della pagina HTML.

Ora è tutto pronto!

Eseguire una SPA nel browser

Poiché questa applicazione è indipendente dal sito principale, abbiamo bisogno di eseguire un altro server web:

1
$ symfony server:stop
1
$ symfony server:start -d --passthru=index.html

Il parametro --passthru comunica al server web di passare tutte le richieste HTTP al file public/index.html (public/ è la cartella web principale predefinita per il server web). Questa pagina è gestita dall’applicazione Preact e recupera la pagina di cui eseguire il render tramite la cronologia del «browser».

Per compilare i CSS e i file JavaScript, eseguire il comando yarn:

1
$ yarn encore dev

Aprire la SPA in un browser:

1
$ symfony open:local

Diamo uno sguardo alla nostra SPA «Hello world»:

Aggiungere un router per gestire gli stati

Attualmente la SPA non è in grado di gestire pagine diverse. Per implementare diverse pagine, abbiamo bisogno di un router, così come per Symfony. Useremo preact-router. Prende un URL come input e trova la corrispondenza con un componente Preact da visualizzare.

Installare preact-router:

1
$ yarn add preact-router

Creare una pagina per la homepage (un componente Preact):

src/pages/home.js
1
2
3
4
5
6
7
import {h} from 'preact';

export default function Home() {
    return (
        <div>Home</div>
    );
};

E un altro per la pagina della conferenza:

src/pages/conference.js
1
2
3
4
5
6
7
import {h} from 'preact';

export default function Conference() {
    return (
        <div>Conference</div>
    );
};

Sostituire il div «Hello World» con il componente Router:

patch_file
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
--- a/src/app.js
+++ b/src/app.js
@@ -1,9 +1,22 @@
 import {h, render} from 'preact';
+import {Router, Link} from 'preact-router';
+
+import Home from './pages/home';
+import Conference from './pages/conference';

 function App() {
     return (
         <div>
-            Hello world!
+            <header>
+                <Link href="/">Home</Link>
+                <br />
+                <Link href="/conference/amsterdam2019">Amsterdam 2019</Link>
+            </header>
+
+            <Router>
+                <Home path="/" />
+                <Conference path="/conference/:slug" />
+            </Router>
         </div>
     )
 }

Eseguire nuovamente il build dell’applicazione:

1
$ yarn encore dev

Aggiornando l’applicazione nel browser, sarà ora possibile fare click sul link «Home» e sui link della conferenza. Si noti che l’URL e i pulsanti avanti/indietro del browser funzionano come ci si aspetterebbe.

Aggiungere lo stile alla SPA

Per quanto riguarda il sito, aggiungiamo il sass loader:

1
$ yarn add node-sass "[email protected]^7.0"

Attivare il sass loader in Webpack e aggiungere un riferimento al foglio di stile:

patch_file
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
--- a/src/app.js
+++ b/src/app.js
@@ -1,3 +1,5 @@
+import '../assets/css/app.scss';
+
 import {h, render} from 'preact';
 import {Router, Link} from 'preact-router';

--- a/webpack.config.js
+++ b/webpack.config.js
@@ -7,6 +7,7 @@ Encore
     .cleanupOutputBeforeBuild()
     .addEntry('app', './src/app.js')
     .enablePreactPreset()
+    .enableSassLoader()
     .enableSingleRuntimeChunk()
     .addPlugin(new HtmlWebpackPlugin({ template: 'src/index.ejs', alwaysWriteToDisk: true }))
 ;

Ora possiamo aggiornare l’applicazione affinché utilizzi i fogli di stile:

patch_file
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
--- a/src/app.js
+++ b/src/app.js
@@ -9,10 +9,20 @@ import Conference from './pages/conference';
 function App() {
     return (
         <div>
-            <header>
-                <Link href="/">Home</Link>
-                <br />
-                <Link href="/conference/amsterdam2019">Amsterdam 2019</Link>
+            <header className="header">
+                <nav className="navbar navbar-light bg-light">
+                    <div className="container">
+                        <Link className="navbar-brand mr-4 pr-2" href="/">
+                            &#128217; Guestbook
+                        </Link>
+                    </div>
+                </nav>
+
+                <nav className="bg-light border-bottom text-center">
+                    <Link className="nav-conference" href="/conference/amsterdam2019">
+                        Amsterdam 2019
+                    </Link>
+                </nav>
             </header>

             <Router>

Eseguire nuovamente il build dell’applicazione:

1
$ yarn encore dev

Ora abbiamo a disposizione una SPA con uno stile completo:

Recupero dei dati dall’API

La struttura dell’applicazione Preact è ora completa: Preact Router gestisce gli stati della pagina, incluso il segnaposto slug per le conferenze, e il foglio di stile dell’applicazione principale viene utilizzato per il design della SPA.

Per rendere dinamica la SPA, abbiamo bisogno di recuperare i dati dall’API tramite chiamate HTTP.

Configurare Webpack per esporre la variabile di ambiente per l’endpoint dell’API:

patch_file
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
--- a/webpack.config.js
+++ b/webpack.config.js
@@ -1,3 +1,4 @@
+const webpack = require('webpack');
 const Encore = require('@symfony/webpack-encore');
 const HtmlWebpackPlugin = require('html-webpack-plugin');

@@ -10,6 +11,9 @@ Encore
     .enableSassLoader()
     .enableSingleRuntimeChunk()
     .addPlugin(new HtmlWebpackPlugin({ template: 'src/index.ejs', alwaysWriteToDisk: true }))
+    .addPlugin(new webpack.DefinePlugin({
+        'ENV_API_ENDPOINT': JSON.stringify(process.env.API_ENDPOINT),
+    }))
 ;

 module.exports = Encore.getWebpackConfig();

La variabile d’ambiente API_ENDPOINT dovrebbe puntare al server del sito dove abbiamo l’API endpoint all’indirizzo relativo /api. La configureremo correttamente quando eseguiremo yarn encore.

Creare un file api.js che astragga il recupero dei dati dall’API:

src/api/api.js
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
function fetchCollection(path) {
    return fetch(ENV_API_ENDPOINT + path).then(resp => resp.json()).then(json => json['hydra:member']);
}

export function findConferences() {
    return fetchCollection('api/conferences');
}

export function findComments(conference) {
    return fetchCollection('api/comments?conference='+conference.id);
}

Ora è possibile adattare i componenti «header» e «home»:

patch_file
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
--- a/src/app.js
+++ b/src/app.js
@@ -2,11 +2,23 @@ import '../assets/css/app.scss';

 import {h, render} from 'preact';
 import {Router, Link} from 'preact-router';
+import {useState, useEffect} from 'preact/hooks';

+import {findConferences} from './api/api';
 import Home from './pages/home';
 import Conference from './pages/conference';

 function App() {
+    const [conferences, setConferences] = useState(null);
+
+    useEffect(() => {
+        findConferences().then((conferences) => setConferences(conferences));
+    }, []);
+
+    if (conferences === null) {
+        return <div className="text-center pt-5">Loading...</div>;
+    }
+
     return (
         <div>
             <header className="header">
@@ -19,15 +31,17 @@ function App() {
                 </nav>

                 <nav className="bg-light border-bottom text-center">
-                    <Link className="nav-conference" href="/conference/amsterdam2019">
-                        Amsterdam 2019
-                    </Link>
+                    {conferences.map((conference) => (
+                        <Link className="nav-conference" href={'/conference/'+conference.slug}>
+                            {conference.city} {conference.year}
+                        </Link>
+                    ))}
                 </nav>
             </header>

             <Router>
-                <Home path="/" />
-                <Conference path="/conference/:slug" />
+                <Home path="/" conferences={conferences} />
+                <Conference path="/conference/:slug" conferences={conferences} />
             </Router>
         </div>
     )
--- a/src/pages/home.js
+++ b/src/pages/home.js
@@ -1,7 +1,28 @@
 import {h} from 'preact';
+import {Link} from 'preact-router';
+
+export default function Home({conferences}) {
+    if (!conferences) {
+        return <div className="p-3 text-center">No conferences yet</div>;
+    }

-export default function Home() {
     return (
-        <div>Home</div>
+        <div className="p-3">
+            {conferences.map((conference)=> (
+                <div className="card border shadow-sm lift mb-3">
+                    <div className="card-body">
+                        <div className="card-title">
+                            <h4 className="font-weight-light">
+                                {conference.city} {conference.year}
+                            </h4>
+                        </div>
+
+                        <Link className="btn btn-sm btn-blue stretched-link" href={'/conference/'+conference.slug}>
+                            View
+                        </Link>
+                    </div>
+                </div>
+            ))}
+        </div>
     );
-};
+}

Infine, Preact Router passa la variabile «slug» al componente Conference come proprietà. Usiamola per visualizzare la conferenza corretta e i suoi commenti, sfruttando nuovamente l’API. Inoltre adattiamo il rendering affinché utilizzi i dati dell’API:

patch_file
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
--- a/src/pages/conference.js
+++ b/src/pages/conference.js
@@ -1,7 +1,48 @@
 import {h} from 'preact';
+import {findComments} from '../api/api';
+import {useState, useEffect} from 'preact/hooks';
+
+function Comment({comments}) {
+    if (comments !== null && comments.length === 0) {
+        return <div className="text-center pt-4">No comments yet</div>;
+    }
+
+    if (!comments) {
+        return <div className="text-center pt-4">Loading...</div>;
+    }
+
+    return (
+        <div className="pt-4">
+            {comments.map(comment => (
+                <div className="shadow border rounded-lg p-3 mb-4">
+                    <div className="comment-img mr-3">
+                        {!comment.photoFilename ? '' : (
+                            <a href={ENV_API_ENDPOINT+'uploads/photos/'+comment.photoFilename} target="_blank">
+                                <img src={ENV_API_ENDPOINT+'uploads/photos/'+comment.photoFilename} />
+                            </a>
+                        )}
+                    </div>
+
+                    <h5 className="font-weight-light mt-3 mb-0">{comment.author}</h5>
+                    <div className="comment-text">{comment.text}</div>
+                </div>
+            ))}
+        </div>
+    );
+}
+
+export default function Conference({conferences, slug}) {
+    const conference = conferences.find(conference => conference.slug === slug);
+    const [comments, setComments] = useState(null);
+
+    useEffect(() => {
+        findComments(conference).then(comments => setComments(comments));
+    }, [slug]);

-export default function Conference() {
     return (
-        <div>Conference</div>
+        <div className="p-3">
+            <h4>{conference.city} {conference.year}</h4>
+            <Comment comments={comments} />
+        </div>
     );
-};
+}

La SPA ora deve conoscere l’URL della nostra API: Questo avviene tramite la variabile d’ambiente API_ENDPOINT: impostiamola con l’URL del server web API (in esecuzione nella cartella ..):

1
$ API_ENDPOINT=`symfony var:export SYMFONY_DEFAULT_ROUTE_URL --dir=..` yarn encore dev

Ora si potrebbe eseguire anche in background:

1
$ API_ENDPOINT=`symfony var:export SYMFONY_DEFAULT_ROUTE_URL --dir=..` symfony run -d --watch=webpack.config.js yarn encore dev --watch

E l’applicazione nel browser ora dovrebbe funzionare correttamente:

Fantastico! Ora abbiamo una SPA completamente funzionale, con router e dati reali. Potremmo organizzare ulteriormente l’app Preact se vogliamo, ma sta già funzionando alla grande.

Deploy della SPA in produzione

SymfonyCloud permette il deploy di più applicazioni per progetto. L’aggiunta di un’altra applicazione può essere fatta creando un file .symfony.cloud.yaml in qualsiasi sottocartella. Crearne uno nella cartella spa/ con il nome spa:

.symfony.cloud.yaml
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
name: spa

type: php:7.3
size: S
disk: 256

build:
    flavor: none

dependencies:
    nodejs:
        yarn: "*"

web:
    commands:
        start: sleep
    locations:
        "/":
            root: "public"
            index:
                - "index.html"
            scripts: false
            expires: 10m

hooks:
    build: |
        set -x -e

        curl -s https://get.symfony.com/cloud/configurator | (>&2 bash)
        yarn-install
        npm rebuild node-sass
        yarn encore prod

Modificare il file .symfony/routes.yaml affinché al sottodominio spa. risponda l’applicazione spa contenuta nella cartella principale del progetto:

1
$ cd ../
patch_file
1
2
3
4
5
6
7
8
--- a/.symfony/routes.yaml
+++ b/.symfony/routes.yaml
@@ -1,2 +1,5 @@
+"https://spa.{all}/": { type: upstream, upstream: "spa:http" }
+"http://spa.{all}/": { type: redirect, to: "https://spa.{all}/" }
+
 "https://{all}/": { type: upstream, upstream: "varnish:http", cache: { enabled: false } }
 "http://{all}/": { type: redirect, to: "https://{all}/" }

Configurare CORS per la SPA

Se si facesse il deploy del codice ora, questo non funzionerebbe poiché un browser bloccherebbe la richiesta API. Dobbiamo permettere esplicitamente alla SPA di accedere all’API. Prendere il nome a dominio corrente legato all’applicazione:

1
$ symfony env:urls --first

Definire, di conseguenza, la variabile d’ambiente CORS_ALLOW_ORIGIN:

1
$ symfony var:set "CORS_ALLOW_ORIGIN=^`symfony env:urls --first | sed 's#/$##' | sed 's#https://#https://spa.#'`$"

Se il dominio è https://master-5szvwec-hzhac461b3a6o.eu.s5y.io/, il comando sed lo convertirà in https://spa.master-5szvwec-hzhac461b3a6o.eu.s5y.io.

Dobbiamo anche impostare la variabile d’ambiente API_ENDPOINT:

1
$ symfony var:set API_ENDPOINT=`symfony env:urls --first`

Commit e deploy:

1
2
3
$ git add .
$ git commit -a -m'Add the SPA application'
$ symfony deploy

Accedere la SPA in un browser specificando l’applicazione come parametro:

1
$ symfony open:remote --app=spa

Usare Cordova per costruire un’applicazione per smartphone

Apache Cordova è uno strumento per costruire applicazioni multipiattaforma per smartphone. La buona notizia è che può utilizzare la SPA che abbiamo appena creato.

Installiamolo:

1
2
$ cd spa
$ yarn global add cordova

Nota

È inoltre necessario installare l’SDK Android. Questa sezione cita solo Android, ma Cordova funziona con tutte le piattaforme mobili, incluso iOS.

Creare la struttura di cartelle dell’applicazione:

1
$ cordova create app

E generare l’applicazione Android:

1
2
3
$ cd app
$ cordova platform add android
$ cd ..

Questo è tutto ciò di cui abbiamo bisogno. Ora è possibile eseguire la «build» dei file di produzione e spostarli in Cordova:

1
2
3
4
$ API_ENDPOINT=`symfony var:export SYMFONY_DEFAULT_ROUTE_URL --dir=..` yarn encore production
$ rm -rf app/www
$ mkdir -p app/www
$ cp -R public/ app/www

Eseguire l’applicazione su uno smartphone o un emulatore:

1
$ cordova run android

  • « Previous Passo 26: Esporre un’API con API Platform
  • Next » Passo 28: Localizzazione di un’applicazione

This work, including the code samples, is licensed under a Creative Commons BY-NC-SA 4.0 license.