Pas 27: Construirea unui SPA

5.0 version
Maintained

Construirea unui SPA

Majoritatea comentariilor vor fi transmise în timpul conferinței în care unii oameni nu aduc laptop. Dar probabil au un smartphone. Ce zici de crearea unei aplicații mobile pentru a verifica rapid comentariile conferinței?

Un mod de a crea o astfel de aplicație mobilă este de a construi o aplicație cu o singură pagină Javascript (SPA). Un SPA rulează local, poate utiliza spațiu de stocare local, poate apela la un API HTTP de pe un server și poate gestiona procese independente pentru a crea o experiență aproape nativă.

Crearea aplicației

Pentru a crea aplicația mobilă, vom folosi Preact și Symfony Encore. Preact este o fundație mică și eficientă, potrivită pentru Guestbook SPA.

Pentru a face cât mai consistent atât site-ul, cât și SPA, vom reutiliza fișierele de stil Sass ale site-ului pentru aplicația mobilă.

Creează aplicația SPA în directorul spa și copie fișierele de stil ale site-ului:

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

Notă

Am creat un director public, deoarece vom interacționa în principal cu SPA prin intermediul unui browser. L-am fi putut numi build doar dacă am fi dorit să construim o aplicație mobilă.

Inițializează fișierul package.json (echivalentul fișierului composer.json pentru JavaScript):

1
$ yarn init -y

Acum, adaugă unele dependențe necesare:

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

Pentru o măsură bună, adaugă un fișier .gitignore:

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

Ultima etapă de configurare este crearea configurației 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();

Crearea modelului principal SPA

A sosit momentul pentru a crea șablonul inițial în care Preact va reda aplicația:

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>

Elementul <div> este locul în care aplicația va fi redată prin JavaScript. Iată prima versiune a codului care redă „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'));

Ultima linie înregistrează funcția App() pe elementul #app al paginii HTML.

Totul este gata acum!

Rularea unui SPA în browser

Deoarece această aplicație este independentă de site-ul principal, trebuie să rulăm un alt server web:

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

Opțiunea --passthru spune serverului web să treacă toate cererile HTTP la fișierul public/index.html (public/ este directorul rădăcină web implicit al serverului web). Această pagină este administrată de aplicația Preact și primește pagina să se redea prin istoricul „browserului”.

Pentru a compila fișierele CSS și JavaScript, rulează yarn:

1
$ yarn encore dev

Deschide SPA-ul într-un browser:

1
$ symfony open:local

Și privește SPA-ul nostru de întâmpinare:

Adăugarea unui router pentru gestionarea stărilor

În prezent, SPA-ul nu este capabi să gestioneze diferite pagini. Pentru a implementa mai multe pagini, avem nevoie de un router, precum cel pentru Symfony. Vom folosi preact-router. Preia o adresă URL ca intrare și o potrivește cu o componentă Preact care va fi afișat.

Instalează router-ul preact:

1
$ yarn add preact-router

Creează o pagină inițială (o componentă Preact):

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

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

Și alta pentru pagina conferinței:

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

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

Înlocuește „Hello World” div cu componenta 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>
     )
 }

Reconstruiește aplicația:

1
$ yarn encore dev

Dacă reîmprospătezi aplicația în browser, poți face acum clic pe linkurile „Home” și conferință. Reține că URL-ul browserului și butoanele înapoi/înainte ale browserului funcționează așa cum te aștepți.

Stilarea SPA

În ceea ce privește site-ul, să adăugăm încărcătorul Sass:

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

Activează încărcătorul Sass din Webpack și adaugă o referință la fișierul de stilzare:

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

Acum putem actualiza aplicația pentru a utiliza fișierele de stil:

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>

Reconstruiește aplicația încă o dată:

1
$ yarn encore dev

Acum te poți bucura de un SPA complet stilat:

Preluarea datelor de la API

Structura aplicației Preact este acum finisată: Preact Router gestionează stările de pagină - inclusiv locul pentru identificatorul de cale - iar fișierul de stil principal este folosit pentru stilul SPA.

Pentru a face SPA-ul dinamic, trebuie să preluăm datele din API prin apeluri HTTP.

Configurează Webpack pentru a expune variabila de mediu endpoint 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();

Variabila de mediu API_ENDPOINT ar trebui să indice către serverul web al site-ului web unde avem punctul final API sub /api. O vom configura corect atunci când vom rula yarn encore în curând.

Creează un fișier api.js care abstractizează preluarea datelor din 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);
}

Poți adapta acum antetul și componentele paginei inițiale:

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

În cele din urmă, Preact Router transmite „identificatorul de cale” componentei Conference ca proprietate. Folosește-l pentru a afișa conferința și comentariile sale, folosind din nou API-ul; și adaptează randarea să folosească datele din 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>
     );
-};
+}

SPA acum trebuie să cunoască adresa URL a API-ului nostru, prin intermediul variabilei de mediu API_ENDPOINT. Seteaz-o la adresa URL a serverului web API (care rulează în directorul ..):

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

De asemenea, poți rula în fundal acum:

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

Iar aplicația din browser ar trebui să funcționeze corect:

Wow! Avem acum un SPA complet funcțional, cu router și date reale. Am putea organiza aplicația Preact în continuare dacă dorim, dar funcționează deja excelent.

Lansarea SPA-ului în producție

SymfonyCloud permite implementarea mai multor aplicații pentru fiecare proiect. Adăugarea unei alte aplicații se poate face prin crearea unui fișier .symfony.cloud.yaml în orice subdirector. Creează unul în spa/ numit 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

Modifică fișierul .symfony/routes.yaml pentru a ruta subdomeniul spa. către aplicația spa stocată în directorul rădăcină al proiectului:

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

Configurarea CORS pentru SPA

Dacă implementezi codul acum, acesta nu va funcționa deoarece un browser ar bloca solicitarea API. Trebuie să permitem în mod explicit SPA-ului să acceseze API-ul. Obține numele de domeniu curent atașat aplicației tale:

1
$ symfony env:urls --first

Definește variabila de mediu CORS_ALLOW_ORIGIN în consecință:

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

Dacă domeniul tău este https://master-5szvwec-hzhac461b3a6o.eu.s5y.io/, apelurile sed îl vor converti în https://spa.master-5szvwec-hzhac461b3a6o.eu.s5y.io.

De asemenea, trebuie să setăm variabila de mediu API_ENDPOINT:

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

Salvează și lansează în producție:

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

Accesați SPA-ul într-un browser specificând aplicația ca steag:

1
$ symfony open:remote --app=spa

Folosind Cordova pentru a crea o aplicație pentru smartphone

Apache Cordova este un instrument care creează aplicații pentru smartphone-uri pe platforme multiple. Și o veste bună, poate folosi SPA-ul pe care tocmai l-am creat.

Să-l instalăm:

1
2
$ cd spa
$ yarn global add cordova

Notă

De asemenea, trebuie să instalezi SDK-ul pentru Android. Această secțiune menționează doar Android, dar Cordova funcționează cu toate platformele mobile, inclusiv cu iOS.

Creează structura directorului aplicației:

1
$ cordova create app

Și generează aplicația Android:

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

Asta este tot ce îți trebuie. Poți acum să compilezi fișierele de producție și să le muți în 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

Rulează aplicația pe un smartphone sau un emulator:

1
$ cordova run android

  • « Previous Pas 26: Expunerea unui API cu API Platform
  • Next » Pas 28: Localizarea unei aplicații

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