Skip to content

Aufbau einer SPA

Die meisten Kommentare werden während der Konferenz eingereicht, wo einige Leute keinen Laptop mitbringen. Wahrscheinlich haben sie jedoch ein Smartphone. Wie wäre es mit der Erstellung einer mobilen App, um schnell die Kommentare zur Konferenz lesen zu können?

Eine Möglichkeit, eine solche mobile Anwendung zu erstellen, ist die Erstellung einer Javascript Single Page Application (SPA). Eine SPA läuft lokal, kann den Local-Storage verwenden, kann eine Remote-HTTP-API aufrufen und Service-Worker nutzen, um eine nahezu native Erfahrung zu schaffen.

Die Anwendung erstellen

Um die mobile Anwendung zu erstellen, werden wir Preact und Symfony Encore verwenden. Preact ist eine kleine und effiziente Basis, die sich gut für die Gästebuch SPA eignet.

Um sowohl die Website als auch die SPA konsistent zu machen, werden wir die Sass-Stylesheets der Website für die mobile Anwendung wiederverwenden.

Erstelle die SPA-Anwendung unterhalb des spa-Verzeichnisses und kopiere die Stylesheets der Website:

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

Note

Wir haben ein public-Verzeichnis erstellt, da wir hauptsächlich über einen Browser mit der SPA interagieren werden. Wir hätten es build nennen können, wenn wir lediglich eine mobile Anwendung entwickeln wollten.

Füge sicherheitshalber eine .gitignore-Datei hinzu:

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

Initialisiere die package.json-Datei (entspricht der composer.json-Datei für JavaScript):

1
$ npm init -y

Füge nun einige erforderliche Dependencies hinzu:

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

Der letzte Konfigurationsschritt besteht darin, die Webpack Encore-Konfiguration zu erstellen:

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

Das SPA Haupt-Template erstellen

Zeit, das initiale Template zu erstellen, in der Preact die Anwendung rendern wird:

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>

Der <div>-Tag ist der Ort, an dem die Anwendung per JavaScript dargestellt wird. Hier ist die erste Version des Codes, der die "Hello World"-Ansicht darstellt:

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

Die letzte Zeile registriert die App()-Funktion auf dem #app-Element der HTML-Seite.

Jetzt ist alles bereit!

Eine SPA im Browser ausführen

Da diese Anwendung unabhängig von der Haupt-Website ist, müssen wir einen anderen Webserver betreiben:

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

Das --passthru-Flag weist den Webserver an, alle HTTP-Requests an die public/index.html-Datei zu übergeben (public/ ist das Standard-Web-Root-Verzeichnis des Webservers). Diese Seite wird von der Preact-Anwendung verwaltet und ermittelt die zu rendernde Seite über den Pfad im Browser.

Um die CSS und die JavaScript-Dateien zu kompilieren, führe npm aus:

1
$ ./node_modules/.bin/encore dev

Öffne die SPA in einem Browser:

1
$ symfony open:local

Und schau Dir unsere Hallo-Welt SPA an:

/

Einen Router zur Behandlung von Zuständen hinzufügen

Die SPA ist derzeit nicht in der Lage, verschiedene Seiten zu verarbeiten. Um mehrere Seiten zu implementieren, benötigen wir einen Router, wie bei Symfony. Wir werden den preact-router verwenden. Er nimmt eine URL als Input und ordnet sie einer Preact-Komponente zu, die angezeigt werden soll.

Installiere den Preact-Router:

1
$ npm install preact-router

Erstelle eine Seite für die Homepage (eine Preact-Komponente):

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

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

Und noch eine für die Konferenzseite:

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

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

Ersetze das "Hello World"-div mit der Router-Komponente:

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

Erstelle die Anwendung neu:

1
$ ./node_modules/.bin/encore dev

Wenn Du die Anwendung im Browser aktualisierst, kannst Du nun auf die Links "Home" und "Konferenz" klicken. Du siehst, dass die Browser-URL und die Vor- und Zurück-Buttons Deines Browsers so funktionieren, wie Du es erwarten würdest.

Die SPA gestalten

Lass uns den Sass-Loader zur Website hinzufügen:

1
$ npm install node-sass sass-loader

Aktiviere den Sass-Loader in Webpack und füge eine Referenz auf das Stylesheet hinzu:

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

Wir können nun die Anwendung aktualisieren, um die Stylesheets zu verwenden:

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>

Erstelle die Anwendung noch einmal neu:

1
$ ./node_modules/.bin/encore dev

Du kommst nun in den Genuss einer komplett gestylten SPA:

/

Daten aus der API holen

Die Preact-Anwendungsstruktur ist nun fertig: Der Preact-Router verarbeitet die Seitenzustände – einschließlich des Platzhalters für den Konferenz-Slug – und das Stylesheet der Hauptanwendung wird zur Gestaltung des SPA verwendet.

Um die SPA dynamisch zu machen, müssen wir die Daten mittels HTTP-Requests aus der API holen.

Konfiguriere Webpack, um die Environment-Variable für die API-Endpunkte zu definieren:

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

Die Environment-Variable API_ENDPOINT sollte auf den Webserver der Website zeigen, wo wir unter /api den API-Endpunkt haben. Wir werden sie ordnungsgemäß konfigurieren, sobald wir npm ausführen.

Erstelle eine api.js-Datei, die den Datenabruf aus der API abstrahiert:

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

Du kannst nun die Header- und Home-Komponenten anpassen:

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/styles/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-primary stretched-link" href={'/conference/'+conference.slug}>
+                            View
+                        </Link>
+                    </div>
+                </div>
+            ))}
+        </div>
     );
-};
+}

Schließlich übergibt der Preact Router den Platzhalter "slug" als Eigenschaft an die Konferenz-Komponente. Verwende ihn um die richtige Konferenz und ihre Kommentare darzustellen, wobei du wieder die API nutzt; passe außerdem das Rendering an, um die API-Daten zu verwenden:

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

Die SPA muss nun die URL zu unserer API über die Environment-Variable API_ENDPOINT kennen. Setze sie auf die API-Webserver-URL (die im ..-Verzeichnis läuft):

1
$ API_ENDPOINT=`symfony var:export SYMFONY_PROJECT_DEFAULT_ROUTE_URL --dir=..` ./node_modules/.bin/encore dev

Du könntest es jetzt auch im Hintergrund laufen lassen:

1
$ API_ENDPOINT=`symfony var:export SYMFONY_PROJECT_DEFAULT_ROUTE_URL --dir=..` symfony run -d --watch=webpack.config.js ./node_modules/.bin/encore dev --watch

Die Anwendung sollte nun im Browser einwandfrei funktionieren:

/
/conference/amsterdam-2019

Wow! Wir haben jetzt eine voll funktionsfähige SPA mit Router und echten Daten. Wir könnten die Preact-App weiter organisieren, wenn wir wollen, aber sie funktioniert bereits hervorragend.

Die SPA zum Produktivsystem deployen

Platform.sh ermöglicht es, mehrere Anwendungen pro Projekt zu deployen. Das Hinzufügen einer weiteren Anwendung kann durch Erstellen einer .platform.app.yaml-Datei in einem beliebigen Unterverzeichnis erfolgen. Erstelle eine unter spa/ namens spa:

.platform.app.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
name: spa

type: nodejs:18

size: S

build:
    flavor: none

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

hooks:
    build: |
        set -x -e

        curl -fs https://get.symfony.com/cloud/configurator | bash

        NODE_VERSION=18 node-build

Bearbeite die .platform/routes.yaml-Datei, um die spa.-Subdomain an die im Projekt-Stammverzeichnis gespeicherte spa-Anwendung weiterzuleiten:

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

CORS für die SPA konfigurieren

Wenn Du den Code jetzt deployst, funktioniert er nicht, da ein Browser die API-Anfrage blockieren würde. Wir müssen der SPA explizit den Zugriff auf die API erlauben. Hole Dir den aktuellen Domainnamen, der mit Deiner Anwendung verknüpft ist:

1
$ symfony cloud:env:url --pipe --primary

Definiere die Environment-Variable CORS_ALLOW_ORIGIN entsprechend:

1
$ symfony cloud:variable:create --sensitive=1 --level=project -y --name=env:CORS_ALLOW_ORIGIN --value="^`symfony cloud:env:url --pipe --primary | sed 's#/$##' | sed 's#https://#https://spa.#'`$"

Wenn Deine Domain https://master-5szvwec-hzhac461b3a6o.eu-5.platformsh.site/ ist, wird sie durch diesed-Aufrufe zu https://spa.master-5szvwec-hzhac461b3a6o.eu-5.platformsh.site umgewandelt.

Wir müssen auch die Environment-Variable API_ENDPOINT setzen:

1
$ symfony cloud:variable:create --sensitive=1 --level=project -y --name=env:API_ENDPOINT --value=`symfony cloud:env:url --pipe --primary`

Committe und Deploye:

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

Greife in einem Browser auf die SPA zu, indem Du die Anwendung als Flag angibst:

1
$ symfony cloud:url -1 --app=spa

Eine Smartphone-Anwendung mit Cordova erstellen

Apache Cordova ist ein Tool, das plattformübergreifende Smartphone-Anwendungen erstellt. Eine gute Nachricht, es kann die SPA nutzen, die wir gerade erstellt haben.

Lass es uns installieren:

1
2
$ cd spa
$ npm install cordova

Note

Du musst auch das Android SDK installieren. Dieser Abschnitt erwähnt nur Android, aber Cordova funktioniert mit allen mobilen Plattformen, einschließlich iOS.

Erstelle die Verzeichnisstruktur der Anwendung:

1
$ ./node_modules/.bin/cordova create app

Und generiere die Android-Applikation:

1
2
3
$ cd app
$ ~/.npm/bin/cordova platform add android
$ cd ..

Das ist alles, was Du brauchst. Du kannst nun die Produktivdateien erstellen und zu Cordova verschieben:

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

Führe die Anwendung auf einem Smartphone oder einem Emulator aus:

1
$ ./node_modules/.bin/cordova run android
This work, including the code samples, is licensed under a Creative Commons BY-NC-SA 4.0 license.
TOC
    Version