Stap 27: Een SPA bouwen

5.0 version
Maintained

Een SPA bouwen

De meeste reacties zullen tijdens de conferentie worden gepost. Sommige mensen zullen daar geen laptop bij zich hebben, maar waarschijnlijk wel een smartphone. Laten we een mobiele app maken om snel de reacties van een conferentie te kunnen checken.

Een manier om zo’n mobiele applicatie te maken is het bouwen van een Javascript Single Page Application (SPA). Een SPA draait lokaal, kan lokale opslag gebruiken, kan een externe HTTP-API aanroepen en kan service workers gebruiken om een bijna native ervaring te creëren.

De applicatie creëren

Om de mobiele applicatie te creëren, gaan we Preact en Symfony Encore gebruiken. Preact is een kleine en efficiënte basis die zeer geschikt is voor de Gastenboek-SPA.

Om de website en de SPA consistent te maken, gaan we de Sass-stylesheets van de website hergebruiken voor de mobiele applicatie.

Maak de SPA-applicatie aan onder de spa-map en kopieer de stylesheets van de website:

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

Notitie

We hebben een public-map aangemaakt omdat we voornamelijk via een browser met de SPA zullen communiceren. We hadden het build kunnen noemen als we alleen een mobiele applicatie zouden bouwen.

Maak het package.json-bestand aan (het Javascript-equivalent van het composer.json-bestand):

1
$ yarn init -y

Voeg nu wat vereiste dependencies toe:

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

Voeg alvast een .gitignore bestand toe:

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

De laatste configuratiestap is het maken van de Webpack Encore-configuratie:

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();

De basistemplate van de SPA aanmaken

Tijd om de initiële template te maken waarin Preact de applicatie zal renderen:

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>

De <div>-tag is waar de toepassing wordt weergegeven door JavaScript. Hier is de eerste versie van de code die de “Hello World”-weergave weergeeft:

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'));

De laatste regel registreert de App()-functie op het #app-element van de HTML-pagina.

Nu is alles klaar!

Een SPA uitvoeren in de browser

Omdat deze applicatie onafhankelijk is van de hoofdwebsite, moeten we een andere webserver draaien:

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

De --passthru-vlag vertelt de webserver om alle HTTP-requests door te geven aan het public/index.html-bestand ( public/ is de standaard root-map van de webserver). Deze pagina wordt beheerd door de Preact-toepassing en die laat de pagina gerenderd worden via de browsergeschiedenis.

Voer yarn uit om de CSS- en de JavaScript-bestanden te compileren:

1
$ yarn encore dev

Open de SPA in een browser:

1
$ symfony open:local

En kijk eens naar onze “hello world”-SPA:

Een router toevoegen om states te beheren

De SPA is momenteel niet in staat om verschillende pagina’s te verwerken. Om meerdere pagina’s te implementeren hebben we een router nodig, zoals voor Symfony. We gaan gebruik maken van de preact-router. Het neemt een URL als invoer en koppelt met een Preact component om weer te geven.

Installeer de preact-router:

1
$ yarn add preact-router

Maak een pagina aan voor de homepage (een Preact component ):

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

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

En nog een voor de conferentiepagina:

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

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

Vervang de “Hello World” div door de Router component:

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>
     )
 }

Bouw de applicatie opnieuw:

1
$ yarn encore dev

Als je de applicatie in de browser vernieuwt, kan je op de “Home” en conferentie links klikken. Merk op dat de browser-URL en de terug/vooruit-knoppen van jouw browser werken zoals je dat zou verwachten.

De SPA van styling voorzien

Zoals bij de website, gaan we de Sass loader toevoegen:

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

Activeer de Sass loader in Webpack en voeg een verwijzing naar de stylesheet toe:

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 }))
 ;

We kunnen de applicatie nu bijwerken om de stylesheets te gebruiken:

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>

Bouw de applicatie opnieuw:

1
$ yarn encore dev

Je kan nu genieten van een SPA die van styling voorzien is:

Gegevens uit de API halen

De Preact applicatiestructuur is nu klaar: Preact router behandelt de pagina states - inclusief de conference slug placeholder - en de belangrijkste stylesheet van de toepassing wordt gebruikt om de SPA van styling te voorzien.

Om de SPA dynamisch te maken, moeten we gegevens via de API ophalen met HTTP-calls.

Configureer Webpack om de API-endpoint-omgevingsvariabele beschikbaar te maken:

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();

De API_ENDPOINT-omgevingsvariabele moet verwijzen naar de webserver van de website waar het API-endpoint /api staat. We zullen dit binnenkort beter configureren wanneer we yarn encore draaien.

Maak een api.js bestand aan dat het ophalen van gegevens uit de API verzorgt:

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);
}

Je kan nu de header- en home componenten aanpassen:

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>
     );
-};
+}

Eindelijk geeft Preact Router de “slug” placeholder netjes door aan het Conference component. Gebruik deze om de juiste conferentie en bijhorende reacties te tonen, wederom met behulp van de API; en pas de rendering aan om de API-gegevens te gebruiken:

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>
     );
-};
+}

De SPA moet de URL naar onze API weten via de API_ENDPOINT-omgevingsvariabele. Stel deze in op de URL van de API-webserver (die in de .. directory wordt uitgevoerd):

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

Je kunt dit nu ook op de achtergrond laten lopen:

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

En de toepassing in de browser zou nu goed moeten werken:

Wow! We hebben nu een volledig functionele SPA, met router en echte data. We zouden de Preact app verder kunnen organiseren als we dat willen, maar het werkt nu al geweldig.

De SPA naar productie deployen

SymfonyCloud maakt het mogelijk om meerdere applicaties per project te deployen. Het toevoegen van een andere toepassing kan gedaan worden door een .symfony.cloud.yaml bestand aan te maken in een subdirectory. Maak er een aan onder de spa/ directory met de naam 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

Bewerk het .symfony/routes.yaml bestand om het spa. subdomein te routeren naar de spa applicatie die is opgeslagen in de hoofdmap van het project:

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}/" }

CORS configureren voor de SPA

Als je de code nu deployed, zal deze niet werken omdat een browser het API-verzoek zal blokkeren. We moeten de SPA expliciet toegang geven tot de API. Gebruik de huidige domeinnaam die aan de applicatie gekoppeld is:

1
$ symfony env:urls --first

Definieer de CORS_ALLOW_ORIGIN omgevingsvariabele net zo:

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

Als jouw domein gelijk is aan https://master-5szvwec-hzhac461b3a6o.eu.s5y.io/, zullen de sed calls deze omzetten naar https://spa.master-5szvwec-hzhac461b3a6o.eu.s5y.io.

We moeten ook de API_ENDPOINT omgevingsvariabele instellen:

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

Commit en deploy:

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

Bezoek de SPA in een browser door de applicatie als een parameter mee te geven:

1
$ symfony open:remote --app=spa

Cordova gebruiken voor het bouwen van een smartphone-applicatie

Apache Cordova is een tool die cross-platform smartphone-toepassingen bouwt. En goed nieuws, het kan gebruik maken van de SPA die we zojuist hebben gecreëerd.

Laten we dit installeren:

1
2
$ cd spa
$ yarn global add cordova

Notitie

Je moet ook de Android SDK installeren. Deze sectie vermeldt alleen Android, maar Cordova werkt met alle mobiele platforms, inclusief iOS.

Maak de mapstructuur van de toepassing aan:

1
$ cordova create app

En genereer de Android-applicatie:

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

Dat is alles wat je nodig hebt. Je kan nu de productiebestanden bouwen en deze naar Cordova verplaatsen:

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

Start de applicatie op een smartphone of emulator:

1
$ cordova run android

  • « Previous Stap 26: Een API beschikbaar maken met API platform
  • Next » Stap 28: Een applicatie internationaliseren

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