گام 27: ساخت یک SPA

5.0 version
Maintained

ساخت یک SPA

اکثر کامنت‌ها در طول کنفرانس ارسال می‌شوند که کاربران با خود لپتاپ به همراه ندارند. اما آن‌ها احتمالاً گوشی هوشمند دارند. چطور است که یک اپلیکیشن موبایل برای بررسی سریع کامنت‌های کنفرانس، ایجاد کنیم؟

یک راه برای ایجاد چنین اپلیکیشن موبایلی‌ای، ساخت یک اپلیکیشن تک‌صفحه‌ای (SPA) به کمک Javascript است. یک SPA به صورت محلی اجرا می‌شود، می‌تواند از انبار محلی (local storage) استفاده کند، می‌تواند یک HTTP API ریموت را فراخوانی کند و از سرویس‌های کارگر (service workers) بهره بگیرد تا یک تجربه‌ی تقریباً بومی (native) را ایجاد کند.

ایجاد اپلیکیشن

ما می‌خواهیم برای ایجاد اپلیکیشن موبایلی، از Preact و Symfony Encore استفاده کنیم. Preact یک پایه‌ی کوچک، کارا و درخور برای SPA مربوط به Guestbook است.

برای اینکه هم وب‌سایت و هم SPA را سازگار نماییم، می‌خواهیم stylesheet‌های Sass مربوط به وب‌سایت را در اپلیکیشن موبایل بازاستفاده کنیم.

اپلیکیشن SPA را در پوشه‌ی spa ایجاد کنید و stylesheet‌های وب‌سایت را کپی کنید:

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

توجه

از آنجایی که عمدتاً از طریق مرورگر با SPA در تعامل خواهیم بود، یک پوشه‌ی public نیز ایجاد کرده‌ایم. اگر تنها می‌خواستیم که یک اپلیکیشن موبایلی ایجاد کنیم، می‌توانستیم نام آن را build بگذاریم.

فایل 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

علاوه بر این، فایل .gitignore را هم اضافه کنید:

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

آخرین گام پیکربندی، ایجاد پیکربندی 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 اپلیکیشن را render خواهد کرد، ایجاد کنیم:

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 در آن render خواهد شد. این اولین نسخه از کد است که نمایشی از «Hello World» را render می‌کند:

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

آخرین خط، تابع App() را بر روی المان #app از صفحه‌ی HTML، ثبت می‌کند.

حالا همه چیز آماده است!

اجرای یک SPA در مرورگر

ار آنجایی که وب‌سایت اصلی از اپلیکیشن مستقل است، ما نیاز داریم که یک وب سرور دیگر اجرا کنیم:

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

پرچم --passthru به وب سرور می‌گوید که تمام درخواست‌های HTML را به فایل public/index.html``بدهد (پوشه‌ی ریشه‌ی پیشفرض در وب سرور، ``public/ است). این صفحه توسط اپلیکیشن Preact مدیریت شده و صفحه‌ی مربوطه را می‌گیرد تا از طریق تاریخچه‌ی «مرورگر» آن را render کند.

برای کامپایل‌کردن فایل‌های CSS و JavaScript، فرمان yarn را اجرا کنید:

1
$ yarn encore dev

SPA را در مرورگر باز کنید:

1
$ symfony open:local

و به SPA‌ی ساده‌ی ما نگاه کنید:

افزودن یک راه‌یاب (Router) برای رسیدگی به وضعیت‌ها

SPA در حال حاضر قادر به رسیدگی به صفحه‌های مختلف نیست. برای پیاده‌سازی صفحه‌های متعدد، مشابه سیمفونی نیاز به یک راه‌یاب داریم. ما قصد داریم از 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 (
        <div>Home</div>
    );
};

و یک صفحه‌ی دیگر برای صفحه‌ی کنفرانس ایحاد کنید:

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

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

div مربوط به «Hello World» را با کامپوننت 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>
     )
 }

اپلیکیشن را مجدداً بسازید:

1
$ yarn encore dev

حالا اگر اپلیکیشن را در مرورگر تازه‌سازی کنید، می‌توانید بر روی پیوندهای «Home» و کنفرانس کلیک کنید. توجه کنید که URL مرورگر و کلیدهای قبلی/بعدی مرورگر به همان نحوی که انتظار دارید کار می‌کنند.

زیباسازیِ SPA

مشابه کاری که برای وب‌سایت انجام دادیم، بیایید بارگیرنده‌ی Sass را اضافه کنیم:

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

بارگیرنده‌ی Sass را در Webpack فعال کنید و یک ارجاع به stylesheet بیافزایید:

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

حالا می‌توانیم اپلیکیشن را به‌روزرسانی کنیم تا از stylesheetها استفاده کند:

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>

یک بار دیگر اپلیکیشن را بسازید:

1
$ yarn encore dev

حالا می‌توانید از SPA کاملاً زیبا‌سازی‌شده، لذت ببرید:

واکشی داده از API

حالا ساختار اپلیکیشن Preact تکمیل شده است: راه‌یاب Preact به وضعیت صفحات (شامل محل قرارگیری slug مربوط به کنفرانس) رسیدگی می‌کند و stylesheet اپلیکیشن اصلی برای زیباسازی SPA استفاده می‌گردد.

ما برای پویاکردن SPA، نیاز داریم که از طریق فراخوانی‌های HTTP، داده‌ها را از API واکشی کنیم.

Webpack را پیکربندی کنید تا متغیر محیط مربوط به پایانه‌ی 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();

متغیر محیط API_ENDPOINT باید در وب سرور وب‌سایت به جایی که پایانه‌ی API قرار دارد، یعنی /api، اشاره کند. ما آن را به زودی و هنگامی که yarn encore را اجرا کنیم، پیکربندی خواهیم کرد.

فایل 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);
}

حالا شما می‌توانید کامپوننت‌های سربرگ و خانه (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>
     );
-};
+}

در نهایت، راه‌یاب Preact محل قرارگیری «slug» را به عنوان یک ویژگی به کامپوننت Conference می‌دهد. از آن استفاده کنید تا کنفرانس صحیح و کامنت‌هایش را مجدداً با بهره‌گیری از API، نمایش دهید و renderشدن را وفق دهید تا از داده‌های 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>
     );
-};
+}

حالا SPA نیاز دارد تا URL مربوط به API ما را از طریق متغیر محیط API_ENDPOINT بداند. آن را بر روی URL وب سرورِ API تنظیم کنید (در حال اجرا در پوشه‌ی ..):

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

همچنین شما می‌توانید آن را در پس‌زمینه اجرا کنید:

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

و حالا اپلیکیشن باید در مرورگر به‌درستی کار کند:

واو! حالا ما یک SPA کاملاً کارا همراه با راه‌یاب و داده‌های واقعی داریم. اگر بخواهیم، می‌توانیم اپلیکیشن Preact را بیش از این سازماندهی کنیم، اما در حال حاضر عالی کار می‌کند.

استقرار SPA در محیط عمل‌آوری

SymfonyCloud اجازه می‌دهد تا برای هر پروژه، چندین اپلیکیشن مستقر کنید. افزودن یک اپلیکیشن دیگر، می‌تواند از طریق ایجاد یک فایل .symfony.cloud.yaml در هر زیرپوشه‌ای انجام شود. یکی از این فایل‌ها را در پوشه‌ی spa/ و با نام 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

فایل .symfony/routes.yaml را ویرایش نمایید تا زیردامنه‌ی spa. را به اپلیکیشن spa که در پوشه‌ی ریشه‌ی پروژه ذخیره شده است، هدایت کند:

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

پیکربندی CORS برای SPA

اگر حالا کد را مستقر کنید، کار نخواهد کرد، چون مرورگر درخواست‌های API را مسدود می‌کند. ما نیاز داریم تا به صورت صریح اجازه دهیم که SPA به API دسترسی پیدا کند. نام دامنه‌ی فعلی که به اپلیکیشن ضمیمه شده است را بگیرید:

1
$ symfony env:urls --first

متغیر محیط CORS_ALLOW_ORIGIN را بر اساس آن تعریف کنید:

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

اگر دامنه‌ی شما https://master-5szvwec-hzhac461b3a6o.eu.s5y.io/ باشد، فراخوانی‌های sed آن را به https://spa.master-5szvwec-hzhac461b3a6o.eu.s5y.io تبدیل می‌کنند.

همچنین ما نیاز داریم که متغیر محیط API_ENDPOINT را تنظیم کنیم:

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

commit کرده و مستقر کنید:

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

در مرورگر از طریق مشخص‌کردن اپلیکیشن با پرچم، به SPA دسترسی پیدا کنید:

1
$ symfony open:remote --app=spa

استفاده از Cordova برای ساخت یک اپلیکیشن گوشی هوشمند

Apache Cordova ابزاری برای ساخت اپلیکیشن‌های گوشی هوشمند به صورت چندسکویی (cross-platform) است. و یک خبر خوب، این ابزار می‌توان از SPAای که همین الان ساختیم، استفاده کند.

بیایید آن را نصب کنیم:

1
2
$ cd spa
$ yarn global add cordova

توجه

علاوه بر این شما نیاز دارید که Android SDK را نصب کنید. این بخش تنها Android را ذکر می‌کند اما Cordova با تمام پلتفرم‌های موبایل از جمله iOS کار می‌کند.

ساختار پوشه‌ی اپلیکیشن را ایجاد کنید:

1
$ cordova create app

و اپلیکیشن Android را تولید کنید:

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

این تمام چیزی است که نیاز دارید. حالا می‌توانید فایل‌های نهایی را بسازید و آن‌ها را به 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

اپلیکیشن را بر روی یک گوشی هوشمند یا یک شبیه‌ساز اجرا کنید:

1
$ cordova run android

  • « Previous گام 26: ارائه‌ی یک API با استفاده از API Platform
  • Next » گام 28: بومی‌سازی یک اپلیکیشن

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