Passo 27: Construindo um SPA

5.0 version
Maintained

Construindo um SPA

A maioria dos comentários será enviada ao longo da conferência, onde algumas pessoas não levam um laptop. Mas provavelmente têm um smartphone. Que tal criar um aplicativo móvel para verificar rapidamente os comentários da conferência?

Uma maneira de criar um aplicativo móvel como esse é construir um Single Page Application (SPA) em JavaScript. Um SPA é executado localmente, pode usar armazenamento local, pode chamar uma API HTTP remota e pode aproveitar os service workers para criar uma experiência praticamente nativa.

Criando o Aplicativo

Para criar o aplicativo móvel, vamos usar o Preact e o Encore do Symfony. Preact é uma base pequena e eficiente, adequada para o SPA do Livro de Vistas.

Para manter o site e o SPA consistentes, vamos reutilizar as folhas de estilo Sass do site para o aplicativo móvel.

Crie o aplicativo SPA no diretório spa e copie as folhas de estilo do site:

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

Nota

Criamos um diretório public, pois vamos interagir com o SPA principalmente através de um navegador. Poderíamos chamá-lo build caso quiséssemos apenas construir um aplicativo móvel.

Inicialize o arquivo package.json (o equivalente do composer.json para JavaScript):

1
$ yarn init -y

Agora, adicione algumas dependências obrigatórias:

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

Por medida de segurança, adicione um arquivo .gitignore:

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

A última etapa de configuração é criar a configuração do 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();

Criando o Template Principal do SPA

Hora de criar o template inicial no qual o Preact irá renderizar o aplicativo:

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>

A tag <div> é onde o aplicativo será renderizado pelo JavaScript. Aqui está a primeira versão do código que renderiza a view “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'));

A última linha registra a função App() no elemento #app da página HTML.

Agora está tudo pronto!

Executando um SPA no Navegador

Como esse aplicativo é independente do site principal, precisamos executar outro servidor web:

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

A flag --passthru informa ao servidor web para passar todas as requisições HTTP para o arquivo public/index.html (public/ é o diretório raiz padrão do servidor web). Esta página é gerenciada pelo aplicativo Preact e obtém a página que vai renderizar pelo histórico do “navegador”.

Para compilar os arquivos CSS e JavaScript, execute yarn:

1
$ yarn encore dev

Abra o SPA em um navegador:

1
$ symfony open:local

E confira o nosso SPA olá mundo:

Adicionando um Roteador para Lidar com Estados

No momento, o SPA não é capaz de manipular páginas diferentes. Para implementar várias páginas, precisamos de um roteador, assim como o Symfony. Vamos usar o preact-router. Ele recebe uma URL como entrada e encontra um componente Preact para exibir.

Instale o preact-router:

1
$ yarn add preact-router

Crie uma página para ser a página inicial (um componente Preact):

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

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

E outra para ser a página da conferência:

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

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

Substitua a div “Hello World” pelo componente 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>
     )
 }

Construa o aplicativo novamente:

1
$ yarn encore dev

Se você atualizar o aplicativo no navegador, agora você pode clicar nos links “Home” e no da conferência. Note que a URL e os botões voltar/avançar do seu navegador funcionam conforme esperado.

Estilizando o SPA

Voltando ao site, vamos adicionar o loader do Sass:

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

Ative o loader do Sass no Webpack e adicione uma referência à folha de estilo:

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

Agora podemos atualizar o aplicativo para usar as folhas de estilo:

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>

Construa o aplicativo mais uma vez:

1
$ yarn encore dev

Agora você pode desfrutar de um SPA totalmente estilizado:

Obtendo Dados da API

A estrutura do aplicativo Preact está concluída: O Preact Router manipula os estados da página - incluindo o placeholder para o slug da conferência - e a folha de estilo principal do aplicativo é usada para estilizar o SPA.

Para tornar o SPA dinâmico, precisamos buscar os dados da API através de chamadas HTTP.

Configure o Webpack para expor a variável de ambiente do endpoint da 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();

A variável de ambiente API_ENDPOINT deve apontar para o servidor web do site onde temos o endpoint da API em /api. Vamos configurá-la corretamente quando executarmos o yarn encore em breve.

Crie um arquivo api.js para abstrair a recuperação dos dados da 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);
}

Agora você pode ajustar os componentes header e home:

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

Finalmente, o Preact Router está passando o placeholder “slug” para o componente Conference como uma propriedade. Use-o para exibir a conferência apropriada e seus comentários, novamente usando a API; e adapte a renderização para usar os dados da 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>
     );
-};
+}

Agora o SPA precisa conhecer a URL da nossa API, através da variável de ambiente API_ENDPOINT. Configure-a como a URL do servidor web da API (executando no diretório ..):

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

Agora você pode executar em segundo plano também:

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

E o aplicativo no navegador agora deve funcionar corretamente:

Uau! Agora temos um SPA totalmente funcional, com roteador e dados reais. Podemos organizar ainda mais o aplicativo Preact se quisermos, mas ele já está funcionando muito bem.

Implantando o SPA em Produção

A SymfonyCloud permite implantar várias aplicações por projeto. A adição de outra aplicação pode ser feita criando um arquivo .symfony.cloud.yaml em qualquer subdiretório. Crie um arquivo desse no diretório spa/ com o nome 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

Edite o arquivo .symfony/routes.yaml, roteando o subdomínio spa. para a aplicação spa, armazenada no diretório raiz do projeto:

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

Configurando CORS para o SPA

Se você implantar o código agora, ele não funcionará, pois o navegador bloqueará a requisição para a API. Precisamos permitir explicitamente que o SPA acesse a API. Obtenha o nome de domínio atual atribuído à sua aplicação:

1
$ symfony env:urls --first

Defina a variável de ambiente CORS_ALLOW_ORIGIN adequadamente:

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

Supondo que seu domínio seja https://master-5szvwec-hzhac461b3a6o.eu.s5y.io/, então as chamadas a sed o converterão para https://spa.master-5szvwec-hzhac461b3a6o.eu.s5y.io.

Também precisamos definir a variável de ambiente API_ENDPOINT:

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

Faça o commit e implante:

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

Acesse o SPA em um navegador especificando a aplicação como uma flag:

1
$ symfony open:remote --app=spa

Usando Cordova para Construir um Aplicativo para Smartphone

O Apache Cordova é uma ferramenta que cria aplicativos multiplataforma para smartphones. Boa notícia: ele pode usar o SPA que acabamos de criar.

Vamos instalá-lo:

1
2
$ cd spa
$ yarn global add cordova

Nota

Você também precisa instalar o Android SDK. Esta seção menciona apenas Android, mas o Cordova funciona com todas as plataformas móveis, incluindo iOS.

Crie a estrutura de diretórios do aplicativo:

1
$ cordova create app

E execute a geração do aplicativo Android:

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

Isso é tudo o que você precisa. Agora você pode construir os arquivos de produção e movê-los para o 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

Execute o aplicativo em um smartphone ou emulador:

1
$ cordova run android

  • « Previous Passo 26: Expondo uma API com a API Platform
  • Next » Passo 28: Localizando uma Aplicação

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