事件触发的一个细节设计

前端开发过程中,事件机制无处不在。比如使用 jQuery 添加 DOM 事件:

$(document).click(function() {
  console.log(1);
});

$(document).click(function() {
  console.log(2);
});

当点击 document 时,控制台中会按照预期输出 1 和 2 。

问题来了:

$(document).click(function() {
  console.log(1);
  DOES_NOT_EXIST++;
});

$(document).click(function() {
  console.log(2);
});

以上代码,点击 document 时,控制台中会输出:

1
Uncaught ReferenceError: DOES_NOT_EXIST is not defined

输出了 1,然后抛了一个异常,没有输出 2 。

如果使用浏览器自身的 addEventListener 注册:

document.addEventListener('click', function() {
  console.log(1);
  DOES_NOT_EXIST++;
}, false);

document.addEventListener('click', function() {
  console.log(2);
}, false);

当点击 document 时,在 Chrome 下,控制台中会输出:

1
Uncaught ReferenceError: DOES_NOT_EXIST is not defined
2

很明显,当 handler 中有异常时,浏览器的 addEventListener 与 jQuery 的处理方式不一样:

  • 继续执行策略:浏览器会抛出异常,然后继续执行其他 handlers 。
  • 停止执行策略:jQuery 会抛出异常,然后停止执行其他 handlers 。

继续讨论前,先留一个小作业:大家可以研究下 YUI、MooTools、Prototype、Dojo 等等类库框架的处理策略。回复给我,全答对者,明天有惊喜。

对于继续执行策略,核心理念是: 事件 handlers 之间应该彼此无依赖,即便有异常也不能影响其他 handlers 的执行。实现上可以通过 try catch 或 setTimeout 等方式,来确保一粒老鼠屎不会坏掉一锅汤。这个理念有很多人、很多类库框架支持。

对于停止执行策略,核心理念是: 事件 handlers 之间应该彼此无依赖,但当某个 handler 异常时,不应该假装没事一样,继续执行其他 handlers 。这个理念也有很多人、很多类库框架支持。因为掉进锅里的老鼠屎很可能有毒,一旦发现了,最明智的做法是别让大家喝了。

无论是继续执行还是停止执行,都同意事件 handlers 之间应该彼此无依赖,这一点上无分歧。但涉及异常时,两种理念下的策略迥异。

这两种处理策略,究竟哪种更好呢?你的想法是怎样的?

两种策略的分歧

同一个事件的 handlers 在触发过程中,当执行某个 handler 发生异常时,昨天提到有两个处理策略:继续执行和停止执行。

目前支持继续执行的类库框架有:MooTools、Prototype、Dojo
目前支持停止执行的类库框架有:YUI3、jQuery、Backbone

这个列表不能说明什么,但值得注意的是,这个问题在 2009 年时,JavaScript 大神 Dean Edwards 就在 Callbacks vs Events 一文中提出过,并且给出了一个非常 Geek 的解决方案。

我印象中,Prototype 等类库,就是在 Dean Edwards 指出这个问题后,将策略修改成了继续执行。

然而,目前更流行的几个类库 jQuery、YUI3 包括新秀 Backbone 等,却依旧坚持停止执行。并非是他们不知道,而是这几个类库的作者,选择了停止执行策略。

这两种策略的主要分歧在于:

  1. 继续执行策略觉得,继续执行是对 handlers 之间无依赖的更好保障。如果停止执行,就破坏了无依赖性,使得后面 handlers 的执行依赖前面 handlers 的无异常性。

  2. 停止执行策略觉得,发生异常时,已经超出了无依赖性的讨论范畴。在类库里面 try catch 或通过其他方式处理都不是最佳解决方式,这应该交给用户去解决,属于 user-land 范畴。

Backbone 作者 Brad Dunbar 的 观点 如下:

While I understand your concern, suppressing errors inside event handlers is a much worse behavior than skipping the rest of the handlers. When something fails, you want to know immediately, not continue as though nothing happened.

大意是说:

与停止执行相比,在事件处理器中抑制错误是一种更糟糕的行为。当某些事情不对时,就应该立刻知道,而不是装着什么也没发生一样继续执行。

jQuery 开发者也有类似的 观点

In order to continue subsequent callbacks, jQuery would have to catch the error, which is not a good solution. If an error is acceptable, a try/catch can be implemented by the user.

大意是:

为了继续执行回调,jQuery 需要捕获错误,这并不是一个好的解决方案。如果某个错误是可以容忍的,那么应该由用户通过 try / catch 去实现。

放在场景中思考

但为什么浏览器的默认行为是继续执行呢?

我的想法是,得分场景来说:展现型页面和功能型页面。

对于展现型页面,比如淘宝首页,页面某一个区域出问题时,最好不要影响其他区域的展现。因为一般来说,各个区域之间不会有依赖。感觉这也是浏览器设计之初,采取继续执行策略的初衷。这个初衷还体现在,当某个 script 块的代码发生异常时,不会影响其他独立 script 块的执行。

对于功能型页面来说,比如 Gmail,当页面某一个区域出问题时,经常意味着底层数据或网络出了问题,这时最好的处理方式是,都停下来,统一给出错误或重试提示,而不是继续进行操作。因为操作已经不可预期,很可能造成不必要甚至错误的操作,比如发出一封错误的邮件等等。

无依赖很难

Backbone 的使用场景应该是功能型页面,因此非常坚持采用停止执行策略。类似 YUI3 也是如此。jQuery 更多是觉得这应该是用户范畴的事,类库不应该处理。

举个例子,对于支付宝来说,由于支付操作涉及用户金额,有可能存在以下可能性:

  1. handler A 检查校验码,有可能通过,有可能不通过。通过时,会设置某个校验标识为 true 。
  2. handler B 提交支付请求,提交前会检查是否通过校验。
  3. 当 handler A 出错时,校验标识有可能是旧值,也有可能被设置成错误值。
  4. handler B 并不依赖 handler A,但依赖校验标识。当 handler A 出错时,校验标识无论是什么值,都已经不可靠,即便是校验通过,也不应该提交支付请求。

这就是说,对于功能型页面来说,一旦有代码错误(不一定是 handler 引发的),就应该尽可能做到停止代码执行,并告知用户出了问题。

这就如一锅汤,一旦滴进了一滴毒药,只要发现有一个人中毒了,最明智的做法就是立刻不再继续把汤盛给其他人,否则毒死一批人,罪孽就大了。

问题的核心是,要判断滴进汤里的是毒药,还是仅仅是一粒沙子。对展现型页面来说,经常是沙子,无伤大雅,但对功能型页面来说,我情愿假设都是毒药,应立刻告知所有人并停止喝汤。

这个例子的背后,还能让我们看到无依赖的 handlers 之间并不一定无依赖。由于代码运行在同一个环境下,有可能共享同一份数据。对于前端代码来说,明显共享的是同一份 DOM 树。这样,当某个 handler 出了问题后,很可能共享的数据、DOM 树已经不可靠。继续执行其他 handlers,很可能已经是在一个不可靠的环境中去运行代码。后续代码已经不可控,特别是对于复杂系统来说。

对于复杂系统,try / catch 并不能保障无依赖性。因为环境的复杂性,继续执行反而可能带来后续的不可控性。

范畴很重要

Backbone 和 jQuery 社区中,这个问题其实被反复提出过,Arale 中也被 提出过。但我始终觉得,在基础类库中去try / catch 并不是最佳解决方案。不光不是最佳方案,更重要的是,这件事,不应该属于类库去解决的,而应该是用户需要去考虑的。

比如,如果用户担心某个 handler 有可能会出问题,那么这个 handler 在可能出问题的地方,本就应该自行try / catch,由用户去负责。对于复杂系统,对于不放心的 handlers,可以通过工厂模式自动封装。比如很多游戏的代码里,会做类似的错误异常统一处理。但具体应该对哪些 handlers 封装异常,由具体游戏的开发者决定。

还有一个有意思的是,少就是多。类库做得越少(保持完整性),用户能做的反而越多。假设类库封装了 handler 的异常,那么对于那些想采取停止执行策略的场景来说,就很不好实现了。反之,则用户自行封装就好。

Sea.js 从 1.x 升级到 2.0,最核心的一个思考就是缩减范畴,不断思考 Sea.js 应该做什么,不应该做什么,砍掉了大量功能,增加了少量功能,目前看起来还是挺不错的。但即便经过半年的升级后,Sea.js 2.0 里,目前依旧发现有少量功能不应该提供,打算在接下来的版本里进一步去掉。

少即是多,确定边界对类库框架来说非常非常重要。

小结

对于展现型页面来说,采用浏览器的继续执行策略,个人觉得是合理的。

对于功能型页面来说,特别是涉及复杂系统时,基础类库中应该尽量少做一些事情,把更多的决定权交给用户。

也许无法说服你,其实也不需要达成某个最终结论。不同应用中,这两种策略都有合适的使用场景。

原文地址:https://www.cnblogs.com/fengyuqing/p/event_trigger.html