-

错误

仅使用浏览器标准的 alert() 对话窗去警告用户他们的提交有错误有那么一点不令人满意,而且显然不是一个良好的用户体验。我们可以做得更好。

相反,让我们建立一个更加灵活的错误报告机制,来更好地在不打断流程的情况下告诉用户到底发生了什么。

我们要实现一个简单的系统,在窗口右上角显示新的错误信息,类似于流行的 Mac OS 应用程序 Growl

介绍本地集合

一开始,我们需要创建一个集合来存储我们的错误。既然错误只与当前会话相关,而且不需要以任何方式长久存在,我们要在这做点新鲜的事儿,创建一个本地集合(Local collection)。这意味着,错误 Errors 集合将会只存在于浏览器中,并且将不作任何尝试去同步回服务器。

为实现它,我们在 client 文件夹中创建错误(确保这集合只在客户端存在),我们将它的 MongoDB 集合命名为 null (因为集合的数据将不会保存在服务器端的数据库中):

// 本地(仅客户端)集合
Errors = new Mongo.Collection(null);

一开始,我们应该建立一个可以储存错误的集合。介于错误只是对于当前的会话,我们将采用及时性集合。这就意味着错误集合只存在于当前的浏览器,该集合不会与服务端同步。

既然集合已经建立了,我们可以创建一个 throwError 函数用来添加新的错误。我们不需要担心 allowdeny 或其他任何的安全考虑,因为这个集合对于当前用户是“本地的”。

throwError = function(message) {
  Errors.insert({message: message});
};

使用本地集合去存储错误的优势在于,就像所有集合一样,它是响应性的————意味着我们可以以显示其他任何集合数据的同样的方式,去响应性地显示错误。

显示错误

我们将在主布局的顶部插入错误信息:

<template name="layout">
  <div class="container">
    {{> header}}
    {{> errors}}
    <div id="main" class="row-fluid">
      {{> yield}}
    </div>
  </div>
</template>

让我们现在在 errors.html 中创建 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 模版

你可能注意到我们在一个文件里面建立了两个模板。直到现在我们一直在遵循“一个文件, 一个模板”的标准,但对于 Meteor 而言,我们把所有模板放在同一个文件里也是一样的(但是这会让 main.html 的代码变得非常混乱!)。

在当前情况下,因为这两个错误模板都比较小,我们破例将它们放在一个文件里,使我们的 repo 代码库更干净些。

我们只需要加上我们的模板 helper 就可以大功告成了!

Template.errors.helpers({
  errors: function() {
    return Errors.find();
  }
});

你可以尝试手动测试我们的新错误消息了。打开浏览器控制台,并输入:

throwError("我就是一个错误!");

两种类型的错误

在这一点上,重要的是要把“应用级(app-level)”的错误和“代码级(code-level)”的错误区别开来。

应用级错误一般是由用户触发,用户从而能够对症采取行动。这些包括像验证错误、权限错误、“未找到”错误,等等。这是是那种你希望展现给用户,以帮助他们解决他们刚刚遇到的任何问题的错误。

代码级错误,作为另一种类型,是实际的代码 bug 非期待情况下触发的,你可能不希望将错误直接呈现给用户,而是通过比如第三方错误跟踪服务(比如 Kadira)去跟踪错误。

在本章中,我们将重点放在处理第一种类型的错误,而不是去抓虫子(bug)。

创建错误

我们知道怎样显示错误,但我们还需要在发现之前去触发错误。实际上我们已经建立了良好的错误情境:重复帖子的警告。我们简单地用新的 throwError 函数去替代 postSubmit 事件 helper 中的 alert 调用:

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。因为这个 URL 已经存在了,你可以看到:

清理错误

你会注意到错误消息在几秒钟后自动消失。这是因为本书开头我们往样式表中添加的一些 CSS 而产生的魔力:

@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 秒延迟,运行一次,当动画完成时,最后停留在最后一帧。

动画 vs 动画

你也许在想为什么我们使用基于 CSS 的动画(预先定义,并且在我们应用控制以外),而不用 Meteor 本身来控制动画。

虽然 Meteor 的确提供插入动画的支持,但是我们想在本章专注于错误。所以我们现在使用“笨”CSS 动画,我们把比较炫丽的东西留在以后的动画章节。

这可以工作了,但是如果你要触发多个错误(比如,通过提交三次同一个连接),你会看到错误信息会堆叠在一起。

这是因为虽然 .alert 元素在视觉上消失了,但仍存留在 DOM 中。我们需要修正这个问题。

这正是 Meteor 发光的情形。由于 Errors 集合是响应性的,我们要做的就是将旧的错误从集合中删除!

我们用 Meteor.setTimeout 指定在一定时间(当前情形,3000毫秒)后执行一个回调函数。

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 回调函数被触发。其中,this 是指当前模板实例,而 this.data 是当前被渲染的对象的数据(这种情况下是,一个错误)。

寻求验证

到现在为止,我们还没有对表单进行任何验证。至少,我们想让用户为新帖子提供 URL 和标题。那么我们确保他们这么做。

我们要做两件事:第一,我们给任何有问题的表单字段的父 div 标签一个特别的 has-error CSS class。第二,我们在字段下方显示一个有用的错误消息。

首先,我们要准备 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>

注意我们传递参数(分别是 urltitle)到每个 helper。这让我们两次重复使用同一个 helper,基于参数修改它的行为。

现在到了有趣的部分:使这些 helper 真正做点什么事情。

我们会用会话 Session 去存储包含任何潜在错误的 postSubmitErrors 对象。当用户使用表单时,这个对象会改变,也就是响应性地更新表单代码和内容。

首先,当 postSubmit 模板被创建时,我们初始化对象。这确保用户不会看到上次访问该页面时遗留下的旧的错误消息。

然后定义我们的两个模板 helper,紧盯 Session.get('postSubmitErrors')field 属性(fieldurltitle 取决于我们如何调用 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 函数来监视 post 对象,返回一个包含任何错误相关消息的(即,titleurl 字段是否未填写)errors 对象:

//...

validatePost = function (post) {
  var errors = {};

  if (!post.title)
    errors.title = "请填写标题";

  if (!post.url)
    errors.url =  "请填写 URL";

  return errors;
}

//...

我们通过 postSubmit 事件 helper 去调用这个函数:

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});
    });
  }
});

注意如果出现任何错误,我们用 return 终止 helper 执行,而不是我们要实际地返回这个值。

服务器端验证

我们还没有完成。我们在客户端验证 URL 和标题是否存在,但是在服务器端呢?毕竟,还会有人仍然尝试通过浏览器控制台输入一个空帖子来手动调用 postInsert 方法。

即使我们不需要在服务器端显示任何错误消息,但是我们依然要利用好那个 validatePost 函数。除了这次我们在 postInsert 方法内调用它,而不只是在事件 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', "你必须为你的帖子填写标题和 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
    };
  }
});

再次,用户正常情况下不必看到“你必须 为你的帖子填写标题和 URL”的消息。这仅会在当用户想绕过我们煞费苦心创建的用户界面而直接使用浏览器的情况下,才会显示。

为了测试,打开浏览器控制台,输入一个没有 URL 的帖子:

Meteor.call('postInsert', {url: '', title: 'No URL here!'});

如果我们完成得顺利的话,你会得到一堆吓人的代码 和“你必须为你的帖子填写标题和 URL”的消息。

编辑验证

为了更加完善,我们为帖子编辑表单添加相同的验证。代码看起来十分相似。首先,是模板:

<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');
    }
  }
});

就像我们为帖子提交表单所做的,我们也想在服务器端验证帖子。请记住我们不是在用一个方法去编辑帖子,而是直接从客户端的 update 调用。

这意味着我们必须添加一个新的 deny 回调函数:

//...

Posts.deny({
  update: function(userId, post, fieldNames, modifier) {
    var errors = validatePost(modifier.$set);
    return errors.title || errors.url;
  }
});

//...

注意的是参数 post 是指已存在的帖子。我们想验证更新,所以我们在 modifier$set 属性中调用 validatePost(就像是 Posts.update({$set: {title: ..., url: ...}}))。

这会正常运行,因为 modifier.$set 像整个 post 对象那样包含同样两个 titleurl 属性。当然,这也的确意味着只部分更新 title 或者 url 是不行的,但是实践中不应有问题。

你也许注意到,这是我们第二个 deny 回调。当添加多个 deny 回调时,如果任何一个回调返回 true,运行就会失败。在此例中,这意味着 update 只有在面向 titleurl 两个字段时才会成功,并且这些字段不能为空。