路由
现在,我们已经创建了一个帖子列表页面(最终是由用户提交的),我们还需要添加一个单独的帖子页面,提供给用户评论对应的帖子。
我们希望可以通过固定链接访问到每个单独的帖子页面,URL 形式是 http://myapp.com/posts/xyz
(这里的 xyz
是 MongoDB 的 _id
标识符),对于每个帖子来说是唯一的。
这意味着我们需要某些路由来看看浏览器的地址栏里面的路径是什么,并相应地显示正确的内容。
添加 Iron Router 包
Iron Router 是特别为了 Meteor Apps 开发的路由包。
它不仅能帮助路由(设置路径),还能帮助过滤(为这些路径分配跳转),甚至能管理订阅(控制路径可以访问哪些数据)。(注意:Iron Router 是由本书《Discover Meteor》的其中一名作者 Tom Coleman 参与开发的。)
首先,让我们从 Atmosphere 中安装这个包:
meteor add iron:router
这个命令是下载并安装 Iron Router 包到我们的 App,这样我们就可以使用了。请注意,在能够顺利使用这个包之前,你可能需要重启你的 Meteor 应用(通过按 ctrl + c
就能停止进程,然后输入 meteor
再次启动它)。
路由器的词汇
在本章我们会接触很多路由器的不同功能。如果你对类似 Rails 的框架有一定实践经验的话,你可能已经很熟悉大部分的这些词汇概念了。但是如果没有的话,这里有一个快速词汇表让你来了解一下:
- 路由规则(Route):路由规则是路由的基本元素。它的工作就是当用户访问 App 的某个 URL 的时候,告诉 App 应该做什么,返回什么东西。
- 路径(Path):路径是访问 App 的 URL。它可以是静态的(
/terms_of_service
)或者动态的(/posts/xyz
),甚至还可以包含查询参数(/search?keyword=meteor
)。 - 目录(Segment):路径的一部分,使用正斜杠(
/
)进行分隔。 - Hooks:Hooks 是可以执行在路由之前,之后,甚至是路由正在进行的时候。一个典型的例子是,在显示一个页面之前检测用户是否拥有这个权限。
- 过滤器(Filter):过滤器类似于 Hooks ,为一个或者多个路由规则定义的全局过滤器。
- 路由模板(Route Template):每个路由规则指向的 Meteor 模板。如果你不指定,路由器将会默认去寻找一个具有相同名称的模板。
- 布局(Layout):你可以想象成一个数码相框的布局。它们包含所有的 HTML 代码放置在当前的模板中,即使模板发生改变它们也不会变。
- 控制器(Controller):有时候,你会发现很多你的模板都在重复使用一些参数。为了不重复你的代码,你可以让这些路由规则继承一个路由控制器(Routing Controller)去包含所有的路由逻辑。
关于更多 Iron Router 的信息,请查看 GitHub上面的完整文档.
路由:把 URL 映射到模板
到目前为止,我们已经使用了一些固定模板(比如 {{> postsList}}
)来为我们布局。因此,尽管我们 App 的内容还可以更改,但是页面的基本结构都已经不变了:一个头(header),它下面是帖子列表。
Iron Router 负责处理在 HTML <body>
标签里面该呈现什么,让我们摆脱了这个枷锁。所以我们不会再自己去定义标签里面的内容,取而代之的是,我们将路由器指定到一个包含 {{> yield}}
标签的布局模板。
这个 {{> yield}}
标签将会定义一个动态区域,它会自动呈现对应于当前线路的相应模板(从现在起,我们将指定这个特殊的模板叫 “route templates”):
我们将开始构建我们的布局和添加 {{> yield}}
标签。首先,我们先从 main.html
文件里面删除 <body>
标签,并把它的内容放到它们共同的模板 layout.html
里面(保存在新的 client/templates/application
文件夹中)。
我们把 main.html
删减内容之后应该是这样的:
<head>
<title>Microscope</title>
</head>
而新创建的 layout.html
现在将会包含 App 的外层布局:
<template name="layout">
<div class="container">
<header class="navbar navbar-default" role="navigation">
<div class="navbar-header">
<a class="navbar-brand" href="/">Microscope</a>
</div>
</header>
<div id="main" class="row-fluid">
{{> yield}}
</div>
</div>
</template>
你会注意到我们已经把 yield
helper 取代了 postsList
模板。
完成之后,我们浏览器标签会显示 Iron Router 默认的帮助页面。这是因为我们还没有告诉路由怎样处理 /
URL,所以它仅仅呈现一个空的模板。
接下来,我们可以恢复之前的根路径 /
URL 映射到 postsList
模板。然后我们在根目录创建一个 /lib
目录,并在里面创建 router.js
文件:
Router.configure({
layoutTemplate: 'layout'
});
Router.route('/', {name: 'postsList'});
我们已经完成了两件重要的事情。第一,我们已经告诉路由器使用我们刚刚创建的 layout
模板作为所有路由的默认布局。
第二,我们已经定义了一个名为 postsList
的路由规则,并映射到 /
路径。
/lib
文件夹
你放在 /lib
文件夹里面的所有文件都会在你的 App 运行的时候确保首先被加载(可能除了 smart 包)。这是放置需要随时准备使用的辅助代码的好地方。
不过有一点注意的是:因为 /lib
文件夹并不是放在 /client
或 /server
文件夹里面,这意味着它的代码将会同时存在于客户端和服务器。
路由规则的名字
在这里我们先清除一些歧义。我们有一个路由规则,叫做叫 postsList
,同时我们也有一个名字叫 postsList
的模板。这里是怎么回事?
默认情况下,Iron Router 会为这个路由规则,指定相同名字的模板。而如果路径(path
参数)没有指定,它也会根据路由规则的名字,去指定同样名字的路径。举个例子,在上面的设置中,如果我们不提供 path
参数,那么访问 /postsList
将会自动获取到 postList
模板。
你可能想知道为什么我们需要在一开始去制定路由规则。这是因为 Iron Router 的部分功能需要使用路由规则去生成 App 的链接信息。其中最常见的一个是 {{pathFor}}
的 Spacebars helper,它需要返回路由规则的 URL 路径。
我们希望主页链接到帖子列表页面,所以除了指定静态的 /
URL ,我们还可以使用 Spacebars helper。虽然它们的效果是一样的,不过这给了我们更多的灵活性,如果我们更改了路由规则的映射路径,helper 仍然可以输出正确的 URL 。
<header class="navbar navbar-default" role="navigation">
<div class="navbar-header">
<a class="navbar-brand" href="{{pathFor 'postsList'}}">Microscope</a>
</div>
</header>
//...
等待数据
如果你要部署当前版本的 App(或启动起来去使用上面的链接),你会注意到在所有帖子完全出现之前,列表里面会空了一段时间。这是因为在第一次加载页面的时候,要等到 posts
订阅完成后,即从服务器抓取完帖子的数据,才能有帖子显示在页面上。
这应该要有一个更好的用户体验,比如提供一些视觉上的反馈让用户知道正在读取数据,这样用户才会去继续等待。
幸好 Iron Router 给了我们一个简单的方法去实现它。我们把订阅放到 waitOn
的返回上。
我们把 posts
订阅从 main.js
移到路由文件中:
Router.configure({
layoutTemplate: 'layout',
waitOn: function() { return Meteor.subscribe('posts'); }
});
Router.route('/', {name: 'postsList'});
我们这里所谈论的是对于网站的每个路由(我们现在只有一个,但是我们马上会添加更多!)我们都订阅了 posts
订阅。
这和我们之前做的(订阅原来被放在了 main.js
文件中,这文件现在应该是空的了,可以删除)关键区别在于 Iron Router 现在可以得知路由什么时候准备好——即当路由得到它需要渲染的数据时。
Get A Load Of This
如果我们只是显示一个空的模板的话,得知 postsList
路由已准备好也做不了什么事情。幸好 Iron Router 自带了一个延缓显示模板的方法,在路由调用模板准备好前,显示一个 loding
加载模板:
Router.configure({
layoutTemplate: 'layout',
loadingTemplate: 'loading',
waitOn: function() { return Meteor.subscribe('posts'); }
});
Router.route('/', {name: 'postsList'});
注意,因为我们在路由器级别上全局定义了 waitOn
方法,所以这个只会在用户第一次访问你的 App 的时候发生一次。在那之后,数据已经被加载到了浏览器的内存,路由器不需要再次去等待它。
最后一块拼图是加载模板。我们将会使用 spin
包去创建一个帅气的动画加载画面。通过 meteor add sacha:spin
去添加它,然后在 client/templates/includes
文件夹内创建 loading
模板:
<template name="loading">
{{>spinner}}
</template>
注意 {{> spinner}}
是 spin
包中的一个模板标签。尽管这部分是来自我们的 App 之外,不过我们就像其他模板一样去使用它就可以了。
这是一个好办法去等待你的订阅,不仅为了用户体验,还因为它可以顺利地确保数据可以马上体现在模板上。这消除了需要处理的模板被呈现之前,底层数据必须可用的问题,这往往需要复杂的解决方案。
第一次接触响应性
响应性是 Meteor 的一个核心部分,虽然我们没有真正的接触到,但我们的加载模板给了我们去接触这个概念的机会。
如果数据还没有加载完成的时候重定向去一个加载模板是很好,不过路由器如何知道在什么时候数据加载完,然后用户应该要重定向回到原本的页面呢?
刚刚我们说的这个就是响应性的体现,不过别担心,很快你会了解到关于它的更多东西。
路由到一个特定的帖子
既然我们已经看到了如何路由到 postsList
模板上,现在让我们建立一个路由来显示一个帖子的详细信息吧。
这里有一个问题:我们不能继续单独定义路由规则与路径的映射,因为可能有成千上万个。所以我们需要建立一个动态的路由规则,并让路由规则去显示我们要查看的帖子。
首先,我们将创建一个新的模板,简单地呈现相同的我们使用在帖子列表的模板。
<template name="postPage">
{{> postItem}}
</template>
我们以后还会添加更多的元素在这个模板上(如注释),但现在它将仅仅作为放置 {{> postItem}}
的外壳。
我们准备创建另一个路由规则,这次 URL 路径 /posts/<ID>
映射到 postPage
模板:
Router.configure({
layoutTemplate: 'layout',
loadingTemplate: 'loading',
waitOn: function() { return Meteor.subscribe('posts'); }
});
Router.route('/', {name: 'postsList'});
Router.route('/posts/:_id', {
name: 'postPage'
});
这个特殊的 :_id
标记告诉路由器两件事:第一,去匹配任何符合 /posts/xyz/
(“xyz”可以是任意字符)格式的路线。第二,无论“xyz”里面是什么,都会把它放到路由器的 params
数组中的 _id
属性里面去。
请注意,我们这里只使用 _id
只是为了方便起见。路由器是没有办法知道你是通过一个实际的 _id
,还是仅仅通过一些随机的字符去访问。
我们现在路由到正确的模板了,但是我们仍然漏了一个事情:路由器通过这个帖子的 _id
可以知道我们想显示哪个帖子,但模板还没有线索。那么,我们要如果解决这个问题呢?
值得庆幸的是,路由器有一个聪明的内置解决方案:它允许你指定一个数据源。你可以把数据源想象成填充的一个美味的蛋糕去填充模板和布局。简单的说,就是你的模板要填上:
在我们的例子中,我们可以从 URL 上获取 _id
,并通过它找到我们的帖子从而获得正确的数据源:
Router.configure({
layoutTemplate: 'layout',
loadingTemplate: 'loading',
waitOn: function() { return Meteor.subscribe('posts'); }
});
Router.route('/', {name: 'postsList'});
Router.route('/posts/:_id', {
name: 'postPage',
data: function() { return Posts.findOne(this.params._id); }
});
所以每次用户访问这条路由规则,我们会找到合适的帖子并将其传递给模板。记住,findOne
返回的是一个与查询相匹配的帖子,而仅仅需要提供一个 id
作为参数,它可以简写成 {_id: id}
。
在路由规则的 data
方法里面,this
对应于当前匹配的路由对象,我们可以使用 this.params
去访问一个比配项(在 path
中通过 :
前缀去表示它们)。
更多关于数据源
通过设置模板的数据源,你可以在模板 helper 里面控制 this
的值。
这个工作通常会隐式地被 {{#each}}
迭代器完成,它会自动设置对应的数据源到每个正在迭代的当前项中:
{{#each widgets}}
{{> widgetItem}}
{{/each}}
当然我们也可以使用 {{#with}}
去显式地操作,它就像简单地说“拿这个对象,提供给下面的模板应用”。例如,我们可以这样写:
{{#with myWidget}}
{{> widgetPage}}
{{/with}}
因此通过传递数据源作为参数给模板调用也可以实现相同的效果,所以前面的代码块可以重写为:
{{> widgetPage myWidget}}
想深入了解数据源,建议阅读我们的博客帖子。
使用动态的路由 Helper
最后,我们 要创建一个新的“评论”按钮,并指向正确的帖子页面。我们可以做一些像 <a href="/posts/{{_id}}">
这种动态模式,不过使用路由 Helper 会更可靠一点。
我们已经把帖子路由规则命名为 postPage
,所以我们可以使用 {{pathFor 'postPage'}}
helper :
<template name="postItem">
<div class="post">
<div class="post-content">
<h3><a href="{{url}}">{{title}}</a><span>{{domain}}</span></h3>
</div>
<a href="{{pathFor 'postPage'}}" class="discuss btn btn-default">Discuss</a>
</div>
</template>
不过等等,路由器到底如何准确地知道从 /posts/xyz
中的哪个位置去获得 xyz
路径?毕竟,我们没有传递任何的 _id
给它。
事实证明,Iron Router 是足够聪明地自己去发现它。我们告诉路由器使用 postPage
路由规则,而路由器知道这条规则的某些地方需要使用 _id
(因为这是我们定义 path
的办法)。
因此,路由器将会在 {{pathFor 'postPage'}}
的上下文环境(即 this
对象)中寻找这个 _id
。而在这个例子中,this
对象对应着一个帖子,它就是我们要寻找的拥有 _id
属性的地方。
又或者,你可以通过传递 Helper 的第二个参数,来明确指定需要找的 _id
在哪里。例如,{{pathFor 'postPage' someOtherPost}}
。实际情况下,如果要获取帖子列表中前一个或者后一个的链接,我们就会使用这种模式。
为了看看它是否已经正常运作,我们去浏览帖子列表页面并点击其中一个“Discuss”的链接。你应该看到类似这样的:
HTML5 pushState
这里我们需要知道的是,这些 URL 变化的产生原因是正在使用 HTML5 pushState.
路由器通过处理 URLs 的点击去访问网站的内部,这样可以防止浏览器跳出我们的 App ,而不只是为了必要的改变 App 的状态。
如果一切运作正常的话,页面应该会瞬间改变。事实上,有时候事情变化得过快,可能需要某种类型的过渡页面。这是本章的范围之外的,但却是一个有趣的话题。
帖子无法找到
让我们别忘了路由工作两种方式:改变我们访问的页面 URL,也能显示我们改变 URL 的新页面。所以我们需要解决当某用户输入错误的 URL 时的情况。
幸好,Iron Rounter 可以通过 notFoundTemplate
选项来为我们解决这个问题。
首先,我们设置一个新模板来显示简单的 404 错误 信息:
<template name="notFound">
<div class="not-found jumbotron">
<h2>404</h2>
<p>Sorry, we couldn't find a page at this address. 抱歉,我们无法找到该页面。</p>
</div>
</template>
然后,我们将 Iron Rounter 指向这个模板:
Router.configure({
layoutTemplate: 'layout',
loadingTemplate: 'loading',
notFoundTemplate: 'notFound',
waitOn: function() { return Meteor.subscribe('posts'); }
});
//...
为了验证这个错误页面,你可以尝试随机输入 URL 像 http://localhost:3000/nothing-here
。
但是稍等,如果有人输入了像 http://localhost:3000/posts/xyz
这种格式的 URL,xyz
不是一个合法的帖子 _id
怎么办?虽然是合法的路由,但是没有指向任何数据。
幸好,如果我们在 route.js
结尾添加了特别的 dataNotFound
hook,Iron Rounter 就能足够智能地解决这个问题。
//...
Router.onBeforeAction('dataNotFound', {only: 'postPage'});
这会告诉 Iron Router 不仅在非法路由情况下,而且在 postPage
路由,每当 data
函数返回“falsy”(比如 null
、false
、undefined
或 空)对象时,显示“无法找到”的页面。
为什么叫 “Iron”?
你也许会想知道命名“Iron Router”背后的故事。根据 Iron Router 的作者 Chris Mather,因为流星(meteor)主要由铁(iron)元素构成的事实。