Meteor-设计模式-全-
Meteor 设计模式(全)
原文:
zh.annas-archive.org/md5/5b730e93326fe3baeb35e01141dba23c
译者:飞龙
前言
简单是解决问题的最短路径。Meteor 是一个简化编程的 Web 开发框架,一旦掌握,开发者只需几天就能原型化应用程序,几周内就能构建生产级应用。该框架的简单性使得维护变得轻而易举;重新组织和重命名文件不会破坏你的代码,代码易于模块化,虚拟环境已成为过去式。Meteor 开发团队通过生产一个功能丰富的框架,将其他框架所学到的所有经验打包进 Meteor,为 Web 开发建立了最短路径。
虽然 Meteor 因其附带的技术特性而简单,但很明显,该框架将因其背后的团队的工作方式而成为行业标准。Meteor 是由一个自项目开始就积极获得资金支持的团队构建的,与 Ruby on Rails、Laravel、CakePHP 等其他许多开源框架不同。这意味着致力于构建框架的人们实际上非常关心它。然而,Meteor 是一个具有活跃社区的开源项目,通过包或修补核心代码不断改进项目。
本书涵盖的内容
第一章, Meteor 入门,涵盖了 Meteor Web 开发的基础知识。它将涵盖相同的语言(CoffeeScript、Stylus 和 Jade)进行编程,教我们关于模板、辅助函数和事件的知识,并展示如何构建 Web 应用程序。
第二章,发布和订阅模式,涵盖了 Meteor Web 开发最重要的部分——发布者和订阅者。通过本章,你将了解如何更好地组织你的数据,只发布客户端所需的信息。
第三章,前端模式,介绍了一些模式来改进你的前端代码。你将学习如何避免代码重复,保持代码模块化,使用不同类型的变量,创建自定义输入元素、动画等等。
第四章,应用模式,涵盖了更复杂的模式,这些模式有助于控制数据如何流入客户端,如何保持数据安全,以及如何连接外部 API。
第五章,测试模式,涵盖了如何维护你的代码。你将学习如何测试应用程序的所有功能,以及如何仅测试函数。这将确保当你未来开始移动事物时,你的代码不会出错。
第六章,部署,介绍了如何将您的应用程序部署到生产环境,使其按预期工作。您将学习如何激活操作日志,如何跟踪错误,以及如何设置 SSL 证书。
您需要为这本书准备的东西
-
Meteor 版本 1.1.0.2 或更高版本
-
类似于 Mac 或 Linux 计算机的 Unix 系统
本书面向对象
本书是为已经参加过 Meteor 入门课程的开发者编写的。建议您具备基本的 Web 开发知识。
约定
在本书中,您将找到许多不同的文本样式,用于区分不同类型的信息。以下是一些这些样式的示例及其含义的解释。
文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称如下所示:“我们正在使用 font awesome 为我们的#features
部分创建一些图标。”
代码块设置如下:
@import "_globals/bootstrap/custom.bootstrap.import.styl"
#products
#promoter
background: $brand-primary
height: 80%
@import "_globals/bootstrap/custom.bootstrap.import.styl"
#products
#promoter
background: $brand-primary
height: 80%
当我们希望您注意代码块中的特定部分时,相关的行或项目将以粗体显示:
// /_globals/client/main.styl
html, body, #__flow-root, #__flow-root > .template
height:100%
任何命令行输入或输出都如下所示:
meteor reset
新术语和重要词汇以粗体显示。屏幕上看到的单词,例如在菜单或对话框中,在文本中如下所示:“将信息粘贴到私钥文本区域。”
注意
警告或重要提示以如下框的形式出现。
小贴士
技巧和窍门如下所示。
读者反馈
我们欢迎读者的反馈。请告诉我们您对这本书的看法——您喜欢或不喜欢的地方。读者反馈对我们来说很重要,因为它帮助我们开发出您真正能从中获得最大收益的标题。
要向我们发送一般反馈,只需发送电子邮件至<feedback@packtpub.com>
,并在邮件主题中提及书的标题。
如果您在某个主题上具有专业知识,并且您对撰写或为书籍做出贡献感兴趣,请参阅我们的作者指南www.packtpub.com/authors。
客户支持
现在您已经是 Packt 图书的骄傲拥有者,我们有一些事情可以帮助您从购买中获得最大收益。
下载示例代码
您可以从www.packtpub.com
下载示例代码文件,适用于您购买的所有 Packt Publishing 图书。如果您在其他地方购买了这本书,您可以访问www.packtpub.com/support
并注册,以便将文件直接通过电子邮件发送给您。
错误清单
尽管我们已经尽一切努力确保我们内容的准确性,但错误仍然会发生。如果您在我们的某本书中发现错误——可能是文本或代码中的错误——如果您能向我们报告这一点,我们将不胜感激。通过这样做,您可以节省其他读者的挫败感,并帮助我们改进本书的后续版本。如果您发现任何勘误,请通过访问 www.packtpub.com/submit-errata
,选择您的书籍,点击勘误提交表单链接,并输入您的勘误详情来报告它们。一旦您的勘误得到验证,您的提交将被接受,勘误将被上传到我们的网站或添加到该标题的勘误部分下的现有勘误列表中。
要查看之前提交的勘误表,请访问 www.packtpub.com/books/content/support
,并在搜索字段中输入书籍名称。所需信息将在勘误部分显示。
盗版
在互联网上,版权材料的盗版是一个跨所有媒体的持续问题。在 Packt,我们非常重视我们版权和许可证的保护。如果您在互联网上发现我们作品的任何非法副本,请立即提供地址或网站名称,以便我们可以寻求补救措施。
请通过 <copyright@packtpub.com>
联系我们,并提供涉嫌盗版材料的链接。
我们感谢您在保护我们作者和我们为您提供有价值内容的能力方面的帮助。
问题
如果您在这本书的任何方面遇到问题,您可以通过 <questions@packtpub.com>
联系我们,我们将尽力解决问题。
第一章. Meteor 入门
即使编程速度较慢,Meteor 也是一个本质上快速的开发框架。本书的目的是提高你的开发速度并提高质量。提高开发速度的两个关键要素是编译器和模式。编译器为你的编程语言添加功能,而模式则增加了解决常见编程问题的速度。
本书将主要涵盖模式,但我们将使用本章快速入门编译器,并了解它们如何与 Meteor 相关——这是一个庞大但简单的主题。我们将查看的编译器如下:
-
CoffeeScript
-
Jade
-
笔记本
我们将回顾一些关于 Meteor 你应该具备的基本知识。这包括以下内容:
-
模板、辅助函数和事件
-
事件循环和合并框
-
必备的包
-
文件夹结构
Meteor 的 CoffeeScript
CoffeeScript 是一个 JavaScript 编译器,它受到 Ruby、Python 和 Haskell 的启发,添加了“语法糖”,这使得 JavaScript 的编写更容易、更易读。CoffeeScript 简化了函数、对象、数组、逻辑语句、绑定、管理作用域等语法。所有 CoffeeScript 文件都保存为 .coffee
扩展名。我们将涵盖函数、对象、逻辑语句和绑定,因为这些是使用最频繁的功能。
对象和数组
CoffeeScript 去掉了花括号({}
)、分号(;
)和逗号,
。仅此一项就可以让你在键盘上避免重复不必要的按键。相反,CoffeeScript 强调正确使用缩进。缩进不仅会使你的代码更易读,而且将是代码正常工作的关键因素。实际上,你可能已经正确地使用了缩进!让我们看看一些例子:
#COFFEESCRIPT
toolbox =
hammer:true
flashlight:false
提示
下载示例代码
你可以从www.packtpub.com
下载示例代码文件,这是你购买的所有 Packt 出版物的链接。如果你在其他地方购买了这本书,你可以访问www.packtpub.com/support
并注册,以便将文件直接通过电子邮件发送给你。
在这里,我们创建了一个名为 toolbox
的对象,它包含两个键:hammer
和 flashlight
。在 JavaScript 中的等效代码将是这样的:
//JAVASCRIPT - OUTPUT
var toolbox = {
hammer:true,
flashlight:false
};
更简单!正如你所见,我们必须缩进来表示 hammer
和 flashlight
属性都是 toolbox
的一部分。在 CoffeeScript 中不允许使用 var
关键字,因为 CoffeeScript 会自动为你应用它。让我们看看我们如何创建一个数组:
#COFFEESCRIPT
drill_bits = [
"1/16 in"
"5/64 in"
"3/32 in"
"7/64 in"
]
//JAVASCRIPT – OUTPUT
var drill_bits;
drill_bits = ["1/16 in","5/64 in","3/32 in","7/64 in"];
在这里,我们可以看到我们不需要任何逗号,但我们需要有括号来确定这是一个数组。
逻辑语句和运算符
CoffeeScript 在逻辑语句和函数中移除了很多括号(()
),这使得代码的逻辑在第一眼看起来更容易理解。让我们看看一个例子:
#COFFEESCRIPT
rating = "excellent" if five_star_rating
//JAVASCRIPT – OUTPUT
var rating;
if(five_star_rating){
rating = "excellent";
}
在这个例子中,我们可以清楚地看到 CoffeeScript 更易于阅读和编写。CoffeeScript 有效地替换了任何逻辑语句中的所有 隐式括号。
将 &&
、||
和 !==
等运算符替换为单词,可以使代码更易读。以下是您将最常使用的运算符列表:
CoffeeScript | JavaScript |
---|---|
is |
=== |
isnt |
!== |
not |
! |
and |
&& |
or |
|| |
true, yes, on |
true |
false, no, off |
false |
@, this |
this |
让我们看看一个稍微复杂一点的逻辑语句,并看看它是如何编译的:
#COFFEESCRIPT
# Suppose that "this" is an object that represents a person and their physical properties
if @eye_color is "green"
retina_scan = "passed"
else
retina_scan = "failed"
//JAVASCRIPT - OUTPUT
if(this.eye_color === "green"){
retina_scan = "passed";
} else {
retina_scan = "failed";
}
注意 this
的上下文是如何传递给 @
符号而不需要点号的,这使得 @eye_color
等于 this.eye_color
。
函数
JavaScript 函数是一段代码块,用于执行特定任务。JavaScript 有几种创建函数的方法,在 CoffeeScript 中得到了简化。它们看起来像这样:
//JAVASCRIPT
//Save an anonymous function onto a variable
var hello_world = function(){
console.log("Hello World!");
}
//Declare a function
function hello_world(){
console.log("Hello World!");
}
CoffeeScript 使用 ->
而不是 function()
关键字。以下示例输出一个 hello_world
函数:
#COFFEESCRIPT
#Create a function
hello_world = ->
console.log "Hello World!"
//JAVASCRIPT - OUTPUT
var hello_world;
hello_world = function(){
return console.log("Hello World!");
}
再次,我们将使用制表符来指定函数的内容,这样就不需要花括号 ({}
) 了。这意味着你必须确保整个函数的逻辑都缩进在其命名空间下。
我们的参数怎么办?我们可以使用 (p1,p2) ->
,其中 p1
和 p2
是参数。让我们让我们的 hello_world
函数输出我们的名字:
#COFFEESCRIPT
hello_world = (name) ->
console.log "Hello #{name}"
//JAVSCRIPT – OUTPUT
var hello_world;
hello_world = function(name) {
return console.log("Hello " + name);
}
在这个例子中,我们可以看到参数是如何放在括号内的。我们还在做 字符串插值。CoffeeScript 允许程序员通过使用 #{}
转义字符串来轻松地将逻辑添加到字符串中。注意,与 JavaScript 不同,你不需要在函数末尾返回任何内容,CoffeeScript 会自动返回最后一条命令的输出。
绑定
在 Meteor 中,我们经常会发现自己需要在嵌套函数和回调中使用 this
的属性。函数绑定 在这些情况下非常有用,并有助于避免在额外的变量中保存数据。函数绑定将函数内部的 this
对象的值设置为函数外部的 this
的值。让我们看一个例子:
#COFFEESCRIPT
# Let's make the context of this equal to our toolbox object
# this =
# hammer:true
# flashlight:false
# Run a method with a callback
Meteor.call "use_hammer", ->
console.log this
在这种情况下,this
对象将返回一个顶层对象,例如浏览器窗口。这毫无用处。现在让我们绑定 this
:
#COFFEESCRIPT
# Let's make the context of this equal to our toolbox object
# this =
# hammer:true
# flashlight:false
# Run a method with a callback
Meteor.call "use_hammer", =>
console.log this
关键区别在于使用 =>
而不是预期的 ->
来定义函数。使用 =>
将使回调的 this
对象等于执行函数的上下文。生成的编译脚本如下:
//JAVASCRIPT
Meteor.call("use_hammer", (function(_this) {
return function() {
return Console.log(_this);
};
})(this));
CoffeeScript 将提高你的编码质量和速度。尽管如此,CoffeeScript 并非完美无瑕。当你开始将函数与嵌套数组结合使用时,事情可能会变得复杂且难以阅读,尤其是在函数使用多个参数构建时。让我们看看一个看起来不像你预期的那样易于阅读的常见查询:
#COFFEESCRIPT
People.update
sibling:
$in:["bob","bill"]
,
limit:1
->
console.log "success!"
这个集合查询传递了三个参数:filter
对象、options
对象和回调函数。为了区分前两个对象,我们不得不在函数同一级别放置一个逗号,然后缩进第二个参数。这很麻烦,但我们可以使用变量来使查询更易读:
#COFFEESCRIPT
filter =
sibling:
$in:["bob","bill"]
options =
limit:1
People.update filter, options, ->
console.log "success!"
前往 coffeescript.org,通过点击“try coffeescript”链接来尝试使用该语言。
Jade for Meteor
Jade 与 CoffeeScript 工作方式相似,但它用于 HTML 而不是其他。我建议您安装 mquandalle:jade
包。所有的 Jade 文件都保存为 .jade
扩展名。本节将涵盖 Jade 在 Meteor 中最常用的方面,例如 HTML 标签、组件和助手。
HTML 标签
与 CoffeeScript 类似,Jade 是一种高度依赖缩进的编程语言。当您想向 HTML 标签添加子元素时,只需使用缩进。可以使用 CSS 选择器表示法('input#name.first'
)添加标签 ID 和类。这意味着类用点(.
)表示,ID 用井号(#
)表示。让我们看一个例子:
//- JADE
div#container
ul.list
li(data-bind="clickable") Click me!
<!-- HTML – OUTPUT -->
<div id="container">
<ul class="list">
<li data-bind="clickable">Click me!</li>
</ul>
</div>
如您所见,特殊属性如 data-bind
使用括号添加。符号如 <
、>
和 闭包不再需要。在这个例子中,我们有一个 div
标签,其 id
属性为 "container"
,一个 ul
标签,其 class
属性为 list
,以及一个具有特殊属性 data-bind
的 li
标签。
您会发现自己在 input
标签上经常使用特殊属性来添加 value
、placeholder
和其他属性。
模板和组件
Meteor 模板是 Jade 组件。在 Meteor 中,我们使用模板标签定义模板,并应用特殊的 name
属性来创建可重用的 HTML 块。在 Jade 中,当我们创建模板时,我们也创建了一个组件。这看起来如下:
//- JADE
template(name="landing")
h3 Hello World!
<!-- HTML – OUTPUT -->
<template name="landing">
<h3>Hello World!</h3>
</template>
现在,我们可以在视图的任何地方使用这个模板作为 Jade 组件。要调用 Jade 组件,您只需在模板名称前加上一个加号。让我们看看一个例子,我们想在 main_layout
页面内放置一个 landing
页面:
//- JADE
template(name="landing")
h3 Hello World!
template(name="main_layout")
+landing
<!-- HTML – OUTPUT -->
<template name="landing">
<h3>Hello World!</h3>
</template>
<template name="main_layout">
{{> landing}}
</template>
就这些!请注意,我们在模板名称前加上了加号 (+
) 来调用它。这相当于在 SpaceBars(Meteor 的 Handlebars 版本)中使用 {{> landing}}
。组件也可以有参数,这些参数可以在模板实例中使用。让我们通过一个例子来输出某人的名字:
//- JADE
template(name="landing")
h3 Hello {{name}}
template(name="main_layout")
+landing(name="Mr Someone")
# COFFEESCRIPT
Template.landing.helpers
"name": ->
Template.instance().data.name
<!-- HTML – OUTPUT -->
<template name="landing">
<h3>Hello {{name}}</h3>
</template>
<template name="main_layout">
{{> landing name="Mr Someone"}}
</template>
向您的模板添加属性可以使您的模板像前面的例子那样灵活。尽管如此,您不太可能需要这样做,因为模板“吸收”来自其父上下文的数据。
助手
Meteor 中的辅助工具是函数,在渲染到视图之前返回数据。我们使用辅助工具进行迭代、逻辑语句和变量。两个基本辅助工具是 each
和 if
,但添加 raix:handlebar-helpers
包将添加其他有用的辅助工具字典,以避免代码重复。让我们看看我们如何使用我们的辅助工具:
//- JADE
template(name="list_of_things")
each things
if selected
p.selected {{name}}
else
p {{name}}
<!-- HTML – OUTPUT -->
<template name="list_of_things">
{{#each things}}
{{#if selected}}
<p class="selected">{{name}}</p>
{{else}}
<p>{{name}}</p>
{{/if}}
{{/each}}
</template>
在这个例子中,each
辅助工具正在遍历另一个名为 things
的辅助工具的返回值,如果 selected
辅助工具解析为 true
,则我们将渲染 p.selected
并使用 name
变量。
重要的是要理解,所有不是 HTML 标签的内容都是辅助工具,如果你想在标签内使用辅助工具,你需要使用 {{}}
或 #{}
来表示这一点。
前往 jade-lang.com 和 handlebars.js
了解更具体的信息。有了这些信息,你应该能够做任何事情。
Stylus for Meteor
Stylus 与 CoffeeScript 和 Jade 类似,但它用于 CSS。我建议您安装 mquandalle:stylus
。这个包预装了有用的工具,如 Jeet
和 Rupture
。所有 Stylus 文件都保存为 .styl
扩展名。关于 Stylus,我们只需要了解三件事:CSS 标签、变量和函数。
CSS 标签
Stylus 是一种语言,它通过使用缩进来代替分号(;
)和大括号({}
),来消除对分号和大括号的需求。让我们看看一个例子:
// STYLUS
// Let's make a vertical positioning class
.vertical-align-middle
//PART 1
position:absolute
width:100%
height:100%
display:table
overflow-x:hidden
.special
background:black
我们可以在 PART 1
中看到,如何通过缩进属性来定义类的属性,使用 .special
选择具有 special
类的 HTML 标签,该标签是 vertical-align-middle
类的子类。让我们看看 PART 1
是如何编译的:
/* CSS – OUTPUT PART 1 */
.vertical-align-middle {
position:absolute;
width:100%;
height:100%;
display:table;
overflow-x:hidden;
}
.vertical-align-middle .special {
background:black;
}
现在让我们添加一个更复杂的选择器:
// STYLUS
// Let's make a vertical positioning class
.vertical-align-middle
//PART 1
...
//PART 2
> *
display:table-cell
vertical-align:middle
PART 2
结合了特殊的 CSS2 选择器:特定父级(>
)和所有元素(*
)。在这个特定的顺序中,CSS2 选择器只选择“任何第一个兄弟元素”并应用规则。让我们看看 PART 2
是如何编译的:
/* CSS – OUTPUT PART 2 */
.vertical-align-middle > * {
display:table-cell;
vertical-align:middle;
}
让我们在当前类中添加一个新的类,使对象对齐到顶部:
// STYLUS
// Let's make a vertical positioning class
.vertical-align-middle
//PART 1
...
//PART 2
...
//PART 3
&.whole-page
top:0
PART 3
使用连字符(&
)来描述一个不是子元素而是与额外类连接的元素。让我们看看 PART 3
是如何编译的:
/* CSS – OUTPUT PART 3 */
.vertical-align-middle.whole-page {
top:0;
}
变量
与 CSS 不同,Stylus 支持变量。当我们想要对网站的外观进行重大更改时,这可以使许多事情变得可管理。假设我们有两个颜色想要在整个网站上使用,但我们知道这些颜色将会改变。让我们将它们定义为变量,这样我们就可以轻松地在以后修改它们:
// STYLUS
primary-color = #ffffff
$secondary-color = #333333
.text-default
color:primary-color
background:$secondary-color
.text-inverted
color:$secondary-color
background:primary-color
简单吗?在这个例子中,primary-color
和 $secondary-color
都是变量。Stylus 可选支持使用货币符号($
)来表示变量。这可以使查找变量变得更加容易。
函数/混合
与 CSS 不同,Stylus 也支持函数。LESS、Stylus 和 Sassy CSS(SCSS)将函数称为 混入。函数会使你的 CSS 混合物更容易在整个项目中共享。我们将介绍 Stylus 中的两种混入类型:混入和透明混入。
混入是接受一组定义参数的函数。让我们看看我们如何编写一个混入:
// STYLUS
animation(duration,delay,timing)
-webkit-animation-duration:duration
animation-duration:duration
-webkit-animation-delay:delay
animation-delay:delay
-webkit-animation-timing-function:timing
animation-timing-function:timing
button
animation(500ms,0,ease-out)
/* CSS – OUTPUT */
button {
-webkit-animation-duration:500ms;
animation-duration:500ms;
-webkit-animation-delay:0;
animation-delay:0;
-webkit-animation-timing-function:ease-out;
animation-timing-function:ease-out;
}
在这个例子中,我们首先定义了 animation
混入,然后我们将混入应用到 button
HTML 标签上。然而,有一种更简单、更有效的方法是通过透明混入来完成这项工作。
透明混入基本上会接受所有参数并将它们保存在一个 arguments
变量中,而无需你定义任何内容。让我们看看:
// STYLUS
animation()
-webkit-animation:arguments
animation:arguments
button
animation(pulse 3s ease infinite)
/* CSS – OUTPUT */
button {
-webkit-animation:pulse 3s ease infinite;
animation:pulse 3s ease infinite;
}
注意我们不需要在混入中定义每个参数,arguments
变量简单地传递了它能找到的所有参数。这对于保持代码的灵活性特别有用。
Stylus 实质上以升级 CSS 的方式,使得代码更容易管理,因此,最终为我们节省了大量开发时间。
前往 stylus-lang.com 和 learnboost.github.io/stylus 了解更多关于 Stylus 的信息。
模板、辅助函数和事件
既然我们对本书中将要使用的语言达成了一致,让我们快速回顾一下我们在开发过程中将使用的一些元素。
模板、辅助函数和事件用于构建应用程序的前端。有效地使用它们是我们设计后端的关键(我们将在 第二章,发布和订阅模式)中讨论)。
模板
Meteor 模板是特殊的 HTML 代码块,用于生成 Meteor 模板对象(Template.<yourtemplate>
)。正是通过 Meteor 模板对象,我们将 HTML 代码与逻辑连接起来。使用过 MVC 框架的人会将这些模板称为视图。这是一个需要理解的关键概念。
打开你的终端并创建一个新的项目:
meteor create basic_meteor
现在让我们添加我们的语言:
meteor add coffeescript
meteor add mquandalle:jade
meteor add mquandalle:stylus
从 /basic_meteor
中删除三个可见文件(不要删除以点开头的任何文件),并创建 /client/layout.jade
。这是每个 Meteor 项目以某种方式存在的东西。让我们开始编程:
//- layout.jade
head
title Meteor Basics
meta(name="viewport" content="user-scalable=no, initial- scale=1.0, maximum-scale=1.0")
meta(name="apple-mobile-web-app-capable" content="yes")
body
+basic_template
basic_template. Let's program this in a new file, /client/basic_template.jade:
//- basic_template.jade
template(name="basic_template")
h1 Hello World!
在幕后,Meteor 正在编译我们的 Jade 模板,并将它们全部放入一个大的文件中。在模板方面,你永远不必担心在加载 layout.jade
之前加载 basic_template.jade
。
在整本书中,我们将使用 meteorhacks:flow-router
和 meteorhacks:flow-layout
来轻松导航到不同的模板。
创建辅助函数
我们已经讨论了 Jade 中的助手是什么,但在 Meteor 中如何创建助手呢?让我们回到我们的 basic_meteor
项目,并创建 /client/basic_template.coffee
。重要的是要理解,Meteor 助手用于控制模板中的变量。那些使用过 MVC 框架的人可以将这个文件视为一个控制器。让我们编写我们的第一个助手:
#basic_template.coffee
Template.basic_template.helpers
"name": ->
"Mr Someone"
注意,助手是在 Meteor 模板对象的 helpers
函数中定义的:Template.<your_template>.helpers(<your_helpers>)
。助手主要是返回任何你想要它们返回的内容的函数,包括 Meteor 集合游标。现在让我们把这些放在一起:
//- basic_template.jade
template(name="basic_template")
h1 Hello {{name}}
这将在 h1
HTML 标签内输出 Hello Mr Someone
。让我们添加一个稍微复杂一些的助手:
#basic_template.coffee
Template.basic_template.helpers
"person": ->
name:"Someone"
prefix: "Mr"
children: [
{
name:"Billy"
}
{
name:"Nancy"
}
]
//- basic_template.jade
template(name="basic_template")
with person
h1 Hello {{prefix}} {{name}}
ul
each children
li {{name}}
在这个例子中,我们使用 with
来设置属于它的 HTML 标签的 数据上下文;这个数据上下文等同于 person
。数据上下文指的是助手内部 this
的值。所以如果你设置一个对象作为数据上下文,this
将等同于那个对象。此外,我们使用 each
语句遍历 children
,以便列出它们的名称。
事件
Meteor 利用常见的 JavaScript HTML 事件,如点击、更改和焦点。事件是任何发生在你正在监听 HTML 元素上的事情。假设我们想要能够通过点击它们来将一个人的名字更改为其中一个孩子的名字。我们通过模板的事件映射来完成这个操作。让我们看看一个例子,说明我们如何在不使用响应性或集合的情况下完成这个操作:
#basic_template.coffee
Template.basic_template.events
"click li": ->
$("h1").text "Hello #{@name}"
简单!为了捕获模板事件,我们需要使用 Template.<your_template>.events(<your_event_map>)
函数。在这个特定的例子中,我们使用 jQuery 来替换文本。
event map
是一个对象,其中属性指定了一组要处理的事件。这些事件可以以下任何一种方式指定:
# Runs any time you click anywhere
"click": ->
# Runs any time you click a li element
"click li": ->
#Runs any time you click a li element OR mouseover a li element
"click li, mouseover li": ->
事件的关键 string
由两部分组成:第一部分始终是事件类型(点击、悬停、更改等),而第二部分始终是 CSS 选择器。
事件循环和合并框
在深入研究 Meteor 之前,理解事件循环和合并框是什么,以及它们如何对你的代码产生不利影响是至关重要的。两者在编程方式上都是相对复杂的,所以我们将会关注理解一般概念。
事件循环
事件循环就像一个队列;它依次运行一系列函数。因为函数是顺序处理的,所以每个函数实际上阻止其他函数在完成之前被处理。
换句话说,事件循环的功能就像一个单行传送带,其中事物正在被检查。对于每次检查,生产线都会停止,什么也不移动。
Meteor 使用 Fibers – 一个 NodeJS 库 – 来解决这个问题。你将运行的大多数函数将在一个单独的纤维上运行。这意味着函数将在一个单独的处理传送带上运行。尽管如此,并非所有函数都是这样构建的,你需要确保你的服务器端函数不会阻止服务器。
那么,哪些函数可能会使服务器被阻止?Meteor.methods()
、Meteor.publish()
以及任何不在服务器纤维内运行的函数。让我们看看我们如何解除每个函数的限制,以及何时应该这样做。
你知道在 Meteor.methods()
下定义的函数将需要很长时间来处理,应该始终在纤维上运行或在纤维上延迟耗时的代码。我们可以通过在方法内部调用 @unblock()
函数来快速解决这个问题。让我们看看一个例子:
# METEOR METHODS
Meteor.methods
#BLOCKING
time_consuming: ->
Meteor.setTimeout ->
console.log "done"
,60000
#NON-BLOCKING
time_consuming_unblock: ->
@unblock()
Meteor.setTimeout ->
console.log "done"
,60000
在这个例子中,当你运行 Meteor.call("time_consuming")
时,服务器将被阻止。当服务器被阻止时,其他访客将无法访问你的网站!相反,如果你运行 Meteor.call("time_consuming_unblock")
,服务器将继续正常工作,但会消耗更多资源来这样做。
安装了 meteorhacks:unblock
包后,Meteor.publish()
也可以轻松解除限制。当我们要开始创建可能消耗大量资源的非常复杂的发布者时,这个包将特别有用。让我们看看一个例子:
# METEOR PUBLISH
#BLOCKING
Meteor.publish "external_API_query", ->
HTTP.get "http://connect.square.com/payments"
#NON-BLOCKING
Meteor.publish "external_API_query_unblocked", ->
@unblock()
HTTP.get "http://connect.square.com/payments"
在这个例子中,我们正在等待一个 HTTP 调用响应。如果我们订阅了 external_API_query
,这肯定会阻止服务器,所以我们使用 external_API_query_unblocked
代替。
所有其他在服务器上运行且你知道会阻止服务器的函数,都应该在纤维上运行。Meteor 有一个特殊的功能帮助我们轻松实现这一点。它被称为 Meteor.wrapAsync()
。让我们看看它是如何工作的:
# METEOR UNBLOCKED FUNCTION
unblock_me = Meteor.wrapAsync ->
Meteor.setTimeout ->
console.log "done"
,60000
小贴士
记住事件循环非常重要,尤其是在我们将我们的网络应用程序连接到将导致服务器出现巨大延迟的外部服务时。
合并框
合并框是识别数据库中所有变化的算法。它基本上处理发布者、订阅者和反应性。合并框还使用 DDP 消息处理数据的初始加载。
重要的是要理解,我们可以通过在 Meteor.publish()
函数下可用的所有命令直接与合并框进行通信。我们能使我们的 Meteor.publish
函数更加优化,网站加载速度就会更快。
我们在线商店的开始
在整本书中,我们将开发一个电子商务网站,以帮助我们理解高级 Meteor 网络开发的核心概念。让我们从创建一个新的项目开始:
meteor create online_shop
必备的包
Atmospherejs.com 一直是寻找包的“必去”网站。在这里,你可以找到社区免费提供的数千个包。有一些包我们绝对需要安装,以使我们的网站正常工作。
首先,我们安装语言:
meteor add coffeescript
meteor add mquandalle:jade
meteor add mquandalle:stylus
meteor add less
接下来,我们将介绍路由和帮助我们的 SEO 和路由的函数:
meteor add kadira:flow-router
meteor add kadira:blaze-layout
meteor add meteorhacks:fast-render
meteor add nimble:restivus
meteor add yasinuslu:blaze-meta
meteor add dfischer:prerenderio
meteor add wizonesolutions:canonical
注意
警告:不要运行 Meteor!除非你正确设置了 canonical,否则它可能会破坏你的项目。
我们还需要一些包来帮助我们管理发布者:
meteor add lepozepo:publish-with-relations
meteor add tmeasday:publish-counts
meteor add meteorhacks:aggregate
meteor add http
meteor add meteorhacks:unblock
下一个包将扩展 Meteor 的功能:
meteor add xorax:multiple-callbacks
meteor add aldeed:collection2
meteor add aldeed:autoform
meteor add fastclick
meteor add reactive-var
meteor add alanning:roles
meteor add accounts-password
meteor add u2622:persistent-session
meteor add ongoworks:security
我们需要这些包来正确管理时间:
meteor add momentjs:moment
meteor add mrt:moment-timezone
对于最后一组,我们将使用一些额外的包,这将使设计过程变得更快:
meteor add kyleking:customizable-bootstrap-stylus
meteor add raix:handlebar-helpers
meteor add fortawesome:fontawesome
meteor add percolate:momentum
我们还需要移除一些包以增强安全性:
meteor remove autopublish
meteor remove insecure
这些包将在本书的后续章节中详细介绍,但这些都是必需的。我们需要解释的第一个包是 wizonesolutions:canonical
包。这个包确保所有进入的流量都被路由到你的 ROOT_URL
,因此当你希望所有流量都流向你的 SSL 网站时特别有用。在运行 Meteor 之前,我们需要做的第一件事是设置 canonical 以只在生产环境中运行。
创建 /server/canonical.coffee
文件,并添加以下代码:
#/server/canonical.coffee
if process.env.NODE_ENV is "development" or process.env.ROOT_URL.indexOf("meteor.com") > -1
Meteor.startup ->
process.env.PACKAGE_CANONICAL_DISABLE = true
PACKAGE_CANONICAL_DISABLE environment variable to make sure that canonical is inactive while you are developing.
环境变量是什么?这些变量是在部署的范围内定义的,并且确保在服务器上的构建完成之前项目知道这些信息。例如,使用哪个数据库、使用哪个域名以及其他设置信息通常可以在这类变量中找到。我们将在最后一章中介绍这些信息。
文件结构
在 Meteor 中,一个合适的文件结构非常重要。我们发现,最佳的工作方式是与功能顶级模块一起工作。这意味着每个文件夹都是一个微服务,因此可以独立运行。这为项目提供了很多模块化,并且其他人很容易理解你试图实现什么。在本节中,我们将介绍这个文件结构和 Meteor 的特殊文件夹。
让我们看看一个示例网络应用程序的文件夹结构:
/online_shop
/online_shop/cart
/online_shop/cart/cart_route.coffee #RUNS ON CLIENT AND SERVER
/online_shop/cart/client
/online_shop/cart/client/cart_view.jade #RUNS ON CLIENT ONLY
/online_shop/cart/client/cart_controller.coffee
/online_shop/cart/client/cart.styl
/online_shop/cart/server
/online_shop/cart/server/cart_publisher.coffee #RUNS ON SERVER ONLY
/online_shop/cart/server/cart_methods.coffee
在这个文件夹结构中,cart
是一个微服务,它由路由、视图、控制器和发布者组成。放在 /client
目录下的文件将被发布到客户端,并且只会在客户端运行。放在 /server
目录下的文件只会在服务器上运行和访问。如果一个文件放在这些目录之外,那么这个文件将在客户端和服务器上同时运行。预期的结构如下:
/project_folder
/project_folder/_globals
./client/<global support function files>
./server/<global support function files>
./lib/collections/<collection>/<file>
./lib/collections/<collection>/server/<permissions file>
/project_folder/router
./client/layouts/<layout file>
./lib/<configuration file>
./lib/<middleware file>
/project_folder/<module>
./<route file>
./client/<template file>
./client/<logic file>
./client/<styles file>
./server/<publishers file>
./server/<methods file>
需要注意的是,/lib
目录将始终在所有其他代码之前运行。让我们将我们的规范文件放在 /_globals/canonical/server
目录下。
让我们创建我们的第一个模块:路由器。创建/router/client/layout.jade
目录,在整个项目中我们只有一个布局。现在让我们编写我们的布局代码:
//- LAYOUT.JADE
head
title Online Shop
meta(charset="utf-8")
//- Allow saving to homescreen
meta(name="apple-mobile-web-app-capable" content="yes")
//- Do not try to detect phone numbers
meta(name="format-detection" content="telephone=no")
//- Make it mobile friendly
meta(name="viewport" content="user-scalable=no, initial- scale=1.0, maximum-scale=1.0")
body
template(name="layout")
+Template.dynamic(template=nav)
+Template.dynamic(template=content)
在这里,我们介绍了Template.dynamic
组件。该组件可以通过更改变量的值来动态渲染其他模板,该值是我们想要渲染的模板的名称。我们决定使用两个变量——nav
和content
——这些变量由路由器控制。所以,基本上,content
变量将改变为不同的字符串,这些字符串等于我们模板的名称。
我们将在下一章中创建我们的着陆
模块,以便学习不仅如何使用路由器,而且如何正确地订阅数据。
摘要
在本章中,我们已经解决了很多问题。现在我们可以更快地编程,因为我们有了像 CoffeeScript、Jade 和 Stylus 这样的工具来帮助我们。此外,我们还学会了如何使用模板、辅助工具和事件来处理我们的 Meteor 前端。理解事件循环和合并框使我们处理复杂、耗时操作时更加谨慎。最后,我们开始构建项目,并采用了一种将使开发更快的项目文件夹结构。
在下一章中,我们将介绍使 Meteor 应用程序可行的两个最重要的部分:Meteor 发布者和 Meteor 订阅者。使用这些模式,您将能够创建快速加载且不会对服务器造成过多压力的网站。
第二章。发布和订阅模式
这本书到目前为止最重要的章节。我们控制发布者和订阅者的方式将决定我们的应用程序在生产中的响应速度有多快。发布者和订阅者是我们数据库和客户端之间的链接。服务器使用发布者向客户端发布信息,而客户端通过订阅发布者来从发布者请求信息。这一切都通过Meteor.publish
和Meteor.subscribe
函数来管理。我们应该能够根据两个目标产生客户端想要看到的数据:
-
减少服务器的压力
-
只发送客户端需要的信息
本章将教会你你可以用来实现每个构建的模板的这些目标的不同模式。以下是我们将涵盖的主题概述,以了解这些模式:
-
模板级别的订阅
-
数据库关系
-
带关系的发布
-
聚合发布者
-
外部 API 发布者
模板级别的订阅
这种模式将Meteor.subscribe
函数附加到模板上。从模板订阅的关键优势是能够隔离模板,并知道它在渲染时仍然有效。
许多 Meteor 开发者将他们的订阅方法附加到他们的路由上。这意味着模板只有在特定路由上才会渲染正确的数据。
使用这种模式,我们将能够在任何地方重用模板,而不用担心数据问题。
为在线商店设置产品
让我们先为我们的online_shop
项目在 MongoDB 中设置一个Products
集合。在第一章,开始使用 Meteor中,我们了解到我们需要将这个定义放在/globals/lib/collections
目录下:
# /globals/lib/collections/products/products.coffee
@Products = new Mongo.Collection "products"
# fields:
# name
# description
# sku
重要的是要注意,我们在Products
变量开头添加了@
符号。这编译成this.Products
。在 Meteor 的 CoffeeScript 中,我们这样做是为了定义一个全局作用域的变量。这意味着Products
变量现在存在于我们将要创建的每个 CoffeeScript 文件中。
我们还需要添加一个权限文件,这样我们才能从控制台修改集合。allow
/deny
函数是给集合添加安全层的规则。如果一个allow
规则对某个操作返回true
,它将允许更改通过。目前,我们将设置所有规则以允许一切。当我们查看第四章,应用模式时,我们将处理权限问题。
# /globals
# ./lib/collections/products/server/products_permissions.coffee
Meteor.startup ->
Products.allow
insert: -> true
update: -> true
remove: -> true
我们使用Meteor.startup
函数确保在设置allow
/deny
规则之前,我们已经将Products
设置为集合。现在我们有了集合,让我们创建一个着陆页面来显示产品列表。让我们首先在/products/client
目录中构建视图:
//- /products/client/products.jade
template(name="products")
h3.text-center products
这个视图只是一个占位符。我们总是在实际开始使用出版商和订阅者之前创建模板对象。
构建出版商
让我们使用Meteor.publish
函数构建一个简单的出版商用于我们的视图。这个出版商将只向订阅客户端发送 10 个文档。我们将在第四章中讨论分页,应用模式。
# /products/server/products_pub.coffee
Meteor.publish "products_pub", ->
Products.find {},
limit:10
订阅出版商
这里魔法开始了。我们将使用Template.<template>.onCreated
函数来订阅我们的出版商。每当在 DOM 中创建模板实例时,onCreated
函数就会运行。因此,如果我们在这个函数中放置一个Meteor.subscribe
函数,这将自动在模板被使用时重新订阅。让我们试一试:
# /products/client/products.coffee
Template.products.onCreated ->
@autorun =>
@subscribe "products_pub"
onCreated hooks. This means that if we were to define a second onCreated function, the first function will not run. We change this behavior using the xorax:multiple-callbacks package. This package, basically, concatenates onCreated, onRendered, and onDestroyed functions so that they cannot be overwritten. Let's take a look:
# /products/client/products.coffee
Template.created "products", ->
@autorun =>
@subscribe "products_pub"
我们只需要更改代码的第一行。这样做是为了我们可以将多个函数附加到onCreated
模板钩子而不会覆盖任何其他函数。
数据库关系
我们的所有集合都将以某种方式与数据库中的其他集合相关联。这个主题将探讨你在设计数据库时应考虑的三种不同类型的关系。
我们数据库的最终形状定义了我们的出版商将是什么样子。如果你的数据都混合在只有一个或两个不同的集合中,你很快就会发现自己难以筛选数据。如果你的数据在多个集合之间分布得太广,代码在长期内将难以维护。那么这个问题的解决方案是什么?
解决数据库关系的方案是理解数据将在客户端如何使用、将修改多频繁以及集合可以有多大。
让我们构建我们其余的集合,以了解良好的关系看起来像什么。
一对一
一对一数据库关系描述了集合中的一个 MongoDB 文档将如何仅与另一个集合中的 MongoDB 文档相关联。你可以将其想象为另一个对象内部的 JavaScript 对象。
在 Meteor 中,你应该为那些你将很少使用、具有独特接口或你将使用而不一定需要访问产品的字段子集创建一对一关系。有了这种关系,构建一个仅发送master_image
的图片上传器和图片拼贴将变得非常容易。
首先,添加一个将处理我们产品图片的集合:ProductImages
。我们将假设我们的前端将具有多种类型的图片,每种图片都将展示在我们构建的界面中的不同部分:
# /lib/collections/product_images/product_images_collection.coffee
@ProductImages = new Mongo.Collection "product_images"
# fields:
# product
# master_image
# side_image
# front_image
# top_image
# cart_image
# /lib/collections/
# ./product_images/server/product_images_permissions.coffee
Meteor.startup ->
ProductImages.allow
insert: -> true
update: -> true
remove: -> true
ProductImages
集合将包含一个名为 product
的字段。该字段通过保存来自 Products
集合的唯一 _id
字段来建立我们的关系。这意味着每次我们想要发布带有 ProductImages
的产品时,我们还需要通过在 ProductImages
中搜索 Product_id
来查询数据库。所以一个辅助器可能看起来像这样:
# COFFEESCRIPT
Template.landing.helpers
"images": ->
ProductImages.findOne product:@_id
一对多
一对多数据库关系描述了在一个集合中的一个 MongoDB 文档将如何与另一个集合中的多个 MongoDB 文档相连接。你可以将其想象为一个存在于特定字段下的 JavaScript 对象数组,但数据如此复杂,以至于你需要将其分离。
现在让我们创建一个 Orders
集合。这个集合将作为一个购物车。虽然你的第一反应可能是创建一个 Carts
集合,但你很快会发现你正在重复订单信息(一个用于购物车,一个用于订单,一旦下单)。我们可以通过添加一个 status
字段来轻松识别订单是否为新的:
# /lib/collections/
# ./orders/orders_collection.coffee
@Orders = new Mongo.Collection "orders"
# fields
# status ("new","pending","complete")
# total_products
# subtotal
# tax
# rate
# amount
# discounts
# discount
# amount
# total
# date_created
我们不要忘记为集合添加权限。每次我们创建一个新的集合时,我们都需要这样做,以确保集合免受黑客攻击并确保客户端可以相应地修改集合。我们将在第四章的 安全 部分中对此进行更多介绍,应用模式。
# /lib/collections/
# ./orders/server/orders_permissions.coffee
Meteor.startup ->
Orders.allow
insert: -> true
update: -> true
remove: -> true
这里有问题,产品没有在集合中定义!我们故意创建了一个将具有与订单详情一对多关系的订单摘要集合。我们不知道订单实际上可能有多广泛。如果我们包含一个包含产品数组的字段,这个列表不仅难以管理,而且可能变得足够大,以至于会崩溃数据库。
让我们把订单详情放在一个名为 OrderDetails
的单独集合中:
# /lib/collections/
# ./order_details/order_details_collection.coffee
@OrderDetails = new Mongo.Collection "order_details"
# fields
# order
# product
# price
# quantity
# subtotal
# tax
# rate
# amount
# total
# discounts
# discount
# amount
# /lib/collections/
# ./order_details/server/order_details_permissions.coffee
Meteor.startup ->
OrderDetails.allow
insert: -> true
update: -> true
remove: -> true
完美!现在我们可以这样说,一个 Order
有多个 OrderDetails
。在这种情况下,OrderDetails
集合中的每个文档代表一个单独的产品以及该产品在订单中的详细信息。我们已经添加了 order
字段来识别这些详情(或在这种情况下,产品)属于哪个特定的订单。
这是一个优秀的设计。通过将细节与顺序分开,我们能够精确控制服务器发送给客户端的数据。记住,这里的目的是尽可能发送最少的可能数据给客户端,以便网站快速加载并减少服务器压力。管理插入和更新也变得更容易。由于我们不需要处理细节数组的索引,我们可以简单地使用 ID 来查找和操作数据。现在当商店管理员订阅Orders
集合时,我们可以这样操作,即服务器只发送管理员需要查看订单的数据。点击订单后,就会订阅仅该订单的详细信息。
多对多
多对多数据库关系描述了一个集合中的 MongoDB 文档如何链接到另一个集合中的多个 MongoDB 文档。你可以将其想象为一个表,其中你重复信息,并且对于每一行只更改一个字段的值,这样你就可以通过该行过滤信息。
这类关系需要映射表。在 MongoDB 的情况下,映射表是一个单独的集合。映射表是每行重复信息但不实际重复条目的部分;它只是将每个集合 ID 配对。
在我们即将编写的示例中,我们想要在产品和标签之间创建一个多对多关系,因为一个产品可以有多个标签,而一个标签也可以有多个产品。在这种情况下,映射表将保存每个 ID 对。所以如果一个产品有两个标签,映射表中将有两个条目,具有相同的产品 ID,但每个条目都有一个不同的标签 ID。如果一个标签是两个产品的组成部分,那么映射表将有两个条目,具有相同的标签 ID,但每个条目都有一个不同的产品 ID。
重要的是要注意,由于它们的复杂性,这类关系往往被忽视。如果你发现自己试图通过每个集合的数组来匹配两个集合,那么你肯定是在尝试一个“多对多”关系。
为了解释这种关系,我们将为我们的产品创建一个Tags
集合:
# /lib/collections/
# ./tags/tags_collection.coffee
@Tags = new Mongo.Collection "tags"
# fields
# name
# /lib/collections/
# ./tags/server/tags_permissions.coffee
Meteor.startup ->
Tags.allow
insert: -> true
update: -> true
remove: -> true
你会注意到,在这种关系中,Tags
集合没有与产品关联的内容,但如果它有,它将是一个包含标签所属产品的数组。为了建立这种关系,我们将创建映射表,即ProductsTags
集合:
# /lib/collections/
# ./products_tags/products_tags_collection.coffee
@ProductsTags = new Mongo.Collection "products_tags"
# fields
# product
# tag
# /lib/collections/
# ./tags/server/products_tags_permissions.coffee
Meteor.startup ->
ProductsTags.allow
insert: -> true
update: -> true
remove: -> true
这个集合允许我们在Products
和Tags
之间有任意的组合关系。假设我们想查看与一个标签相关的所有产品。在这种情况下,我们首先查询该标签的映射表,然后使用结果查询标签。
小心!多对多关系可能难以发现。如果在数据库设计过程中遇到任何困难,不要忘记考虑这种可能性。
带关系的发布
我们理解了我们的集合是如何相关的,但我们如何能够轻松地发布带有这些关系的数据?
在 Meteor 中,由于反应性和发布者的工作方式,发布关系可能会出现问题。你可能会期望通过简单地向两个相关集合查询并返回一个数组就能发布一个完美的反应性集合。但这并不是事实。Meteor.publish
函数在依赖项改变时不会重新运行。这意味着如果关系断开,相关的文档将保持发布状态,或者更糟糕的是,如果另一个客户端创建了新的关系,相关的数据将不会发布。
为了处理 Meteor 中的数据库关系和反应性,我们使用lepozepo:publish-with-relations
包。当关系断开时,此包会自动以最有效的方式订阅新数据。如果你熟悉 MySQL,这个包使得 JOIN 操作变得简单。
发布带有图片的产品(一对一)
我们将使用/products
目录,这个模块将是我们的首页。首先,让我们为我们的视图设置一个路由:
# /products/products_route.coffee
FlowRouter.route "/",
name:"products"
action: ->
FlowLayout.render "layout",
content:"products"
我们正在使用FlowRouter.route
函数来定义我们首页的路径,以及FlowLayout.render
函数来定义要使用哪个布局模板。你会注意到FlowLayout.render
函数接收两个参数:第一个参数定义要渲染哪个布局模板,第二个参数定义在布局模板中渲染的位置。
现在我们可以开始处理发布者了。目标是发布 10 个产品,只配对它们的master_image
:
# /products/server/products_pub.coffee
Meteor.publish "products", ->
@relations
collection:Products
options:
limit:10
mappings:[
{
key:"product"
collection:ProductImages
}
]
@ready()
注意我们通过@relations
函数传递的对象中的mappings
键。数组内的所有对象都必须至少有一个collection
键。可选的,它们可以包含key
和foreign_key
。在这种情况下,我们使用key
来表示Products
集合中的_id
字段等于ProductImages
集合中product
字段的字符串。这是发布数据最有效的方式。该包将自动确保所有集合的变化都能实时反映。
Meteor.publish
函数有一个特性:当数据发生变化时,光标会相应地反应,但持有光标的函数不会。当涉及到创建关系时,这种影响是明显的。让我们看看不使用包来处理我们的关系时,我们的代码会是什么样子:
# DO NOT CODE THIS INTO YOUR PROJECT
products_cursor = Products.find {},limit:10
# Make products an array with all the _ids
products = products_cursor.map (product) ->
product._id
# Find their images
images_cursor = ProductImages.find
product:
$in:products
# Return the cursors in an array
[products_cursor,images_cursor]
在这个例子中,假设由于某种原因Products
集合的顺序发生了变化。这将使products_cursor
按预期在客户端反应性地改变,但由于ProductImages
集合没有变化,并且Meteor.publish
在依赖项发生变化时不会重新运行,因此新发布产品的图片将不会反应性地发布!
发布带有详细信息的订单(一对多)
现在我们将处理我们的Orders
和OrderDetails
集合。让我们为创建订单的功能设置模板、路由和订阅者。我们可以将此模块命名为cart
并将其存储在orders
目录下:
# /orders/cart/client/cart.jade
template(name="cart")
h3.text-center cart
# /orders/cart/client/cart.coffee
Template.created "cart", ->
@autorun =>
order = Session.get "cart.order"
@subscribe "cart",
order:order
注意,order
变量将通过使用Session
变量来获取我们的订单_id
。我们这样做是因为Session
变量是响应式的;如果由于某种原因值发生变化,这将确保订阅者重新运行。订阅者重新运行是因为变量是在@autorun
函数内定义的。
此外,我们正在将一个对象作为参数传递给@subscribe
函数,以便发布者知道我们正在谈论哪个订单:
# /orders/cart/cart_route.coffee
FlowRouter.route "/cart",
name:"cart"
action: ->
FlowLayout.render "layout",
content:"cart"
我们需要发布三个不同的集合以获取购物车所需的所有信息:Orders
、OrderDetails
和Products
。此发布者可以遵循我们在第一个发布者中找到的相同逻辑:
# /orders/cart/server/cart_pub.coffee
Meteor.publish "cart", (ops={}) ->
if ops.order and not _.isEmpty ops.order
@relations
collection:Orders
filter:
_id:ops.order
status:"new"
mappings:[
{
key:"order"
collection:OrderDetails
options:
limit:25
mappings:[
{
foreign_key:"product"
collection:Products
}
]
}
]
@ready()
此发布者通过在参数中传递ops={}
来预定义函数的默认值。在确保ops.order
存在且不是空字符串后,我们建立关系。我们需要确保Order
的状态具有"new"
值以进行安全起见,所以我们将其硬编码在filter
键中。
注意,在这种情况下,我们正在使用foreign_key
。这表明OrderDetails
集合有一个包含与Products
集合的_id
字段相等的字符串的product
字段。
目前,我们将OrderDetails
限制为25
。
发布带有产品的标签(多对多)
我们绝对希望能够通过标签过滤我们的产品。我们可以遵循前一个主题中的相同模式。让我们修改我们的products
模块。
首先,我们的订阅者需要反应性地将一个标签 _id 数组转换为数组。我们可以现在使用一个Session
变量来设置一个标签 _id 数组,这样我们就可以轻松地从控制台直接修改它:
# /products/client/products.coffee
Template.created "products", ->
@autorun =>
# tags is an array of tag _ids
tags = Session.get "products.tags"
filter = {}
if tags and not _.isEmpty tags
_.extend filter,
tags:tags
@subscribe "products", filter
我们使用下划线的_.extend
函数确保如果存在tags
,则filter
变量有一个tags
键。现在我们的发布者将变得更加复杂:
# /products/server/products_pub.coffee
Meteor.publish "products", (ops = {}) ->
limit = 10
if ops.tags and not _.isEmpty ops.tags
@relations
collection:Tags
filter:
_id:
$in:ops.tags
mappings:[
{
collection:ProductsTags
key:"tag"
mappings:[
{
collection:Products
foreign_key:"product"
options:
limit:limit
mappings:[
{
collection:ProductImages
key:"product"
}
]
}
]
}
]
else
@relations
collection:Products
options:
limit:limit
mappings:[
{
key:"product"
collection:ProductImages
}
]
@ready()
虽然代码可能看起来很长,但逻辑很容易理解。首先,我们使用我们在订阅者中定义的tags
数组过滤了Tags
集合。然后,我们使用key
和foreign_key
的组合链式关系。在这种情况下,映射表(ProductsTags
)所服务的函数是清晰的。
关键,外键,选项和过滤器
从lepozepo:publish-with-relations
包中理解的核心概念是@relations
函数中提供的选项。
key
和foreign_key
默认为_id
字段。key
始终引用集合内的字段,而foreign_key
始终引用父集合的字段。
options
和 filter
选项等价于 Meteor - MongoDB 查询的第二个和第一个参数(分别):Products.find(filter,options)
。
有许多包的工作方式类似于 lepozepo:publish-with-relations
,我们在这本书中没有探讨它们,但它们确实值得留意:reywood:publish-composite
、lepozepo:reactive-publish
和 cottz:publish-relations
。我发现最后一个包是其中最好的,因为它使用简单,并迫使开发者创建更智能的数据库关系。
聚合发布者
有时我们的数据库有大量的信息,我们希望综合起来。一些开发者选择将所有信息发布到客户端,并让客户端进行综合。正如我们迄今为止所学的,这可能会对性能产生负面影响。其他开发者可能会使用 Meteor.method
来返回综合数据。这当然对客户端更好,但如果计算量很大,这将对我们的服务器造成负担。
处理这类问题的最佳方式是使用 MongoDB 的聚合框架将计算的繁重工作转移到我们的数据库上,然后我们可以将结果与 Meteor.publish
特殊函数配对:@added
、@changed
和 @removed
。
聚合框架
MongoDB 的聚合框架使用管道的概念来处理数据。基本上,管道是一系列 Mongo 将要遵循的步骤,以生成您所需的数据。
我们通过添加 meteorhacks:aggregate
来安装了对聚合框架的支持。这使我们能够访问框架的所有服务器端命令。您最终会使用最常用的命令是 $match
、$group
和 $project
。
让我们为我们的 dashboard
模块构建一个发布者,并首先构建模板:
# /dashboard/client/dashboard.jade
template(name="dashboard")
h3.text-center dashboard
# /dashboard/client/dashboard.coffee
Template.created "dashboard", ->
@autorun =>
@subscribe "dashboard"
# /dashboard/dashboard_route.coffee
FlowRouter.route "/dashboard",
name:"dashboard"
action: ->
FlowLayout.render "layout",
content:"dashboard"
现在,我们需要构建一个只存在于客户端的集合。我们的服务器将手动与这个集合通信。我们特别将这个集合放在客户端,因为我们不希望服务器将数据记录到数据库中。我们可以将集合附加到 (/dashboard/client/dashboard.coffee
) 客户端控制器文件中:
# /dashboard/client/dashboard.coffee
@_dashboard = new Mongo.Collection "_dashboard"
Template.created "dashboard", ->
@autorun =>
@subscribe "dashboard"
由于这个集合是客户端的,我们在名称开头加了一个下划线来区分它。当然,这并不是必需的,但它有助于防止重复的集合名称。
我们的目标是发布 "pending"
订单的总计和子总计。我们的管道步骤很简单:
-
过滤集合以显示状态等于
"pending"
的订单。 -
对每个过滤订单的总计求和:
# /dashboard/server/dashboard_pub.coffee Meteor.publish "dashboard", -> totals = Orders.aggregate [ { $match: status:"pending" } { $group: _id:null total: $sum:"$total" subtotal: $sum:"$subtotal" discount: $sum:"$discount.amount" } ] console.log totals
要使用聚合框架,你使用附加到集合对象的 .aggregate
函数;在这种情况下是 Orders
。此函数仅接受一个数组作为参数——这是因为数组是框架将要遵循的步骤的有序集合。数组中的每个步骤都由一个对象表示,并且总是以一个运算符开始。
在这里,我们决定使用 $match
来过滤订单以找到待处理的订单,然后我们使用了 $group
来累计 total
和 subtotal
的值。请注意,$group
表达式有一个强制性的 _id
键。此键定义了我们想要如何分组集合。通过将 _id
键设置为 null
,我们表示我们希望将集合中的所有文档分组到一个单一的对象中。
$sum
是一个累加运算符。当你使用这样的运算符时,你可以通过使用货币符号($
)后跟字段名称的字符串来访问文档属性。此外,你可以使用点表示法("$discount.amount"
)访问对象中的对象。
totals
的结果是包含单个对象的数组,具有 total
、subtotal
和 discount
键。
发布结果
发布结果比看起来要容易得多。我们只需要使用绑定到 Meteor.publish
的 @added
函数。基本上,@added
函数会通知订阅者已向发布集合中添加了数据:
# /dashboard/server/dashboard_pub.coffee
Meteor.publish "dashboard", ->
totals = Orders.aggregate [
{
$match:
status:"pending"
}
{
$group:
_id:null
total:
$sum:"$total"
subtotal:
$sum:"$subtotal"
discount:
$sum:"$discount.amount"
}
]
if totals and totals.length > 0 and totals[0]
@added "_dashboard","totals",totals[0]
最后两行确保 totals
数组存在,如果不存在,则将数组中的第一个对象发布到我们的 _dashboard
集合。@added
函数有三个必需的参数。第一个参数是集合的名称,第二个是文档的 _id
,第三个是文档。
注意,这类发布者不是响应式的,这意味着我们不需要添加 @changed
或 @removed
函数。然而,我们可以更进一步。我们不需要为每个需要聚合发布者的模块创建一个集合,我们可以创建一个主集合来管理我们所有的聚合发布者。
首先,我们删除我们的 _dashboard
集合并创建一个新的 Aggregate
集合:
# /dashboard/client/dashboard.coffee
Template.created "dashboard", ->
@autorun =>
@subscribe "dashboard"
# /globals/lib/collections/aggregate/client/aggregate.coffee
@Aggregate = new Mongo.Collection "aggregate"
现在,我们需要修改我们的发布者:
# /dashboard/server/dashboard_pub.coffee
Meteor.publish "dashboard", ->
totals = Orders.aggregate [
{
$match:
status:"pending"
}
{
$group:
_id:null
total:
$sum:"$total"
subtotal:
$sum:"$subtotal"
discount:
$sum:"$discount.amount"
}
]
if totals and totals.length > 0 and totals[0]
@added "aggregate","dashboard.totals",totals[0]
通过这种方式,我们现在可以通过在客户端控制台调用以下函数来访问我们的值:
Aggregate.findOne "dashboard.totals"
重要的是要理解,一旦我们离开路由,订阅者将被停止,aggregate
集合将被清除。这种默认行为给了这个集合很大的灵活性。
为什么使用发布者与聚合框架比使用 Meteor.method
更有效?Meteor.method
是设计用来触发服务器中的关键函数并返回简单消息的。另一方面,发布者是设计用来控制数据集的。你很快会发现,发布者比方法更容易控制和优化。
外部 API 发布者
应该避免这类发布者。它们从外部服务(如 Stripe 或 Square)获取原始数据很棒,但它们通常稍微慢一些,因为这意味着需要与另一个服务器进行主动通信。当我们与其他服务器集成时,我们应该始终构建一个单独的同步服务器。我们将在另一章中讨论 API。
尽管如此,这种发布模式在边缘情况下可能很有用,因此了解这个选项的存在很重要。
HTTP 包
HTTP 是一个协作系统的协议;它是允许用户连接到网页的协议。HTTP 协议可以用来从我们的服务器访问其他服务器上的数据。
我们将使用 Meteor 的集成 HTTP Request
模块与 Stripe 的服务器进行通信。我们选择与 Stripe 集成,因为它是一个易于集成且比市场上大多数其他支付处理器更可靠的支付处理器。我们在运行 meteor add http
时添加了这个包。此模块具有你期望的所有功能:.get
、.post
、.put
和 .del
。对于这个主题,我们只将涵盖 .get
函数。
我们的目标是从 Stripe 获取数据。首先在 Stripe 中创建一个免费账户。创建账户后,转到你的仪表板并将其设置为 "test"
。
现在通过点击 + 创建支付 来创建一个支付。使用以下数据来进行支付:
-
金额:
10
-
卡号:
4242 4242 4242 4242
在我们的测试环境中,我们不需要添加 CVC,Stripe 会自动将到期日期设置为今天起一年。
点击 账户设置 并复制你的测试密钥。我们将使用它来授权我们的请求。
我们可以先在 /dashboard_pub.coffee
下创建一个新的发布者;我们目前只获取最后三个费用——我们稍后会修改这个设置:
# /dashboard/server/dashboard_pub.coffee
Meteor.publish "dashboard", ->
...
Meteor.publish "latest_sales", ->
@unblock()
HTTP.get "https://api.stripe.com/v1/charges?limit=3",
headers:
"Authorization":"Bearer <TEST SECRET KEY>"
(error,result) =>
if not error
_.each result.data.data, (payment) =>
@added "aggregate", "dashboard.sales.#{Meteor.uuid()}",payment
else
console.log error
我们在这里使用的 @unblock
函数是通过 meteorhacks:unblock
包提供的。它的工作方式与 Meteor.methods
中的 @unblock
函数相同;这确保了发布者在等待从 Stripe 收到信息时不会阻塞服务器。解除聚合发布者的阻塞至关重要!如果我们不解除发布者的阻塞,那么当用户离开页面时,客户端可能会变得无响应。
你可能自己在想:我们能不能直接在客户端发起 HTTP 请求而不必担心阻塞服务器?不,我们不能。如果你在客户端运行这个函数,你就必须暴露你的密钥,这是一个重大的安全漏洞。
HTTP.get
函数有三个重要的字段:URL、options
对象和回调函数。URL 是 Stripe 在其 API 文档中提供的地址——在这种情况下,我们使用了/charges
URL 并传递了一个参数limit
来获取最后三笔销售。options
对象用于传递请求所需的所有信息。在这种情况下,我们将使用headers
键来设置我们的Authorization
头。options
对象可以接受几个键;你可以在docs.meteor.com找到这些信息。我们的回调函数接收请求的结果。与 Meteor 中的大多数函数一样,它返回两个参数;第一个是一个错误对象,如果没有错误则为未定义,而第二个是实际的结果。
在这种情况下,我们正在寻找的数据包含在result.data.data
数组中。然后我们可以轻松地使用我们的@added
函数发布数据。请注意,我们同时绑定了回调函数和_.each
函数,这样我们就可以访问到@added
。
让我们订阅我们的新发布者来查看我们的数据:
# /dashboard/client/dashboard.coffee
Template.created "dashboard", ->
@autorun =>
@subscribe "dashboard"
@subscribe "latest_sales"
尝试在你的控制台中运行Aggregate.find().fetch()
来查看结果。
摘要
在本章中,我们学习了如何使用模板钩子将订阅者从模板中隔离出来。我们还学习了如何根据我们将如何使用它来优化我们数据库的结构。当涉及到发布者时,我们学习了如何返回每个可能的数据结构而不破坏响应性。我们介绍了聚合发布者,并学习了在发布之前如何合成数据,而不会损害服务器的性能。
在下一章中,我们将介绍一些前端技术,这些技术将帮助我们保持代码的灵活性并使前端看起来很棒。
第三章。前端模式
本章将介绍一组有用的模式,这将加快您的前端工作流程。Meteor 使构建丰富的前端体验变得容易,但我们必须注意我们的界面需要多少计算才能运行。过多的计算会减慢移动设备。在本章中,我们将学习如何使我们的计算简短、DOM 简单和动画有效。您将学习以下主题:
-
响应式设计
-
超级助手
-
变量类型
-
快速表单
-
加载
-
动画
-
搜索引擎优化
响应式设计
应用程序总是根据其外观来评判。现在我们知道了如何让它表现良好,我们需要学习如何让它看起来很好。如今,在构建前端时,同时考虑以下两点是一种常见的做法:
-
极简主义:这种设计风格通过仅显示使网站正常运行的必要元素来使您的网站简洁。少即是多。
-
响应式:这种编程风格使您的网站无论屏幕大小如何都能使用。HTML 标记将始终根据屏幕宽度进行调整。
在这里,我们只将介绍如何编写响应式前端,因为极简主义是一个广泛且高度争议的话题。
通用设置
定制化的第一层开始于我们已安装的 kyleking:customizable-bootstrap-stylus
包。按照以下步骤设置此包:
创建以下目录:
/_globals/client/bootstrap/custom.bootstrap.json
。
(保留 JSON 文件为空。)
使用以下方式运行 Meteor 项目:
meteor
您会注意到已添加了三个新文件。这些文件将在运行时编译以生成您版本的 Twitter Bootstrap。
打开 /_globals/client/bootstrap/custom.bootstrap.json
,并确保所有变量都设置为 true
:
{"_route":"/_globals/client/bootstrap/custom.bootstrap.json"},
{
"modules" : {
"variables": true,
"mixins": true,
"normalize": true,
"print": true,
"glyphicons": true,
"scaffolding": true,
"utilities": true,
"type": true,
"code": true,
"grid": true,
"tables": true,
"forms": true,
"buttons": true,
"component-animations": true,
"dropdowns": true,
"button-groups": true,
"input-groups": true,
"navs": true,
"navbar": true,
"breadcrumbs": true,
"pagination": true,
"pager": true,
"labels": true,
"badges": true,
"jumbotron": true,
"thumbnails": true,
"alerts": true,
"progress-bars": true,
"media": true,
"list-group": true,
"panels": true,
"responsive-embed": true,
"wells": true,
"close": true,
"modals": true,
"tooltip": true,
"popovers": true,
"carousel": true,
"responsive-utilities": true
}
}
通过将所有变量设置为 true
,我们正在引入 bootstrap
所有的功能。如果您不想使用框架中的任何功能,您可以通过在此文件中将它设置为 false
来禁用它。
您需要手动重新启动 Meteor 以使这些更改生效。因此,请在您的终端上按 Ctrl + C 停止 Meteor 并再次运行 meteor
命令。
现在,我们可以编辑 bootstrap
。首先打开 /_globals/client/bootstrap/custom.bootstrap.import.styl
目录。在这里,您将找到一个我们可以玩转的 Stylus 变量库。
让我们移除圆角并修改按钮边框颜色:
// /_globals/client/bootstrap/custom.bootstrap.import.styl
...
// Line #114
$border-radius-base ?= 0px
$border-radius-large ?= 0px
$border-radius-small ?= 0px
...
// Line #156
$btn-default-color ?= #333
$btn-default-bg ?= #fff
$btn-default-border ?= $btn-default-color
$btn-primary-color ?= #fff
$btn-primary-bg ?= $brand-primary
$btn-primary-border ?= $btn-primary-bg
$btn-success-color ?= #fff
$btn-success-bg ?= $brand-success
$btn-success-border ?= $btn-success-bg
$btn-info-color ?= #fff
$btn-info-bg ?= $brand-info
$btn-info-border ?= $btn-info-bg
$btn-warning-color ?= #fff
$btn-warning-bg ?= $brand-warning
$btn-warning-border ?= $btn-warning-bg
$btn-danger-color ?= #fff
$btn-danger-bg ?= $brand-danger
$btn-danger-border ?= $btn-danger-bg
如果我们需要为自定义样式表使用 bootstrap 混合和变量怎么办?我们可以在需要访问一切时使用 @import
命令:
@import "_globals/bootstrap/client/custom.bootstrap.import.styl"
让我们通过添加一个大型推广者来自定义我们的产品页面。用新的 #promoter
标题重写产品模板:
//- /products/client/products.jade
template(name="products")
div#products.template
header#promoter
h3 Your Brand
在我们编写产品样式之前,让我们在 /_globals/client/main.styl
中创建一个全局样式表,用于任何应用程序范围内的样式。在这里,我们要确保我们的初始视图设置正确,以便我们可以正确地使用相对单位:
// /_globals/client/main.styl
html, body, #__flow-root, #__flow-root > .template
html, body, body > .template
height:100%
使用这组规则,我们基本上确保了包括我们的第一个 .template
DOM 元素在内的所有内容都具有视口的高度。这种技术使我们能够使相对百分比高度按预期工作。
我们还需要为 BlazeLayout 设置根 DOM 元素。让我们在客户端/服务器配置文件中完成这项操作:
# /_globals/router/config.coffee
if Meteor.isClient
BlazeLayout.setRoot 'body'
现在,创建 products
样式的目录:/products/client/products.styl
。我们将首先将变量导入到文件中,并设置一些简单的规则,使促销看起来更好:
// products/client/products.styl
@import "_globals/bootstrap/custom.bootstrap.import.styl"
#products
#promoter
background: $brand-primary
height: 80%
太好了,这为我们可能想要添加的任何促销图片设置了 promoter
部分,并且网站在移动设备上的外观相当不错(但还不够好)。
Bootstrap
Bootstrap 是一个包含 CSS 规则的库,它通过包含的类库使简单布局的开发变得快速。
让我们扩展我们的产品模板,升级我们的 #promoter
标题并添加一个 #features
部分。在这种情况下,我们将使用 Bootstrap 来管理网格:
//- /products/client/products.jade
template(name="products")
div#products.template
header#promoter
div.container
div.row
div.col-xs-12
h3 Your Brand
section#features
div.container
div.row
div.col-xs-12.col-sm-4
h3.text-center
span.fa-stack.fa-lg
i.fa.fa-square.fa-stack-2x.text-primary
i.fa.fa-users.fa-inverse.fa-stack-1x
h5.text-center 24 Hour Support
div.col-xs-12.col-sm-4
h3.text-center
span.fa-stack.fa-lg
i.fa.fa-square.fa-stack-2x.text-primary
i.fa.fa-truck.fa-inverse.fa-stack-1x
h5.text-center Next Day Delivery
div.col-xs-12.col-sm-4
h3.text-center
span.fa-stack.fa-lg
i.fa.fa-square.fa-stack-2x.text-primary
i.fa.fa-bomb.fa-inverse.fa-stack-1x
h5.text-center Mind Blown Guarantee
我们在这里做了很多事情,但没有什么复杂的。首先,我们将 #promoter
标题的内容包装在 Bootstrap 的网格中。Bootstrap 网格只有在具有 row
类的情况下才会按预期工作。container
类控制我们的页面宽度断点。
Bootstrap 列的设置使用 col-
类前缀,后跟目标屏幕尺寸,然后是一个可选的偏移指示符,最后是元素将跨越或偏移的列数:
div.col-<screen size>-<optional offset>-<number of columns>
Class | Breakpoint | Offset |
---|---|---|
col-xs-* |
<768px | col-xs-offset-* |
col-sm-* |
≥768px | col-sm-offset-* |
col-md-* |
≥992px | col-md-offset-* |
col-lg-* |
≥1200px | col-lg-offset-* |
Bootstrap 总共有 12 列(这可以在我们的 variables
文件中修改);每一列都支持偏移和嵌套。记住,如果未定义其他屏幕尺寸,则最小的屏幕尺寸将覆盖所有其他屏幕尺寸。因此,如果您想为每个尺寸定义三个列,您只需为最小尺寸的屏幕定义它即可。在这个例子中,我们将我们的 #features
部分分成三个列,每个列的尺寸都大于 768 px,并在小于 768 px 的任何地方将其视为单列。
如果在任何时候需要嵌套 Bootstrap 列,那么您需要将这些新列放置在 row
类下。以下示例在一个六列长的 div
内部中心对另一个六列长的 div
:
div.row
div.col-xs-6
div.col-xs-6
div.row
div.col-xs-6.col-xs-offset-3
我们还使用 Font Awesome 为我们的 #features
部分创建了一些图标。在这里,我们使用 Font Awesome 的不太为人所知的 图标堆叠 功能。要使用此功能,您只需将一组 Font Awesome 图标包装在一个 span.fa-stack
元素中。
Font Awesome 有一份大约 500 个图标的列表。如果你想使用不同的图标集,请随意查看在线图标列表fortawesome.github.io/Font-Awesome/icons
。
现在让我们在我们的前端添加一点 Jeet 和 Rupture。
使用 Rupture 的 Jeet 网格系统
我们安装的 Stylus 版本(mquandalle:stylus
)附带了许多有用的混合函数,其中最显著的是Jeet和Rupture。Jeet 是一个比 Twitter Bootstrap 更优越的网格系统,因为它更灵活,而 Rupture 是一组简化媒体查询的函数。了解如何利用 Jeet 和 Twitter Bootstrap 将提高你前端设计的质量。
让我们修复我们的#promoter
标题,使我们的页面对在较小设备上以横幅模式浏览的用户更加友好:
// products/client/products.styl
@import "_globals/client/bootstrap/custom.bootstrap.import.styl"
@import "jeet" // Add jeet
#products
#promoter
background: $brand-primary
height: 80%
+below($screen-sm-min) // If the screen width is smaller than -sm
+portrait() // And the device is in portrait mode
height: 30%
+landscape() // And the device is in landscape mode
col(1/3)
height: 100%
#features
+below($screen-sm-min) // If the screen width is smaller than -sm
+landscape() // And the device is in landscape mode
col(2/3)
height: 100%
.container
width:100% // Override bootstrap container
Rupture 添加了几个混合函数来轻松控制@media
查询:below
、above
、between
、landscape
、portrait
以及更多。你会发现自己在使用below
、landscape
和portrait
时比其他情况更频繁。要在 Stylus 中使用这些混合函数,我们只需在命令前加上一个加号(+
)。
在这个例子中,我们使用+below($screen-sm-min)
来定义所有宽度低于 Bootstrap 的-sm
断点的设备的 CSS 规则。然后我们将使用+portrait()
和+landscape()
来定义横幅和横幅模式的 CSS 规则。
接下来,我们将使用 Jeet 的col()
混合函数来控制主列。这个混合函数接受一个分数,该分数定义了元素的宽度作为分数。考虑分子是你希望元素占用的列数,分母是总列数:
col(<column width>/<number of columns>)
在这个例子中,当我们切换到小设备上的横幅模式时,我们的#promoter
标题宽度为三列中的一列,而我们的#features
部分宽度为三列中的两列。
超级辅助函数
在 Meteor 中,你很快会发现自己在模板中重复使用辅助函数,用于简单的格式化等任务。我们可以通过创建一个全局函数字典来防止重复,这就是我们所说的超级辅助函数。为此,我们将利用 Meteor 的渲染引擎——Blaze。
重要的是要理解 Blaze 与 Spacebars 深度集成,而 Spacebars 是 Meteor 对 HandlebarsJS 的更新版本。HandlebarsJS 是一个 JavaScript 模板引擎,它通过使用{{}}
在前端启用辅助函数和组件的使用。这个遗留意味着 Spacebars 拥有很多 HandlebarsJS 的功能。因此,HandlebarsJS 中的大部分文档也适用于 Meteor 辅助函数。
定义 Blaze 辅助函数
Meteor 提供了Template.registerHelper
函数来创建全局辅助函数。让我们创建一些帮助我们格式化货币的工具:
# /_globals/client/formatters.coffee
Template.registerHelper "format_money", (value) ->
if _.isNumber value
"$#{(value / 100).toFixed(2)}"
如您所见,全局辅助器的语法与模板辅助器的语法完全相同。现在,我们可以在任何模板中使用这个辅助器。让我们在我们的 /products
目录下创建一个产品模板,并在这里尝试它:
//- /products/client/product.jade
template(name="product")
div.col-xs-12.col-sm-4.col-md-3
article#product
h5 {{name}}
div.offer
span.price {{format_money price}}
我们还需要修补我们的产品模板:
//- /products/client/products.jade
template(name="products")
div#products.template
header#promoter
...
section#features
...
br
section#featured_products
div.container
div.row
each products
+product
让我们在我们的产品集合中添加一些临时条目。我们将在这章的其余部分使用这些条目:
@Products = new Mongo.Collection "products"
if Meteor.isServer
if Products.find().count() is 0
Products.insert
name:"Nuka Cola"
price: 1099
Products.insert
name:"1up Soda"
price: 999
Products.insert
name:"JuggerNog"
price: 899
# /products/client/products.coffee
Template.created "products", ->
...
Template.products.helpers
products: ->
Products.find()
注意,在这个数据模式中,我们定义 price
为分。当在 JavaScript 中处理金钱时,这是一种常见的做法,以避免算术浮点问题。这意味着什么?请转到您的网络浏览器控制台,并运行以下操作:0.1 + 0.2
。奇怪的是,JavaScript 并没有响应 0.3
;它响应的是 0.3000000000004
。这是因为 JavaScript 对十进制数的内部 64 位浮点表示。这意味着 JavaScript 并不完全理解十进制数是什么。这就是为什么,当我们处理金钱时,我们不使用十进制数,我们只使用最小的可用货币单位。
制作全局字典
等等,我们还可以让这个格式化器在我们的应用程序中更广泛地可用。让我们将其转换成一个字典,并与我们的控制器共享。换句话说,我们希望能够使用一个如 {{format.money amount}}
的辅助器,并在我们的 CoffeeScript 中以 format.money amount
的形式使用它:
# /_globals/client/formatters.coffee
@format =
money: (value) ->
if _.isNumber value
"$#{(value / 100).toFixed(2)}"
Template.registerHelper "format", ->
format
首先,我们创建一个包含我们定义的对象,并使用 @
(等于 this
)将其设置为全局。然后我们将这个定义对象放入一个全局辅助器中。很简单!现在我们知道了如何构建一个可以在任何地方使用的超级辅助器。为了测试这个,请在浏览器控制台中输入 format.money(1099)
;这应该返回 "$10.99"。
变量类型
我们需要了解四种类型的变量来优化我们的前端工作流程:会话、持久、文件作用域和 ReactiveVar。有了这些变量,我们可以根据用户与视图的交互轻松构建动态网站。
会话变量是仅在会话期间存在的变量。每当用户访问网站时,会话就开始了。这些变量可以在不同的路由之间共享,并且是响应式的。
持久变量是存储在本地存储中的变量,这样即使在会话关闭后也可以读取。
文件作用域变量是仅存在于文件作用域内的变量。这些变量通常不是响应式的。
ReactiveVar 变量可以作用域到文件或全局,并且是响应式的。这些变量不会像会话变量那样持久化。
会话变量
一些 Meteor 开发者认为会话变量被过度使用。这绝对是正确的,如果你没有管理它们。大部分情况下,会话变量应该用于处理在应用程序中跨多个路由共享的数据,没有数据库中的位置,需要响应性,并且可以通过热代码重载持久存在。
如果你停下来思考一下,不是很多事物都需要所有这些属性。一开始,你可能严重依赖它们来控制你的前端,但随着你的前端变得更加简约,你会意识到它们其实没有必要。
然而,如果你打算使用它们,你应该遵循两条规则来维护它们,如下所示:
-
基于模板的分类法
-
如果你不会使用它们,请清除它们
会话变量的分类法应该始终遵循<template.variable>
模式,以防止它们相互污染。假设我们想使用会话变量来控制全局警告,会话变量看起来会是这样:
Session.set "products.alert",true
当我们的用户离开视图时,我们应该清除会话变量。clear
函数是由我们在第一章中安装的u2622:persistent-session
包添加的,使用 Meteor 入门:
# Clear "products.alert" variable after "products" template is destroyed
Template.destroyed "products", ->
Session.clear "products.alert"
# Clear all variables after the "products" template is destroyed
Template.destroyed "products", ->
Session.clear()
小贴士
不要将会话变量与 Meteor 方法一起使用来获取服务器数据。虽然方法可以工作,但它会使你的数据容易受到攻击并在客户端被修改。
此外,尽量远离使用由多个键或数组组成的复杂对象的会话变量。你很快会发现自己在使用像_.find
和_.findWhere
这样的较小查找工具来使用数据。
持久变量
持久变量是会话变量,它们在会话中持续存在。在 Meteor 中,一个常见的会话变量如果用户刷新页面将会重置。持久变量利用 AmplifyJS,它反过来使用 HTML5 本地存储并回退到将变量存储在用户的浏览器中。这些类型的变量存在是因为u2622:persistent-session
包。
我们可以使用这个变量来跟踪用户的订单,以防他们离开网站且未登录。因此,如果用户打开一个订单并填充它,他们可以在稍后回来而不需要在我们的网站上创建用户账户。
让我们创建一个add-to-cart
按钮,它将利用持久变量:
# /products/client/product.coffee
Template.product.events
"click button.add-to-cart": (event) ->
# Get the session variable
order_id = Session.get "global.order"
order = Orders.findOne order_id
# Insert Order if it doesn't exist
unless order
order_id = Orders.insert
status:"new"
total_products:0
subtotal:0
total:0
else
order_id = order._id
# Set the session variable for future reference
Session.setPersistent "global.order",order_id
# Find the order
order = Orders.findOne order_id
# Check for details on this product
detail = OrderDetails.findOne
product:@_id
order:order._id
if detail
# Increase by one if the details exist
OrderDetails.update detail._id,
$inc:
quantity:1
Orders.update order._id,
$inc:
total_products:1
subtotal:@price
total:@price
else
# Insert if details do not exist
OrderDetails.insert
quantity:1
product:@_id
order:order._id
Orders.update order._id,
$inc:
total_products:1
subtotal:@price
total:@price
我们需要构建一个发布者来实现这一点,但我们必须做出选择。因为我们既有products
和product
模板,每个都可以订阅一个发布者并拉取相同的数据。我们应该从product
还是products
模板订阅?让我们两者都做!扩展产品发布者将确保我们获得模板所需的所有数据,而附加的发布者将帮助我们管理在需要时“产品详情”页面和促销活动:
# /products/server/products_pub.coffee
Meteor.publish "products", (ops={}) ->
...
if ops.order and not _.isEmpty ops.order
@relations
collection:Orders
filter:
_id:ops.order
status:"new"
mappings:[
{
collection:OrderDetails
key:"order"
}
]
然后我们在客户端使用订单键连接我们的订阅者:
# /products/client/products.coffee
Template.created "products", ->
@autorun =>
...
order = Session.get "global.order"
if order and not _.isEmpty order
_.extend filter,
order:order
@subscribe "products", filter
这样一来,现在我们可以向订单中添加产品,刷新网站,并且仍然回到我们的订单。让我们完成 product
模板的发布者构建:
# /products/server/product_pub.coffee
Meteor.publish "product", (ops={}) ->
if ops.product and not _.isEmpty ops.product
@relations
collection:Products
options:
_id:ops.product
mappings:[
{
key:"product"
collection:ProductImages
}
{
collection:ProductsTags
key:"product"
mappings: [
{
collection:Tags
foreign_key:"tag"
}
]
}
]
if ops.order and not _.isEmpty ops.order
@relations
collection:Orders
filter:
_id:ops.order
status:"new"
mappings:[
{
collection:OrderDetails
key:"order"
filter:
product:ops.product
}
]
@ready()
注意,我们的产品只带来了与该产品以及该订单相关的数据。现在我们可以编写我们的订阅者程序:
# /products/client/product.coffee
Template.created "product", ->
@autorun =>
filter = {}
# Get the product ID from the context
product = @data._id
_.extend filter,
product:product
# Get the order if any
order = Session.get "global.order"
if order and not _.isEmpty order
_.extend filter,
order:order
@subscribe "product", filter
...
文件作用域变量
这些是您在大多数情况下想要使用的变量类型。它们是什么?它们是常规变量!这意味着它们不是反应式的,所以要注意。常规变量仅在文件范围内有效,因此您不需要担心与其他文件中具有相同名称的变量发生冲突。
这些变量对于诸如列表等您知道将不会改变且不需要在应用程序的其他地方使用的项目非常有用。要使用一个,只需在您的 Meteor 特定函数之外输入一个变量。
ReactiveVar 变量
ReactiveVar 变量是反应式变量,它们在控制台中的可用性不同于会话变量。这使得它们非常适合那些需要保持相对难以从控制台访问的反应式事物。
如果在任何时候您正在创建一个高度反应式的界面,您应该用这些变量填充它,这样您就不必担心管理它们。如果有热代码重新加载或页面刷新,这些变量会自动清除。
要创建一个反应式变量,您只需使用其构造函数定义一个变量:
reactive_variable = new ReactiveVar(<optional-default-value>)
这个变量将公开设置器和获取器函数。这意味着您使用 .get
命令来查看变量的值,使用 .set
命令来更改它:
# Get the value of the variable
reactive_variable.get()
# Set the value of the variable
reactive_variable.set "hello"
让我们使用 ReactiveVar 变量来为我们的数量字段创建一个丰富的数字输入界面。我们将首先定义一个路由名称为 order_quantity
并带有 product
参数:
# /orders/cart/cart_route.coffee
FlowRouter.route "/cart",
...
FlowRouter.route "/cart/:product/quantity",
name:"order_quantity"
action: ->
FlowLayout.render "layout",
content:"order_quantity"
我们可以使用一个新按钮将我们的产品连接起来,该按钮将带我们进入修改数量视图,并传递我们的产品 ID:
# /products/client/product.coffee
Template.created "product", ->
...
Template.product.events
"click button.add-to-cart": (event) ->
...
"click button.modify-quantity": ->
FlowRouter.go "order_quantity",
product:@_id
//- /products/client/product.jade
template(name="product")
div.col-xs-12.col-sm-4.col-md-3
article#product
h5 {{name}}
div.offer
span.price {{format.money price}}
button.add-to-cart.btn.btn-block.btn-primary Add to Cart
button.modify-quantity.btn.btn-block.btn-info Quantity
现在,我们需要设计一个响应式的数字键盘。首先,让我们在我们的 /_globals/client/main.styl
目录中添加一个全局的 vertical-align
类,这样我们就可以轻松地垂直对齐元素:
// /_globals/client/main.styl
.vertical-align
position:relative
transform:translateY(-50%)
top:50%
我们将使用 Jeet 来控制这个设计,因为它需要对我们的 DOM 元素进行更具体的控制。让我们首先在我们的 order_quantity
模板中创建页面的布局:
//- /orders/cart/client/order_quantity.jade
template(name="order_quantity")
div#order_quantity.template
section#number
h1.text-center.vertical-align {{total}}
section#number-pad
each numbers
div.number
h2.text-center.vertical-align {{number}}
div.delete
p.text-center.vertical-align
i.fa.fa-undo.fa-2x
div.add-to-cart
p.text-center.vertical-align
i.fa.fa-check.fa-2x
注意,我们正在使用 vertical-align
类来对齐我们想要垂直对齐的元素。我们将将这些元素中的每一个都做成一个大按钮。此外,我们将创建一个 numbers
辅助器来将数字写入数字键盘,而 total
辅助器将是我们的反应式变量。在我们创建辅助器之前,让我们看看我们的样式:
// /orders/cart/client/order_quantity.styl
@import "_globals/client/bootstrap/custom.bootstrap.import.styl"
@import "jeet" // Add jeet
#order_quantity
overflow:hidden
background:$brand-primary
color:white
section#number
height:50%
h1
margin:0
section#number-pad
height:50%
.number, .delete, .add-to-cart
cursor:pointer
height:25%
col(1/3,gutter:0,cycle:3)
h2, p
margin:0
在这个例子中,我们向 Jeet 传递了参数:gutter
和cycle
。gutter
确保没有边距,而cycle
确保元素只能以每行最多 3 个元素的方式形成行。我们让每个部分恰好占据屏幕高度的 50%,但通过将overflow
设置为隐藏来确保不会发生滚动。因为我们总是会有四行数字,所以我们为每个数字设置了 25%的高度。
现在,让我们将我们的ReactiveVar
变量知识付诸实践。首先,我们设置我们的变量并将其附加到模板实例上,如下所示:
# /orders/cart/client/order_quantity.coffee
# Attach a reactive variable to the instance
# this variable controls our total
Template.created "order_quantity", ->
@total = new ReactiveVar()
让我们构建numbers
辅助函数和将渲染我们的(total
)反应性变量的辅助函数:
# /orders/cart/client/order_quantity.coffee
...
Template.order_quantity.helpers
# Create a list of numbers for the number pad
"numbers": ->
_.map [1,2,3,4,5,6,7,8,9,0], (v,k) ->
number:String v
# Get the reactive variable
# this will automatically update when the variable changes
"total": ->
Template.instance().total.get()
我们需要处理事件来更新我们的ReactiveVar
变量:
# /orders/cart/client/order_quantity.coffee
...
Template.order_quantity.events
# Concatenate numbers to make it work like a number pad
"click .number": (event,i) ->
total = i.total.get()
if total
new_total = "#{total}#{@number}"
else
new_total = "#{@number}"
i.total.set new_total
# Remove last number from string
"click .delete": (event,i) ->
total = i.total.get()
if total
i.total.set total.slice 0,-1
"click .add-to-cart": (event,i) ->
# Get the session variable
order_id = Session.get "global.order"
order = Orders.findOne order_id
# Get the total
total = i.total.get()
unless total
return
else
total = Number total
# Get the product with the ID from the router
product = Products.findOne FlowRouter.current().params.product
# Insert Order if it doesn't exist
unless order
order_id = Orders.insert
status:"new"
total_products:0
subtotal:0
total:0
else
order_id = order._id
# Set the session variable for future reference
Session.setPersistent "global.order",order_id
# Find the order
order = Orders.findOne order_id
# Check for details on this product
detail = OrderDetails.findOne
product:product._id
order:order._id
if detail
# Increase by one if the details exist
OrderDetails.update detail._id,
$inc:
quantity:total
Orders.update order_id,
$inc:
total_products:1
subtotal:product.price * total
total:product.price * total
else
# Insert if details do not exist
OrderDetails.insert
quantity:total
product:product
order:order._id
Orders.update order._id,
$inc:
total_products:1
subtotal:product.price * total
total:product.price * total
FlowRouter.go "products"
如果你仔细看看add-to-cart
事件的工作方式,你会注意到它与为产品模板编写的那个事件几乎相同。在这个时候,我们实际上是在重复自己,但我们将通过Meteor.methods
来减少代码的重复性。
注意我们是如何使用 ReactiveVar 的,就像我们使用会话变量一样,不同之处在于我们没有将变量暴露给控制台。这有助于使界面更加安全。
让我们别忘了添加这个视图所需的发布者,我们需要Products
、Orders
和OrderDetails
:
# /orders/cart/client/order_quantity.coffee
Template.created "order_quantity", ->
@total = new ReactiveVar()
@autorun =>
@subscribe "order_quantity",
product:FlowRouter.current().params.product
order:Session.get "global.order"
# /orders/cart/server/order_quantity_pub.coffee
Meteor.publish "order_quantity", (ops={}) ->
if ops.product and not _.isEmpty ops.product
@relations
collection:Products
filter:
_id:ops.product
@relations
collection:Orders
filter:
_id:ops.order
status:"new"
mappings:[
{
key:"order"
collection:OrderDetails
filter:
product:ops.product
}
]
@ready()
我们学习了如何使用 ReactiveVar 变量构建自定义表单。这个主题涵盖了 ReactiveVar 并触及了 Jeet。我们学习了如何使用反应性变量来创建丰富的用户体验。
表单
到目前为止,我们的按钮和表单非常低效,因为它们不容易重复,因此也不容易维护。我们可以使用两种方法来使我们的代码更容易管理:
-
Meteor Methods
-
Autoform
使用 Meteor 方法,我们可以轻松创建可重复和安全的函数,而 autoforms 可以识别集合的结构并从中生成表单元素。这提供了相当一层的安全保障,而且不需要太多努力。Autoform 和 Meteor Methods 也可以一起使用,以进一步增强安全性。
autoform 方法可以通过aldeed:autoform
和aldeed:collection2
包实现。
Meteor Methods
Meteor Methods 可以在客户端或服务器上使用。它们是通过使用Meteor.methods(<object-of-functions>)
函数创建的,并通过Meteor.call(<function-name>, <callback function>)
函数运行。从客户端运行一个 Meteor 方法会导致同一函数运行两个版本。一个在服务器上运行并操作数据,另一个在客户端运行并模拟数据操作。这就是我们所说的方法占位符。让我们看看一些示例代码来理解这个概念:
# Define your method CLIENT SIDE
Meteor.methods
say_hello: ->
console.log "hello"
# Define your method SERVER SIDE
Meteor.methods
say_hello: ->
console.log "I don't want to say hello"
# Run the function CLIENT SIDE
Meteor.call "say_hello"
一旦运行函数,Meteor 方法将激活服务器端和客户端函数。因此,在这种情况下,服务器将在控制台输出 "我不想说你好",而客户端将输出 "hello"。这是 Meteor 提供的一个特殊功能,用于帮助验证代码或在服务器端并行运行其他函数,同时不向客户端暴露敏感数据。
然而,值得注意的是,客户端方法将立即运行,而服务器端方法将需要更长的时间。因此,客户端代码应该用于模拟,而服务器端代码应该用于验证。
让我们定义并使用一个在客户端和服务器端都运行的 Meteor 方法。让我们首先重新定义我们之前编程的 add-to-cart
事件:
# /orders/cart/cart_methods.coffee
Meteor.methods
"cart.add-to-cart": (ops={},callback) ->
#ops
# order
# product
# quantity
order = Orders.findOne ops.order
product = Products.findOne ops.product
# Insert Order if it doesn't exist
unless order
order_id = Orders.insert
status:"new"
total_products:0
subtotal:0
total:0
else
order_id = order._id
# Set the session variable for future reference
if Meteor.isClient
Session.setPersistent "global.order",order_id
# Find the order
order = Orders.findOne order_id
# Check for details on this product
detail = OrderDetails.findOne
product:product._id
order:order._id
if detail
# Increase by one if the details exist
OrderDetails.update detail._id,
$inc:
quantity:ops.quantity
Orders.update order._id,
$inc:
total_products:ops.quantity
subtotal:product.price * ops.quantity
total:product.price * ops.quantity
else
# Insert if details do not exist
OrderDetails.insert
quantity:ops.quantity
product:product._id
order:order._id
Orders.update order._id,
$inc:
total_products:ops.quantity
subtotal:product.price * ops.quantity
total:product.price * ops.quantity
# Run the callback function if it exists
callback and callback(null, true)
Meteor.isClient. We have modified the rest of our code to accept quantity, a product ID, and an order ID, which is all we need to be able to manage both the events.
此外,我们在方法末尾添加了一行代码来检查回调函数,如果存在则执行它。观察我们如何在函数开始时将一个函数作为我们的参数之一接受。为了遵循 Meteor 的做法,函数执行完成后,我们返回 null
作为错误对象,并返回 true
作为结果。现在让我们替换事件:
# /orders/cart/client/order_quantity.coffee
# Attach a reactive variable to the instance
# this variable controls our total
Template.created "order_quantity", ->
...
Template.order_quantity.helpers
...
Template.order_quantity.events
...
"click .add-to-cart": (event,i) ->
# Get the total
total = i.total.get()
unless total
return
else
total = Number total
Meteor.call "cart.add-to-cart",
order:Session.get "global.order"
product:FlowRouter.current().params.product
quantity:total
(error,r) ->
if not error
FlowRouter.go "products"
# /products/client/product.coffee
Template.created "product", ->
...
Template.product.events
"click button.add-to-cart": (event) ->
Meteor.call "cart.add-to-cart",
order:Session.get "global.order"
product:@_id
quantity:1
(error,r) ->
if not error
FlowRouter.go "products"
Meteor 方法非常适合创建丰富且易于重复的用户界面,但如果你只更新单个集合,你最终会发现自己在重复某些模式。这就是自动表单发挥作用的地方。
自动表单
autoform
插件是与 collection2
插件一起安装的。这两个插件协同工作,可以快速创建支持错误处理的表单。关于这些插件的文档非常丰富,但阅读这些文档对于确保你的视图安全且易于构建是必要的。你可以在 github.com/aldeed/meteor-autoform
找到这些文档。我们将介绍一个简单的模式,以展示如何轻松地操作自动表单。
要使用自动表单,你首先需要为你的集合创建一个 collection2
架构。这定义了自动表单将要使用的规则来验证和生成我们的输入字段。让我们填充我们的产品架构:
# /_globals/lib/collections/products/products_collection.coffee
@Products = new Mongo.Collection "products"
Products.attachSchema new SimpleSchema
name:
type:String
label:"Name"
description:
type:String
label:"Description"
optional:true
sku:
type:String
label:"SKU"
optional:true
price:
type:Number
label:"Price"
new SimpleSchema constructor, then we have attached the schema to the collection using the .attachSchema function. The SimpleSchema constructor takes an object where the first key defines the name of the field and the object within that key defines the way the field will behave. We will dive into this further in the next chapter.
将架构附加到我们的集合中确保客户端控制台命令不能添加不属于架构的信息到数据库。让我们重置我们的项目以确保我们的新产品适应新的架构:
meteor reset
现在,我们可以使用自动表单为我们的产品集合创建一个简单的插入表单。让我们创建模板和路由:
//- /products/client/create_product.jade
template(name="create_product")
h3.text-center create product
div.container
div.row
div.col-xs-12
+autoForm collection="Products" type="insert" id="insert_product" preserveForm="true"
+afQuickField name="name" autocorrect="off" autocomplete="off"
+afQuickField name="price"
+afQuickField name="description"
+afQuickField name="sku"
button.btn.btn-block.btn-primary Add Product
# /products/products_route.coffee
FlowRouter.route "/",
...
FlowRouter.route "/products/create",
name:"create_product"
action: ->
FlowLayout.render "layout",
content:"create_product"
注意,我们不需要编写控制器来使这个功能工作。在这个情况下,我们需要理解两个关键组件:autoForm
和 afQuickField
。
当我们使用 autoForm
与一个集合一起时,我们必须使用 collection
参数声明集合的名称。此外,我们需要使用 type
参数定义表单是进行插入、更新还是方法调用,最后,我们给表单一个 id
,它将被用作表单的 HTML id
属性。此外,我们传递 preserveForm
参数以确保数据在热代码重新加载中保持持久。autoform 还接受其他一些参数,但这些是最常用的。
afQuickField
组件专门创建了一个可以轻松处理错误的 bootstrap3
输入字段。请注意,我们还可以将这些组件以及参数添加 HTML 属性(如 autocorrect
和 autocomplete
)。有各种方法可以自定义这些输入,但我们建议只使用两种:直接修改 CSS 或利用 afFieldInput
和 afFieldIsInvalid
来构建新元素。
让我们进一步自定义我们的 price
字段:
div.form-group(class="{{#if afFieldIsInvalid name='price'}} has-error has-feedback {{/if}}")
label.control-label {{afFieldLabelText name="price"}}
+afFieldInput name="price" validation="submit"
if afFieldIsInvalid name="price"
span.glyphicon.glyphicon-certificate.form-control-feedback
span.help-block {{afFieldMessage name="price"}}
通过这样做,我们非常容易地使用 bootstraps 的类为我们的字段添加一个 glyphicon
,但这在需要时可以用于适应任何框架。
尽管如此,这个表单仍然存在一些问题,人们输入价格时使用的是美元而不是分,但我们的模式只接受分。让我们在提交之前使用 autoform 的 钩子 来转换这些数据:
# /products/client/create_product.coffee
AutoForm.addHooks "insert_product",
formToDoc: (product) ->
product.price = product.price * 100
product
docToForm: (product) ->
product.price = product.price / 100
product
Autoform 有一个很长的钩子列表,但到目前为止,对于转换来说最有用的是一个 formToDoc
钩子。这个函数在每次表单被转换为文档以进行处理时都会运行,这包括在错误处理之前。我们不使用“before hook”,因为这个钩子将在错误处理之后运行。此外,我们正在使用一个 docToForm
钩子来确保在代码重新加载后我们的 price
字段保持完整。Autoform 有以下钩子:
before:
insert:
update:
update-pushArray:
method:
method-update:
normal:
after:
insert:
update:
update-pushArray:
method:
method-update:
normal:
onSubmit:
onSuccess:
onError:
formToDoc:
formToModifier:
docToForm:
beginSubmit:
endSubmit:
通过这种方式,我们可以快速创建和修改表单,使其符合我们的喜好,同时保持可用性。远离需要数组的字段!在我们的应用程序中,它们很难管理,并且总可以用我们模型中的新集合来表示。
加载数据
如果我们没有正确加载数据,我们的前端性能可能会受到影响,因为它可能会使 DOM 快速多次重绘。如果计算复杂,那么我们需要等待所有数据都可用,并在一次单独的扫描中计算一切。如果我们不等待数据,那么计算将在数据接收时运行,这反过来又会导致 DOM 非常快地多次重绘。
为了解决这个问题,我们可以轻松地检查我们的订阅者是否已准备好,并在他们准备好之前显示一个加载符号。
设计加载指示器
我们首先创建一个加载模板。让我们使用 font awesome 来处理我们的动画,并确保我们可以轻松地更改加载器的颜色:
//- /loader/loader.jade
template(name="loader")
div#loader.template
div(class="{{color_class}}").vertical-align.text-center
i.fa.fa-5x.fa-cog.fa-spin
// /loader/loader.styl
#loader
height:100%
在这种情况下,我们使用 fa-spin
类来使齿轮旋转,并添加了一个 color_class
辅助类,它将接受 Bootstraps 的 text-
类来定义颜色。同时,我们确保加载器填充了它所持有的内容的全部,这样当我们使用它时,一切都能整齐对齐。
实现加载指示器
让我们为我们的产品列表实现加载指示器。由于我们将要实现的方式,我们很容易让我们的界面为每个产品或产品列表显示加载指示器。控制后者将更有效率。
因此,让我们修改我们的产品模板:
//- /products/client/products.jade
template(name="products")
div#products.template
header#promoter
...
section#features
...
section#featured_products
div.container
div.row
if Template.subscriptionsReady
each products
+product
else
div(style="height:160px;")
+loader color_class="text-primary"
br
Meteor 核心提供了一个特殊的 Template.subscriptionsReady
辅助函数,用于检查在该模板实例中创建的订阅是否都处于就绪状态。这是使用我们 Template.created
函数中的 @subscribe
而不是 Meteor.subscribe
的隐藏优势之一。
在这种情况下,我们只在 products
变量上运行 each
迭代,直到所有订阅都准备就绪。在此之前,我们使用我们的主色调渲染 loader
组件。
这种模式的优点在于我们可以精确控制加载器想要放置的位置以及它何时出现。
动画和过渡
网页动画是一个庞大且仍在增长的领域,如果你想要它变得复杂,它确实可以变得非常复杂。然而,最终,高效的动画才是真正能产生影响的。你如何使动画高效?你让它保持简单,并让浏览器为你进行动画处理。远离使用 JavaScript 来识别位置和改变颜色。过于依赖 JavaScript 的动画将始终有糟糕的性能。这种糟糕的性能发生是因为 JavaScript 无法在所有设备上有效运行,因此,在不需要使用视频内存的情况下,DOM 的多次更改会在设备允许的最大速度下发生。
Canvas 是一个设计用来通过 JavaScript 和 WebGL 渲染变化的 HTML 元素。通过 canvas 动画任何内容都能提升性能,但它更适合那些想要进行图形密集型工作,如 3D 渲染或游戏精灵动画的应用程序。在这本书中,我们不会涉及 canvas,因为你可能永远不需要它。
忽略 canvas 的强大功能,我们只剩下两个 CSS 工具来制作丰富的动画:animation
和 transition
。animation
属性旨在用于需要关键帧的复杂动画,而 transition
属性旨在用于简单的状态转换动画。让我们先看看每个属性的例子,以了解它们是如何工作的,然后我们将把它们应用到 Meteor 中。
使用 CSS 进行动画
transition
属性是最容易理解的属性。你基本上在 CSS 属性中定义你将要动画化的属性以及如何进行动画化:
.box
transition: <CSS property> <duration> <timing function> <delay>
重要的是要意识到,由于 Stylus Nib(此包中包含的另一个有用的 Stylus 插件),我们不必担心这个特定属性的供应商前缀。Nib 会自动为我们处理这个问题。通过将过渡属性添加到 box
类中,我们告诉浏览器对定义的属性上发生的任何变化进行动画处理。这些变化是如何发生的呢?通过新的类。让我们看一个小例子:
.box
transition: opacity 300ms ease-in
opacity: 0
.in
opacity: 1
通过这组 CSS 规则,我们使盒子在 .in
类动态添加之前不可见。一旦添加了类,DOM 元素就会淡入。可用的时序函数有:ease
、linear
、ease-in
、ease-out
、ease-in-out
、step-start
和 step-end
。
animation
属性稍微复杂一些,因为它不需要动态类来激活动画:
.box
animation:
<animation name>
<duration>
<timing function>
<delay>
<direction>
<iteration count>
<fill mode>
<play state>
@keyframes <animation name>
0%
background:blue
50%
background:green
100%
background:red
如您所见,过渡和动画之间的区别在于你可以对动画有多少控制力。通过使用 animation name
和 @keyframes
选择器,我们可以控制动画的哪个点我们想要改变属性以及如何改变。你可以将关键帧上的数字更改为你想要的任何数字。
注意到 animation
属性有几个新的参数:迭代次数
、填充模式
和播放状态
。迭代次数
参数定义了动画将要播放的次数;你可以将其设置为无限或一个特定的数字。填充模式
定义了当元素不在动画状态时你希望元素处于的状态:forwards
将值设置为最后一个定义的关键帧,backwards
设置为第一个定义的关键帧,两者都设置为 first
和 last
,而 none
将不执行任何操作(默认)。播放状态
控制动画是否播放(暂停或运行)。
如果需要,我们可以使用第二个 CSS 选择器来控制我们的 play state
,就像我们为 transition
属性所做的那样。
在 Meteor 中执行动画
在 Meteor 中,使用助手控制渲染到 DOM 上的类非常容易,因此我们可以快速注入一个将触发我们的 CSS 动画的类。当我们想要在 DOM 中不存在的元素上触发动画时,我们会遇到问题。
例如,一旦我们的产品加载完成,它们就会突然出现。如果我们想使列表淡入,而加载器淡出呢?这就是 Meteor 的隐藏的 @_uihooks
函数发挥作用的地方。
在撰写本文时,此功能处于测试阶段,并且仍然有些问题。处理由此引起所有问题的最佳方式是通过 percolate:momentum
包。这样可以避免理解 uihooks
当前存在的问题以及如何绕过它们。
momentum
包设计用来通过使用在Template.rendered
函数上下文中提供的@_uihooks
函数来拦截 Blaze。它非常容易使用,并且与 VelocityJS 一起打包,这是一个处理过渡相当有效的 jQuery 插件。我们可以使用两者的组合来帮助保持事情简单。
要自定义动量,我们需要注册处理我们 DOM 中可能发生的三个钩子的动量插件:insertElement
、removeElement
和moveElement
。
insertElement |
当一个 DOM 元素被创建时发生 |
---|---|
removeElement |
当一个 DOM 元素被销毁时发生 |
moveElement |
当一个 DOM 元素从其 DOM 位置移动时发生(排序可能会触发此事件) |
让我们构建一个名为fade-fast
的插件,该插件将控制产品加载指示器的淡入和淡出:
# /_globals/client/momentum/fade-fast.coffee
Momentum.registerPlugin 'fade-fast', (options) ->
insertElement: (node, next) ->
$(node)
.addClass "animate opacity invisible"
.insertBefore(next)
Meteor.setTimeout ->
$(node).removeClass "invisible"
,250
removeElement: (node) ->
$(node).velocity opacity:0, 250, "easeOut", ->
$(this).remove()
我们使用node
变量来修改将要注入 DOM 的元素,并且我们总是使用insertBefore
方法,将next
变量作为参数来渲染。请注意,在这种情况下,当我们插入时,我们是在元素渲染到 DOM 之前添加三个类,然后在 250 毫秒后移除不可见类。
当一个元素被移除时,我们将使用velocity
来修改opacity
属性,并在恰好 250 毫秒内将其降至0
。Velocity 接受一个回调命令,我们将使用它最终从 DOM 中移除元素。
现在我们可以添加我们的样式并将助手放置在我们的视图中:
//- /products/client/products.jade
...
section#featured_products
div.container
div.row
+momentum(plugin="fade-fast")
if Template.subscriptionsReady
each products
div.col-xs-12.col-sm-4.col-md-3
+product
else
div(style="height:160px;")
+loader color_class="text-primary"
// _globals/client/main.styl
.vertical-align
...
.animate
&.opacity
transition: opacity 500ms
opacity:1
&.invisible
opacity:0
注意,我们添加了一个momentum(plugin="fade-fast")
组件,它定义了将要渲染具有动画的元素的区域的插件。然后我们创建将使用 CSS 过渡使每个 DOM 元素淡入的类。
SEO
Meteor 有一个大问题。它默认不支持服务器端渲染。这意味着爬虫不知道如何解析我们的页面,因为服务器没有渲染页面,而是客户端!有许多方法可以解决这个问题。你可以尝试使用meteorhacks:ssr
来启用服务器端渲染,或者你可以让一个免费的服务为你自动化这个过程。我们将使用一个服务来简化事情。
Prerender.io
认识一下 prerender.io,这是获取网站解析的最简单方法。Prerender.io 主要是一个免费服务,它确切地知道如何解析你的页面。它基本上导航到你的webapp
并解析每个页面,然后当爬虫击中我们的webapp
时,我们的服务器将从 prerender.io 获取解析后的页面,并用这个页面进行响应。
对于页面数量最少且被爬虫访问的 web 应用,该服务是免费的。你拥有的页面越多,动态性越强,价格就越高,但除非你正在构建一个高度动态的公共网页,否则这种情况不太可能。即使如此,prerender.io
项目可以下载并个人设置(因此仍然是免费的)。
我们已经安装了dfischer:prerenderio
包来轻松设置服务。我们唯一剩下的事情就是进行授权。首先,前往www.prerender.io并创建一个账户,然后点击他们导航栏上的安装令牌标签,并复制您的令牌。现在将您的令牌粘贴到配置文件下:
# /_globals/server/prerenderio.coffee
prerenderio.set "prerenderToken","<yourtoken>"
简单!您的网站现在可被爬取。现在让我们配置我们的路由器以与预渲染一起工作,并添加一个 404 页面:
# /router/config.coffee
if Meteor.isClient
Template.created ->
except = [
"Template.__dynamicWithDataContext"
"Template.__dynamic"
"Template.layout"
"Template.layout"
"body"
]
unless _.contains except, @view.name
window.prerenderReady = false
if @subscriptionsReady()
window.prerenderReady = true
FlowRouter.notFound =
action: ->
FlowLayout.render "layout",
content:"not_found"
注意,我们正在创建一个全局的Template.created
钩子函数,并将prerenderReady
默认设置为false
。然后,我们使用@subscriptionsReady()
检查我们的订阅是否就绪,并将prerenderReady
设置为true
。重要的是要理解@subscriptionsReady()
只检查在模板中通过@subscribe
创建的订阅!
此外,请注意,我们正在创建一个异常列表。这是为了确保我们只检查需要它们的模板上的订阅者。
这种模式确保预渲染在实际上已加载所有数据之前不会缓存页面。为什么不使用FlowRouter
全局触发器呢?遗憾的是,这些触发器无法访问正在渲染的模板实例(因为可能有多个)。如果您需要更多控制,您可以按路由/模板设置此选项。
让我们着手处理我们的 404 页面模板:
//- /router/client/not_found.jade
template(name="not_found")
div#not_found.template
div.text-center.vertical-align
h3 404!
h5 Page not found!
为了确保我们的 404 页面打印出正确的 404 响应,我们不得不使用Meta
。
使用 Meta
要控制预渲染,我们需要使用yasinuslu:blaze-meta
包。这个包基本上向我们的head
HTML 标签注入一个模板,这样我们就可以轻松地设置标签。预渲染使用标签来识别页面是否应该响应 404。我们还可以使用 Meta 来设置我们的标题、描述、关键词和 robots 属性。让我们先从让我们的 404 页面响应 404 开始:
# /router/client/not_found.coffee
Template.rendered "not_found", ->
Meta.set [
{
name:"name"
property:"prerender-status-code"
content:"404"
}
{
name:"name"
property:"robots"
content:"noindex, nofollow"
}
]
Meta
对象只有四个函数:set
、setTitle
、unset
和config
。您不太可能使用unset
,因为元标签不会通过路由持久化。set
和unset
函数都需要一个对象数组,并且每个对象都需要所有三个键:name
、property
和content
。
在前面的例子中,我们将prerender-status-code
设置为404
,将robots
设置为noindex, nofollow
。通过这种方式,我们确保我们的 404 页面实际上返回了一个 404 错误,并且爬虫不会索引该页面。
此外,我们还需要配置Meta
,以确保页面的名称始终正确:
# /router/config.coffee
if Meteor.isClient
Meta.config
options:
title:"Crashing Meteor"
suffix:""
...
由于Meta
控制着我们的title
标签,我们需要从layout.jade
中移除该标签。
Schema.org
现在既然我们的网站可被爬取,我们希望我们的产品在搜索引擎中脱颖而出。让我们升级我们的产品以启用丰富片段。丰富片段遵循 schema.org 标准为搜索引擎爬虫(如 Google)构建结构化数据。有了这些信息,搜索引擎可以在其网站上更突出地展示您的数据,使搜索更有效,甚至可以在其他服务中使用它。遵循他们的指南是个好主意,因为 Google 使用这些指南来指导其爬虫。
要做到这一点,我们只需要在我们的产品页面上设置尽可能多的 schema.org 属性。访问schema.org/Product
获取可用属性的完整列表。最常用的属性包括:
属性 | 类型 | 描述 |
---|---|---|
name (必需) |
Text |
产品的名称 |
image |
URL |
产品链接 |
description |
Text |
产品描述 |
offers |
OFFER OBJECT |
这包括多个定义对象价格的参数 |
price (必需) |
Number |
产品的价格 |
priceCurrency (必需) |
Text (ISO 4217) [ex: USD] |
价格的货币 |
现在让我们通过使用MicroData
规范来实践:
//- /products/client/product.jade
template(name="product")
article#product(itemscope itemtype="http://schema.org/Product")
h5(itemprop="name") {{name}}
div.offer(itemprop="offers" itemscope itemtype="http://schema.org/Offer")
meta(itemprop="priceCurrency" content="USD")
span.price(itemprop="price") {{format.money price}}
button.add-to-cart.btn.btn-block.btn-primary Add 1 to Cart
button.modify-quantity.btn.btn-block.btn-info Add more to Cart
如您所见,要使用MicroData
规范,您需要向您的视图添加各种自定义属性。在大多数情况下,每次您要声明某个特定事物时,您都需要通过itemscope
和itemtype
属性来表示这一点。当我们想要声明此声明下的属性时,我们需要确保我们的 DOM 元素是此声明的子元素,并且属性是通过itemprop
属性声明的。
摘要
本章介绍了几个有用的模式,使得实现丰富和最小化界面变得容易。首先,我们学习了如何结合使用 Twitter Bootstrap 框架、Jeet 和 Rupture;这改善了我们的 DOM 组织方式。我们还学习了如何创建超级助手——可以在视图和控制器中调用的函数。
我们深入研究了我们可以使用的不同类型的变量,以保持我们的代码安全并创建丰富的界面。我们还学习了如何使用 Meteor 方法和使用 autoform 轻松保存信息。我们介绍了如何实现简单的加载指示器以及如何在 Meteor 中实现动画。最后,我们学习了如何使用 prerender.io 和Meta
包实现一点 SEO。
下一章将涵盖应用范围内的模式。第四章,应用模式将教会我们如何过滤和分页浏览集合,如何全面保护我们的应用程序,以及如何与外部 API 集成。
第四章:应用模式
本章将涵盖共享服务器和客户端代码的应用程序级模式。使用这些模式,你的代码将变得更加安全且易于管理。你将学习以下主题:
-
过滤和分页集合
-
安全性
-
外部 API
过滤和分页集合
到目前为止,我们发布集合时并没有过多考虑我们向客户端推送了多少文档。我们发布的文档越多,网页加载的时间就越长。为了解决这个问题,我们将学习如何只显示一定数量的文档,并允许用户通过过滤或分页在集合中导航。
使用 Meteor 的响应性构建过滤器和分页很容易。
路由注意事项
路由器始终可以接受两种类型的参数:查询参数和普通参数。查询参数是通常在网站 URL 后面跟着一个问号(<url-path>?page=1
)的对象,而普通参数是你在路由 URL 中定义的类型(<url>/<normal-parameter>/named_route/<normal-parameter-2>
)。在分页等事物上设置查询参数是一种常见的做法,以避免你的路由创建 URL 冲突。
当两个路由看起来相同但参数不同时,会发生 URL 冲突。例如,一个产品路由如/products/:page
与一个产品详情路由如/products/:product-id
冲突。虽然由于普通参数的不同,这两个路由的表达方式不同,但你使用相同的 URL 到达这两个路由。这意味着路由器唯一能够区分它们的方法是通过编程路由。因此,用户必须知道必须运行FlowRouter.go()
命令才能到达任何一个产品页面,而不是简单地使用 URL。
这就是为什么我们将使用查询参数来保持我们的过滤和分页状态。
状态化分页
状态化分页简单来说就是给用户一个选项,让他们能够复制并粘贴 URL 到不同的客户端,并看到集合中完全相同的部分。这对于使网站易于分享非常重要。
在第二章中,我们创建了产品发布者和订阅者来详细说明我们的发布者。现在我们将了解如何以响应式的方式控制我们的订阅,以便用户可以导航整个集合。
首先,我们需要设置我们的路由器以接受页码。然后我们将使用这个数字在我们的订阅者中获取所需的数据。为了设置路由器,我们将使用FlowRouter
查询参数(在 URL 旁边放置问号的参数)。
让我们设置我们的查询参数:
# /products/client/products.coffee
Template.created "products", ->
@autorun =>
tags = Session.get "products.tags"
filter =
page: Number(FlowRouter.getQueryParam("page")) or 0
if tags and not _.isEmpty tags
_.extend filter,
tags:tags
order = Session.get "global.order"
if order and not _.isEmpty order
_.extend filter,
order:order
@subscribe "products", filter
Template.products.helpers
...
pages:
current: ->
FlowRouter.getQueryParam("page") or 0
Template.products.events
"click .next-page": ->
FlowRouter.setQueryParams
page: Number(FlowRouter.getQueryParam("page")) + 1
"click .previous-page": ->
if Number(FlowRouter.getQueryParam("page")) - 1 < 0
page = 0
else
page = Number(FlowRouter.getQueryParam("page")) - 1
FlowRouter.setQueryParams
page: page
我们在这里做的事情很简单。首先,我们使用page
键扩展过滤器对象,该键获取当前页面查询参数的值,如果该值不存在,则将其设置为0
。"getQueryParam
"是一个反应性数据源,autorun
函数将在值变化时重新订阅。然后我们将创建一个视图的辅助函数,这样我们就可以看到我们所在的页面以及设置页面查询参数的两个事件。
但是等等。我们如何知道分页限制何时达到?这正是tmeasday:publish-counts
包非常有用的地方。它使用发布者的特殊函数来精确计算正在发布的文档数量。
让我们设置我们的发布者:
# /products/server/products_pub.coffee
Meteor.publish "products", (ops={}) ->
limit = 10
product_options =
skip:ops.page * limit
limit:limit
sort:
name:1
if ops.tags and not _.isEmpty ops.tags
@relations
collection:Tags
...
collection:ProductsTags
...
collection:Products
foreign_key:"product"
options:product_options
mappings:[
...
]
else
Counts.publish this,"products",
Products.find()
noReady:true
@relations
collection:Products
options:product_options
mappings:[
...
]
if ops.order and not _.isEmpty ops.order
...
@ready()
为了发布我们的计数,我们使用了Counts.publish
函数。这个函数接受几个参数:
Counts.publish <always this>,<name of count>, <collection to count>, <parameters>
注意,我们使用了noReady
参数来防止ready
函数提前运行。通过这样做,我们生成一个计数器,可以通过运行Counts.get "products"
在客户端访问。现在你可能想知道,为什么不使用Products.find().count()
呢?在这个特定场景中,这确实是一个很好的主意,但你绝对必须使用Counts
函数来使计数反应化,这样如果任何依赖项发生变化,它们都会被考虑在内。
让我们修改我们的视图和辅助函数以反映我们的计数器:
# /products/client/products.coffee
...
Template.products.helpers
pages:
current: ->
FlowRouter.getQueryParam("page") or 0
is_last_page: ->
current_page = Number(FlowRouter.getQueryParam("page")) or 0
max_allowed = 10 + current_page * 10
max_products = Counts.get "products"
max_allowed > max_products
//- /products/client/products.jade
template(name="products")
div#products.template
...
section#featured_products
div.container
div.row
br.visible-xs
//- PAGINATION
div.col-xs-4
button.btn.btn-block.btn-primary.previous-page
i.fa.fa-chevron-left
div.col-xs-4
button.btn.btn-block.btn-info {{pages.current}}
div.col-xs-4
unless pages.is_last_page
button.btn.btn-block.btn-primary.next-page
i.fa.fa-chevron-right
div.clearfix
br
//- PRODUCTS
+momentum(plugin="fade-fast")
...
太好了!现在用户可以复制并粘贴 URL 以获得他们之前的结果。这正是我们需要确保我们的客户可以分享链接的原因。如果我们保持页面变量局限于Session
或ReactiveVar
,那么分享 Web 应用的状态将是不可能的。
过滤
过滤和搜索也是任何 Web 应用的关键方面。过滤的工作方式类似于分页;发布者接受额外的变量来控制过滤。我们想确保这是有状态的,因此我们需要将其集成到我们的路由中,并且我们需要编程我们的发布者来对此做出反应。此外,过滤器需要与分页器兼容。让我们先修改发布者:
# /products/server/products_pub.coffee
Meteor.publish "products", (ops={}) ->
limit = 10
product_options =
skip:ops.page * limit
limit:limit
sort:
name:1
filter = {}
if ops.search and not _.isEmpty ops.search
_.extend filter,
name:
$regex: ops.search
$options:"i"
if ops.tags and not _.isEmpty ops.tags
@relations
collection:Tags
mappings:[
...
collection:ProductsTags
mappings:[
collection:Products
filter:filter
...
]
else
Counts.publish this,"products",
Products.find filter
noReady:true
@relations
collection:Products
filter:filter
...
if ops.order and not _.isEmpty ops.order
...
@ready()
要构建任何过滤器,我们必须确保创建过滤器的属性存在,并基于此_.extend
我们的filter
对象。这使得我们的代码更容易维护。注意,我们可以轻松地将过滤器添加到包含Products
集合的每个部分。通过这种方式,我们确保了即使在标签过滤了数据的情况下,过滤器也始终被使用。通过将过滤器添加到Counts.publish
函数中,我们确保了发布者也与分页兼容。
让我们构建我们的控制器:
# /products/client/products.coffee
Template.created "products", ->
@autorun =>
ops =
page: Number(FlowRouter.getQueryParam("page")) or 0
search: FlowRouter.getQueryParam "search"
...
@subscribe "products", ops
Template.products.helpers
...
pages:
search: ->
FlowRouter.getQueryParam "search"
...
Template.products.events
...
"change .search": (event) ->
search = $(event.currentTarget).val()
if _.isEmpty search
search = null
FlowRouter.setQueryParams
search:search
page:null
首先,我们将filter
对象重命名为ops
,以保持发布者和订阅者之间的统一。然后,我们在ops
对象上附加了一个search
键,该键接受搜索查询参数的值。请注意,我们可以为search
传递一个未定义的值,并且我们的订阅者不会失败,因为发布者已经检查了该值是否存在,并基于此扩展了过滤器。始终在服务器端验证变量以确保客户端不会意外地破坏事物更好。此外,我们还需要确保我们知道该参数的值,以便在pages
辅助程序下创建一个新的search
辅助程序。最后,我们为搜索栏构建了一个事件。请注意,当它们不适用时,我们正在将查询参数设置为null
。这确保了如果不需要它们,它们不会出现在我们的 URL 中。
最后,我们需要创建搜索栏:
//- /products/client/products.jade
template(name="products")
div#products.template
header#promoter
...
div#content
section#features
...
section#featured_products
div.container
div.row
//- SEARCH
div.col-xs-12
div.form-group.has-feedback
input.input-lg.search.form-control(type="text" placeholder="Search products" autocapitalize="off" autocorrect="off" autocomplete="off" value="{{pages.search}}")
span(style="pointer-events:auto; cursor:pointer;").form-control-feedback.fa.fa-search.fa-2x
...
注意,我们的搜索输入有点杂乱,带有特殊属性。所有这些属性都确保我们的输入不会执行我们不希望它执行的事情,针对 iOS Safari。保持对非标准属性(如这些)的关注很重要,以确保网站对移动设备友好。您可以在以下位置找到这些属性的更新列表:developer.apple.com/library/safari/documentation/AppleApplications/Reference/SafariHTMLRef/Articles/Attributes.html
。
安全性
许多包保护 Meteor 堆栈的某些部分,即使如此,你也不能完全依赖这些包。此外,你必须非常小心地选择你选择的包!一些包可能会拦截核心功能,将信息从你的应用程序中过滤出来。这意味着在安装之前,你应该始终查看该包的源代码。
这个话题通常是新手 Meteor 开发者的疏忽,然而,它却是需要了解的最重要的话题之一。为了确保我们的 webapp 安全,我们需要:
-
定义角色(设置用户之间的区别)
-
为每个集合定义模式(限制它们可以修改的字段)
-
定义拒绝规则(限制谁可以修改字段)
-
在必要时使用方法来检查参数(确保需要时具有复杂的安全性)
-
设置浏览器策略
角色
使用角色,几乎每个 Web 应用都会在用户和他们可以做什么之间创建区别。为了帮助我们轻松管理角色,我们安装了alanning:roles
包。使用此包,我们将控制谁访问我们的路由以及谁可以修改我们的集合。
此包使Roles.userIsInRole
函数可用,该函数使用roles
集合确保用户处于正确的角色:
Roles.userIsInRole <user-id OR user-object>, [<list of allowed roles>], <group>
假设你需要在用户访问某个功能之前检查该用户是admin
还是manager
。为此,你只需做以下操作:
if Roles.userIsInRole Meteor.userId(), ["admin","manager"]
# allow
让我们在应用程序中添加一个 admin
角色。我们可以从创建一个初始化文件开始,该文件将自动构建我们的管理员用户:
# /_globals/server/initial_setup.coffee
Meteor.startup ->
# Users
if Meteor.users.find().count() is 0
user = Accounts.createUser
email:"you@email.com"
password:"1234"
Roles.addUsersToRoles user,["admin"]
注意,我们正在使用 Roles.addUsersToRoles
函数将新用户的角色设置为 admin
,并且这一操作是在服务器端完成的。始终在服务器端设置用户角色。接下来,让我们构建一个只有未登录时才能访问的登录路由:
# /login/login_route.coffee
FlowRouter.route "/login",
name:"login"
triggersEnter:[RT.non_user_only]
action: ->
BlazeLayout.render "layout",
content:"login"
为了确保在用户第一次访问网站时角色在正确的时间运行,我们需要确保 FlowRouter
在角色加载后激活。为此,我们使用 FlowRouter.initialize()
和 FlowRouter.wait()
。
# /_globals/router/config.coffee
if Meteor.isClient
BlazeLayout.setRoot 'body'
FlowRouter.wait()
Meteor.startup ->
# Initialize roles before FlowRouter
Tracker.autorun (computation) ->
if Roles.subscription.ready() and not FlowRouter._initialized
FlowRouter.initialize()
computation.stop()
FlowRouter.route
函数接受一个 triggersEnter
参数和一个 triggersExit
参数。这可以用来根据角色重定向用户。这两个参数都是函数数组,因此可以为每个路由添加多个触发器。为了让我们更容易操作,我们将在全局 RT
对象下创建一个触发器字典。注意,我们不在触发器数组中执行函数,所以不包括括号。
让我们先移动 /router
文件夹到 /_globals
文件夹。这将确保 RT
对象是首先定义的东西。完成此操作后,我们应该定义两个触发器:
# /_globals/router/triggers.coffee
@RT =
non_user_only: (context,redirect) ->
if Meteor.userId()
if context and context.oldRoute
redirect context.oldRoute.path
else
redirect "/"
admin_only: (context,redirect) ->
if not Roles.userIsInRole Meteor.userId(),["admin"]
if context and context.oldRoute
redirect context.oldRoute.path
else
redirect "/"
注意,当 FlowRouter
调用这些函数时,它将包括一个 context
对象和一个 redirect
函数。context
对象包含我们试图连接的路由和我们的上一个路由的信息,而 redirect
函数用于重定向用户。在这种情况下,我们尝试将用户重定向到上一个路由,如果它存在的话;如果不存在,则重定向到根目录。
现在,让我们将 admin_only
触发器添加到除 products
和 login
之外的所有路由:
# /_globals/router/triggers.coffee
@RT =
non_user_only: (context,redirect) ->
...
admin_only: (context,redirect) ->
...
FlowRouter.triggers.enter [RT.admin_only], except:["products","login","cart","order_quantity"]
我们可以轻松地创建一个全局触发器,该触发器不适用于 products
和 login
路由,使用 FlowRouter.triggers.<enter or exit>
函数。我们不必担心包含我们的 404
路由,因为默认情况下,它不会运行触发器。
最后,让我们构建一个自定义登录页面:
//- /login/client/login.jade
template(name="login")
div#login.template
div.vertical-align.container
div.row
div.col-xs-12.col-sm-6.col-sm-offset-3
form.login
div.form-group
label Email
input.email.input-lg.form-control.text-center(type="text" placeholder="email" value="{{email}}" autocapitalize="off" autocorrect="off" autocomplete="off")
div.form-group
label Password
input.password.input-lg.form-control.text-center(type="password" placeholder="password")
if error
div.row
div.col-xs-12
div.alert.alert-warning {{error}}
button.login.btn.btn-block.btn-primary.btn-lg Log In
# /login/client/login.coffee
Template.created "login", ->
@error = new ReactiveVar false
Template.login.events
"submit .login": (event,i) ->
event.preventDefault()
email = $(".email").val()
pw = $(".password").val()
# Check Email
if email and not _.isEmpty email.trim()
email = email.replace /\s/g,""
email = email.trim().toLowerCase()
else
i.error.set "Email is invalid"
return
# Check Password
if not pw or _.isEmpty pw
i.error.set "Password is invalid"
return
i.error.set false
Meteor.loginWithPassword email, pw, (error) ->
if not error
i.error.set false
$("input").val ""
FlowRouter.go "dashboard"
else
i.error.set error.reason
Template.login.helpers
"error": ->
Template.instance().error.get()
在这里,我们使用了 Meteor 的核心 Meteor.loginWithPassword
函数进行登录,并使用 ReactiveVar
变量跟踪错误。
Collection2
假设一个恶意用户访问网站并迅速在浏览器控制台中识别出我们的一个集合。他们将通过调用 Products.update
函数来更新其中一个产品。因为我们的网站不安全,他们可以成功调用类似以下的内容:
# Malicious User
Products.update("productid",{$set:{you:"have been modified"}})
这样将成功创建一个对于该特定产品不应存在的字段!
使用 aldeed:collection2
包,我们将通过白名单字段来保护我们的集合。这确保了允许的用户只能在我们的集合上设置可接受的价值,并且这些值符合某些标准。
一个 collection
字段可以接受以下参数:
参数 | 用途 |
---|---|
type |
这定义了值的类型。这可以是任何 JavaScript 原始类型:String 、Number 、Boolean 、Date 、Object 或原始类型的数组,例如 [Object] 、[String] 。 |
decimal |
这仅在 type:Number 时可用。这定义了一个数字是否是十进制。这可以是 true 或 false 。 |
optional |
这定义了在插入时字段是否是必需的。这可以是 true 或 false 。 |
regEx |
这用于检查字符串是否与定义的 regEx 表达式匹配。这可以是任何 regEx 表达式,例如 /^[A-Z]$/ 。 |
allowedValues |
这用于检查字符串是否与数组中的任何值匹配。这只能是一个字符串数组,例如 ["Green","Blue"] 。 |
blackbox |
这允许任何组合的值和对象被放置为值。这只能是 true 或 false 。 |
denyUpdate |
这定义了字段是否可以被更新。这只能是 true 或 false 。 |
denyInsert |
这定义了字段是否可以被插入。如果此字段设置为 true ,则必须同时设置 optional:true 。这只能是 true 或 false 。 |
autoValue |
这定义了字段在操作期间将采取的值。这只能是 function 。 |
custom |
这定义了将验证字段的自定义函数。这只能是 function 。 |
unique |
这定义了字段值是否应该是唯一的。这只能是 true 或 false 。 |
使用这些参数,可以轻松锁定包括 Meteor.users
集合在内的每个集合,并极大地提高我们应用程序的安全性。
由于我们将把所有数值数据以百为单位保存,所以我们永远不会使用 decimal
参数。无论是否处理金钱,都应该始终避免使用小数。
自动值和自定义参数在其上下文中暴露了关键的变量和函数:
上下文变量和函数 | 使用 |
---|---|
this.isInsert |
布尔型。这用于检查字段是否正在被插入。 |
this.isUpdate |
布尔型。这用于检查字段是否正在被更新。 |
this.upsert |
布尔型。检查字段是否正在被更新。 |
this.userId |
字符串型。这用于检查当前的 userId 。如果不存在则返回 undefined ,对于所有由服务器启动的函数返回 null 。 |
this.isFromTrustedCode |
布尔型。这用于检查字段是否被服务器端代码修改。 |
this.isSet |
布尔型。这用于检查字段是否正在被修改。 |
this.value |
任何类型。如果 this.isSet ,则这将代表字段的值。 |
this.operator |
字符串型。如果 this.isSet 和 this.isUpdate ,则这将代表修改值的操作符($pull 、$push 、$addToSet 、$set 等)。 |
this.field("<field-name>") |
这是一个返回对象的函数。它获取正在修改的字段的对象表示。从这个对象中,你可以使用 isSet 、value 和 operator 来获取更多信息。例如,this.field("name").value 将返回字段名称的值,如果它被设置的话。 |
使用这些函数,我们可以根据修改的状态添加自定义验证器和自定义自动值。我们还可以检查其他字段的值,并在需要时对其做出反应。如果你的验证需要与其他集合的复杂查询,请不要依赖这个包!这个工具严格用于控制特定集合的值,而不是关系。请记住,并非我们所有的集合都在客户端可用,因此无法正确验证。我们将在下一个主题中解决这类验证。
让我们保护所有我们的集合。我们将只显示 Orders
和 OrderDetails
集合的模式:
# /_globals/lib/collections/orders/orders_collection.coffee
@Orders = new Mongo.Collection "orders"
Orders.attachSchema new SimpleSchema
status:
type:String
allowedValues:["new","pending","complete"]
total_products:
type:Number
subtotal:
type:Number
tax_total:
type:Number
optional:true
total:
type:Number
date_created:
type:Number
autoValue: ->
if @isInsert
return Date.now()
if @isUpsert
$setOnInsert:Date.now()
@OrderDetails = new Mongo.Collection "order_details"
OrderDetails.attachSchema new SimpleSchema
order:
type:String
product:
type:String
price:
type:Number
quantity:
type:Number
subtotal:
type:Number
tax:
type:Object
optional:true
"tax.rate":
type:Number
"tax.amount":
type:Number
total:
type:Number
如您所见,在我们的集合中为字段设置白名单的模式是一个包含 Collection2
参数的简单对象。请注意,我们可以使用 MongoDB 的点表示法来定义子对象的规则。这意味着我们也可以以相同的方式为数组和对象数组设置规则:
people:
type:[Object]
"people.$.name":
type:String
"people.$.age":
type:Number
尽管如此,你不必经常设置复杂的规则,因为它们是创建新集合的明确指标。
注意,除了name
和type
之外,在我们的模式中我们没有使用任何复杂的自定义验证。为什么?我们应该检查用户是否是管理员,或者修改是否来自服务器端代码,或者订单是否属于客户?
虽然我们可以开始为每个字段添加一些检查列表,但问题的真正根源在于允许用户直接从控制台修改我们的集合。为了完全保护我们的订单,我们需要修改允许/拒绝规则并使用可信代码。
理解这些模式也被服务器端代码使用是很重要的。这确保了客户端和服务器都无法破坏我们的键。
拒绝规则
现在我们知道了将在我们的集合中出现的字段,我们需要确保服务器允许正确的人修改这些集合。为此,我们首先需要确切了解允许/拒绝规则是如何工作的。
Meteor 有两个核心函数控制集合修改是否允许:Meteor.allow
和 Meteor.deny
。
Meteor.allow
函数允许在其中一个规则解析为 true
时立即修改集合。这也意味着其他允许规则不会被评估!了解这一点后,一些开发者将逻辑塞入单个允许规则中,这很容易失败。这是不好的做法,因为代码将难以维护。
另一方面,Meteor.deny
函数将始终运行,并覆盖解析为 true
的 Meteor.allow
规则。为了有效地管理我们的拒绝规则,我们将使用 ongoworks:security
包。使用这个包,我们可以轻松地构建可重用且易于阅读的规则,并将其设置在我们的集合上。
让我们从移除我们项目中的所有允许规则开始。然后我们可以为我们的 Orders
集合设置一些规则:
/_globals/lib/collections/orders/server/orders_permissions.coffee
Meteor.startup ->
# Admin may only modify status
Orders.permit "update"
.ifLoggedIn()
.ifHasRole "admin"
.onlyProps "status"
.apply()
通过这个简单的规则,我们拒绝了所有不符合此规则的任何内容。此包直接与我们的 roles
包集成,因此我们可以轻松使用 ifHasRole
函数。此规则确保只有管理员用户可以通过控制台更新订单的 status
字段。为了确保规则被应用,我们使用了 apply
函数。
我们需要了解关于拒绝规则的三件事:逻辑、内置函数和自定义函数。
规则中的逻辑决定了它们是作为 AND 还是 OR 操作符来执行。如果我们在一个规则中包含多个函数,那么我们定义 AND 规则。我们为我们的订单定义的函数是一个 AND 规则,因为它在允许修改通过之前会检查 ifLoggedIn
AND ifHasRole
AND onlyProp
。如果我们想创建一个 OR 规则,我们只需创建一个新的规则。让我们试试这个:
Orders.permit "update"
.ifLoggedIn()
.ifHasRole "admin"
.onlyProps "status"
.apply()
Orders.permit ["insert","remove"]
.never()
.apply()
在这里,我们声明用户可以 update
ifLoggedIn
AND ifHasRole
AND onlyProp
或永远不允许 insert
/remove
。
该包包含一些内置函数,有助于应用规则:
函数 | 用途 |
---|---|
never() |
这阻止数据库操作 |
ifLoggedIn() |
如果用户已登录,则允许数据库操作 |
ifHasUserId(<用户 ID>) |
如果用户 ID 是特定字符串,则允许数据库操作 |
ifHasRole(<角色字符串>) ifHasRole({role:<角色字符串>,group:<组>}) |
如果用户属于特定角色,则允许数据库操作 |
onlyProps(<字符串或字符串数组>) |
这仅允许对某些顶级字段进行数据库操作(这不会识别数组和子对象) |
exceptProps(<字符串或字符串数组>) |
这允许对除了这些之外的所有顶级字段进行数据库操作(这不会识别数组和子对象) |
自定义拒绝规则
虽然 security
包的函数很有用,但你可能需要自定义函数来更精确地处理你的安全。要构建自定义函数,你需要使用 Security.defineMethod
函数:
Security.defineMethod <function name>,
transform:<function>
deny: <function (type, args, userid, doc, fields, modifier)>
此函数接受两个参数:transform
和 deny
。transform
函数允许在它们进入 deny
函数之前修改字段,而 deny
函数是 Meteor.deny
的扩展版本。deny
函数传递多个参数,包括有关正在修改的文档和用户的信息。这些参数是:type
、arguments
、userId
、document
、fields
和 modifier
。最后两个参数(fields
和 modifier
)仅在 type
等于 update
时传递。
让我们添加一个自定义函数:
# /_globals/server/security.coffee
Security.defineMethod "ifUserIsOwner",
deny: (type,args,user,doc) ->
user isnt (doc.user or doc._id)
在这里,我们定义了一个ifUserIsOwner
函数,该函数检查当前登录用户的 ID 是否等于修改的文档上的用户
字段或_id
字段。
小贴士
注意,规则逻辑拒绝非文档所有者的用户进行数据库操作。
现在我们可以使用这个规则来保护我们的用户
集合:
# /_globals/server/security.coffee
Security.defineMethod "ifUserIsOwner",
...
Security.permit(["update"]).collections([Meteor.users])
.ifUserIsOwner()
.onlyProps ["emails"]
.apply()
Security.permit(["insert","update","remove"]).collections([Meteor.users])
.ifHasRole "admin"
.apply()
注意,我们以不同的方式将规则附加到Meteor.users
集合上。我们这样做是因为Meteor.users
集合是一个特殊的集合,它的初始化方式与我们的其他集合不同,这样我们就确保了规则被正确附加。
在这个例子中,我们允许用户从控制台自由修改电子邮件
字段,而只有管理员用户可以从控制台修改所有用户。
然而,现在我们已经从客户端锁定代码,我们该如何使事情正常工作呢?直接从客户端的事件运行代码将会失败,因为代码是不可信的。我们需要构建一个可信的代码来处理对数据库的更改。解决方案很简单:Meteor 方法。
Meteor 方法 – 第二轮
我们已经介绍了Meteor.methods
的工作方式,但我们还没有讨论可信代码和不可信代码之间的区别。
可信代码可以通过将multi
设置为true
一次修改多个文档,并可以使用任意的 Mongo 选择器来查找要修改的文档。它绕过了由允许和拒绝设置的任何访问控制规则。可信代码包括所有服务器端代码和Meteor.methods
。
不可信代码一次只能修改一个指定的_id
的单个文档。修改只有在检查了任何适用的允许和拒绝规则之后才允许。不可信代码不能执行更新插入操作。不可信代码包括客户端代码,如事件处理程序和控制台。
这意味着每次我们直接在客户端修改集合时,我们都在运行受限于我们的拒绝规则的不可信代码。了解这一点后,很明显,大多数代码,尤其是具有关系的复杂代码,应该在 Meteor 方法上运行。
但等等。用户能否在客户端修改方法的代码?他们当然可以,但请记住代码是在一个存根中运行的。存根确保正确的代码在服务器上运行,而客户端代码在服务器响应之前临时更新集合。这就是 Meteor 所说的乐观 UI。
因此,如果有人篡改了我们 Meteor 方法的客户端版本,服务器端版本仍然会正常运行,UI 也会正确更新。
但重要的是要理解,Meteor.methods
仍然绑定到我们在Collection2
上设置的规则,这对于团队环境中的团队来说是一个很好的功能,因为不是每个人都了解所有数据模型的结构。
那么我们应该在哪里使用不受信任的代码?答案很大程度上取决于你的应用程序,但大部分情况下,你希望所有内容都在受信任的代码上运行,因为它更容易维护且更安全。不受信任的代码主要用于控制数据库未连接的事物,或允许用户几乎可以要求任何信息。
为了正确使用 Meteor.methods
,我们需要对 Meteor.methods
进行验证。我们使用 Meteor 的核心 check
包来完成此操作。让我们升级我们的 cart.add-to-cart
方法:
# /orders/cart/cart_methods.coffee
Meteor.methods
"cart.add-to-cart": (ops={}) ->
# Validate data
check ops,
order:Match.Optional(Match.OneOf(String,null))
product:String
quantity:Number
...
# Insert Order if it doesn't exist
unless order
...
else
# Validate order status
if order.status isnt "new"
throw new Meteor.Error 405, "Not Allowed"
order_id = order._id
...
首先,我们使用了 check
函数来验证 ops
对象的结构,并确保对象中的每个键都匹配正确的数据原语类型。当验证失败时,函数将自动停止并返回一个 404 Match Failed 错误给客户端。
接下来,我们检查了订单的状态。如果订单不是新的,则抛出 Meteor.Error
。这将同样短路函数并返回 error
对象给客户端。当你设置 Meteor 方法中的错误时,你将始终使用 throw new Meteor.Error(<error message>)
来传达错误。
我们不需要担心对错误做任何事情。我们将在不同的主题中看到如何跟踪应用程序错误。
check
函数只接受两个变量:
check <value>, <pattern>
value
参数接受将被分析的变量,而 pattern
参数接受验证器。模式可以是像 String
、Number
和 Boolean
这样的 JavaScript 原始类型,验证器的数组,验证器的对象,或者像函数一样复杂。模式只需返回 true
以使验证通过。
Meteor 通过 Match
对象包含了一些有用的 pattern
函数:
模式函数 | 使用 |
---|---|
Match.Any() |
这允许任何值通过验证。 |
Match.Integer() |
这允许任何 32 位整数。不允许 Infinity 和 NaN 。 |
Match.ObjectIncluding(<object>:<pattern>) |
这允许一个对象包含不在对象中定义的键/值对。我们使用的示例不允许其他键/值对进入方法。 |
Match.Optional(<second-pattern>) |
这允许一个值可以是 undefined 。如果值已定义,则将评估 second-pattern 。 |
Match.OneOf(<pattern1>, <pattern2>,...) |
这允许一个值通过如果它与定义的任何模式匹配。 |
Match.Where(<function(value){}>) |
这将运行 function 并将定义的值作为第一个参数传递。如果函数返回 true ,则验证通过。 |
现在这一切都很棒,让我们感觉更加安全,但事实上,我们正在将服务器端逻辑共享到客户端。这意味着我们绝对不能在 Meteor 方法中包含敏感数据。如果我们想传递敏感数据,最好将其存储在服务器端变量中并调用它,或者如果您想走得更远,可以使用文件夹将客户端方法与服务器分离。
管理等待时间
第一章简要解释了阻止对客户端可能产生的影响。总结来说,如果一个函数正在等待第三方或执行耗时操作,你应该解除该函数的阻止。然而,这会产生什么影响呢?
记住,方法被放置在传送带上。当我们解除一个函数的阻止时,我们将方法放置在另一个传送带上,我们无法在其中放置其他方法。这意味着如果另一个方法依赖于解除阻止的方法来完成,可能会出现严重问题,因为该方法可以在解除阻止的方法之前、期间或之后运行。
注意,我们的cart.add-to-cart
方法没有解除阻止的函数。这是为了保证服务器按照客户端的顺序将项目添加到购物车中。那么,如果我们方法内部有可以放在单独传送带上的内容,我们该怎么办?是否可以并行执行某些操作,而用户不必等待继续?
Meteor.defer(<function>)
是一个特殊且未记录的函数,它可以接受一段特定的代码片段并在不阻止调用它的函数的情况下在单独的传送带上运行。假设我们想在每次创建新订单时通知管理员,如下所示:
# /orders/cart/cart_methods.coffee
Meteor.methods
"cart.add-to-cart": (ops={}) ->
# Validate data
...
# Insert Order if it doesn't exist
unless order
...
if Meteor.isServer
Meteor.defer ->
Email.send
to:"you@email.com"
from:"me@email.com"
subject:"New Customer!"
text:"Someone has created a new order"
...
在这个例子中,我们将Email.send
函数包裹在Meteor.defer
函数中,以便并行运行电子邮件。通过并行运行延迟函数并专注于产生对用户真正重要的结果,这样做提高了代码的性能。通过这种方式,我们确保了电子邮件,一个我们知道需要很长时间才能完成的进程,不会阻塞服务器。
浏览器策略
现在我们能够保护我们的集合和函数,我们需要保护整个应用程序。我们可以使用browser-policy
包来实现这种保护。现在让我们安装它:
meteor add browser-policy
这个包究竟做了什么?通过添加这个包,我们打开了访问一系列配置选项的途径,这将帮助我们设置应用程序头和内容安全策略,以防止跨站脚本和数据注入攻击。
这类攻击通常被用来窃取您的数据(数据盗窃)、改变您网站的外观(网站篡改)以及分发恶意软件。我们肯定希望避免所有这些攻击。
那么,这是如何工作的呢?通过添加包,我们默认已经保护了我们的应用程序免受许多攻击,但我们需要能够控制这一点。为此,该包公开了两个对象,每个对象都有特定的一组函数:BrowserPolicy.framing
和BrowserPolicy.content
。这两个函数都必须在服务器上设置。
框架
使用BrowserPolicy.framing
,我们可以控制我们的 Web 应用程序是否可以在 iframe 中渲染。我们有三个函数来控制这一点:
函数 | 用途 |
---|---|
*.framing.disallow() |
这将永远不会在 iframe 中渲染,无论来源如何。 |
*.framing.restrictToOrigin(origin) |
这将仅在由指定来源创建的 iframe 中渲染。这可能只需要一个origin 字符串,并且只能调用一次。此外,它在 WebKit 中并不完全受支持。 |
*framing.allowAll() |
这将在任何 iframe 中渲染。 |
你会发现自己在大多数情况下使用BrowserPolicy.framing.disallow()
,但重要的是要理解,如果来源相同,你仍然可以将你的应用程序 iframe 化。
内容
使用BrowserPolicy.content
,我们可以精确控制内容将如何加载到我们的 Web 应用程序中。我们为此有很多函数。然而,我们实际上最终只会使用其中的一些。这些函数是:
函数 | 用途 |
---|---|
*.content.allowInlineScripts() |
这允许 DOM 脚本标签运行。DEFAULT 。 |
*.content.disallowInlineScripts() |
这不允许 DOM 脚本标签运行。 |
*.content.allowEval() |
这允许通过使用eval 函数从字符串构建 JavaScript。 |
*.content.disallowEval() |
这不允许通过使用eval 函数从字符串构建 JavaScript。DEFAULT 。 |
*.content.allowInlineStyles() |
这允许内联样式和样式 DOM 元素运行。DEFAULT 。 |
*.content.disallowInlineStyles() |
这不允许内联样式和样式 DOM 元素运行。 |
还有更多!下一组函数定义了内容类型的白名单以及它们如何被允许加载。ContentType
可以取以下值:Script
、Object
、Image
、Media
、Font
、Frame
和Connect
。
函数 | 用途 |
---|---|
*.allow<ContentType>Origin(origin)``*.allowScriptOrigin(origin)``*.allowObjectOrigin(origin)``*.allowImageOrigin(origin)``*.allowMediaOrigin(origin)``*.allowFontOrigin(origin)``*.allowFrameOrigin(origin)``*.allowConnectOrigin(origin) |
这允许从origin 字符串加载ContentType 。此函数可以多次调用,并支持通配符。如果没有指定协议(http / https ),则两者都允许。 |
*.allow<ContentType>DataUrl() |
这允许从data: URL 加载ContentType 。这将允许以 base64 编码的图像渲染。 |
*.allow<ContentType>SameOrigin() |
这允许从与 Web 应用程序相同的来源加载ContentType 。 |
*.disallow<ContentType>() |
这不允许加载 ContentType 。 |
*.allowSameOriginForAll() |
这允许从与 webapp 相同的源加载所有类型的内容。 |
*.allowDataUrlForAll() |
这允许从 data: URL 加载所有类型的内容。 |
*.allowOriginForAll(origin) |
这允许从指定的 origin 加载所有类型的内容。 |
*.disallowAll() |
这不允许从任何地方加载任何类型的内容。 |
根据我们想要我们的应用程序实现的目标,我们可能需要调整我们的规则。让我们将我们的安全策略配置为推荐选项:
# /_globals/server/security.coffee
Meteor.startup ->
# Prevent webapp from loading on an iframe
BrowserPolicy.framing.disallow()
# Prevent inline scripting
BrowserPolicy.content.disallowInlineScripts()
trusted_sites = [
'*.google-analytics.com'
'*.mxpnl.com'
'placehold.it'
'placeholdit.imgix.net'
]
_.each trusted_sites, (trusted_site) ->
BrowserPolicy.content.allowOriginForAll "https://#{trusted_site}"
外部 API
现在我们知道了如何保护我们的应用程序,我们需要了解如何保持外部数据源是最新的。我们可以使用两种模式来确保我们服务器上的信息是最新的:同步和webhooks。
同步
同步基本上会从我们的数据源持续获取数据并刷新数据库。这种技术在我们需要从数据源保存信息并使用这些信息通过聚合框架生成分析数据时非常有用。
为了保持我们的服务器同步,我们需要确保获取信息的进程不会阻塞服务器。我们可以使用非阻塞函数,如 Meteor.setInterval
来确保这一点。
让我们与 Stripe 进行同步。首先,我们需要创建一个集合来捕获支付,然后我们必须设置权限和我们的 Stripe 密钥,最后,我们将构建 HTTP GET
函数:
# /_globals/lib/collections/stripe/payments_collection.coffee
@Payments = new Mongo.Collection "payments"
# /_globals/lib/collections/stripe/server/payments_permissions.coffee
Meteor.startup ->
# Nobody may modify
Payments.permit ["insert","remove","update"]
.never()
.apply()
# /_globals/server/stripe.coffee
@Stripe =
secret:"secret-key"
publishable:"publishable-key"
# /stripe/server/payments.coffee
_.extend Stripe,
get_payments: (ops={}) ->
params =
limit:100
if ops.starting_after_id
_.extend params,
starting_after:ops.starting_after_id
HTTP.get "https://api.stripe.com/v1/charges",
headers:
"Authorization":"Bearer #{Stripe.secret}"
params:params
(error,result) ->
if not error
_.each result.data?.data, (charge) ->
Payments.upsert _id:charge.id,
$set:charge
if result.data.has_more
last = _.last result.data.data
Stripe.get_payments
starting_after_id:last.id
else
throw new Meteor.Error error
Meteor.setInterval Stripe.get_payments,3600000
注意,当我们创建我们的集合时,我们没有给它一个模式。我们这样做是因为我们想要确保我们的集合在端点数据发生变化时具有灵活性。为了保护我们的集合,我们需要确保没有人能够以任何方式修改它,除非是受信任的代码。然后我们创建了一个配置对象,它将保存我们的 Stripe 密钥和公钥。
此外,我们还创建了一个 /stripe/server/payments.coffee
目录。在这里,我们将 get_payments
函数添加到了我们在 _
globals
目录中定义的 Stripe
对象。为了使这正常工作,我们必须传递一个 params
对象来控制我们从 Stripe 服务器请求数据的方式。预期在构建每个 GET
请求时都会传递 params
,因为这将控制端点的 分页
。Stripe 在其文档中解释说,我们可以通过首先检查是否存在更多数据(通过 has_more
键)然后通过 starting_after
参数传递最后一个对象 ID 来获取数据的下一页。
在这一切结束时,我们使用了Meteor.setInterval(<function>,<delay in milliseconds>)
函数来确保函数每小时运行一次。我们完成了吗?还没有。虽然这段代码确实会填充我们的Payments
集合,但它也可能导致我们的服务器崩溃。为什么?请求总是从时间的开始查询到今天,这使得服务器逐渐花费更长的时间。另一个重要的问题是,如果有一个GET
请求正在处理,并且在一小时内没有完成,另一个请求可能会并行开始,消耗更多的资源。
我们可以通过控制我们的间隔和限制间隔获取数据的时间范围来防止这种情况。
首先,让我们确保一次只有一个间隔在运行:
# /stripe/server/payments.coffee
_.extend Stripe,
payments:
get: (ops={}) ->
if not Stripe.payments.is_running
Stripe.payments.is_running = true
params =
limit:100
if ops.starting_after_id
_.extend params,
starting_after:ops.starting_after_id
HTTP.get "https://api.stripe.com/v1/charges",
headers:
"Authorization":"Bearer #{Stripe.secret}"
params:params
(error,result) ->
if not error
_.each result.data?.data, (charge) ->
Payments.upsert _id:charge.id,
$set:charge
if result.data.has_more
last = _.last result.data.data
Stripe.payments.get
starting_after_id:last.id
else
Stripe.payments.is_running = false
else
Stripe.payments.is_running = false
throw new Meteor.Error error
set_interval: ->
Meteor.setInterval Stripe.payments.get,360000
is_running:false
Stripe.payments.set_interval()
注意,我们现在将支付一起放在一个payments
对象下。然后我们简单地设置并检查了一个is_running
布尔键,以查看该过程是否正在运行。现在,如果我们把间隔减少到 1 毫秒,它将在前一个请求处理完毕后才会从外部 API 获取数据。
现在,我们可以使用starting_after
参数来确保我们只获取最新的信息。为此,我们必须使用moment
函数按时间过滤数据,并获取最新的支付信息:
# /stripe/server/payments.coffee
_.extend Stripe,
payments:
get: (ops={}) ->
if not Stripe.payments.is_running
Stripe.payments.is_running = true
params =
limit:100
if ops.starting_after_id
_.extend params,
starting_after:ops.starting_after_id
else
date_after = moment().utc().startOf("day").subtract(10,"days").unix()
latest_payment = Payments.findOne created:$gte:date_after,
sort:
created:1
if latest_payment
_.extend params
starting_after:latest_payment.id
...
在这个例子中,我们只是使用moment
来识别 10 天前的 Unix 时间戳。moment
对象是通过momentjs:moment
包提供的。请注意,我们正在使用utc()
函数来在开发和生产环境中一致地设置startOf("day")
。然后我们查询了服务器,如果支付存在,我们将使用支付 ID 作为我们的starting_after
参数。
重要的是要理解,我们之所以能够轻松地进行此查询,仅仅是因为 Stripe 发送的信息中包含 Unix 时间戳。并非每个 API 都有 Unix 时间戳。很可能会让你插入或转换他们的数据以适应自己的需求。这可以通过在需要时扩展他们的响应来解决。
Webhooks
Webhooks 是通过其他服务器直接与我们通信的方式。它们基本上会向我们的一个端点发送一个POST
请求,通知我们的服务器发生了某些事情。
在 Stripe 的情况下,我们将添加一个端点来捕获 Stripe 的所有收费 webhooks。为此,我们将使用nimble:restivus
包。
Restivus
是一个优秀的包,它使得维护带有版本控制和用户认证的 RESTful API 变得容易。它只在服务器端运行,以确保安全性。
首先,我们需要创建一个Restivus
的实例。这个实例将持有我们端点的第一个版本的路线:
# /_globals/server/stripe.coffee
@Stripe =
secret:"secret"
publishable:"public"
hooks:
v1:new Restivus
apiPath:"stripe"
version:"v1"
在这里,我们将我们的新服务器端端点附加到全局Stripe
对象下的hooks
和v1
键下。这将使得在服务器中的任何位置创建版本 1 的新路由变得容易。Restivus
实例接受一些参数,你最终会用到的最常见参数包括:
参数 | 使用 |
---|---|
apiPath |
字符串定义了所有端点的父路由。如果我们定义apiPath 为"stripe" ,并且一个路由为"charge" ,那么该路由的路径将是ROOT_URL/stripe/charge 。 |
version |
字符串定义 API 的版本号并将其添加到父路由。如果我们定义version 为"v1" ,那么所有路由都将采用以下形式:ROOT_URL/<apiPath>/v1/<route> 。 |
enableCors |
布尔值设置路由是否可以从外部域名访问。默认:true 。 |
现在让我们创建一个更新Payments
集合的端点。要创建一个端点,我们需要在Restivus
实例上定义一个路由。为此,我们只需调用我们定义的全局对象并使用addRoute
函数:
# /stripe/server/endpoints/charges.coffee
Meteor.startup ->
Stripe.hooks.v1.addRoute "charge",
post: ->
payment = @request.body.data?.object
if payment
Payments.upsert _id:payment.id,
$set:payment
@done()
现在我们的端点可以捕获 Stripe 的 webhook 通知。注意,POST
请求中包含的信息位于@request.body
对象中。在分析此对象后,我们可以看到 Stripe 发送的对象。在这种情况下,Stripe 发送一个包含data
键的对象,该键又包含一个object
键,它包含支付信息。如果你想看到 Stripe 响应的信息,只需在控制台中登录即可。
此外,我们正在返回一个@done()
函数,该函数通知 Stripe 请求已被处理。这确保 Stripe 不需要再次尝试通知我们的服务器。
addRoute
函数可以处理所有类型的 HTTP 请求,包括get
、put
、delete
、patch
和options
。端点在其上下文中包含这些变量以帮助处理请求:
上下文变量 | 使用 |
---|---|
this.user |
认证通过后的Meteor.user 对象。 |
this.userId |
认证通过后的Meteor.userId 字符串。 |
this.urlParams |
在 URL 字符串中定义的参数:ROOT_URL/stripe/v1/charge/:more 要访问more 参数,你需要调用@urlParams.more 。 |
this.queryParams |
在 URL 字符串中定义的查询参数:ROOT_URL/stripe/v1/charge?more=data 要访问more 参数,你需要调用@urlQueryParams.more 。 |
this.bodyParams |
请求的主体。这相当于@request.body 。 |
this.request |
一个 NodeJS 请求对象。 |
this.response |
一个 NodeJS 响应对象。 |
this.done() |
在处理响应后必须调用此函数。 |
现在,我们可以通过访问 Stripe 的设置页面并创建一个 webhook 来设置 Stripe 的 webhooks。确保将它们的 URL 指向我们构建的 URL。
我们也可以发送几个测试钩子,以确保一切按预期工作。通过使用同步模式和 webhooks 模式,我们能够保持我们的数据是最新的。如果 API 允许,你应该始终使用这两种模式。为什么?这是因为他们的 webhooks 服务器可能会失败。
摘要
本章涵盖了三个重要内容:如何控制我们发布的数据量,如何保护我们的应用,以及如何更好地与外部 API 集成。我们学习了一种构建具有过滤功能的分页模式。然后我们学习了如何构建用户角色和模式以更好地保护我们的应用程序的访问。接下来,我们了解了允许/拒绝规则的限制,并通过编写有效的拒绝规则来解决这些限制。我们很快意识到这些拒绝规则因为不安全而阻止了所有事件处理程序的功能。为了克服这一限制,我们学习了如何构建可信代码。在最后,集成 Stripe 教会了我们如何创建非阻塞的同步函数,以及如何使用restivus
来捕获来自外部服务器的传入消息。
下一章将介绍如何测试和维护我们的代码的基础知识。有了下一章,我们就可以分享我们的代码,而不用担心其他人会破坏它。
第五章:测试模式
本章将介绍确保我们的代码易于维护的测试模式。通过这些模式,您将学习如何实现回归测试——一种在代码投入生产之前识别新代码是否破坏旧代码的方法。构建测试对于维护代码和与他人协作至关重要。您将学习以下主题:
-
行为测试
-
单元测试
在 Meteor 中进行测试仍在积极开发中,但我们将要介绍的功能是基本的,不太可能发生变化。
行为测试
行为测试也被称为端到端测试。行为测试的目的是简单的:确保项目的某个功能正在工作。功能指的是应用程序背后的业务逻辑。例如,我们当前项目的功能之一是在我们的着陆页中查看产品列表。另一个功能是能够将可变数量的产品添加到订单中。
要在 Meteor 中运行行为测试,我们实际上需要构建一个可以访问我们的网站并尝试使这些功能工作的机器人。虽然这听起来很复杂,但与 Cucumber 结合时,Meteor Velocity 项目简化了其中许多操作。
Velocity 是一个为其他测试框架奠定基础的项目。它通过创建测试可以运行的项目的镜像来实现。
Cucumber 是一个基于示例的测试框架。它的目的是在编程之前用简单的英语描述应用程序功能。在协作环境中,这些测试是最重要的,因为它们确保应用程序按预期运行。我们现在将使用 Cucumber 包,因为它是目前唯一支持行为测试的测试框架之一。
要构建测试,我们首先需要安装xolvio:cucumber
包:
meteor add xolvio:cucumber
此包会自动安装 Velocity 以及所有其他必需的包。现在运行 Meteor 命令以启动服务器。以下将发生以下两件事:
-
如果有要运行的测试,将打开一个新浏览器窗口
-
当前项目将在右上角有一个点
我们称之为镜像的新浏览器窗口是运行我们定义的所有行为测试的客户端。虽然这很好,但每次运行 Meteor 命令时都打开和关闭第二个浏览器窗口很烦人。
要消除第二个浏览器窗口,我们可以使用phantomjs
来运行测试。为此,我们将简单地使用一些设置运行 Meteor 命令:
SELENIUM_BROWSER=phantomjs meteor
PhantomJS 是一个无头浏览器。无头浏览器是一个没有图形用户界面(GUI)的网页浏览器(如 Safari 和 Chrome)。换句话说,它是一个为机器人设计的浏览器,这正是我们的机器人需要运行测试的。
让我们使用此命令创建一个自定义的 Meteor 别名。使用您喜欢的文本编辑器打开~/.bash_profile
目录。如果您使用 Sublime Text 3,可以运行以下命令:
sublime ~/.bash_profile
在此文件中,将此行添加到文档的末尾:
# ~/.bash_profile
alias devmeteor='SELENIUM_BROWSER=phantomjs meteor'
现在,完全退出您的终端,然后再次打开它。前往您的项目,并运行 devmeteor
命令以启动您的项目。
前往项目 URL。在这里,您将注意到项目右上角有一个蓝色圆圈。点击此按钮以显示 velocity 测试仪表板。每次测试失败时,您将在这里看到失败的原因。每次代码发生变化时,所有测试都会重新运行。
让我们从构建我们的第一个行为测试开始。在这个测试中,我们将检查我们是否可以向订单中添加项目。为此,我们需要编程两个关键组件:
-
功能
-
步骤
步骤基本上是 JavaScript 或 CoffeeScript 中的代码片段,用于解释功能。在编写功能后,步骤片段会自动生成。因此,我们需要首先编写功能的描述。
我们所有的测试都将保存在 /tests
目录下。行为测试将始终放在 /cucumber
文件夹下。在撰写本书时,这已经成为强制性的,因为 Velocity 仅在以下特殊目录下运行:
# /tests/cucumber/features/cart/add to cart.feature
Feature: Add to Cart
As a customer
I want to add items to my cart
So I can checkout
Background:
Given I am an anonymous customer
@dev
Scenario: Add one to cart
When I navigate to "/"
And I click on the first button with class ".add-to-cart"
Then I should have one product with quantity 1 in my cart
注意,文件以 .feature
结尾,位于 /features
目录下。将文件放置在 features
目录下对于测试套件能够识别您的功能文件也是强制性的。
此文件是用 Gherkin 语言编写的。如果您想添加注释,可以使用井号(#
),就像我们在 CoffeeScript 中做的那样。Feature
关键字描述了功能;这可以是任何内容,只要它能帮助您识别功能即可。在此之后,我们可以看到三行,描述了功能的目的。这种描述可以是您想要的任何内容,因为这不影响测试,但通常使用以下语法来帮助识别功能是否有用:
As a [role]
I want [feature]
So that [benefit]
接下来,我们将找到 Background
和 Scenario
关键字。两者都是一系列导致结果的操作。对于每个功能,可以有多个场景来测试功能的各个部分。Background
关键字定义了在运行 Scenario
关键字下的操作之前要执行的操作列表。尽量保持背景简短且简单,并记住它们将为每个场景运行。
此外,请注意,我们在 Scenario
关键字上方有一个 @dev
关键字;此关键字控制 Scenario
将在哪里运行。如果您不包含关键字,它只有在您在终端中运行 meteor --test
时才会运行。如果您包含 @dev
,则测试将在您对 Web 应用程序进行更改时每次运行。此外,您还可以包含 @ignore
以完全忽略测试。
Given
、When
、And
和 Then
关键字是运行我们测试代码中的步骤的命令:
-
Given
:Given
的目的是在发生任何交互之前将应用程序置于已知状态。 -
When
:When
的目的是描述用户执行的关键操作。 -
And
:And
的目的在于更流畅地编写场景。它们基本上用前一个关键字替换And
。 -
Then
:Then
的目的在于观察和评估结果。这个短语将始终确保系统已经产生了某些东西。
现在我们已经编写了第一个功能,让我们生成将执行该功能的步骤。运行您的别名命令:
devmeteor
一旦服务器启动,Velocity 将生成一个可以用来跟踪项目cucumber.log
的命令。在不关闭 Meteor 的情况下打开一个单独的终端并运行该命令。它应该看起来像这样:
tail -f /Users/YOU/pathtoyourproject/online_shop/.meteor/local/log/cucumber.log
现在,您将看到类似以下内容:
如果您不这样做,您可以在不离开cucumber.log
的情况下重新启动 Meteor 项目。在这里,我们可以看到该功能期望的步骤的 JavaScript 版本。让我们在特殊的/step_definitions
目录下重写这些步骤。此目录必须始终是相关.feature
文件的兄弟目录。
复制代码片段,并在/tests/cucumber/features/cart/step_definitions/steps.coffee
目录下创建一个新文件。我们将把这些片段转换为 CoffeeScript,因为我们一直在使用它。您可以在js2.coffee
快速完成此操作。
注意到每个函数都在最后传递一个callback
变量并调用pending()
函数。当测试运行时,这表明函数尚未构建,并在 Cucumber 日志中显示为挂起。我们在完成与函数的工作后可以移除callback
变量。注意,也会传递参数。
使用此代码运行测试将不会工作;我们需要正确初始化测试。为此,我们首先使用 CoffeeScripts 的do
函数并将命令附加到module.exports
函数。您需要为每个测试文件执行此操作:
# /tests/cucumber/features/cart/step_definitions/steps.coffee
do ->
'use strict'
module.exports = ->
@Given ...
@When ...
@Then ...
现在测试实际上正在运行,我们可以深入了解。我们将首先解决的函数是Given
。Given
需要确保用户是完全新的并且没有任何活跃的订单。为此,我们将使用固定装置。固定装置是仅在测试中可用的Meteor.methods
。我们可以使用这些方法来清除我们的数据库或添加种子数据。让我们首先创建一个确保用户是匿名的固定装置,以及一个清除所有订单的固定装置:
# /tests/cucumber/fixtures/cart_fixtures.coffee
do ->
'use strict'
Meteor.methods
"anonymous_user": ->
if @userId
@setUserId null
"reset_orders": ->
Orders.remove {}
OrderDetails.remove {}
现在,我们可以在Given
函数(或任何其他函数)中使用@server.call
函数调用这些方法:
# /tests/cucumber/features/cart/step_definitions/steps.coffee
do ->
'use strict'
module.exports = ->
@Given /^I am an anonymous customer$/, ->
@server.call "reset_orders"
@server.call "anonymous_user"
注意,我们正在使用@server
,但我们可以使用@client
,因为这是一个对Meteor.method
的调用。现在让我们编写When
:
# /tests/cucumber/features/cart/step_definitions/steps.coffee
do ->
'use strict'
...
module.exports = ->
url = require('url')
@Given /^I am an anonymous customer$/, ->
...
@When /^I navigate to "([^"]*)"$/, (path) ->
@browser
.url url.resolve(process.env.ROOT_URL, path)
@When /^I click on the first button with class "([^"]*)"$/, (button) ->
@browser
.waitForExist "body *"
.waitForVisible ".product"
.element ".product:nth-child(1) #{button}"
.click()
@browser
对象提供了 webdriver 实例的访问权限。这意味着我们可以使用 webdriver 函数来模拟用户点击、检查元素和浏览网络。注意,我们也可以通过require
函数要求 NPM 模块。在这种情况下,我们将要求url
模块来帮助识别路由。
第一个When
函数需要一个由正则表达式定义的单个参数。在这种情况下,它是path
变量。参数将按它们在正则表达式中定义的顺序逐个列出,并以回调函数(我们不需要使用)结束:(arg1, arg2, arg3, callback) ->
。
第二个When
函数需要在对其进行操作之前等待 DOM 加载。为此,我们将使用waitForExist
和waitForVisible
函数。如果元素没有渲染,则由于该函数中的超时,测试将失败。记住,如果你想要查看测试正在做什么,你可以浏览到你的镜像。
你可以在webdriver.io/api.html
找到可用的 webdriver 函数的完整列表,但这里列出了你将最常使用的几个:
函数 | 使用 |
---|---|
waitForExist(selector[,timeout,reverse]) |
默认超时时间:500 ,反向:false 。这等待一个元素在 DOM 上渲染。将reverse 标志设置为true 将等待元素停止存在。 |
waitForVisible(selector[,timeout,reverse]) |
默认超时时间:500 ,反向:false 。这等待一个元素变得可见(检查显示 CSS 属性是否未设置为任何值,元素是否不在视口中,以及opacity CSS 属性是否未设置为0 )。将reverse 标志设置为true 将等待元素变得不可见。 |
click(selector)``doubleClick(selector)``leftClick(selector)``rightClick(selector) |
这将点击一个元素。可以接受 CSS 选择器。 |
setValue(selector,values)``addValue(selector,values) |
这向元素发送一系列按键。也可以使用 Unicode 字符来模拟诸如退格键和箭头键等操作。addValue 函数将附加到一个已设置的值上。 |
getText(selector)``getValue(selector) |
这将获取节点文本或输入值。 |
getCssProperty(selector,property)``getAttribute(selector,attribute) |
这将获取 CSS 属性或 DOM 元素属性的数据。property 变量将返回一个对象而不是字符串。 |
then(function(valueFromGet)) |
这使用从任何get 函数获得的数据。第一个参数总是get 函数的值。 |
现在让我们构建我们的Then
函数,并使用Chai来评估一切是否按预期进行:
# /tests/cucumber/features/cart/step_definitions/steps.coffee
do ->
'use strict'
module.exports = ->
...
@Then /^I should have one product with quantity one in my cart$/, ->
@browser
.waitForVisible ".order_detail"
.getText ".order_detail:nth-child(1) .quantity"
.then (quantity) ->
expect quantity
.to.equal "1"
在这里,我们使用了then
函数来处理.quantity
节点的值。我们使用 Chai 来检查获取的值是否正确。Chai 函数的列表很长,你将发现自己会使用它们中的大多数。它们很容易猜测!你可以在这里找到所有这些函数:chaijs.com/api/bdd
。
单元测试
单元测试比行为测试更容易构建。这些测试确保只有 Web 应用程序的一部分工作正确,例如Meteor.method
或模板辅助函数。
单元测试使查找损坏行为测试中的错误更快。它们应该主要用于容易损坏的部分,例如发布者或特定的辅助工具。
要运行单元测试,我们将使用sanjo:jasmine
包:
meteor add sanjo:jasmine
现在创建两个目录:/jasmine/client/integration
和 /jasmine/server/integration
。这些是jasmine
运行测试的特殊目录。让我们为products
发布者快速构建一个测试:
# /tests/jasmine/client/integration/publishers/products_pub_test.coffee
do ->
'use strict'
describe "Products publisher", ->
it "should return product data", ->
# SETUP
subscription = Meteor.subscribe("products")
if subscription.ready()
# EXECUTE
product = Products.findOne()
# VERIFY
expect product
.not.toBeUndefined()
Jasmine 很简单。首先,您使用describe
函数描述功能对象,然后使用it
函数解释功能中应该工作的每个部分。通常将评估函数分为三个块:SETUP
、EXECUTE
和VERIFY
。在设置阶段,我们确保一切准备就绪以便测试运行,然后执行一系列函数,最后使用 Chai 表达式验证测试是否通过或失败。
单元测试非常适合测试代码中的特殊之处,这些特殊之处用户可能看不到在视觉上反映出来。请查看 Jasmine 文档中的jasmine.github.io/2.3/introduction.html
,以获取一系列优秀的示例。
摘要
在本章中,我们学习了如何为我们的 Web 应用程序构建简单的测试。此外,我们还了解到,它们是团队和非团队环境中维护应用程序开发过程中的关键部分。行为测试是确保应用程序按预期运行所有功能的测试,而单元测试是确保特定弱点按预期运行的测试。在测试时要小心。虽然保持测试活跃以进行维护很重要,但更重要的是关注产品的编程。如果您没有时间编写完整的行为测试,至少为对 Web 应用程序运行至关重要的函数编写一个单元测试。
在下一章中,我们将介绍如何将我们的 Web 应用程序部署到生产质量的服务器上,以及如何在应用程序运行后轻松识别应用程序产生的错误。
第六章。部署
本章将介绍使我们的网络应用程序上线所需的步骤。您还将学习如何设置 SSL 证书以及如何积极跟踪生产中发生的错误。本章将涵盖以下主题:
-
设置 Modulus.io
-
设置 Compose.io
-
自动错误跟踪
-
设置 SSL 证书
设置 Modulus
Modulus 目前是托管 Meteor 项目的最佳场所。为什么?它易于设置和维护。虽然还有其他几个服务可以提供相同的结果,但每个都需要更多的服务器专业知识,并且您需要投入相当多的时间。我们不是在开发服务器。我们是在开发应用程序。
Modulus.io 提供了对粘性会话、WebSockets 和免费 SSL 端点的支持。
请记住,Meteor 的 Galaxy 主机服务即将发布,并将无疑成为托管 Meteor 网络应用程序的最佳场所。在此之前,Modulus 是最佳选择。
让我们从在 modulus.io
创建一个免费账户开始:
接下来,您需要安装 demeteorizer
工具和 modulus CLI
工具。在您的终端中运行以下命令:
npm install –g demeteorizer
npm install –g modulus
这没有工作!如果您的 npm install
命令失败,那么您需要安装 node
和 npm
。
为了确保我们正确安装 npm
,我们首先需要添加 homebrew
。通过运行以下命令进行安装:
ruby -e "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/master/install)"
完成此操作后,运行以下命令:
brew install node
当这个命令完成所有安装后,我们将在命令行中全局可用 node
和 npm
。
现在,我们可以轻松安装 demeteorizer
和 modulus
:
npm install –g demeteorizer
npm install –g modulus
优秀。现在我们拥有了所有需要的工具。让我们创建一个新的项目(如果您想感觉像专业人士,您也可以使用 CLI):
确保选择项目的运行环境为 Node.JS 并将服务器的内存大小设置为 192MB。如果您发现有很多流量进入,您可以在任何时候增加服务器的内存以轻松扩展应用程序。
创建您的服务器后,转到 管理 选项卡:
现在复制位于 您的项目 URL 文本旁边的项目生成的 URL:
将页面滚动到最底部的 环境变量。我们需要做的第一件事是设置 ROOT_URL
环境变量。在此粘贴项目 URL。确保它具有 https
协议。在这里,我们正在利用 Modulus 的安全 SSL 端点。wizonesolutions:canonical
包将确保所有路由都将击中我们的 ROOT_URL
:
接下来,我们需要设置我们的数据库。我们无法部署此应用程序,因为应用程序不知道它应该在哪里保存信息。
设置 Compose
尽管 Modulus.io 提供 MongoDB 托管服务,但 Modulus 并不提供对 Meteor 的oplog tailing功能的访问权限,也没有对多个副本集的支持。这两个功能对于生产环境来说是必不可少的,原因如下。
MongoDB 副本集是您数据库的精确副本。当您在 Compose 中首次创建数据库时,您会自动获得一个主副本集和一个辅助副本集来支持它。由于故障是不可避免的,辅助副本集的存在是为了在主副本集失败时立即替换它。在数据库领域,这被称为数据冗余。
这层安全性带来了一个有趣的问题;辅助数据库如何知道主数据库正在发生的变化?操作日志,简称 oplog,是 Mongo 用于记录对数据库所做的所有更改的特殊集合。这些更改由副本集读取,以反映所有必要的更改。
太好了,现在让我们了解 Meteor 如何使用 oplog。Meteor 默认的poll
和diff
方法用于监视数据库中的更改速度较慢。它通过每 10 秒比较数据库和客户端之间的更改来实现。这默认会在您的数据库上创建多次不必要的访问,并且速度不快(因为更改可能在 Meteor 寻找更改之前 9 秒发生)。为了使 Meteor 表现更好,Meteor 团队利用了 Mongo 的 oplog。通过监听 Mongo 的 oplog 中的更改,Meteor 可以确切地知道何时以及哪些更改需要推送到客户端。这被称为 oplog tailing。
Oplog tailing 通过有效地跟踪 Mongo 的操作日志,极大地提高了 Meteor 的反应性能。保证没有利用这个功能的实际生产应用将无法顺利运行。
使用compose.io
创建您的账户:
现在创建一个新的 MongoDB:
现在点击用户标签页。在这里,我们需要添加一个可以访问数据库和 oplog 的用户。这就是 Modulus 将使用的用户。在以下示例中,我们将用户设置为root
,密码为root
,并将oplog access
设置为true
:
点击管理标签页。复制显示在副本集 URI下的数据库 URI:
注意字符串中的<user>
和<password>
部分。我们将用之前定义的用户凭据替换这些字段。
返回 Modulus.io 并访问项目的管理页面。滚动到环境变量部分。在这里,我们需要将刚刚复制的 URI 添加到两个不同的变量中:MONGO_URL
和MONGO_OPLOG_URL
。
MONGO_URL
将看起来像这样:
mongodb://root:root@candidate.1.mongolayer.com:10197,candidate.13.mongolayer.com:10810/online_shop
注意您可以删除查询参数(问号及其之后的内容)。
MONGO_OPLOG_URL
将看起来像这样:
mongodb://root:root@candidate.1.mongolayer.com:10197,candidate.13.mongolayer.com:10810/local?authSource=online_shop
注意,我们在尾随斜杠之后修改了信息,将其修改为 /local?authSource=online_shop
,其中 online_shop
是数据库名称。
极好!这是我们最后一次为项目部署配置任何内容。现在我们可以使用 modulus
CLI 进行部署。
前往您的终端并运行以下命令以登录到您的用户:
modulus login
如果您使用 GitHub 账户创建您的 Modulus 账户,那么请传递 github
标志:
modulus login --github
按照命令行的说明操作。一旦登录,请确保您位于 Meteor 项目的 root
目录(运行 Meteor 命令的地方)。从这里,运行以下命令以部署到您的新服务器:
modulus deploy
Modulus 会询问您要将项目部署到哪个项目,并开始部署。Modulus 做什么?它首先确定项目是一个 Meteor 项目,然后运行 demeteorizer 将项目转换为通用的 Node.js 应用程序。然后,该应用程序被部署到服务器并自动启动。
设置 Kadira
完美!现在我们可以部署生产质量的网络应用程序了,我们需要了解如何识别它们的问题。这就是 kadira.io
发挥作用的地方。Kadira 是一个针对 Meteor 的性能和错误监控工具。它将收集客户端和服务器上触发的 Meteor.Errors
。它还将显示发布者和订阅者的性能数据。
Kadira 可以做更多的事情,而且起始计划完全是免费的。让我们先注册一下。注册后,您需要创建一个新的应用程序:
为应用程序决定一个名称,并在字段中输入相同的名称,然后点击创建应用程序:
创建后,您将看到如下视图:
完成,您需要复制步骤 2 中的代码并安装 meteorhacks:kadira
和 meteorhacks:zones
包:
meteor add meteorhacks:kadira
meteor add meteorhacks:zones
meteorhacks:zones
包改进了客户端的错误描述。Kadira 将自动利用这个包。需要注意的是,meteorhacks:zones
包是可选的,因为它仍在积极开发中,可能会在 Meteor 中引起奇怪的行为。现在,让我们为 Kadira 创建一个配置文件:
# /_globals/server/kadira.coffee
if process.env.NODE_ENV is "production"
Kadira.connect 'appId', 'secret'
我们完成了!使用这个简单的配置,我们现在可以轻松地在 Kadira 上跟踪应用程序错误等:
您会在屏幕的左上角注意到错误标签。此标签将显示我们应用程序中可能发生的所有错误列表。查看 meteorhacks
和他们的学院以了解更多关于优化您的 Meteor 网络应用程序的信息。
设置 SSL 证书
SSL,或安全套接字层,是一种在客户端和服务器之间创建加密连接的技术。如果我们想确保传输到我们服务器的数据是加密的,这是必要的;这包括信用卡信息等数据。
设置 SSL 可能会很痛苦,因为它需要一些命令行知识。我们喜欢从 www.namecheap.com/
购买我们的 SSL 证书,因为它们便宜且能完成任务。
您可以获得的最低成本的 SSL 证书是PositiveSSL;您可以在以下端点找到该产品:www.namecheap.com/security/ssl-certificates/single-domain.aspx
。
在购买证书后,您需要生成一个证书签名请求(CSR)。让我们来做这件事。您首先将被重定向到购买摘要页面。点击管理:
现在点击立即激活并保持窗口打开:
接下来,在您的项目目录中打开终端,并运行以下命令创建一个 /.csr
目录:
mkdir .csr
cd .csr
现在,让我们使用 openssl
创建我们的 CSR。运行以下命令:
openssl req -nodes -newkey rsa:2048 -keyout private.key -out server.csr
这将提示您输入网站联系信息;所有信息字段都必须填写您的公司或您的信息。最重要的字段是通用名称字段,它必须是 www.yourdomain.com
。包括 www
将同时保护 www.yourdomain.com
和 yourdomain.com
。
命令已创建一个 private.key
文件和一个 server.csr
文件;您可以通过运行以下命令来检查:
ls
请将 private.key
放在一个安全的地方!您稍后需要这个文件。现在使用任何文本编辑器(如 Sublime、Atom)或如果您更喜欢更基础的编辑器,可以使用 nano 或 vim 打开 server.csr
文件。您可以通过运行以下命令来查看当前目录文件夹的内容:
open .
复制此文件中的所有文本。它应该看起来像这样:
-----BEGIN CERTIFICATE REQUEST-----
A bunch of letters and numbers
-----END CERTIFICATE REQUEST-----
前往 namecheap,将选择框更改为其他并将 CSR 粘贴到 namecheap 的输入 csr文本区域字段。它应该看起来像这样:
现在填写审批人信息:
确保您选择的电子邮件是活跃的并且受您控制!如果您无法从您选择的电子邮件接收电子邮件,您将无法将 SSL 证书应用到您的 webapp!在下一屏,只需提交您的订单并等待SSL 证书验证电子邮件到达:
复制您的验证码,然后在电子邮件中的此处链接上点击。这将带您到另一个网站,您需要粘贴复制的验证码并点击下一步:
关闭窗口,等待您收到一个包含 ZIP 文件的新的电子邮件。下载 ZIP 文件并将其解压。这将包含四个文件:AddTrustExternalCARoot.crt
、COMODORSAAddTrustCA.crt
、COMODORSADomainValidationSecureServerCA.crt
和 www_yourdomain_com.crt
。
现在,我们需要使用这些文件为 Modulus 创建一个证书颁发机构包。这基本上是我们所有证书的串联版本。通过在证书所在命令行中运行此命令来生成它:
cat www_yourdomain_com.crt COMODORSADomainValidationSecureServerCA.crt COMODORSAAddTrustCA.crt AddTrustExternalCARoot.crt > certificate_bundle.crt
此命令将生成一个包含所有证书的certificate_bundle.crt
目录。
现在,浏览到 Modulus.io 的管理页面;确保您已经通过将域名添加到自定义域名列表中,将自定义 URL 指向您的 Modulus 实例。点击加号图标:
现在,打开与 CSR 一起生成的private.key
文件,复制文件内的全部文本。将信息粘贴到私钥文本区域。然后打开certificate_bundle.crt
文件,复制信息到这里,并将其粘贴到证书文本区域:
确保将您的证书指向www
域名和非www
域名。
现在为了完成这个过程,我们需要确保wizonesolutions:canonical
包将流量路由到受保护的域名。滚动到环境变量部分,并将ROOT_URL
替换为以https
协议开始的您的域名。您的ROOT_URL
应如下所示:https://yourdomain.com
。
您可能需要重新启动服务器以确保所有设置生效。现在访问您的网站,您将看到您被自动重定向到您网站的受保护版本!
摘要
本章内容直接明了。我们学习了如何将我们的应用程序部署到由 modulus.io 托管的托管服务器上。我们还学习了如何将我们的 Modulus 项目设置到由 Compose 提供的生产质量数据库服务器上。我们选择 Compose 而不是 Modulus,因为我们可以通过它设置 Meteor 的 oplog tailing。为了帮助我们跟踪应用程序错误,我们安装了 Kadira。我们还学习了如何为我们的服务器设置 SSL 证书以进一步保护我们的网站。有了这些知识,我们可以构建生产质量的 Web 应用程序。