Meteor 錯誤

2022-06-30 13:58 更新

錯誤

僅使用瀏覽器標(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)心 allowdeny 或其他任何的安全考慮,因?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)建 errorserror 模版:

<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">&times;</button>
    {{message}}
  </div>
</template>

Twin 模版

你可能注意到我們在一個文件里面建立了兩個模板。直到現(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)。

創(chuàng)建錯誤

我們知道怎樣顯示錯誤,但我們還需要在發(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)動畫完成時,最后停留在最后一幀。

動畫 vs 動畫

你也許在想為什么我們使用基于 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ù)(這種情況下是,一個錯誤)。

尋求驗(yàn)證

到現(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ù)(分別是 urltitle)到每個 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 屬性(fieldurltitle 取決于我們?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)消息的(即,titleurl 字段是否未填寫)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í)際地返回這個值。

服務(wù)器端驗(yàn)證

我們還沒有完成。我們在客戶端驗(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àn)證

為了更加完善,我們?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 對象那樣包含同樣兩個 titleurl 屬性。當(dāng)然,這也的確意味著只部分更新 title 或者 url 是不行的,但是實(shí)踐中不應(yīng)有問題。

你也許注意到,這是我們第二個 deny 回調(diào)。當(dāng)添加多個 deny 回調(diào)時,如果任何一個回調(diào)返回 true,運(yùn)行就會失敗。在此例中,這意味著 update 只有在面向 titleurl 兩個字段時才會成功,并且這些字段不能為空。

以上內(nèi)容是否對您有幫助:
在線筆記
App下載
App下載

掃描二維碼

下載編程獅App

公眾號
微信公眾號

編程獅公眾號