Etap 27: Budowa aplikacji jednostronicowej (ang. single-page application, SPA)
Budowa aplikacji jednostronicowej (ang. single-page application, SPA)¶
Większość komentarzy zostanie dodana podczas trwania konferencji, na którą część uczestników nie zabierze laptopa, ale prawdopodobnie będą mieć ze sobą smartfon. A gdyby tak utworzyć aplikację mobilną pozwalającą szybko sprawdzić komentarze z konferencji?
Jednym ze sposobów na stworzenie takiej aplikacji mobilnej jest zbudowanie aplikacji jednostronicowej (SPA) opartej o JavaScript. SPA działa lokalnie, może korzystać z lokalnej pamięci masowej, może wysłać żądanie (ang. request) do zdalnego API HTTP i może wykorzystać mechanizmy service workers do stworzenia niemalże natywnego doświadczenia.
Tworzenie aplikacji¶
Do stworzenia aplikacji mobilnej wykorzystamy Preact i Symfony Encore. Preact jest małym i efektywnym frameworkiem, dobrze pasującym do SPA dla księgi gości.
Aby zarówno strona internetowa, jak i SPA były spójne, wykorzystamy ponownie arkusze stylów Sass naszej strony internetowej w aplikacji mobilnej.
Utwórz aplikację SPA w katalogu spa
i skopiuj arkusze stylów strony internetowej:
1 2 3 | $ mkdir -p spa/src spa/public spa/assets/styles
$ cp assets/styles/*.scss spa/assets/styles/
$ cd spa
|
Informacja
Stworzyliśmy katalog public
, ponieważ będziemy głównie używać SPA poprzez przeglądarkę. Mogliśmy go nazwać build
, gdybyśmy chcieli zbudować tylko aplikację mobilną.
Utwórz plik package.json
(odpowiednik composer.json
dla JavaScript):
1 | $ yarn init -y
|
Teraz dodaj kilka wymaganych zależności:
1 | $ yarn add @symfony/webpack-encore @babel/core @babel/preset-env babel-preset-preact preact html-webpack-plugin bootstrap
|
Dodatkowo, dodaj plik .gitignore
:
1 2 3 4 5 | /node_modules
/public
/yarn-error.log
# used later by Cordova
/app
|
Ostatnim krokiem w konfiguracji jest utworzenie pliku konfiguracyjnego Webpack Encore:
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();
|
Tworzenie szablonu głównego SPA¶
Czas na utworzenie początkowego szablonu, w którym Preact będzie renderował aplikację:
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>
|
Element <div>
to miejsce, w którym aplikacja będzie renderowana przez JavaScript. Oto pierwsza wersja kodu, która wyświetla widok „Hello World”:
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'));
|
Ostatnia linia rejestruje funkcję App()
na elemencie #app
strony HTML.
Wszystko jest już gotowe!
Uruchomienie SPA w przeglądarce¶
Ponieważ aplikacja ta jest niezależna od głównej strony internetowej, musimy uruchomić kolejny serwer WWW:
1 | $ symfony server:stop
|
1 | $ symfony server:start -d --passthru=index.html
|
Flaga --passthru
mówi serwerowi WWW, aby przekazywał wszystkie żądania HTTP do pliku public/index.html
(jest to domyślny katalog główny serwera WWW). Strona ta jest zarządzana przez aplikację Preact i otrzymuje stronę do renderowania poprzez historię „przeglądarki”.
Aby skompilować pliki CSS i JavaScript, uruchom yarn
:
1 | $ yarn encore dev
|
Otwórz SPA w przeglądarce:
1 | $ symfony open:local
|
I spójrz na naszą SPA „hello world”:

Dodanie routera do obsługi stanu aplikacji¶
Obecnie SPA nie jest w stanie obsługiwać różnych stron. Aby zaimplementować kilka stron, potrzebujemy routera, tak jak w przypadku Symfony. Użyjemy preact-router. Pobiera adres URL jako wejście i dopasowuje komponent Preact, który ma wyświetlić.
Zainstaluj preact-router:
1 | $ yarn add preact-router
|
Utwórz stronę główną (komponent Preact):
1 2 3 4 5 6 7 | import {h} from 'preact';
export default function Home() {
return (
<div>Home</div>
);
};
|
I kolejną stronę dla konferencji:
1 2 3 4 5 6 7 | import {h} from 'preact';
export default function Conference() {
return (
<div>Conference</div>
);
};
|
Zamień div
„Hello World” na komponent Router
:
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>
)
}
|
Przebuduj aplikację:
1 | $ yarn encore dev
|
Jeśli odświeżysz aplikację w przeglądarce, możesz teraz kliknąć na „Home” i odnośniki do konferencji. Zauważ, że adres URL przeglądarki i przyciski „wstecz/dalej” przeglądarki działają tak, jak tego oczekujemy.
Stylowanie SPA¶
Jeśli chodzi o stronę internetową, dodajmy kompilator Sass:
1 | $ yarn add node-sass sass-loader
|
Włącz kompilator Sass w Webpacku i dodaj referencję do arkusza stylów:
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 }))
;
|
Możemy teraz zaktualizować aplikację, aby móc korzystać z arkuszy stylów:
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="/">
+ 📙 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>
|
Ponownie przebuduj aplikację:
1 | $ yarn encore dev
|
Teraz możesz cieszyć się w pełni ostylowaną SPA:

Pobieranie danych z API¶
Struktura aplikacji Preact została już utworzona: Preact Router obsługuje stany stron – wliczając w to parametr ze slugiem konferencji – a główny arkusz stylów aplikacji jest używany do stylowania SPA.
Aby SPA była dynamiczna, musimy pobrać dane z API poprzez połączenia HTTP.
Skonfiguruj Webpack, aby udostępnić zmienną środowiskową punktu końcowego (ang. endpoint) API:
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();
|
Zmienna środowiskowa``API_ENDPOINT`` powinna wskazywać na serwer strony internetowej, na którym znajduje się punkt końcowy (ang. endpoint) API pod /api
. Skonfigurujemy go prawidłowo, kiedy niebawem uruchomimy yarn encore
.
Utwórz plik api.js
, który obsłuży pobieranie danych z API:
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);
}
|
Teraz można dostosować nagłówek i główny komponent:
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-blue stretched-link" href={'/conference/'+conference.slug}>
+ View
+ </Link>
+ </div>
+ </div>
+ ))}
+ </div>
);
-};
+}
|
Na koniec, Preact Router przekazuje symbol zastępczy (ang. placeholder) „slug” do komponentu Conference jako atrybut. Użyj go do wyświetlania właściwej konferencji i jej komentarzy, ponownie korzystając z API i przystosuj renderowanie do korzystania z danych API:
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 musi teraz znać adres URL do naszego API, dzięki zmiennej środowiskowej API_ENDPOINT
. Ustaw ją na adres URL serwera WWW API (działający w katalogu ..
):
1 | $ API_ENDPOINT=`symfony var:export SYMFONY_PROJECT_DEFAULT_ROUTE_URL --dir=..` yarn encore dev
|
Możesz też uruchomić w tle:
1 | $ API_ENDPOINT=`symfony var:export SYMFONY_PROJECT_DEFAULT_ROUTE_URL --dir=..` symfony run -d --watch=webpack.config.js yarn encore dev --watch
|
Aplikacja w przeglądarce powinna teraz działać poprawnie:


Wow! Mamy teraz w pełni funkcjonalną SPA z routerem i prawdziwymi danymi. Moglibyśmy rozwinąć aplikację Preact dalej, jeśli chcemy, ale na tą chwilę działa świetnie.
Wdrażanie SPA w środowisku produkcyjnym¶
SymfonyCloud pozwala na wdrażanie wielu aplikacji w jednym projekcie. Dodanie kolejnej aplikacji można wykonać poprzez utworzenie pliku .symfony.cloud.yaml
w dowolnym podkatalogu. Utwórz taki plik w katalogu spa/
o nazwie spa
:
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 | name: spa
type: php:8.0
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 -s https://get.symfony.com/cloud/configurator | (>&2 bash)
(>&2
unset NPM_CONFIG_PREFIX
export NVM_DIR=${SYMFONY_APP_DIR}/.nvm
yarn-install
set +x && . "${SYMFONY_APP_DIR}/.nvm/nvm.sh" && set -x
yarn encore prod
)
|
Edytuj plik .symfony/routes.yaml
w celu przekierowania subdomeny spa.
do aplikacji spa
przechowywanej w katalogu głównym projektu:
1 | $ cd ../
|
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}/" }
|
Konfigurowanie CORS dla SPA¶
Wdrożony teraz kod nie zadziała, gdyż żądanie do API zostałoby zablokowane przez przeglądarkę. Musimy wyraźnie zezwolić SPA na dostęp do API. Pobierz bieżącą nazwę domeny, w której znajduje się Twoja aplikacja:
1 | $ symfony env:urls --first
|
Zdefiniuj zmienną środowiskową CORS_ALLOW_ORIGIN
:
1 | $ symfony var:set "CORS_ALLOW_ORIGIN=^`symfony env:urls --first | sed 's#/$##' | sed 's#https://#https://spa.#'`$"
|
Jeśli twoja domena to https://master-5szvwec-hzhac461b3a6o.eu.s5y.io/
, komenda sed
przekonwertuje ją do https://spa.master-5szvwec-hzhac461b3a6o.eu.s5y.io
.
Musimy również ustawić zmienną środowiskową API_ENDPOINT
:
1 | $ symfony var:set API_ENDPOINT=`symfony env:urls --first`
|
Zatwierdź (ang. commit) i wdróż (ang. deploy) zmiany:
1 2 3 | $ git add .
$ git commit -a -m'Add the SPA application'
$ symfony deploy
|
Uzyskaj dostęp do SPA w przeglądarce, przekazując nazwę aplikacji w parametrze:
1 | $ symfony open:remote --app=spa
|
Używanie narzędzia Cordova do budowy aplikacji na smartfony¶
Apache Cordova jest narzędziem budującym wieloplatformowe aplikacje na smartfony. I dobra wiadomość jest taka, że może skorzystać z właśnie stworzonej przez nas SPA.
Zainstaluj Cordovę:
1 2 | $ cd spa
$ yarn global add cordova
|
Informacja
Musisz również zainstalować Android SDK. Ta sekcja wspomina tylko o Androidzie, ale Cordova działa na wszystkich platformach mobilnych, w tym iOS.
Stwórz strukturę katalogów aplikacji:
1 | $ cordova create app
|
I wygeneruj aplikację dla systemu Android:
1 2 3 | $ cd app
$ cordova platform add android
$ cd ..
|
To wszystko, czego potrzebujesz. Teraz możesz zbudować pliki produkcyjne i przenieść je do Cordovy:
1 2 3 4 | $ API_ENDPOINT=`symfony var:export SYMFONY_PROJECT_DEFAULT_ROUTE_URL --dir=..` yarn encore production
$ rm -rf app/www
$ mkdir -p app/www
$ cp -R public/ app/www
|
Uruchom aplikację na smartfonie lub emulatorze:
1 | $ cordova run android
|
- « Previous Etap 26: Udostępnianie API za pomocą biblioteki API Platform
- Next » Etap 28: Lokalizacja aplikacji
This work, including the code samples, is licensed under a Creative Commons BY-NC-SA 4.0 license.