您现在已经了解了 MEAN 应用程序的机制,接下来我们将对第一期文章中创建的 MEAN.JS 应用程序进行定制。我们在第二期文章中对该应用程序有了一个大致的了解。在第三期文章中,我将演示该应用程序的基本 CRUD 功能。您还会了解一些有关响应式 Web 设计和 Bootstrap 的内容。

本系列其余部分将要构建的应用程序被命名为 UGLI:User Group List and Information 应用程序。我从 2010 年开始运营 HTML5 Denver User Group(前身是 Boulder Java User Group,更早以前是 Denver Java User Group),因此我是本地用户组的狂热粉丝,但是让我不解的是一直没有专门的软件来运行用户组。现在我们就要解决这个问题了。

许多用户组都在 Meetup.com 建立了一个在线主页。我使用 MEAN 和 UGLI 应用程序的目标并不是要取代 Meetup.com;相反,我想与它建立更深入的集成。Meetup.com 集中了运行成功的用户组所需的大部分核心功能;注册新用户,发布会议细节、处理 RSVP 等等。但是对于用户组领导者来说仍然缺失一些关键功能,包括管理一组会议主持人(presenter)并链接到幻灯片(slide deck)。UGLI 可以填补这方面的空缺。(参见 下载 获得完整的样例代码)。

创建应用程序 UGLI 的第一个任务就是调整应用程序的标记(branding)。需要在应用程序的服务器端对 config 和 app 目录做一些修改;另外要对客户端的 public 目录做一些修改。

首先从 config/env/all.js 中的元数据开始。将标题修改为 HTML5 Denver(或您选择的用户组),并将描述修改为 HTML5 Denver User Group,如清单 1 所示。

清单 1. config/env/all.js
'use strict';

module.exports = {
    app: {
        title: 'HTML5 Denver',
        description: 'HTML5 Denver User Group',
        keywords: 'MongoDB, Express, AngularJS, Node.js'
    },

config/env/development.js 中的标题也需要修改,如清单 2 所示。上篇文章中我们已经了解到 development.js 和 all.js 会在运行时合并。

清单 2. config/env/development.js
'use strict';

module.exports = {
    db: 'mongodb://localhost/test-dev',
    app: {
        title: 'HTML5 Denver'
    },

接下来,修改导航栏左上角显示的品牌。为此,需要编辑 public/modules/core/views/header.client.view.html。在大概第 9 列的地方找到 anchor 标记和 navbar-brand 类,将 body 修改为 HTML5 Denver,如清单 3 所示。

清单 3. public/modules/core/views/header.client.view.html
<div class="container">
    <div class="navbar-header">
        <button class="navbar-toggle" type="button">
            <span class="sr-only">Toggle navigation</span>
            <span class="icon-bar"></span>
            <span class="icon-bar"></span>
            <span class="icon-bar"></span>
        </button>
        <a href="/#!/" class="navbar-brand">HTML5 Denver</a>
    </div>

    <!-- ...snip... -->
</div>

要验证所做的修改,请在命令行输入 mongod 启动 MongoDB,然后输入 grunt 启动应用程序。在浏览器中查看 Web 应用程序,看看标记是否显示在菜单和标题栏中。

要完成标记修改,需要替换 public/modules/core/views/home.client.view.html 中的标准文本(boilerplate),该文本显示在主页的正文中。创建一个名为 home.client.view.html.original 的副本,这样就可以在稍后往回引用(如果需要的话)。

该文件利用 Bootstrap 框架的功能,确保您的网站从一开始就面向移动应用。在继续之前,需要了解 Bootstrap 提供的 12 列的网格布局。

了解 Bootstrap 和响应式 Web 设计

查看任意硬拷贝新闻或杂志,您都会看到其中使用了列。有时,一副图片或标题因为某种设计风格而需跨越多个列,但是一个基本的柱状布局构成了几乎所有打印页面的基础。

Web 页面也是如此。例如,访问 TIME 网站。您看到它的布局也是基于列的。但是,当您将浏览器窗口的宽度从全屏缩小到非常窄的时候,注意会发生什么。可见列的数量将随着窗口变小而减少,并随着窗口增大而增多。

为什么使用 12 个列?

人类只有 10 个手指头,因此才有了以 10 为基数的十进制计数系统。如果您曾在 CSS 中创建过自定义色彩,您可能会熟悉以 16 为基数的十六进制数字。那么为什么 Twitter 选择 12 个列作为基本布局,而不是 10 或 16?因为 12 可以被 2、3、4 和 6 整除,可以很容易将布局分为两列、三列、四列或六列(或者 12 列或单独一个列)。自古巴比伦、罗马、中国和其他古文明时代就开始盛行以 12 为基数的十二进制系统。这就是为什么我们将一天分为 12(或 24)个小时,将一年分为 12 个月,将一英尺分为 12 英寸。用拇指轻点按手指关节划分的手的各个部分(如巴比伦人一样),每只手可以数到 12,这样您就更容易理解 Bootstrap 的十二进制算法。

这种效应被称为 响应式 Web 设计,因为 Web 页面会 响应 并调整设计来适应设备所要求的屏幕尺寸。现代 Web 开发人员构建的网站可以无缝地支持从最小的手持设备到拥有最大屏幕的台式机或壁挂屏幕等各种设备。分别使用 http://m.* 和 http://www.* URL 为智能手机、平板电脑、笔记本等创建专门的、离散的网站,这种做法早已过时。

响应式 Web 设计 并不是 一个全能的解决方案;相反,它是 “一个外观要求可以适应所有设备的网站”。您不需要选择用户访问网站所使用的设备类型,因此您的设计具备内置的灵活性,可以相应地进行自我调整。

许多流行网站(包括 Facebook 和 Instagram)更多地是通过移动设备而不是传统计算机来进行访问的。Twitter 的用户群绝大多数是移动用户。Twitter 规范了其响应式 Web 设计策略并实现了与 Bootstrap 相同的开源化。Bootstrap 有 12 列的布局,可以根据您用来定义列的 CSS 类进行缩小或放大。

请注意,MEAN.JS 应用程序中对 MongoDBExpressAngularJSNode.js 使用了四个列的布局,如图 1 所示。

图 1. Bootstrap 的列布局示例

Bootstrap 列布局的屏幕截图

现在查看 public/modules/core/views/home.client.view.html 中的源代码,如清单 4 所示,看看 Bootstrap 的 12 列布局是什么样子的。

清单 4. public/modules/core/views/home.client.view.html
<div class="row">
    <div class="col-md-3">
        <h2><strong>M</strong>ongoDB</h2>
    </div>
    <div class="col-md-3">
        <h2><strong>E</strong>xpress</h2>
    </div>
    <div class="col-md-3">
        <h2><strong>A</strong>ngularJS</h2>
    </div>
    <div class="col-md-3">
        <h2><strong>N</strong>ode.js</h2>
    </div>
</div>

如果您向一个父 div 添加 class="row",那么您可以向子 div 添加 class="col-xx-N" 属性来将它们分成几个列。N 值必须介于 1 和 12 之间,xx 值取决于您希望优化布局的设备的尺寸:

  • xs 适用于极小设备(低于 768 像素宽)
  • sm 用于小型设备(768 和 991 像素之间)
  • md 适合中型设备(992 和 1,199 像素之间)
  • lg 适合大型设备(1,200 像素或更高)

查看 Bootstrap CSS 文档的 网格系统 小节,了解有关的更多信息。

由于清单 4 中的每个列针对中型(md)设备进行了优化,因此如果在屏幕宽度低于 992 像素的设备上访问该页面,列将垂直堆叠而不是水平堆叠。将您的浏览器窗口变得足够窄来触发这一更改,如图 2 所示。

图 2. 移动设备上的响应式 Web 设计示例

移动设备上的响应式 Web 设计屏幕截图

现在,可以使用我们已经需到的知识,使用特定于 UGLI 的文本替换 home.client.view.html 中的标准文本。

首先,从 W3C HTML5 徽标页面 下载 256 像素的 HTML5 徽标,并将其复制到 public/modules/core/img/brand/HTML5_Logo_256.png。然后使用清单 5 中的源代码替换 public/modules/core/views/home.client.view.html 中现有的 HTML。

清单 5. public/modules/core/views/home.client.view.html
<section>
    <div class="jumbotron text-center">
        <div class="row">
            <div class="col-md-4">
                <img alt="HTML5" class="img-responsive center-block" src="modules/core/img/brand/HTML5_Logo_256.png" />
            </div>
            <div class="col-md-8">
                <h1>The HTML story is still being written.</h1> 
                <h2><em>Come hear the latest chapter at the HTML5 Denver User Group.</em></h2>
            </div>
        </div>
    </div>
</section>

在较宽的浏览器窗口中查看网站时,HTML5 徽标会出现在文本旁边,如图 3 所示。

图 3. 新的 UGLI 主页

新的 UGLI 主页的屏幕截图

当您将浏览器窗口变得足够窄时,徽标会出现在文本的上方,如图 4 所示。

图 4. 新的 UGLI 主页,它会出现在移动设备上

新的 UGLI 主页会出现在移动设备上的屏幕截图

使用 Bootstrap 可以轻松地让您的网站对移动应用程序变得更友好,我在为客户构建每个新网站时都使用 Bootstrap 作为基础技术。

现在我们将要在 MEAN 堆栈中处理 CRUD。

基础 CRUD

Meetup.com 可以帮助我很好地管理用户组活动。但是,在某个活动结束后,就时间方面而言,该活动的重要性不如当天晚上的谈话。

换句话说,这个网站的一个用户用例就是:“下次会议要讨论什么?”Meetup.com 可以很好地满足这种用户用例。

第二个用户用例(“向我显示与 MEAN 堆栈有关的所有谈话,不管是什么时候发生的” )正是我准备通过 UGLI 应用程序解决的用例。要实现这个用例,必须围绕一个新的名为 Talk 的模型对象创建一个 CRUD 基础架构。幸运的是,可以使用一个 Yeoman 生成器来实现这个基础架构。

在应用程序的根目录,输入 yo meanjs:crud-module talks。响应提示:

  1. 选择所有四个补充文件夹(css、img、directives 和 filters)。
  2. 回答 Yes,将 CRUD 模块链接添加到菜单。
  3. 当生成器询问要使用哪个菜单时,接受默认设置(topbar)。

清单 6 显示了交互式命令行序列。

清单 6. 使用 Yeoman 生成器生成一个新的 CRUD 模块
$ yo meanjs:crud-module talks
[?] Which supplemental folders would you like to include in your angular module? 
css, img, directives, filters
[?] Would you like to add the CRUD module links to a menu? Yes
[?] What is your menu identifier? topbar
   create app/controllers/talks.server.controller.js
   create app/models/talk.server.model.js
   create app/routes/talks.server.routes.js
   create app/tests/talk.server.model.test.js
   create public/modules/talks/config/talks.client.routes.js
   create public/modules/talks/controllers/talks.client.controller.js
   create public/modules/talks/services/talks.client.service.js
   create public/modules/talks/tests/talks.client.controller.test.js
   create public/modules/talks/config/talks.client.config.js
   create public/modules/talks/views/create-talk.client.view.html
   create public/modules/talks/views/edit-talk.client.view.html
   create public/modules/talks/views/list-talks.client.view.html
   create public/modules/talks/views/view-talk.client.view.html
   create public/modules/talks/talks.client.module.js

在清单 6 中,请注意,生成器创建了服务器端基础架构(保存在 app 目录中):路由、一个控制器、一个模型和一个单元测试。它还在 public/modules/talks 目录下构建了所有客户端工件。

您稍后将向 Talk 对象添加一些自定义字段。在此之前,在浏览器中访问网站,查看默认情况下会得到哪些内容。

单击右上角的 Signin 链接,输入本系列早些时候创建的用户名和密码,或者单击 Signup 并创建一组新的凭证。

完成登录后,可以在左上角看到一个 Talks 菜单。从菜单中选择 New Talk 打开一个 HTML 表单,其中提供了一个独立的 Name 字段,如图 5 所示。

图 5. 自定义之前的 New Talk 表单

自定义之前的 New Talk 表单的屏幕截图

这是一个良好的开端,但是要捕捉 Talk 的所有属性,您需要的不仅仅是一个简单文本。

添加新字段实现持久性

要向 Talk 添加新字段,必须编辑 6 个文件 — 四个用于显示,两个用于持久性:

  • app/models/talk.server.model.js
  • public/modules/controllers/talks.client.controller.js
  • public/modules/talks/views/create-talk.client.view.html
  • public/modules/talks/views/edit-talk.client.view.html
  • public/modules/talks/views/view-talk.client.view.html
  • public/modules/talks/views/list-talks.client.view.html

首先要处理持久性。解决方案一半用在服务器端,另一半用在客户端。

服务器端模型(在 app/models/talk.server.model.js 中定义)是应用程序的原型。您将在其中命名字段,提供数据类型,验证规则等等。

客户端控制器(在 public/modules/controllers/talks.client.controller.js 中定义)收集来自用户的数据输入,并通过 HTTP 请求将数据推到服务器。控制器还通过连接获得 JSON 数据,并提供给视图以用于演示。

此架构的一个有趣之处是对象模型永远不会离开服务器。对象是来自客户机的数据的具体化实现,并在 HTTP 响应中序列化到 JSON。

该应用程序有两个控制器(一个位于服务器端,另一个位于客户端),但是我们只关心客户端控制器。服务器端控制器只是将进入的 JSON 推入到模型对象。因此在向模型添加额外字段时不需要对服务器端控制器做任何调整。客户端控制器要进行一些调整来容纳新的字段。

打开 app/models/talk.server.model.js,向服务器端模型添加新的字段,如清单 7 所示。您可以看到展开的 name 字段(如 图 5 所示),同时还定义了两个元数据字段:created 和 user。

清单 7. app/models/talk.server.model.js
/**
 * Talk Schema
 */
var TalkSchema = new Schema({
    name: {
        type: String,
        default: '',
        required: 'Please fill Talk name',
        trim: true
    },
    created: {
        type: Date,
        default: Date.now
    },
    user: {
        type: Schema.ObjectId,
        ref: 'User'
    }
});

这个基于 JSON 的模式无需多加解释。在定义新字段时,您可以指定数据类型、默认值和错误消息,以显示给必要的字段。您还可以做出许多其他优化。查看 Mongoose documentation,获得有关的更多信息。

对 description、presenter 和 slidesUrl 添加新字段,如清单 8 所示。在本例中,description 和 presenter 都是必要字段。slidesUrl 字段是可选字段。

清单 8. app/models/talk.server.model.js
/**
 * Talk Schema
 */
var TalkSchema = new Schema({
    name: {

        type: String,
        default: '',
        required: 'Please fill Talk name',
        trim: true
    },
    description: {
        type: String,
        default: '',
        required: 'Please fill Talk description',
        trim: true
    },  
    presenter: {
        type: String,
        default: '',
        required: 'Please fill Talk presenter',
        trim: true
    },
    slidesUrl: {
        type: String,
        default: '',
        trim: true
    },
    created: {
        type: Date,
        default: Date.now
    },
    user: {
        type: Schema.ObjectId,
        ref: 'User'
    }
});

此时,您的服务器端后端已经准备好接收新字段。现在您需要处理客户端控制器。打开 public/modules/controllers/talks.client.controller.js,添加新的字段,如清单 9 所示。

清单 9. public/modules/controllers/talks.client.controller.js
// Create new Talk
$scope.create = function() {
    // Create new Talk object
    var talk = new Talks ({
        name: this.name,
        description: this.description,
        presenter: this.presenter,
        slidesUrl: this.slidesUrl
    });

    // Redirect after save
    talk.$save(function(response) {
        $location.path('talks/' + response._id);
    }, function(errorResponse) {
        $scope.error = errorResponse.data.message;
    });

    // Clear form fields
    this.name = '';
    this.description = '';
    this.presenter = '';
    this.slidesUrl = '';
};

在 $scope.create 函数中,表格字段将被聚集到一个 JSON 对象,并被发送给服务器,以便实现持久存储。从模型向控制器添加相应的字段后,您就实现了持久存储。

现在我们要将注意力转移到演示层,这样用户就可以查看新字段并进行交互。

添加新字段以进行显示

查看 public/modules/talks/views/。有四个字段与 CRUD 生命周期有关:

  • create-talk.client.view.html
  • edit-talk.client.view.html
  • view-talk.client.view.html
  • list-talks.client.view.html

打开 create-talk.client.view.html,如清单 10 所示。

清单 10. 生成的 create-talk.client.view.html
<section>
  <div class="page-header">
    <h1>New Talk</h1>
  </div>
  <div class="col-md-12">
    <form class="form-horizontal">
      <fieldset>
        <div class="form-group">
          <label class="control-label" for="name">Name</label>
          <div class="controls">

          </div>
        </div>
        <div class="form-group">

        </div>
        <div class="text-danger">
          <strong></strong>
        </div>
      </fieldset>
    </form>
  </div>
</section>

将与 Name 有关的代码块复制三次,以便支持 Description、Presenter 和 slidesUrl,如清单 11 所示。我将 Description 字段设置为 textarea,而不是一个简单的文本字段。同样,我从 slidesUrl 字段移除了 required 属性,并将 input type 从 text 修改为 url。

清单 11. 更新 create-talk.client.view.html
<section>
  <div class="page-header">
    <h1>New Talk</h1>
  </div>
  <div class="col-md-12">
    <form class="form-horizontal">
      <fieldset>
        <div class="form-group">
          <label class="control-label" for="name">Name</label>
          <div class="controls">

          </div>
        </div>
        <div class="form-group">
          <label class="control-label" for="description">Description</label>
          <div class="controls">
            <textarea id="description" class="form-control"></textarea>
          </div>
        </div>
        <div class="form-group">
          <label class="control-label" for="presenter">Presenter</label>
          <div class="controls">

          </div>
        </div>
        <div class="form-group">
          <label class="control-label" for="slidesUrl">Slides</label>
          <div class="controls">

          </div>
        </div>                        
        <div class="form-group">

        </div>
        <div class="text-danger">
          <strong></strong>
        </div>
      </fieldset>
    </form>
  </div>
</section>

在 Web 浏览器中,您新修改的 New Talk 页面应当类似图 6 所示。

图 6. 自定义后的 New Talk 表单

自定义后的 New Talk 表单的屏幕截图

如果对所做的更改感到满意,请打开 edit-talk.client.view.html 并执行相应的更改,如清单 12 所示。

清单 12. edit-talk.client.view.html
<div class="col-md-12">
    <form class="form-horizontal">
      <fieldset>
        <div class="form-group">
          <label class="control-label" for="name">Name</label>
          <div class="controls">

          </div>
        </div>
        <div class="form-group">
          <label class="control-label" for="description">Description</label>
          <div class="controls">
            <textarea id="description" class="form-control"></textarea>
          </div>
        </div>
        <div class="form-group">
          <label class="control-label" for="presenter">Presenter</label>
          <div class="controls">

          </div>
        </div>
        <div class="form-group">
          <label class="control-label" for="slidesUrl">Slides</label>
          <div class="controls">

          </div>
        </div>
        <div class="form-group">

        </div>
        <div class="text-danger">
          <strong></strong>
        </div>
      </fieldset>
    </form>
</div>

请注意,用于编辑的 HTML 与之前修改的创建表单稍微有些不同。在编辑时,您已经有了一个 Talk 对象,因此 data-ng-model 属性将以完全限定的方式引用字段,比如用 talk.name 而不是 name。在 Web 浏览器中查看修改,如图 7 所示。

图 7. 自定义后的 Edit Talk 表单

自定义后的 Edit Talk 表单的屏幕截图

view-talk.client.view.html 页面是对象的只读视图。用户在保存新的 Talk,更新现有的 Talk 或从列表页面中选择 Talk 后将来到该视图。如清单 13 所示做出修改。

清单 13. edit-talk.client.view.html
<div class="page-header">
  <h1></h1>
  <h2><em>by {{talk.presenter}} 
    <span>[<a href="{{talk.slidesUrl}}">slides</a>]</span></em></h2>
  <p>{{talk.description}}</p>              
</div>

前面提到 slidesUrl 是可选字段。在视图页面中,您将使用 ng-if 指令有条件地显示字段(如果已填充)。在浏览器中查看页面,检查这一行为,如图 8 所示。

图 8. 自定义后的 View Talk 表单

自定义后的 View Talk 表单的屏幕截图

List 视图是最后一个需要做出调整的视图。打开 list-talks.client.view.html 并如清单 14 所示进行修改。

清单 14. list-talks.client.view.html
<div class="list-group">
    <a class="list-group-item">
    <h4 class="list-group-item-heading"></h4>
        <p><em>by {{talk.presenter}}</em></p>
    </a>
</div>

请注意,这里使用 data-ng-repeat 指令显示了服务器返回的 talk 列表中的每个 talk。在浏览器中查看结果,如图 9 所示。

图 9. 自定义后的 List Talks 表单

自定义后的 List Talks 表单的屏幕截图

此时,您已经了解了 MEAN 堆栈交互的各个方面。您使用 Bootstrap 的响应式 Web 设计功能确保您的网站能够适应所有 设备,而不仅限于传统的有 101 个键和鼠标的传统台式机。您已经领略了使用 Yeoman 生成器向应用程序添加新 CRUD 模块的强大之处及其便利性。该生成器将原始工件放到正确的目录中,您只需要对它们进行自定义即可。

在下一期文章中,您将了解将来自远程源的数据合并到应用程序中有多么简单。具体地讲,您将开始使用 Meetup 的 RESTful API 从 Meetup.com 直接拉取活动数据。请继续领略精通 MEAN 的乐趣。