Разработка 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:
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:
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 будет рендерить приложение:
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":
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'));
    В последней строке мы указываем функцию App() для рендера в элементе #app на HTML-странице.
Все готово!
Запуск SPA в браузере
Поскольку данное приложение работает независимо от основного сайта, нам нужно запустить ещё один веб-сервер:
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:
1 2 3 4 5 6 7
import {h} from 'preact';
export default function Home() {
    return (
        <div>Home</div>
    );
};
    И потом ещё одну страницу для конференций:
1 2 3 4 5 6 7
import {h} from 'preact';
export default function Conference() {
    return (
        <div>Conference</div>
    );
};
    Замените элемент 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="/">
+                            📙 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:
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
    Сейчас приложение в браузере должно работать корректно:
    
    Вау! Теперь у нас есть полностью рабочее SPA-приложение с маршрутизацией и реалистичными данными. Мы можем и дальше улучшать наше приложение на Preact, но оно уже работает отлично.
Развёртывание SPA в продакшене
Platform.sh позволяет развёртывать несколько приложений в рамках одного проекта. Для добавления другого приложения нужен новый файл .platform.app.yaml в любой поддиректории. Создайте такой файл в директории 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
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