路由

5

翻译进度

本章内容:

  • 学习 Meteor 的路由。
  • 创建拥有唯一 URL 的帖子讨论页。
  • 学习如何正确链接到这些 URL。
  • 现在,我们已经创建了一个帖子列表页面(最终是由用户提交的),我们还需要添加一个单独的帖子页面,提供给用户评论对应的帖子。

    我们希望可以通过固定链接访问到每个单独的帖子页面,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
    
    Terminal 终端

    这个命令是下载并安装 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>
    
    client/main.html

    而新创建的 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>
    
    client/templates/application/layout.html

    你会注意到我们已经把 yield helper 取代了 postsList 模板。

    完成之后,我们浏览器标签会显示 Iron Router 默认的帮助页面。这是因为我们还没有告诉路由怎样处理 / URL,所以它仅仅呈现一个空的模板。

    接下来,我们可以恢复之前的根路径 / URL 映射到 postsList 模板。然后我们在根目录创建一个 /lib 目录,并在里面创建 router.js 文件:

    Router.configure({
      layoutTemplate: 'layout'
    });
    
    Router.route('/', {name: 'postsList'});
    
    lib/router.js

    我们已经完成了两件重要的事情。第一,我们已经告诉路由器使用我们刚刚创建的 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>
    
    //...
    
    client/templates/application/layout.html

    提交 5-1

    非常基本的路由。

    等待数据

    如果你要部署当前版本的 App(或启动起来去使用上面的链接),你会注意到在所有帖子完全出现之前,列表里面会空了一段时间。这是因为在第一次加载页面的时候,要等到 posts 订阅完成后,即从服务器抓取完帖子的数据,才能有帖子显示在页面上。

    这应该要有一个更好的用户体验,比如提供一些视觉上的反馈让用户知道正在读取数据,这样用户才会去继续等待。

    幸好 Iron Router 给了我们一个简单的方法去实现它。我们把订阅放到 waitOn 的返回上。

    我们把 posts 订阅从 main.js 移到路由文件中:

    Router.configure({
      layoutTemplate: 'layout',
      waitOn: function() { return Meteor.subscribe('posts'); }
    });
    
    Router.route('/', {name: 'postsList'});
    
    lib/router.js

    我们这里所谈论的是对于网站的每个路由(我们现在只有一个,但是我们马上会添加更多!)我们都订阅了 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'});
    
    lib/router.js

    注意,因为我们在路由器级别上全局定义了 waitOn 方法,所以这个只会在用户第一次访问你的 App 的时候发生一次。在那之后,数据已经被加载到了浏览器的内存,路由器不需要再次去等待它。

    最后一块拼图是加载模板。我们将会使用 spin 包去创建一个帅气的动画加载画面。通过 meteor add sacha:spin 去添加它,然后在 client/templates/includes 文件夹内创建 loading 模板:

    <template name="loading">
      {{>spinner}}
    </template>
    
    client/templates/includes/loading.html

    注意 {{> spinner}}spin 包中的一个模板标签。尽管这部分是来自我们的 App 之外,不过我们就像其他模板一样去使用它就可以了。

    这是一个好办法去等待你的订阅,不仅为了用户体验,还因为它可以顺利地确保数据可以马上体现在模板上。这消除了需要处理的模板被呈现之前,底层数据必须可用的问题,这往往需要复杂的解决方案。

    提交 5-2

    等待帖子的订阅。

    第一次接触响应性

    响应性是 Meteor 的一个核心部分,虽然我们没有真正的接触到,但我们的加载模板给了我们去接触这个概念的机会。

    如果数据还没有加载完成的时候重定向去一个加载模板是很好,不过路由器如何知道在什么时候数据加载完,然后用户应该要重定向回到原本的页面呢?

    刚刚我们说的这个就是响应性的体现,不过别担心,很快你会了解到关于它的更多东西。

    路由到一个特定的帖子

    既然我们已经看到了如何路由到 postsList 模板上,现在让我们建立一个路由来显示一个帖子的详细信息吧。

    这里有一个问题:我们不能继续单独定义路由规则与路径的映射,因为可能有成千上万个。所以我们需要建立一个动态的路由规则,并让路由规则去显示我们要查看的帖子。

    首先,我们将创建一个新的模板,简单地呈现相同的我们使用在帖子列表的模板。

    <template name="postPage">
      {{> postItem}}
    </template>
    
    client/templates/posts/post_page.html

    我们以后还会添加更多的元素在这个模板上(如注释),但现在它将仅仅作为放置 {{> 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'
    });
    
    lib/router.js

    这个特殊的 :_id 标记告诉路由器两件事:第一,去匹配任何符合 /posts/xyz/(“xyz”可以是任意字符)格式的路线。第二,无论“xyz”里面是什么,都会把它放到路由器的 params 数组中的 _id 属性里面去。

    请注意,我们这里只使用 _id 只是为了方便起见。路由器是没有办法知道你是通过一个实际的 _id ,还是仅仅通过一些随机的字符去访问。

    我们现在路由到正确的模板了,但是我们仍然漏了一个事情:路由器通过这个帖子的 _id 可以知道我们想显示哪个帖子,但模板还没有线索。那么,我们要如果解决这个问题呢?

    值得庆幸的是,路由器有一个聪明的内置解决方案:它允许你指定一个数据源。你可以把数据源想象成填充的一个美味的蛋糕去填充模板和布局。简单的说,就是你的模板要填上:

    The data context.
    The data context.

    在我们的例子中,我们可以从 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); }
    });
    
    lib/router.js

    所以每次用户访问这条路由规则,我们会找到合适的帖子并将其传递给模板。记住,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>
    
    client/templates/posts/post_item.html

    提交 5-3

    路由到一个单独的帖子页面。

    不过等等,路由器到底如何准确地知道从 /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>
    
    client/templates/application/not_found.html

    然后,我们将 Iron Rounter 指向这个模板:

    Router.configure({
      layoutTemplate: 'layout',
      loadingTemplate: 'loading',
      notFoundTemplate: 'notFound',
      waitOn: function() { return Meteor.subscribe('posts'); }
    });
    
    //...
    
    lib/router.js

    为了验证这个错误页面,你可以尝试随机输入 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'});
    
    lib/router.js

    这会告诉 Iron Router 不仅在非法路由情况下,而且在 postPage 路由,每当 data 函数返回“falsy”(比如 nullfalseundefined 或 空)对象时,显示“无法找到”的页面。

    提交 5-4

    添加了页面无法找到的模板。

    为什么叫 “Iron”?

    你也许会想知道命名“Iron Router”背后的故事。根据 Iron Router 的作者 Chris Mather,因为流星(meteor)主要由铁(iron)元素构成的事实。