---
title: 錯誤
slug: errors
date: 0009/01/01
number: 9
points: 10
contents: 創建一個更好的顯示錯誤和信息的機制。|實施更嚴格的表單驗證。|添加行內表單錯誤報告。
paragraphs: 31
---
僅使用瀏覽器標準的 `alert()` 對話窗去警告用戶他們的提交有錯誤有那么一點不令人滿意,而且顯然不是一個良好的用戶體驗。我們可以做得更好。
相反,讓我們建立一個更加靈活的錯誤報告機制,來更好地在不打斷流程的情況下告訴用戶到底發生了什么。
我們要實現一個簡單的系統,在窗口右上角顯示新的錯誤信息,類似于流行的 Mac OS 應用程序 [Growl](http://growl.info/)。
### 介紹本地集合(Local collection)
一開始,我們需要創建一個集合來存儲我們的錯誤。既然錯誤只與當前會話相關,而且不需要以任何方式長久存在,我們要在這做點新鮮的事兒,創建一個*本地集合(Local collection)*。這意味著,錯誤 `Errors` 集合將會只存在于*瀏覽器*中,并且將不作任何嘗試去同步回服務器。
為實現它,我們在 `client` 文件夾中創建錯誤(確保這集合只在客戶端存在),我們將它的 MongoDB 集合命名為 `null` (因為集合的數據將不會保存在服務器端的數據庫中):
~~~js
// 本地(僅客戶端)集合
Errors = new Mongo.Collection(null);
~~~
<%= caption "client/helpers/errors.js" %>
一開始,我們應該建立一個可以儲存錯誤的集合。介于錯誤只是對于當前的會話,我們將采用及時性集合。這就意味著錯誤集合只存在于當前的瀏覽器,該集合不會與服務端同步。
既然集合已經建立了,我們可以創建一個 `throwError` 函數用來添加新的錯誤。我們不需要擔心 `allow` 和 `deny` 或其他任何的安全考慮,因為這個集合對于當前用戶是“本地的”。
~~~js
throwError = function(message) {
Errors.insert({message: message});
};
~~~
<%= caption "client/helpers/errors.js" %>
使用本地集合去存儲錯誤的優勢在于,就像所有集合一樣,它是響應性的————意味著我們可以以顯示其他任何集合數據的同樣的方式,去響應性地顯示錯誤。
### 顯示錯誤
我們將在主布局的頂部插入錯誤信息:
~~~html
<template name="layout">
<div class="container">
{{> header}}
{{> errors}}
<div id="main" class="row-fluid">
{{> yield}}
</div>
</div>
</template>
~~~
<%= caption "client/templates/application/layout.html" %>
<%= highlight "4" %>
讓我們現在在 `errors.html` 中創建 `errors` 和 `error` 模版:
~~~html
<template name="errors">
<div class="errors">
{{#each errors}}
{{> error}}
{{/each}}
</div>
</template>
<template name="error">
<div class="alert alert-danger" role="alert">
<button type="button" class="close" data-dismiss="alert">×</button>
{{message}}
</div>
</template>
~~~
<%= caption "client/templates/includes/errors.html" %>
<% note do %>
### Twin 模版
你可能注意到我們在一個文件里面建立了兩個模板。直到現在我們一直在遵循“一個文件, 一個模板”的標準,但對于 Meteor 而言,我們把所有模板放在同一個文件里也是一樣的(但是這會讓 `main.html` 的代碼變得非常混亂!)。
在當前情況下,因為這兩個錯誤模板都比較小,我們破例將它們放在一個文件里,使我們的 repo 代碼庫更干凈些。
<% end %>
我們只需要加上我們的模板 helper 就可以大功告成了!
~~~js
Template.errors.helpers({
errors: function() {
return Errors.find();
}
});
~~~
<%= caption "client/templates/includes/errors.js" %>
你可以嘗試手動測試我們的新錯誤消息了。打開瀏覽器控制臺,并輸入:
~~~js
throwError("我就是一個錯誤!");
~~~
<%= screenshot "9-1", "測試錯誤消息。" %>
<%= commit "9-1", "基本的錯誤報告。" %>
<% note do %>
### 兩種類型的錯誤
在這一點上,重要的是要把“應用級(app-level)”的錯誤和“代碼級(code-level)”的錯誤區別開來。
**應用級**錯誤一般是由用戶觸發,用戶從而能夠對癥采取行動。這些包括像驗證錯誤、權限錯誤、“未找到”錯誤,等等。這是是那種你希望展現給用戶,以幫助他們解決他們剛剛遇到的任何問題的錯誤。
**代碼級**錯誤,作為另一種類型,是實際的代碼 bug 非期待情況下觸發的,你可能*不希望*將錯誤直接呈現給用戶,而是通過比如第三方錯誤跟蹤服務(比如 [Kadira](http://kadira.io))去跟蹤錯誤。
在本章中,我們將重點放在處理第一種類型的錯誤,而不是去抓蟲子(bug)。
<% end %>
### 創建錯誤
我們知道怎樣顯示錯誤,但我們還需要在發現之前去觸發錯誤。實際上我們已經建立了良好的錯誤情境:重復帖子的警告。我們簡單地用新的 `throwError` 函數去替代 `postSubmit` 事件 helper 中的 `alert` 調用:
~~~js
Template.postSubmit.events({
'submit form': function(e) {
e.preventDefault();
var post = {
url: $(e.target).find('[name=url]').val(),
title: $(e.target).find('[name=title]').val()
};
Meteor.call('postInsert', post, function(error, result) {
// display the error to the user and abort
if (error)
return throwError(error.reason);
// show this result but route anyway
if (result.postExists)
throwError('This link has already been posted');
Router.go('postPage', {_id: result._id});
});
}
});
~~~
<%= caption "client/templates/posts/post_submit.js" %>
<%= highlight "13,17" %>
既然到此,我們也針對 `postEdit` 事件 helper 做同樣的事情:
~~~js
Template.postEdit.events({
'submit form': function(e) {
e.preventDefault();
var currentPostId = this._id;
var postProperties = {
url: $(e.target).find('[name=url]').val(),
title: $(e.target).find('[name=title]').val()
}
Posts.update(currentPostId, {$set: postProperties}, function(error) {
if (error) {
// display the error to the user
throwError(error.reason);
} else {
Router.go('postPage', {_id: currentPostId});
}
});
},
//...
});
~~~
<%= caption "client/templates/posts/post_edit.js" %>
<%= highlight "15" %>
<%= commit "9-2", "實際使用錯誤報告。" %>
親自試一試:嘗試建立一個帖子并輸入 URL `http://meteor.com`。因為這個 URL 已經存在了,你可以看到:
<%= screenshot "9-2", "觸發一個錯誤" %>
### 清理錯誤
你會注意到錯誤消息在幾秒鐘后自動消失。這是因為本書開頭我們往樣式表中添加的一些 CSS 而產生的魔力:
~~~css
@keyframes fadeOut {
0% {opacity: 0;}
10% {opacity: 1;}
90% {opacity: 1;}
100% {opacity: 0;}
}
//...
.alert {
animation: fadeOut 2700ms ease-in 0s 1 forwards;
//...
}
~~~
<%= caption "client/stylesheets/style.css" %>
我們定義了一個有四幀透明度屬性變化(分別是 0%、10%、90% 和 100% 貫穿整個動畫過程)的 `fadeOut` CSS 動畫,并附在了 `.alert` class 樣式。
動畫時長為 2700 毫秒,使用 `ease-in` 效果,有 0 秒延遲,運行一次,當動畫完成時,最后停留在最后一幀。
<% note do %>
### 動畫 vs 動畫
你也許在想為什么我們使用基于 CSS 的動畫(預先定義,并且在我們應用控制以外),而不用 Meteor 本身來控制動畫。
雖然 Meteor 的確提供插入動畫的支持,但是我們想在本章專注于錯誤。所以我們現在使用“笨”CSS 動畫,我們把比較炫麗的東西留在以后的動畫章節。
<% end %>
這可以工作了,但是如果你要觸發多個錯誤(比如,通過提交三次同一個連接),你會看到錯誤信息會堆疊在一起:
<%= screenshot "9-3", "堆棧溢出。" %>
這是因為雖然 `.alert` 元素在視覺上消失了,但仍存留在 DOM 中。我們需要修正這個問題。
這正是 Meteor 發光的情形。由于 `Errors` 集合是響應性的,我們要做的就是將舊的錯誤從集合中刪除!
我們用 `Meteor.setTimeout` 指定在一定時間(當前情形,3000毫秒)后執行一個回調函數。
~~~js
Template.errors.helpers({
errors: function() {
return Errors.find();
}
});
Template.error.onRendered(function() {
var error = this.data;
Meteor.setTimeout(function () {
Errors.remove(error._id);
}, 3000);
});
~~~
<%= caption "client/templates/includes/errors.js" %>
<%= highlight "7~12" %>
<%= commit "9-3", "在3秒后清除錯誤消息。" %>
一旦模板在瀏覽器中渲染完畢,[`onRendered`](http://docs.meteor.com/#/full/template_onRendered) 回調函數被觸發。其中,`this` 是指當前模板實例,而 `this.data` 是當前被渲染的對象的數據(這種情況下是,一個錯誤)。
### 尋求驗證
到現在為止,我們還沒有對表單進行任何驗證。至少,我們想讓用戶為新帖子提供 URL 和標題。那么我們確保他們這么做。
我們要做兩件事:第一,我們給任何有問題的表單字段的父 `div` 標簽一個特別的 `has-error` CSS class。第二,我們在字段下方顯示一個有用的錯誤消息。
首先,我們要準備 `postSubmit` 模板來包含這些新 helper:
~~~html
<template name="postSubmit">
<form class="main form">
<div class="form-group {{errorClass 'url'}}">
<label class="control-label" for="url">URL</label>
<div class="controls">
<input name="url" id="url" type="text" value="" placeholder="Your URL" class="form-control"/>
<span class="help-block">{{errorMessage 'url'}}</span>
</div>
</div>
<div class="form-group {{errorClass 'title'}}">
<label class="control-label" for="title">Title</label>
<div class="controls">
<input name="title" id="title" type="text" value="" placeholder="Name your post" class="form-control"/>
<span class="help-block">{{errorMessage 'title'}}</span>
</div>
</div>
<input type="submit" value="Submit" class="btn btn-primary"/>
</form>
</template>
~~~
<%= caption "client/templates/posts/post_submit.html" %>
<%= highlight "3,7,10,14" %>
注意我們傳遞參數(分別是 `url` 和 `title`)到每個 helper。這讓我們兩次重復使用同一個 helper,基于參數修改它的行為。
現在到了有趣的部分:使這些 helper 真正做點什么事情。
我們會用會話 **Session** 去存儲包含任何潛在錯誤的 `postSubmitErrors` 對象。當用戶使用表單時,這個對象會改變,也就是響應性地更新表單代碼和內容。
首先,當 `postSubmit` 模板被創建時,我們初始化對象。這確保用戶不會看到上次訪問該頁面時遺留下的舊的錯誤消息。
然后定義我們的兩個模板 helper,緊盯 `Session.get('postSubmitErrors')` 的 `field` 屬性(`field` 指 `url` 或 `title` 取決于我們如何調用 helper)。
`errorMessage` 只是返回消息本身,而 `errorClass` 檢查消息是否*存在*,如果為真返回 `has-error`。
~~~js
Template.postSubmit.onCreated(function() {
Session.set('postSubmitErrors', {});
});
Template.postSubmit.helpers({
errorMessage: function(field) {
return Session.get('postSubmitErrors')[field];
},
errorClass: function (field) {
return !!Session.get('postSubmitErrors')[field] ? 'has-error' : '';
}
});
//...
~~~
<%= caption "client/templates/posts/post_submit.js" %>
<%= highlight "1~12" %>
你可以測試 helper 是否工作正常,打開瀏覽器控制臺并輸入以下代碼:
~~~js
Session.set('postSubmitErrors', {title: 'Warning! Intruder detected. Now releasing robo-dogs.'});
~~~
<%= caption "瀏覽器控制臺" %>
<%= screenshot "9-4", "紅色警告!Red alert!" %>
下一步將 `postSubmitErrors` Session 會話對象綁在表單上。
開始之前,我們在 `posts.js` 中添加一個新的 `validatePost` 函數來監視 `post` 對象,返回一個包含任何錯誤相關消息的(即,`title` 或 `url` 字段是否未填寫)`errors` 對象:
~~~js
//...
validatePost = function (post) {
var errors = {};
if (!post.title)
errors.title = "請填寫標題";
if (!post.url)
errors.url = "請填寫 URL";
return errors;
}
//...
~~~
<%= caption "lib/collections/posts.js" %>
<%= highlight "3~13" %>
我們通過 `postSubmit` 事件 helper 去調用這個函數:
~~~js
Template.postSubmit.events({
'submit form': function(e) {
e.preventDefault();
var post = {
url: $(e.target).find('[name=url]').val(),
title: $(e.target).find('[name=title]').val()
};
var errors = validatePost(post);
if (errors.title || errors.url)
return Session.set('postSubmitErrors', errors);
Meteor.call('postInsert', post, function(error, result) {
// 向用戶顯示錯誤信息并終止
if (error)
return throwError(error.reason);
// 顯示這個結果且繼續跳轉
if (result.postExists)
throwError('This link has already been posted');
Router.go('postPage', {_id: result._id});
});
}
});
~~~
<%= caption "client/templates/posts/post_submit.js" %>
<%= highlight "10~12" %>
注意如果出現任何錯誤,我們用 `return` 終止 helper 執行,而不是我們要實際地返回這個值。
<%= screenshot "9-5", "抓到錯誤." %>
### 服務器端驗證
我們還沒有完成。我們在*客戶端*驗證 URL 和標題是否存在,但是在*服務器端*呢?畢竟,還會有人仍然嘗試通過瀏覽器控制臺輸入一個空帖子來手動調用 `postInsert` 方法。
即使我們不需要在服務器端顯示任何錯誤消息,但是我們依然要利用好那個 `validatePost` 函數。除了這次我們在 `postInsert` *方法*內調用它,而不只是在事件 helper:
~~~js
Meteor.methods({
postInsert: function(postAttributes) {
check(this.userId, String);
check(postAttributes, {
title: String,
url: String
});
var errors = validatePost(postAttributes);
if (errors.title || errors.url)
throw new Meteor.Error('invalid-post', "你必須為你的帖子填寫標題和 URL");
var postWithSameLink = Posts.findOne({url: postAttributes.url});
if (postWithSameLink) {
return {
postExists: true,
_id: postWithSameLink._id
}
}
var user = Meteor.user();
var post = _.extend(postAttributes, {
userId: user._id,
author: user.username,
submitted: new Date()
});
var postId = Posts.insert(post);
return {
_id: postId
};
}
});
~~~
<%= caption "lib/collections/posts.js" %>
<%= highlight "9~11" %>
再次,用戶正常情況下不必看到“你必須 為你的帖子填寫標題和 URL”的消息。這僅會在當用戶想繞過我們煞費苦心創建的用戶界面而直接使用瀏覽器的情況下,才會顯示。
為了測試,打開瀏覽器控制臺,輸入一個沒有 URL 的帖子:
~~~js
Meteor.call('postInsert', {url: '', title: 'No URL here!'});
~~~
如果我們完成得順利的話,你會得到一堆嚇人的代碼 和“你必須為你的帖子填寫標題和 URL”的消息。
<%= commit "9-4", "驗證帖子提交內容。" %>
### 編輯驗證
為了更加完善,我們為帖子*編輯*表單添加相同的驗證。代碼看起來十分相似。首先,是模板:
~~~html
<template name="postEdit">
<form class="main form">
<div class="form-group {{errorClass 'url'}}">
<label class="control-label" for="url">URL</label>
<div class="controls">
<input name="url" id="url" type="text" value="{{url}}" placeholder="Your URL" class="form-control"/>
<span class="help-block">{{errorMessage 'url'}}</span>
</div>
</div>
<div class="form-group {{errorClass 'title'}}">
<label class="control-label" for="title">Title</label>
<div class="controls">
<input name="title" id="title" type="text" value="{{title}}" placeholder="Name your post" class="form-control"/>
<span class="help-block">{{errorMessage 'title'}}</span>
</div>
</div>
<input type="submit" value="Submit" class="btn btn-primary submit"/>
<hr/>
<a class="btn btn-danger delete" href="#">Delete post</a>
</form>
</template>
~~~
<%= caption "client/templates/posts/post_edit.html" %>
<%= highlight "3,7,10,14" %>
然后是模板 helper:
~~~js
Template.postEdit.onCreated(function() {
Session.set('postEditErrors', {});
});
Template.postEdit.helpers({
errorMessage: function(field) {
return Session.get('postEditErrors')[field];
},
errorClass: function (field) {
return !!Session.get('postEditErrors')[field] ? 'has-error' : '';
}
});
Template.postEdit.events({
'submit form': function(e) {
e.preventDefault();
var currentPostId = this._id;
var postProperties = {
url: $(e.target).find('[name=url]').val(),
title: $(e.target).find('[name=title]').val()
}
var errors = validatePost(postProperties);
if (errors.title || errors.url)
return Session.set('postEditErrors', errors);
Posts.update(currentPostId, {$set: postProperties}, function(error) {
if (error) {
// 向用戶顯示錯誤消息
throwError(error.reason);
} else {
Router.go('postPage', {_id: currentPostId});
}
});
},
'click .delete': function(e) {
e.preventDefault();
if (confirm("Delete this post?")) {
var currentPostId = this._id;
Posts.remove(currentPostId);
Router.go('postsList');
}
}
});
~~~
<%= caption "client/templates/posts/post_edit.js" %>
<%= highlight "1~12,25~27,32" %>
就像我們為帖子提交表單所做的,我們也想在服務器端驗證帖子。請記住我們不是在用一個方法去編輯帖子,而是直接從客戶端的 `update` 調用。
這意味著我們必須添加一個新的 `deny` 回調函數:
~~~js
//...
Posts.deny({
update: function(userId, post, fieldNames, modifier) {
var errors = validatePost(modifier.$set);
return errors.title || errors.url;
}
});
//...
~~~
<%= caption "lib/collections/posts.js" %>
<%= highlight "3~8" %>
注意的是參數 `post` 是指*已存在的*帖子。我們想驗證*更新*,所以我們在 `modifier` 的 `$set` 屬性中調用 `validatePost`(就像是 `Posts.update({$set: {title: ..., url: ...}})`)。
這會正常運行,因為 `modifier.$set` 像整個 `post` 對象那樣包含同樣兩個 `title` 和 `url` 屬性。當然,這也的確意味著只部分更新 `title` 或者 `url` 是不行的,但是實踐中不應有問題。
你也許注意到,這是我們第二個 `deny` 回調。當添加多個 `deny` 回調時,如果任何一個回調返回 `true`,運行就會失敗。在此例中,這意味著 `update` 只有在面向 `title` 和 `url` 兩個字段時才會成功,并且這些字段不能為空。
<%= commit "9-5", "當編輯時,驗證帖子內容。" %>