步骤 27: 构建单页应用

大多数的评论会在会议期间提交。在开会时,有些人没带笔记本电脑,但他们很可能都有智能手机。创建一个移动应用程序来快速查看会议评论,你觉得怎么样?

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

创建应用

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

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

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

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

注解

因为我们主要是通过浏览器来和单页应用交互的,所以我们创建了 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 应用:

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

增加路由管理器来处理状态

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

安装 preact-router:

  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

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

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. .addPlugin(new HtmlWebpackPlugin({ template: 'src/index.ejs', alwaysWriteToDisk: true }))
  17. ;

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

patch_file

  1. --- a/src/app.js
  2. +++ b/src/app.js
  3. @@ -9,10 +9,20 @@ import Conference from './pages/conference';
  4. function App() {
  5. return (
  6. <div>
  7. - <header>
  8. - <Link href="/">Home</Link>
  9. - <br />
  10. - <Link href="/conference/amsterdam2019">Amsterdam 2019</Link>
  11. + <header className="header">
  12. + <nav className="navbar navbar-light bg-light">
  13. + <div className="container">
  14. + <Link className="navbar-brand mr-4 pr-2" href="/">
  15. + &#128217; Guestbook
  16. + </Link>
  17. + </div>
  18. + </nav>
  19. +
  20. + <nav className="bg-light border-bottom text-center">
  21. + <Link className="nav-conference" href="/conference/amsterdam2019">
  22. + Amsterdam 2019
  23. + </Link>
  24. + </nav>
  25. </header>
  26. <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. +
  51. +export default function Home({conferences}) {
  52. + if (!conferences) {
  53. + return <div className="p-3 text-center">No conferences yet</div>;
  54. + }
  55. -export default function Home() {
  56. return (
  57. - <div>Home</div>
  58. + <div className="p-3">
  59. + {conferences.map((conference)=> (
  60. + <div className="card border shadow-sm lift mb-3">
  61. + <div className="card-body">
  62. + <div className="card-title">
  63. + <h4 className="font-weight-light">
  64. + {conference.city} {conference.year}
  65. + </h4>
  66. + </div>
  67. +
  68. + <Link className="btn btn-sm btn-blue stretched-link" href={'/conference/'+conference.slug}>
  69. + View
  70. + </Link>
  71. + </div>
  72. + </div>
  73. + ))}
  74. + </div>
  75. );
  76. -};
  77. +}

最后,Preact Router 把 “slug” 占位符作为属性传递给会议组件。通过调用 API,可以用这个占位符来展示对应的会议和它的评论;修改渲染的代码,让它使用 API 的数据:

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: 构建单页应用 - 图3

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

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

在生产环境中部署这个应用

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

.symfony.cloud.yaml

  1. name: spa
  2. type: php:8.0
  3. size: S
  4. build:
  5. flavor: none
  6. web:
  7. commands:
  8. start: sleep
  9. locations:
  10. "/":
  11. root: "public"
  12. index:
  13. - "index.html"
  14. scripts: false
  15. expires: 10m
  16. hooks:
  17. build: |
  18. set -x -e
  19. curl -s https://get.symfony.com/cloud/configurator | (>&2 bash)
  20. (>&2
  21. unset NPM_CONFIG_PREFIX
  22. export NVM_DIR=${SYMFONY_APP_DIR}/.nvm
  23. yarn-install
  24. set +x && . "${SYMFONY_APP_DIR}/.nvm/nvm.sh" && set -x
  25. yarn encore prod
  26. )

修改 .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

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

  1. $ cordova run android

深入学习


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