僅使用瀏覽器標(biāo)準(zhǔn)的 alert()
對話窗去警告用戶他們的提交有錯誤有那么一點(diǎn)不令人滿意,而且顯然不是一個良好的用戶體驗(yàn)。我們可以做得更好。
相反,讓我們建立一個更加靈活的錯誤報告機(jī)制,來更好地在不打斷流程的情況下告訴用戶到底發(fā)生了什么。
我們要實(shí)現(xiàn)一個簡單的系統(tǒng),在窗口右上角顯示新的錯誤信息,類似于流行的 Mac OS 應(yīng)用程序 Growl。
一開始,我們需要創(chuàng)建一個集合來存儲我們的錯誤。既然錯誤只與當(dāng)前會話相關(guān),而且不需要以任何方式長久存在,我們要在這做點(diǎn)新鮮的事兒,創(chuàng)建一個本地集合(Local collection)。這意味著,錯誤 Errors
集合將會只存在于瀏覽器中,并且將不作任何嘗試去同步回服務(wù)器。
為實(shí)現(xiàn)它,我們在 client
文件夾中創(chuàng)建錯誤(確保這集合只在客戶端存在),我們將它的 MongoDB 集合命名為 null
(因?yàn)榧系臄?shù)據(jù)將不會保存在服務(wù)器端的數(shù)據(jù)庫中):
// 本地(僅客戶端)集合
Errors = new Mongo.Collection(null);
一開始,我們應(yīng)該建立一個可以儲存錯誤的集合。介于錯誤只是對于當(dāng)前的會話,我們將采用及時性集合。這就意味著錯誤集合只存在于當(dāng)前的瀏覽器,該集合不會與服務(wù)端同步。
既然集合已經(jīng)建立了,我們可以創(chuàng)建一個 throwError
函數(shù)用來添加新的錯誤。我們不需要擔(dān)心 allow
和 deny
或其他任何的安全考慮,因?yàn)檫@個集合對于當(dāng)前用戶是“本地的”。
throwError = function(message) {
Errors.insert({message: message});
};
使用本地集合去存儲錯誤的優(yōu)勢在于,就像所有集合一樣,它是響應(yīng)性的————意味著我們可以以顯示其他任何集合數(shù)據(jù)的同樣的方式,去響應(yīng)性地顯示錯誤。
我們將在主布局的頂部插入錯誤信息:
<template name="layout">
<div class="container">
{{> header}}
{{> errors}}
<div id="main" class="row-fluid">
{{> yield}}
</div>
</div>
</template>
讓我們現(xiàn)在在 errors.html
中創(chuàng)建 errors
和 error
模版:
<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>
你可能注意到我們在一個文件里面建立了兩個模板。直到現(xiàn)在我們一直在遵循“一個文件, 一個模板”的標(biāo)準(zhǔn),但對于 Meteor 而言,我們把所有模板放在同一個文件里也是一樣的(但是這會讓 main.html
的代碼變得非?;靵y?。?/p>
在當(dāng)前情況下,因?yàn)檫@兩個錯誤模板都比較小,我們破例將它們放在一個文件里,使我們的 repo 代碼庫更干凈些。
我們只需要加上我們的模板 helper 就可以大功告成了!
Template.errors.helpers({
errors: function() {
return Errors.find();
}
});
你可以嘗試手動測試我們的新錯誤消息了。打開瀏覽器控制臺,并輸入:
throwError("我就是一個錯誤!");
在這一點(diǎn)上,重要的是要把“應(yīng)用級(app-level)”的錯誤和“代碼級(code-level)”的錯誤區(qū)別開來。
應(yīng)用級錯誤一般是由用戶觸發(fā),用戶從而能夠?qū)ΠY采取行動。這些包括像驗(yàn)證錯誤、權(quán)限錯誤、“未找到”錯誤,等等。這是是那種你希望展現(xiàn)給用戶,以幫助他們解決他們剛剛遇到的任何問題的錯誤。
代碼級錯誤,作為另一種類型,是實(shí)際的代碼 bug 非期待情況下觸發(fā)的,你可能不希望將錯誤直接呈現(xiàn)給用戶,而是通過比如第三方錯誤跟蹤服務(wù)(比如 Kadira)去跟蹤錯誤。
在本章中,我們將重點(diǎn)放在處理第一種類型的錯誤,而不是去抓蟲子(bug)。
我們知道怎樣顯示錯誤,但我們還需要在發(fā)現(xiàn)之前去觸發(fā)錯誤。實(shí)際上我們已經(jīng)建立了良好的錯誤情境:重復(fù)帖子的警告。我們簡單地用新的 throwError
函數(shù)去替代 postSubmit
事件 helper 中的 alert
調(diào)用:
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});
});
}
});
既然到此,我們也針對 postEdit
事件 helper 做同樣的事情:
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});
}
});
},
//...
});
親自試一試:嘗試建立一個帖子并輸入 URL http://meteor.com
。因?yàn)檫@個 URL 已經(jīng)存在了,你可以看到:
你會注意到錯誤消息在幾秒鐘后自動消失。這是因?yàn)楸緯_頭我們往樣式表中添加的一些 CSS 而產(chǎn)生的魔力:
@keyframes fadeOut {
0% {opacity: 0;}
10% {opacity: 1;}
90% {opacity: 1;}
100% {opacity: 0;}
}
//...
.alert {
animation: fadeOut 2700ms ease-in 0s 1 forwards;
//...
}
我們定義了一個有四幀透明度屬性變化(分別是 0%、10%、90% 和 100% 貫穿整個動畫過程)的 fadeOut
CSS 動畫,并附在了 .alert
class 樣式。
動畫時長為 2700 毫秒,使用 ease-in
效果,有 0 秒延遲,運(yùn)行一次,當(dāng)動畫完成時,最后停留在最后一幀。
你也許在想為什么我們使用基于 CSS 的動畫(預(yù)先定義,并且在我們應(yīng)用控制以外),而不用 Meteor 本身來控制動畫。
雖然 Meteor 的確提供插入動畫的支持,但是我們想在本章專注于錯誤。所以我們現(xiàn)在使用“笨”CSS 動畫,我們把比較炫麗的東西留在以后的動畫章節(jié)。
這可以工作了,但是如果你要觸發(fā)多個錯誤(比如,通過提交三次同一個連接),你會看到錯誤信息會堆疊在一起。
這是因?yàn)殡m然 .alert
元素在視覺上消失了,但仍存留在 DOM 中。我們需要修正這個問題。
這正是 Meteor 發(fā)光的情形。由于 Errors
集合是響應(yīng)性的,我們要做的就是將舊的錯誤從集合中刪除!
我們用 Meteor.setTimeout
指定在一定時間(當(dāng)前情形,3000毫秒)后執(zhí)行一個回調(diào)函數(shù)。
Template.errors.helpers({
errors: function() {
return Errors.find();
}
});
Template.error.onRendered(function() {
var error = this.data;
Meteor.setTimeout(function () {
Errors.remove(error._id);
}, 3000);
});
一旦模板在瀏覽器中渲染完畢,onRendered
回調(diào)函數(shù)被觸發(fā)。其中,this
是指當(dāng)前模板實(shí)例,而 this.data
是當(dāng)前被渲染的對象的數(shù)據(jù)(這種情況下是,一個錯誤)。
到現(xiàn)在為止,我們還沒有對表單進(jìn)行任何驗(yàn)證。至少,我們想讓用戶為新帖子提供 URL 和標(biāo)題。那么我們確保他們這么做。
我們要做兩件事:第一,我們給任何有問題的表單字段的父 div
標(biāo)簽一個特別的 has-error
CSS class。第二,我們在字段下方顯示一個有用的錯誤消息。
首先,我們要準(zhǔn)備 postSubmit
模板來包含這些新 helper:
<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>
注意我們傳遞參數(shù)(分別是 url
和 title
)到每個 helper。這讓我們兩次重復(fù)使用同一個 helper,基于參數(shù)修改它的行為。
現(xiàn)在到了有趣的部分:使這些 helper 真正做點(diǎn)什么事情。
我們會用會話 Session 去存儲包含任何潛在錯誤的 postSubmitErrors
對象。當(dāng)用戶使用表單時,這個對象會改變,也就是響應(yīng)性地更新表單代碼和內(nèi)容。
首先,當(dāng) postSubmit
模板被創(chuàng)建時,我們初始化對象。這確保用戶不會看到上次訪問該頁面時遺留下的舊的錯誤消息。
然后定義我們的兩個模板 helper,緊盯 Session.get('postSubmitErrors')
的 field
屬性(field
指 url
或 title
取決于我們?nèi)绾握{(diào)用 helper)。
errorMessage
只是返回消息本身,而 errorClass
檢查消息是否存在,如果為真返回 has-error
。
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' : '';
}
});
//...
你可以測試 helper 是否工作正常,打開瀏覽器控制臺并輸入以下代碼:
Session.set('postSubmitErrors', {title: 'Warning! Intruder detected. Now releasing robo-dogs.'});
下一步將 postSubmitErrors
Session 會話對象綁在表單上。
開始之前,我們在 posts.js
中添加一個新的 validatePost
函數(shù)來監(jiān)視 post
對象,返回一個包含任何錯誤相關(guān)消息的(即,title
或 url
字段是否未填寫)errors
對象:
//...
validatePost = function (post) {
var errors = {};
if (!post.title)
errors.title = "請?zhí)顚憳?biāo)題";
if (!post.url)
errors.url = "請?zhí)顚?URL";
return errors;
}
//...
我們通過 postSubmit
事件 helper 去調(diào)用這個函數(shù):
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);
// 顯示這個結(jié)果且繼續(xù)跳轉(zhuǎn)
if (result.postExists)
throwError('This link has already been posted');
Router.go('postPage', {_id: result._id});
});
}
});
注意如果出現(xiàn)任何錯誤,我們用 return
終止 helper 執(zhí)行,而不是我們要實(shí)際地返回這個值。
我們還沒有完成。我們在客戶端驗(yàn)證 URL 和標(biāo)題是否存在,但是在服務(wù)器端呢?畢竟,還會有人仍然嘗試通過瀏覽器控制臺輸入一個空帖子來手動調(diào)用 postInsert
方法。
即使我們不需要在服務(wù)器端顯示任何錯誤消息,但是我們依然要利用好那個 validatePost
函數(shù)。除了這次我們在 postInsert
方法內(nèi)調(diào)用它,而不只是在事件 helper:
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', "你必須為你的帖子填寫標(biāo)題和 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
};
}
});
再次,用戶正常情況下不必看到“你必須 為你的帖子填寫標(biāo)題和 URL”的消息。這僅會在當(dāng)用戶想繞過我們煞費(fèi)苦心創(chuàng)建的用戶界面而直接使用瀏覽器的情況下,才會顯示。
為了測試,打開瀏覽器控制臺,輸入一個沒有 URL 的帖子:
Meteor.call('postInsert', {url: '', title: 'No URL here!'});
如果我們完成得順利的話,你會得到一堆嚇人的代碼 和“你必須為你的帖子填寫標(biāo)題和 URL”的消息。
為了更加完善,我們?yōu)樘?em>編輯表單添加相同的驗(yàn)證。代碼看起來十分相似。首先,是模板:
<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>
然后是模板 helper:
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');
}
}
});
就像我們?yōu)樘犹峤槐韱嗡龅?,我們也想在服?wù)器端驗(yàn)證帖子。請記住我們不是在用一個方法去編輯帖子,而是直接從客戶端的 update
調(diào)用。
這意味著我們必須添加一個新的 deny
回調(diào)函數(shù):
//...
Posts.deny({
update: function(userId, post, fieldNames, modifier) {
var errors = validatePost(modifier.$set);
return errors.title || errors.url;
}
});
//...
注意的是參數(shù) post
是指已存在的帖子。我們想驗(yàn)證更新,所以我們在 modifier
的 $set
屬性中調(diào)用 validatePost
(就像是 Posts.update({$set: {title: ..., url: ...}})
)。
這會正常運(yùn)行,因?yàn)?modifier.$set
像整個 post
對象那樣包含同樣兩個 title
和 url
屬性。當(dāng)然,這也的確意味著只部分更新 title
或者 url
是不行的,但是實(shí)踐中不應(yīng)有問題。
你也許注意到,這是我們第二個 deny
回調(diào)。當(dāng)添加多個 deny
回調(diào)時,如果任何一個回調(diào)返回 true
,運(yùn)行就會失敗。在此例中,這意味著 update
只有在面向 title
和 url
兩個字段時才會成功,并且這些字段不能為空。
更多建議: