Разработка 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
:
Сгенерируйте файл package.json
(аналог файла composer.json
для JavaScript):
1
$ npm init -y
А теперь добавим несколько необходимых зависимостей:
1
$ npm install @symfony/webpack-encore @babel/core @babel/preset-env babel-preset-preact preact html-webpack-plugin bootstrap
И последнее — сконфигурируем Webpack Encore:
Создание основного шаблона для SPA
Пришло время создать главный шаблон, в котором Preact будет рендерить приложение:
В теге <div>
с помощью JavaScript будет отрендерено приложение. Первоначальная версия только отобразит на экране надпись "Hello World":
В последней строке мы указываем функцию 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-файлов выполните команду npm
:
1
$ ./node_modules/.bin/encore dev
Откройте SPA в браузере:
1
$ symfony open:local
И посмотрите на надпись "Hello world!", которую вывел SPA:
Добавление маршрутизатора для обработки состояний
SPA не может обработать несколько страниц. Чтобы добавить их поддержку нам нужен маршрутизатор, по аналогии как в Symfony. Для этого мы будем использовать preact-router. Он принимает URL-адрес и сопоставляет его с Preact-компонентом, что его отрендерить страницу.
Установим preact-router:
1
$ npm install preact-router
Создадим главную страницу в виде компонента Preact:
И потом ещё одну страницу для конференций:
Замените элемент 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
$ ./node_modules/.bin/encore dev
Если вы обновите страницу в браузере, то сможете нажать на ссылки "Home" и конференции. Обратите внимание, что URL-адрес вместе с браузерными кнопками перемещения вперёд и назад работают вполне ожидаемым образом (как и в обычных статичных приложениях).
Стилизация SPA
Давайте установим загрузчик Sass на сайт:
1
$ npm install 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
$ ./node_modules/.bin/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
. Установим её позже, когда начнём выполнять команду npm
.
Создайте файл api.js
, в котором будет находится логика получения данных из 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 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=..` ./node_modules/.bin/encore dev
Вы также можете запустить сервер в фоновом режиме:
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
Сейчас приложение в браузере должно работать корректно:
Вау! Теперь у нас есть полностью рабочее SPA-приложение с маршрутизацией и реалистичными данными. Мы можем и дальше улучшать наше приложение на Preact, но оно уже работает отлично.
Развёртывание SPA в продакшене
Platform.sh позволяет развёртывать несколько приложений в рамках одного проекта. Для добавления другого приложения нужен новый файл .platform.app.yaml
в любой поддиректории. Создайте такой файл в директории spa/
:
Отредактируйте файл .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
$ npm install cordova
Note
Также необходимо установить Android SDK. В этой книге мы добавим поддержку только для Android, хотя Cordova работает со всеми мобильными платформами, включая iOS.
Создайте структуру директорий для приложения:
1
$ ./node_modules/.bin/cordova create app
А теперь сгенерируйте приложение под Android:
1 2 3
$ cd app
$ ~/.npm/bin/cordova platform add android
$ cd ..
Это всё, что нужно. Теперь вы можете собрать файлы приложения и передать в Cordova:
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
Запустите приложение на вашем смартфоне или эмуляторе:
1
$ ./node_modules/.bin/cordova run android
Двигаемся дальше