50% discount in conference replays
2020 and 2021 events
In English, French, German, Polish and Spanish

Разработка SPA

Разработка SPA

Большинство комментариев будет отправлено во время конференции, на которую не все принесут с собой ноутбуки. Зато, скорее всего, у них будут смартфоны. Так почему бы не создать мобильное приложение, в котором можно быстро посмотреть комментарии с конференции?

Собрать одностраничное приложение (JavaScript Single Page Application, SPA) — один из способов создать такое мобильное приложение. SPA запускается локально, может использовать локальное хранилище, выполнять HTTP-запросы к сторонним API, а ещё поддерживает сервис-воркеры, которые дают преимущества почти настоящего (нативного) приложения.

Создание приложения

Для создания мобильного приложения будем использовать Preact и Symfony Encore. Preact — это небольшая и эффективная библиотека, которая хорошо подходит для нашего SPA-приложения гостевой книги.

Чтобы сделать сайт и SPA понятным и предсказуемым, для мобильного приложения мы будем использовать те же таблицы стилей Sass, что и для сайта.

Создайте SPA-приложение в директории spa и скопируйте туда таблицы стилей:

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

Note

Мы создали директорию public, поскольку, как правило, посещать SPA-приложение будем через браузер. Мы бы назвали эту директорию build в случае, если нам нужно было только мобильное приложение.

Также не забудем про файл .gitignore:

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

Сгенерируйте файл package.json (аналог файла composer.json для JavaScript):

1
$ yarn init -y

А теперь добавим несколько необходимых зависимостей:

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

И последнее — сконфигурируем 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();

Создание основного шаблона для SPA

Пришло время создать главный шаблон, в котором Preact будет рендерить приложение:

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>

В теге <div> с помощью JavaScript будет отрендерено приложение. Первоначальная версия только отобразит на экране надпись "Hello World":

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

function App() {
    return (
        
Hello world!
) } render(, document.getElementById('app'));

В последней строке мы указываем функцию App() для рендера в элементе #app на HTML-странице.

Все готово!

Запуск SPA в браузере

Поскольку данное приложение работает независимо от основного сайта, нам нужно запустить ещё один веб-сервер:

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

Флаг --passthru указывает веб-серверу, что необходимо перенаправлять все HTTP-запросы на файл public/index.html (public/ — корневая директория веб-сервера по умолчанию). Preact инициализирован на этой странице и через API истории браузера он узнает, какую страницу нужно отрендерить.

Для сборки CSS- и JavaScript-файлов выполните команду yarn:

1
$ yarn encore dev

Откройте SPA в браузере:

1
$ symfony open:local

И посмотрите на надпись "Hello world!", которую вывел SPA:

/

Добавление маршрутизатора для обработки состояний

SPA не может обработать несколько страниц. Чтобы добавить их поддержку нам нужен маршрутизатор, по аналогии как в Symfony. Для этого мы будем использовать preact-router. Он принимает URL-адрес и сопоставляет его с Preact-компонентом, что его отрендерить страницу.

Установим preact-router:

1
$ yarn add preact-router

Создадим главную страницу в виде компонента Preact:

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

export default function Home() {
    return (
        
Home
); };

И потом ещё одну страницу для конференций:

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

export default function Conference() {
    return (
        
Conference
); };

Замените элемент div с "Hello World" на компонент 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>
     )
 }

Пересоберите приложение:

1
$ yarn encore dev

Если вы обновите страницу в браузере, то сможете нажать на ссылки "Home" и конференции. Обратите внимание, что URL-адрес вместе с браузерными кнопками перемещения вперёд и назад работают вполне ожидаемым образом (как и в обычных статичных приложениях).

Стилизация SPA

Давайте установим загрузчик Sass на сайт:

1
$ yarn add node-sass sass-loader

Включите загрузчик Sass в Webpack, чтобы можно было импортировать таблицу стилей в коде:

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

Теперь в приложении можно подключить стили:

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>

Пересоберите приложение ещё раз:

1
$ yarn encore dev

А сейчас можно насладиться полностью стилизованным SPA:

/

Получение данных при помощи API

Итак, структура Preact-приложения закончена: Preact Router управляет отображением страниц, включая обработку динамических URL-адресов каждой конференции. Кроме этого, стили основного приложения используется в SPA.

Чтобы сделать SPA динамическим, получим данные из API, выполнив HTTP-запросы.

С помощью Webpack определим глобальную переменную в приложении с URL-адресом 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();

В переменной окружения API_ENDPOINT будет храниться адрес точки входа API, который у нас доступен по пути /api. Установим её позже, когда начнём выполнять команду yarn.

Создайте файл api.js, в котором будет находится логика получения данных из 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);
}

Теперь воспользуемся 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
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>
     );
-};
+}

Preact Router передает заполнитель "slug" в качестве свойства компоненту Conference. Используйте его для отображения соответствующей конференции и комментариев к ней через всё тот же API; также изменим компонент конференции, чтобы он использовал данные из 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-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>
     );
-};
+}

Теперь нам нужно задать URL-адрес нашего API, присвоив его переменной окружения API_ENDPOINT. Используйте для этого URL-адрес веб-сервера API (запущен в директории ..):

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

Вы также можете запустить сервер в фоновом режиме:

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

Сейчас приложение в браузере должно работать корректно:

/
/conference/amsterdam-2019

Вау! Теперь у нас есть полностью рабочее SPA-приложение с маршрутизацией и реалистичными данными. Мы можем и дальше улучшать наше приложение на Preact, но оно уже работает отлично.

Развёртывание SPA в продакшене

Platform.sh позволяет развёртывать несколько приложений в рамках одного проекта. Для добавления другого приложения нужен новый файл .platform.app.yaml в любой поддиректории. Создайте такой файл в директории 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
28
29
name: spa

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

        yarn-install
        unset NPM_CONFIG_PREFIX
        export NVM_DIR=${PLATFORM_APP_DIR}/.nvm
        set +x && . "${PLATFORM_APP_DIR}/.nvm/nvm.sh" && set -x
        yarn encore prod

Отредактируйте файл .platform/routes.yaml так, чтобы перенаправлять запросы с поддомена spa. в приложение spa, которое находится в корневой директории проекта:

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 для SPA

Если попробовать сейчас развернуть приложение, то оно не будет работать, потому что браузер не даст выполнить запрос к API. Чтобы этого не было, нам нужно явно разрешить SPA обращаться к API. Для этого сначала нужно узнать текущий домен, на котором развёрнуто ваше приложение:

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

Затем определите переменную окружения CORS_ALLOW_ORIGIN, как показано ниже:

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.#'`$"

К примеру, если у вас домен https://master-5szvwec-hzhac461b3a6o.eu-5.platformsh.site/, то после выполнения команды sed, он преобразуется в https://spa.master-5szvwec-hzhac461b3a6o.eu-5.platformsh.site.

Нам также нужно установить переменную окружения API_ENDPOINT:

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

Зафиксируйте изменения и разверните:

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

Чтобы автоматически открыть в браузере развёрнутое SPA-приложение, выполните следующую команду:

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

Сборка приложения для смартфона с помощью Cordova

Apache Cordova — это инструмент для создания кроссплатформенных мобильных приложений. Хотя при этом его можно применить с нашим только что созданным SPA.

Давайте установим его:

1
2
$ cd spa
$ yarn global add cordova

Note

Также необходимо установить Android SDK. В этой книге мы добавим поддержку только для Android, хотя Cordova работает со всеми мобильными платформами, включая iOS.

Создайте структуру директорий для приложения:

1
$ ~/.yarn/bin/cordova create app

А теперь сгенерируйте приложение под Android:

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

Это всё, что нужно. Теперь вы можете собрать файлы приложения и передать в Cordova:

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

Запустите приложение на вашем смартфоне или эмуляторе:

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