步骤 27: 构建单页应用

    创建这样一个移动应用的方式之一是构建一个使用 JavaScript 的单页应用(SPA)。一个单页应用运行在本地,可以使用本地存储,能够调用远程 HTTP API 接口,也可以利用 service worker 创造接近原生应用的体验。

    我们会使用 PreactSymfony 的 Encore 来创建这个移动应用。Preact 作为一个轻量而高效的基础库,很适合用于我们的留言本单页应用。

    为了保持网站和单页应用的一致性,我们会在移动应用里重用网站的 Sass 样式表。

    在 目录下创建这个单页应用,将网站的样式表复制进来:

    注解

    因为我们主要是通过浏览器来和单页应用交互的,所以我们创建了 public 目录。如果只是想要开发一个移动应用,我们也可以把它命名为 build

    初始化 package.json 文件(这个文件对 JavaScript 的意义就相当于 composer.json 对 PHP 的意义):

    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. /node_modules
    2. /public
    3. /yarn-error.log
    4. # used later by Cordova
    5. /app

    最后一步是创建 Webpack Encore 的配置:

    webpack.config.js

    1. const Encore = require('@symfony/webpack-encore');
    2. const HtmlWebpackPlugin = require('html-webpack-plugin');
    3. Encore
    4. .setOutputPath('public/')
    5. .setPublicPath('/')
    6. .cleanupOutputBeforeBuild()
    7. .addEntry('app', './src/app.js')
    8. .enablePreactPreset()
    9. .enableSingleRuntimeChunk()
    10. .addPlugin(new HtmlWebpackPlugin({ template: 'src/index.ejs', alwaysWriteToDisk: true }))
    11. ;
    12. module.exports = Encore.getWebpackConfig();

    创建单页应用的主模板

    是时候创建初始模板了,Preact 会在其中渲染应用程序:

    src/index.ejs

    1. <!DOCTYPE html>
    2. <html>
    3. <head>
    4. <meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
    5. <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    6. <meta name="msapplication-tap-highlight" content="no" />
    7. <meta name="viewport" content="user-scalable=no, initial-scale=1, maximum-scale=1, minimum-scale=1, width=device-width" />
    8. <title>Conference Guestbook application</title>
    9. </head>
    10. <body>
    11. <div id="app"></div>
    12. </body>
    13. </html>

    <div> 标签就是 JavaScript 进行渲染应用的地方。这是代码的第一版,它会渲染一个 “Hello World” 视图:

    src/app.js

    1. import {h, render} from 'preact';
    2. function App() {
    3. return (
    4. <div>
    5. Hello world!
    6. </div>
    7. )
    8. }
    9. render(<App />, document.getElementById('app'));

    最后一行把 App() 函数注册到 HTML 页面的 #app 元素上。

    现在一切就绪!

    在浏览器中运行单页应用

    由于这个应用是独立于网站之外的,我们需要运行另外一个 web 服务器:

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

    --passthru 选项告诉 web 服务器把所有 HTTP 请求传递到 public/index.html 文件(public/ 是 web 服务器默认的 web 根目录)。这个页面由 Preact 应用来管理,它根据 “browser” 中的历史来获取要渲染的页面。

    运行 yarn 来编译 CSS 和 JavaScript 文件:

    1. $ yarn encore dev

    在浏览器中打开这个应用:

    1. $ symfony open:local

    看一下我们的 Hello World 应用:

    目前这个单页应用不能处理不同的页面。要实现多页面,我们需要一个路由管理器,就像在 Symfony 里的一样。我们会使用 preact-router。它以一个 URL 作为输入,然后匹配出一个要渲染的 Preact 组件。

    1. $ yarn add preact-router

    为首页创建一个页面(即一个 Preact 组件):

    src/pages/home.js

    1. import {h} from 'preact';
    2. export default function Home() {
    3. return (
    4. <div>Home</div>
    5. );
    6. };

    和另一个用于会议页面的组件:

    src/pages/conference.js

    Router 组件代替 “Hello World” 的 div 元素:

    patch_file

    1. --- a/src/app.js
    2. +++ b/src/app.js
    3. @@ -1,9 +1,22 @@
    4. import {h, render} from 'preact';
    5. +import {Router, Link} from 'preact-router';
    6. +
    7. +import Home from './pages/home';
    8. +import Conference from './pages/conference';
    9. function App() {
    10. return (
    11. <div>
    12. - Hello world!
    13. + <header>
    14. + <Link href="/">Home</Link>
    15. + <br />
    16. + <Link href="/conference/amsterdam2019">Amsterdam 2019</Link>
    17. + </header>
    18. +
    19. + <Router>
    20. + <Home path="/" />
    21. + <Conference path="/conference/:slug" />
    22. + </Router>
    23. </div>
    24. )
    25. }

    重新构建应用:

    1. $ yarn encore dev

    你在浏览器里刷新应用后,你可以点击“首页”和会议的链接。注意,浏览器的 URL 和后退/前进按钮会如你所预期的那样工作。

    为单页应用添加样式

    就像在网站中一样,我们来添加 Sass 加载器:

    1. $ yarn add node-sass sass-loader

    在 Webpack 里启用 Sass 加载器,并在样式表里添加引用:

    patch_file

    1. --- a/src/app.js
    2. +++ b/src/app.js
    3. @@ -1,3 +1,5 @@
    4. +import '../assets/styles/app.scss';
    5. +
    6. import {h, render} from 'preact';
    7. import {Router, Link} from 'preact-router';
    8. --- a/webpack.config.js
    9. +++ b/webpack.config.js
    10. @@ -7,6 +7,7 @@ Encore
    11. .cleanupOutputBeforeBuild()
    12. .addEntry('app', './src/app.js')
    13. .enablePreactPreset()
    14. + .enableSassLoader()
    15. .enableSingleRuntimeChunk()
    16. ;

    现在我们可以更新应用程序来使用新的样式表:

    patch_file

    1. +++ b/src/app.js
    2. @@ -9,10 +9,20 @@ import Conference from './pages/conference';
    3. function App() {
    4. return (
    5. <div>
    6. - <header>
    7. - <Link href="/">Home</Link>
    8. - <br />
    9. - <Link href="/conference/amsterdam2019">Amsterdam 2019</Link>
    10. + <header className="header">
    11. + <nav className="navbar navbar-light bg-light">
    12. + <div className="container">
    13. + <Link className="navbar-brand mr-4 pr-2" href="/">
    14. + &#128217; Guestbook
    15. + </Link>
    16. + </div>
    17. + </nav>
    18. +
    19. + <nav className="bg-light border-bottom text-center">
    20. + <Link className="nav-conference" href="/conference/amsterdam2019">
    21. + Amsterdam 2019
    22. + </Link>
    23. + </nav>
    24. </header>
    25. <Router>

    再构建一次应用:

    1. $ yarn encore dev

    现在你可以好好欣赏这个拥有完善样式的单页应用了:

    步骤 27: 构建单页应用 - 图2

    从 API 获取数据

    现在这个 Preact 应用的结构已经完成了:Preact Router 会处理包括会议 slug 占位在内的页面状态,主样式表也会给单页应用提供样式。

    为了使单页应用使用动态内容,我们需要通过 HTTP 请求来从 API 接口获取数据。

    配置 Webpack 来暴露出包含 API 地址的环境变量:

    patch_file

    1. --- a/webpack.config.js
    2. +++ b/webpack.config.js
    3. @@ -1,3 +1,4 @@
    4. +const webpack = require('webpack');
    5. const Encore = require('@symfony/webpack-encore');
    6. const HtmlWebpackPlugin = require('html-webpack-plugin');
    7. @@ -10,6 +11,9 @@ Encore
    8. .enableSassLoader()
    9. .enableSingleRuntimeChunk()
    10. .addPlugin(new HtmlWebpackPlugin({ template: 'src/index.ejs', alwaysWriteToDisk: true }))
    11. + .addPlugin(new webpack.DefinePlugin({
    12. + 'ENV_API_ENDPOINT': JSON.stringify(process.env.API_ENDPOINT),
    13. + }))
    14. ;
    15. module.exports = Encore.getWebpackConfig();

    API_ENDPOINT 环境变量要指向网站的 web 服务器,那里的 API 端点都在 /api 路径下。我们在稍后运行 yarn encore 时会把它设置好。

    创建 api.js 文件,对从 API 获取数据做一层抽象:

    src/api/api.js

    1. function fetchCollection(path) {
    2. return fetch(ENV_API_ENDPOINT + path).then(resp => resp.json()).then(json => json['hydra:member']);
    3. }
    4. export function findConferences() {
    5. return fetchCollection('api/conferences');
    6. }
    7. export function findComments(conference) {
    8. return fetchCollection('api/comments?conference='+conference.id);
    9. }

    现在你可以调整页头和首页组件:

    patch_file

    1. --- a/src/app.js
    2. +++ b/src/app.js
    3. @@ -2,11 +2,23 @@ import '../assets/styles/app.scss';
    4. import {h, render} from 'preact';
    5. import {Router, Link} from 'preact-router';
    6. +import {useState, useEffect} from 'preact/hooks';
    7. +import {findConferences} from './api/api';
    8. import Home from './pages/home';
    9. import Conference from './pages/conference';
    10. function App() {
    11. + const [conferences, setConferences] = useState(null);
    12. +
    13. + useEffect(() => {
    14. + findConferences().then((conferences) => setConferences(conferences));
    15. + }, []);
    16. +
    17. + if (conferences === null) {
    18. + return <div className="text-center pt-5">Loading...</div>;
    19. + }
    20. +
    21. return (
    22. <div>
    23. <header className="header">
    24. @@ -19,15 +31,17 @@ function App() {
    25. </nav>
    26. <nav className="bg-light border-bottom text-center">
    27. - <Link className="nav-conference" href="/conference/amsterdam2019">
    28. - Amsterdam 2019
    29. - </Link>
    30. + {conferences.map((conference) => (
    31. + <Link className="nav-conference" href={'/conference/'+conference.slug}>
    32. + {conference.city} {conference.year}
    33. + </Link>
    34. + ))}
    35. </nav>
    36. </header>
    37. <Router>
    38. - <Home path="/" />
    39. - <Conference path="/conference/:slug" />
    40. + <Home path="/" conferences={conferences} />
    41. + <Conference path="/conference/:slug" conferences={conferences} />
    42. </Router>
    43. </div>
    44. )
    45. --- a/src/pages/home.js
    46. +++ b/src/pages/home.js
    47. @@ -1,7 +1,28 @@
    48. import {h} from 'preact';
    49. +import {Link} from 'preact-router';
    50. +export default function Home({conferences}) {
    51. + if (!conferences) {
    52. + return <div className="p-3 text-center">No conferences yet</div>;
    53. + }
    54. -export default function Home() {
    55. return (
    56. - <div>Home</div>
    57. + <div className="p-3">
    58. + {conferences.map((conference)=> (
    59. + <div className="card border shadow-sm lift mb-3">
    60. + <div className="card-body">
    61. + <div className="card-title">
    62. + <h4 className="font-weight-light">
    63. + {conference.city} {conference.year}
    64. + </h4>
    65. + </div>
    66. +
    67. + <Link className="btn btn-sm btn-blue stretched-link" href={'/conference/'+conference.slug}>
    68. + View
    69. + </Link>
    70. + </div>
    71. + </div>
    72. + ))}
    73. + </div>
    74. );
    75. -};
    76. +}

    patch_file

    1. --- a/src/pages/conference.js
    2. +++ b/src/pages/conference.js
    3. @@ -1,7 +1,48 @@
    4. import {h} from 'preact';
    5. +import {findComments} from '../api/api';
    6. +import {useState, useEffect} from 'preact/hooks';
    7. +
    8. +function Comment({comments}) {
    9. + if (comments !== null && comments.length === 0) {
    10. + return <div className="text-center pt-4">No comments yet</div>;
    11. + }
    12. +
    13. + if (!comments) {
    14. + return <div className="text-center pt-4">Loading...</div>;
    15. + }
    16. +
    17. + return (
    18. + <div className="pt-4">
    19. + {comments.map(comment => (
    20. + <div className="shadow border rounded-lg p-3 mb-4">
    21. + <div className="comment-img mr-3">
    22. + {!comment.photoFilename ? '' : (
    23. + <a href={ENV_API_ENDPOINT+'uploads/photos/'+comment.photoFilename} target="_blank">
    24. + <img src={ENV_API_ENDPOINT+'uploads/photos/'+comment.photoFilename} />
    25. + </a>
    26. + )}
    27. + </div>
    28. +
    29. + <h5 className="font-weight-light mt-3 mb-0">{comment.author}</h5>
    30. + <div className="comment-text">{comment.text}</div>
    31. + </div>
    32. + ))}
    33. + </div>
    34. + );
    35. +}
    36. +
    37. +export default function Conference({conferences, slug}) {
    38. + const conference = conferences.find(conference => conference.slug === slug);
    39. + const [comments, setComments] = useState(null);
    40. +
    41. + useEffect(() => {
    42. + findComments(conference).then(comments => setComments(comments));
    43. + }, [slug]);
    44. -export default function Conference() {
    45. return (
    46. - <div>Conference</div>
    47. + <div className="p-3">
    48. + <h4>{conference.city} {conference.year}</h4>
    49. + <Comment comments={comments} />
    50. + </div>
    51. );
    52. -};
    53. +}

    这个单页应用需要通过 API_ENDPOINT 环境变量来知道我们 API 的 URL。把它设为 API 使用的 web 服务器的 URL(它在 .. 目录中运行):

    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

    现在应用程序在浏览器中可以很好地工作了:

    步骤 27: 构建单页应用 - 图4

    哇!现在我们有了一个使用路由和真实数据的全功能单页应用了。如果我们想的话,还可以进一步整理这个应用程序,但它已经工作得很好了。

    SymfonyCloud 可以为每个项目部署多个应用。在任何子目录下创建一个 .symfony.cloud.yaml 文件就可以新建一个应用。我们在 spa/ 目录下创建一个名为 spa 的应用:

    .symfony.cloud.yaml

    修改 .symfony/routes.yaml 文件,把 spa. 子域名路由到项目根目录下存储的 spa 应用:

    1. $ cd ../

    patch_file

    1. --- a/.symfony/routes.yaml
    2. +++ b/.symfony/routes.yaml
    3. @@ -1,2 +1,5 @@
    4. +"https://spa.{all}/": { type: upstream, upstream: "spa:http" }
    5. +"http://spa.{all}/": { type: redirect, to: "https://spa.{all}/" }
    6. +
    7. "https://{all}/": { type: upstream, upstream: "varnish:http", cache: { enabled: false } }
    8. "http://{all}/": { type: redirect, to: "https://{all}/" }

    为单页应用配置 CORS

    如果你现在部署代码,它不会正常运行,因为浏览器会阻止 API 请求。我们需要显式地允许该应用来访问 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`

    提交并部署:

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

    通过把该应用作为一个命令行选项,在浏览器中打开它:

    1. $ symfony open:remote --app=spa

    使用 Cordova 构建手机原生应用

    Apache Cordova 是一个用于构建跨平台手机原生应用的工具。好消息是,我们刚刚创建的这个单页应用可以使用它进行构建。

    我们来安装它:

    1. $ cd spa
    2. $ yarn global add cordova

    注解

    我们也需要装安卓 SDK。这一节只讨论安卓,但 Cordova 可用于所有移动平台,包括 iOS 系统。

    创建应用的目录结构:

    1. $ cordova create app

    生成安卓应用:

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

    这就是你全部所需的。现在你能构建生产环境下的文件,并将它们移动到 Cordova:

    1. $ API_ENDPOINT=`symfony var:export SYMFONY_PROJECT_DEFAULT_ROUTE_URL --dir=..` yarn encore production
    2. $ rm -rf app/www
    3. $ mkdir -p app/www
    4. $ cp -R public/ app/www

    在智能手机或模拟器上运行这个原生应用: