事件触发的一个细节设计
前端开发过程中,事件机制无处不在。比如使用 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 等,却依旧坚持停止执行。并非是他们不知道,而是这几个类库的作者,选择了停止执行策略。
这两种策略的主要分歧在于:
-
继续执行策略觉得,继续执行是对 handlers 之间无依赖的更好保障。如果停止执行,就破坏了无依赖性,使得后面 handlers 的执行依赖前面 handlers 的无异常性。
-
停止执行策略觉得,发生异常时,已经超出了无依赖性的讨论范畴。在类库里面
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 更多是觉得这应该是用户范畴的事,类库不应该处理。
举个例子,对于支付宝来说,由于支付操作涉及用户金额,有可能存在以下可能性:
- handler A 检查校验码,有可能通过,有可能不通过。通过时,会设置某个校验标识为 true 。
- handler B 提交支付请求,提交前会检查是否通过校验。
- 当 handler A 出错时,校验标识有可能是旧值,也有可能被设置成错误值。
- 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 里,目前依旧发现有少量功能不应该提供,打算在接下来的版本里进一步去掉。
少即是多,确定边界对类库框架来说非常非常重要。
小结
对于展现型页面来说,采用浏览器的继续执行策略,个人觉得是合理的。
对于功能型页面来说,特别是涉及复杂系统时,基础类库中应该尽量少做一些事情,把更多的决定权交给用户。
也许无法说服你,其实也不需要达成某个最终结论。不同应用中,这两种策略都有合适的使用场景。