💎一站式轻松地调用各大LLM模型接口,支持GPT4、智谱、星火、月之暗面及文生图 广告
--- title: 路由 slug: routing date: 0005/01/01 number: 5 points: 5 photoUrl: http://www.flickr.com/photos/ikewinski/9517814403/ photoAuthor: Mike Lewinski contents: 学习 Meteor 的路由。|创建拥有唯一 URL 的帖子讨论页。|学习如何正确链接到这些 URL。 paragraphs: 72 --- 现在,我们已经创建了一个帖子列表页面(最终是由用户提交的),我们还需要添加一个单独的帖子页面,提供给用户评论对应的帖子。 我们希望可以通过**固定链接**访问到每个单独的帖子页面,URL 形式是 `http://myapp.com/posts/xyz`(这里的 `xyz` 是 MongoDB 的 `_id` 标识符),对于每个帖子来说是唯一的。 这意味着我们需要某些**路由**来看看浏览器的地址栏里面的路径是什么,并相应地显示正确的内容。 ### 添加 Iron Router 包 [Iron Router](https://github.com/EventedMind/iron-router) 是特别为了 Meteor Apps 开发的路由包。 它不仅能帮助路由(设置路径),还能帮助过滤(为这些路径分配跳转),甚至能管理订阅(控制路径可以访问哪些数据)。(注意:Iron Router 是由本书*《Discover Meteor》*的其中一名作者 Tom Coleman 参与开发的。) 首先,让我们从 Atmosphere 中安装这个包: ~~~bash meteor add iron:router ~~~ <%= caption "Terminal 终端" %> 这个命令是下载并安装 Iron Router 包到我们的 App,这样我们就可以使用了。请注意,在能够顺利使用这个包之前,你可能需要重启你的 Meteor 应用(通过按 `ctrl + c` 就能停止进程,然后输入 `meteor` 再次启动它)。 <% note do %> ### 路由器的词汇 在本章我们会接触很多路由器的不同功能。如果你对类似 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上面的完整文档](https://github.com/EventedMind/iron-router). <% end %> ### 路由:把 URL 映射到模板 到目前为止,我们已经使用了一些固定模板(比如 `{{> postsList}}`)来为我们布局。因此,尽管我们 App 的内容还可以更改,但是页面的基本结构都已经不变了:一个头(header),它下面是帖子列表。 Iron Router 负责处理在 HTML `<body>` 标签里面该呈现什么,让我们摆脱了这个枷锁。所以我们不会再自己去定义标签里面的内容,取而代之的是,我们将路由器指定到一个包含 `{{> yield}}` 标签的布局模板。 这个 `{{> yield}}` 标签将会定义一个动态区域,它会自动呈现对应于当前线路的相应模板(从现在起,我们将指定这个特殊的模板叫 “route templates”): <%= diagram "router-diagram", "布局和模板。", "pull-center" %> 我们将开始构建我们的布局和添加 `{{> yield}}` 标签。首先,我们先从 `main.html` 文件里面删除 `<body>` 标签,并把它的内容放到它们共同的模板 `layout.html` 里面(保存在新的 `client/templates/application` 文件夹中)。 我们把 `main.html` 删减内容之后应该是这样的: ~~~html <head> <title>Microscope</title> </head> ~~~ <%= caption "client/main.html" %> 而新创建的 `layout.html` 现在将会包含 App 的外层布局: ~~~html <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> ~~~ <%= caption "client/templates/application/layout.html" %> 你会注意到我们已经把 `yield` helper 取代了 `postsList` 模板。 完成之后,我们浏览器标签会显示 Iron Router 默认的帮助页面。这是因为我们还没有告诉路由怎样处理 `/` URL,所以它仅仅呈现一个空的模板。 接下来,我们可以恢复之前的根路径 `/` URL 映射到 `postsList` 模板。然后我们在根目录创建一个 `/lib` 目录,并在里面创建 `router.js` 文件: ~~~js Router.configure({ layoutTemplate: 'layout' }); Router.route('/', {name: 'postsList'}); ~~~ <%= caption "lib/router.js"%> 我们已经完成了两件重要的事情。第一,我们已经告诉路由器使用我们刚刚创建的 `layout` 模板作为所有路由的默认布局。 第二,我们已经定义了一个名为 `postsList` 的路由规则,并映射到 `/` 路径。 <% note do %> ### `/lib` 文件夹 你放在 `/lib` 文件夹里面的所有文件都会在你的 App 运行的时候确保首先被加载(可能除了 smart 包)。这是放置需要随时准备使用的辅助代码的好地方。 不过有一点注意的是:因为 `/lib` 文件夹并不是放在 `/client` 或 `/server` 文件夹里面,这意味着它的代码将会同时存在于客户端和服务器。 <% end %> ### 路由规则的名字 在这里我们先清除一些歧义。我们有一个路由规则,叫做叫 `postsList` ,同时我们也有一个名字叫 `postsList` 的**模板**。这里是怎么回事? 默认情况下,Iron Router 会为这个路由规则,指定相同名字的模板。而如果路径(`path` 参数)没有指定,它也会根据路由规则的名字,去指定同样名字的**路径**。举个例子,在上面的设置中,如果我们不提供 `path` 参数,那么访问 `/postsList` 将会自动获取到 `postList` 模板。 你可能想知道为什么我们需要在一开始去制定路由规则。这是因为 Iron Router 的部分功能需要使用路由规则去生成 App 的链接信息。其中最常见的一个是 `{{pathFor}}` 的 Spacebars helper,它需要返回路由规则的 URL 路径。 我们希望主页链接到帖子列表页面,所以除了指定静态的 `/` URL ,我们还可以使用 Spacebars helper。虽然它们的效果是一样的,不过这给了我们更多的灵活性,如果我们更改了路由规则的映射路径,helper 仍然可以输出正确的 URL 。 ~~~html <header class="navbar navbar-default" role="navigation"> <div class="navbar-header"> <a class="navbar-brand" href="{{pathFor 'postsList'}}">Microscope</a> </div> </header> //... ~~~ <%= caption "client/templates/application/layout.html"%> <%= highlight "3" %> <%= commit "5-1", "非常基本的路由。" %> ### 等待数据 如果你要部署当前版本的 App(或启动起来去使用上面的链接),你会注意到在所有帖子完全出现之前,列表里面会空了一段时间。这是因为在第一次加载页面的时候,要等到 `posts` 订阅完成后,即从服务器抓取完帖子的数据,才能有帖子显示在页面上。 这应该要有一个更好的用户体验,比如提供一些视觉上的反馈让用户知道正在读取数据,这样用户才会去继续等待。 幸好 Iron Router 给了我们一个简单的方法去实现它。我们把订阅放到 `waitOn` 的返回上。 我们把 `posts` 订阅从 `main.js` 移到路由文件中: ~~~js Router.configure({ layoutTemplate: 'layout', waitOn: function() { return Meteor.subscribe('posts'); } }); Router.route('/', {name: 'postsList'}); ~~~ <%= caption "lib/router.js" %> <%= highlight "3" %> 我们这里所谈论的是对于网站的*每个*路由(我们现在只有一个,但是我们马上会添加更多!)我们都订阅了 `posts` 订阅。 这和我们之前做的(订阅原来被放在了 `main.js` 文件中,这文件现在应该是空的了,可以删除)关键区别在于 Iron Router 现在可以得知路由什么时候准备好——即当路由得到它需要渲染的数据时。 ### Get A Load Of This 如果我们只是显示一个空的模板的话,得知 `postsList` 路由已准备好也做不了什么事情。幸好 Iron Router 自带了一个延缓显示模板的方法,在路由调用模板准备好前,显示一个 `loding` 加载模板: ~~~js Router.configure({ layoutTemplate: 'layout', loadingTemplate: 'loading', waitOn: function() { return Meteor.subscribe('posts'); } }); Router.route('/', {name: 'postsList'}); ~~~ <%= caption "lib/router.js" %> <%= highlight "3,4" %> 注意,因为我们在路由器级别上全局定义了 `waitOn` 方法,所以这个只会在用户第一次访问你的 App 的时候发生一次。在那之后,数据已经被加载到了浏览器的内存,路由器不需要再次去等待它。 最后一块拼图是加载模板。我们将会使用 `spin` 包去创建一个帅气的动画加载画面。通过 `meteor add sacha:spin` 去添加它,然后在 `client/templates/includes` 文件夹内创建 `loading` 模板: ~~~html <template name="loading"> {{>spinner}} </template> ~~~ <%= caption "client/templates/includes/loading.html" %> 注意 `{{> spinner}}` 是 `spin` 包中的一个模板标签。尽管这部分是来自我们的 App 之外,不过我们就像其他模板一样去使用它就可以了。 这是一个好办法去等待你的订阅,不仅为了用户体验,还因为它可以顺利地确保数据可以马上体现在模板上。这消除了需要处理的模板被呈现之前,底层数据必须可用的问题,这往往需要复杂的解决方案。 <%= commit "5-2", "等待帖子的订阅。" %> <% note do %> ### 第一次接触响应性 响应性是 Meteor 的一个核心部分,虽然我们没有真正的接触到,但我们的加载模板给了我们去接触这个概念的机会。 如果数据还没有加载完成的时候重定向去一个加载模板是很好,不过路由器如何知道在什么时候数据加载完,然后用户应该要重定向回到原本的页面呢? 刚刚我们说的这个就是响应性的体现,不过别担心,很快你会了解到关于它的更多东西。 <% end %> ### 路由到一个特定的帖子 既然我们已经看到了如何路由到 `postsList` 模板上,现在让我们建立一个路由来显示一个帖子的详细信息吧。 这里有一个问题:我们不能继续单独定义路由规则与路径的映射,因为可能有成千上万个。所以我们需要建立一个**动态**的路由规则,并让路由规则去显示我们要查看的帖子。 首先,我们将创建一个新的模板,简单地呈现相同的我们使用在帖子列表的模板。 ~~~html <template name="postPage"> {{> postItem}} </template> ~~~ <%= caption "client/templates/posts/post_page.html" %> 我们以后还会添加更多的元素在这个模板上(如注释),但现在它将仅仅作为放置 `{{> postItem}}` 的外壳。 我们准备创建另一个路由规则,这次 URL 路径 `/posts/<ID>` 映射到 `postPage` 模板: ~~~js Router.configure({ layoutTemplate: 'layout', loadingTemplate: 'loading', waitOn: function() { return Meteor.subscribe('posts'); } }); Router.route('/', {name: 'postsList'}); Router.route('/posts/:_id', { name: 'postPage' }); ~~~ <%= caption "lib/router.js" %> <%= highlight "8~10" %> 这个特殊的 `:_id` 标记告诉路由器两件事:第一,去匹配任何符合 `/posts/xyz/`(“xyz”可以是任意字符)格式的路线。第二,无论“xyz”里面是什么,都会把它放到路由器的 `params` 数组中的 `_id` 属性里面去。 请注意,我们这里只使用 `_id` 只是为了方便起见。路由器是没有办法知道你是通过一个实际的 `_id` ,还是仅仅通过一些随机的字符去访问。 我们现在路由到正确的模板了,但是我们仍然漏了一个事情:路由器通过这个帖子的 `_id` 可以知道我们想显示哪个帖子,但模板还没有线索。那么,我们要如果解决这个问题呢? 值得庆幸的是,路由器有一个聪明的内置解决方案:它允许你指定一个**数据源**。你可以把数据源想象成填充的一个美味的蛋糕去填充模板和布局。简单的说,就是你的模板要填上: <%= diagram "router-diagram-2", "The data context.", "pull-center" %> 在我们的例子中,我们可以从 URL 上获取 `_id` ,并通过它找到我们的帖子从而获得正确的数据源: ~~~js 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); } }); ~~~ <%= caption "lib/router.js" %> <%= highlight "10" %> 所以每次用户访问这条路由规则,我们会找到合适的帖子并将其传递给模板。记住,`findOne` 返回的是一个与查询相匹配的帖子,而仅仅需要提供一个 `id` 作为参数,它可以简写成 `{_id: id}` 。 在路由规则的 `data` 方法里面,`this` 对应于当前匹配的路由对象,我们可以使用 `this.params` 去访问一个比配项(在 `path` 中通过 `:` 前缀去表示它们)。 <% note do %> ### 更多关于数据源 通过设置模板的**数据源**,你可以在模板 helper 里面控制 `this` 的值。 这个工作通常会隐式地被 `{{#each}}` 迭代器完成,它会自动设置对应的数据源到每个正在迭代的当前项中: ~~~html {{#each widgets}} {{> widgetItem}} {{/each}} ~~~ 当然我们也可以使用 {{#with}} 去显式地操作,它就像简单地说“拿这个对象,提供给下面的模板应用”。例如,我们可以这样写: ~~~html {{#with myWidget}} {{> widgetPage}} {{/with}} ~~~ 因此通过传递数据源作为**参数**给模板调用也可以实现相同的效果,所以前面的代码块可以重写为: ~~~js {{> widgetPage myWidget}} ~~~ 想深入了解数据源,建议[阅读我们的博客帖子](https://www.discovermeteor.com/blog/a-guide-to-meteor-templates-data-contexts/)。 <% end %> ### 使用动态的路由 Helper 最后,我们 要创建一个新的“评论”按钮,并指向正确的帖子页面。我们可以做一些像 `<a href="/posts/{{_id}}">` 这种动态模式,不过使用路由 Helper 会更可靠一点。 我们已经把帖子路由规则命名为 `postPage` ,所以我们可以使用 `{{pathFor 'postPage'}}` helper : ~~~html <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> ~~~ <%= caption "client/templates/posts/post_item.html"%> <%= highlight "6" %> <%= commit "5-3", "路由到一个单独的帖子页面。" %> 不过等等,路由器到底如何准确地知道从 `/posts/xyz` 中的哪个位置去获得 `xyz` 路径?毕竟,我们没有传递任何的 `_id` 给它。 事实证明,Iron Router 是足够聪明地自己去发现它。我们告诉路由器使用 `postPage` 路由规则,而路由器知道这条规则的某些地方需要使用 `_id`(因为这是我们定义 `path` 的办法)。 因此,路由器将会在 `{{pathFor 'postPage'}}` 的上下文环境(即 `this` 对象)中寻找这个 `_id`。而在这个例子中,`this` 对象对应着一个帖子,它就是我们要寻找的拥有 `_id` 属性的地方。 又或者,你可以通过传递 Helper 的第二个参数,来明确指定需要找的 `_id` 在哪里。例如,`{{pathFor 'postPage' someOtherPost}}`。实际情况下,如果要获取帖子列表中前一个或者后一个的链接,我们就会使用这种模式。 为了看看它是否已经正常运作,我们去浏览帖子列表页面并点击其中一个“Discuss”的链接。你应该看到类似这样的: <%= screenshot "5-2", "一个单独的帖子页面。" %> <% note do %> ### HTML5 pushState 这里我们需要知道的是,这些 URL 变化的产生原因是正在使用 [HTML5 pushState](https://developer.mozilla.org/en-US/docs/Web/Guide/API/DOM/Manipulating_the_browser_history?redirectlocale=en-US&redirectslug=Web%2FGuide%2FDOM%2FManipulating_the_browser_history). 路由器通过处理 URLs 的点击去访问网站的内部,这样可以防止浏览器跳出我们的 App ,而不只是为了必要的改变 App 的状态。 如果一切运作正常的话,页面应该会瞬间改变。事实上,有时候事情变化得过快,可能需要某种类型的过渡页面。这是本章的范围之外的,但却是一个有趣的话题。 <% end %> ### 帖子无法找到 让我们别忘了路由工作两种方式:改变我们访问的页面 URL,也能显示我们改变 *URL* 的新页面。所以我们需要解决当某用户输入*错误的* URL 时的情况。 幸好,Iron Rounter 可以通过 `notFoundTemplate` 选项来为我们解决这个问题。 首先,我们设置一个新模板来显示简单的 404 错误 信息: ~~~html <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> ~~~ <%= caption "client/templates/application/not_found.html"%> 然后,我们将 Iron Rounter 指向这个模板: ~~~js Router.configure({ layoutTemplate: 'layout', loadingTemplate: 'loading', notFoundTemplate: 'notFound', waitOn: function() { return Meteor.subscribe('posts'); } }); //... ~~~ <%= caption "lib/router.js"%> <%= highlight "4" %> 为了验证这个错误页面,你可以尝试随机输入 URL 像 `http://localhost:3000/nothing-here`。 但是稍等,如果有人输入了像 `http://localhost:3000/posts/xyz` 这种格式的 URL,`xyz` *不是*一个合法的帖子 `_id` 怎么办?虽然是合法的路由,但是没有指向任何数据。 幸好,如果我们在 `route.js` 结尾添加了特别的 `dataNotFound` hook,Iron Rounter 就能足够智能地解决这个问题。 ~~~js //... Router.onBeforeAction('dataNotFound', {only: 'postPage'}); ~~~ <%= caption "lib/router.js"%> <%= highlight "4" %> 这会告诉 Iron Router 不仅在非法路由情况下,而且在 `postPage` 路由,每当 `data` 函数返回“falsy”(比如 `null`、`false`、`undefined` 或 空)对象时,显示“无法找到”的页面。 <%= commit "5-4", "添加了页面无法找到的模板。" %> <% note do %> ### 为什么叫 “Iron”? 你也许会想知道命名“Iron Router”背后的故事。根据 Iron Router 的作者 Chris Mather,因为流星(meteor)主要由铁(iron)元素构成的事实。 <% end %>