---
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 %>