2014年3月18日星期二

了解并改变你的习惯

最近看了一本书叫做《The Power of Habit》,觉得书本身写得超好,故事和道理穿插的写法让你好像追小说一样没办法把书(或者是 Kindle)放下来,最终你理解了书中所讲的道理,但却不会像阅读一本讲道理的书一样疲劳。这本书说了很多跟习惯有关的研究和案例,解释了习惯是如何形成的,讲述了商业社会如何利用习惯改变员工改变顾客,最后还讨论了一下习惯在社会层面的影响。

书的开头通过一个叫做 Eugene Pauly(简称 E.P.)的病人引入人类对习惯形成的研究。E.P. 曾患脑炎,治愈后大脑留下了不可修复的损伤,他因此无法获得新的长期记忆。但因为他还能获得短期记忆,也就是记住最近 15 分钟发生的事情,同时他还拥有脑炎之前的长期记忆。尽管 E.P. 之后的生活变得十分奇怪,例如他会起床吃早餐然后回到床上,过一会儿又再起床吃早餐,但研究人员却在他身上发现了「奇迹」。

E.P. 自己记不住新家在哪里,所以每天只能由妻子带着在家附近绕圈散步。有一天妻子穿好衣服正准备出门时发现 E.P. 不见了,就只能挨家挨户地去找,结果回到家后却发现 E.P. 在看电视。问他发生了什么事他当然说不知道,因为他记不住 15 分钟以前的事情,但桌上的松果和他手上的泥证明他是出去回来了。之后他妻子试着跟踪他,发现他能够沿着他们每天散步的路线自己走回来,但前提是路线上的关键特征不变。之后研究人员也发现了同样的情况。在 E.P. 家客厅问他「厨房在哪」,他会说不知道。但如果让他去拿点坚果过来,他就能跑到厨房里拿坚果。显然 E.P. 在无法产生新的长期记忆的前提下,他的大脑仍然能够学习新的知识。这其实就是新习惯的形成。

那在习惯形成的过程中,大脑到底发生了什么变化?同一时代的另外一组科学家通过动物实验找到了解释。他们把老鼠放在一个丁字形迷宫里面,并且一开始让老鼠处于丁字底部的笼子里。当把笼子打开后,老鼠可以往前走到丁字路口。接下来如果它往左走将会发现巧克力,如果它往右走则会发现什么都没有。在一只老鼠最初几次重复这个实验时,它的大脑从笼子打开到找到巧克力一直出于活跃状态,因为它需要主动探索这个迷宫。但在实验重复次数多了以后,老鼠的大脑只在笼子打开那一刻和最后找到巧克力那一刻活跃,中间过程都是无意识的进行的。科学家得出的结论是,习惯由提示、例行事务、奖励组成。提示会让大脑进入自动模式,之后大脑无意识地执行例行事务,最后得到奖励强化习惯。

那习惯又是如何导致上瘾的呢?这次轮到拿猴子做实验的科学家了。他们把猴子固定在电脑屏幕前,然后在屏幕上显示有颜色的形状。如果猴子在看到这些形状时拉动控制杆,一滴黑莓汁就会被送到猴子嘴里。最初猴子对屏幕上发生的事情一点也不感兴趣,但在尝到第一滴黑莓汁后它变得专注于屏幕上的变化。在若干次重复操作后,猴子已经习惯了屏幕给出提示,自己完成拉控制杆的例行事务,然后等待黑莓汁的奖励。这时候猴子的脑电波表明,猴子是在得到黑莓汁后才感到兴奋的。但如果继续重复这个实验,猴子兴奋的时刻就会往前移动,最终移动到形状出现后控制杆拉下前。这时候猴子就上瘾了。

上瘾之前的猴子是可以被打断的。如果形状出现后打断猴子,让它离开实验出去玩,或者给食物它吃,它都会选择出去玩或者吃东西。但一旦猴子上瘾了,它就不能被打断了,因为它的大脑在见到提示后已经开始预期奖励的出现。如果这时候推迟黑莓汁或者稀释黑莓汁,猴子就会变得不开心甚至是愤怒。这些都是上瘾的标志。这也正是习惯强大的地方——让人在得到奖励之前就开始预期奖励的出现。当然,这也是培养良好习惯的有效手段,正确地组合提示、例行事务和奖励,然后通过重复训练直到上瘾。例如说每天早上醒来看到床边的跑鞋,然后起来就出去跑步,跑完回来看到计步器的数字感到满足。重复若干次后就会形成习惯。

在通过一系列的故事讲解完习惯和成瘾背后的科学后,这本书接着通过若干市场案例解释习惯对消费者的影响。第一个例子是 20 世纪初 Pepsodent 牙膏的流行。当时美国人不爱刷牙,但广告先驱 Claude Hopkins 成功让美国人培养起每天用 Pepsodent 牙膏刷牙的习惯。Pepsodent 牙膏的成功在于刷完牙后它会在口腔内留下一股凉凉的刺激性感觉。尽管这种感觉会让人感觉到略微不舒服,但 Pepsodent 成功让消费者把这种感觉和一口洁白的牙齿关联起来,最终消费者不刷牙的话就会感觉到总是少了点什么。

一个跟 Pepsodent 相反的案例是 P&G 的 Febreze。Febreze 原本是纯粹的除味剂,喷洒过后能让各种不愉快的气味消失。P&G 花了不少研发费用在 Febreze 上面,接着又投入了不少市场研究费用,但这个产品就是很少人买。在 P&G 即将要放弃 Febreze 的时候,市场研究团队终于发现了产品失败的关键——Febreze 使用后不会留下任何气味。没有气味不就是 Febreze 的目标吗?但这样就无法让消费者形成喷洒 Febreze 的习惯,大多数免费获得 Febreze 样品的消费者用了几次后就忘记使用了。最终 P&G 为 Febreze 加入了一种清新愉悦的气味。Febreze 从此热卖,因为消费者记得他们是为了这种气味带来的愉悦才喷洒的。

接着这本书开始介绍如何改变习惯。习惯一定形成了,就非常难被消除,但习惯可以被改变。如果能够维持原有的提示和奖励,例行事务是可以被替换掉的。例如说,如果你每天下午都要到公司楼下买杯咖啡,那你可以尝试分析一下提示和奖励是什么。你可以通过控制实验来寻找提示和奖励。如果你每天 3:30 去买咖啡然后跟同事一起喝,你可以实验替换习惯的不同部分,例如说找一天 3:00 就去,找一天改买奶昔,找一天出去逛一圈就回来,再找一天直接去跟同事聊天。可能你最终会发现提示是你每天 3:30 感觉无聊了,能让你感到满足的奖励是跟同事聊天,这时候你就可以把例行事务替换掉了——每天 3:30 去跟同事聊聊天,然后满足得回到自己座位继续干活,从而能够少喝一杯咖啡。

除去科学的部分以外,信念可能是改变习惯配方中的神秘成分。有时候如果一个人相信自己能够逃脱原本的坏习惯,最终他就能成功改变习惯。但如果他不相信他能够获得改变,那他也就有可能最终半途而废。这是作者对美国 AA(匿名戒酒会)帮助戒酒成功之余必须引入宗教信仰的解释。因为 AA 强调是匿名无记录的,所以外界无法对 AA 戒酒的过程进行科学的记录和分析,但不少人在 AA 戒酒成功后都会提到自己在经历过上帝显灵后才下定决心戒酒然后成功的。对此唯一的科学解释就是,改变习惯需要一点自证预言,相信自己能成功这点本身也能帮助你成功。(AA 的另一点效用源自社会压力。就算是匿名,在小组内公开宣布要戒酒的人更有动力去戒酒。)

到此这本书结束了第一部分,也就是习惯对人的影响。之后第二部分讲习惯对企业的影响,例如美国前任财政部长 Paul O’Neill 在出任 Alcoa(美国铝业公司)的 CEO 时是如何通过强调安全习惯来改变整家公司的文化的。安全是小习惯,但在公司实现零安全事故的目标后,工人和管理层就会开始相信更大的改变也是可以做到的,结果让这家濒临破差的大公司起死回生。此外,Alcoa 濒临破产的状态正好是建立新习惯的优势之一。这时候员工已经觉得管理层无能而不愿意听从管理层命令,管理层唯一能做的就是给员工危机感,让他们知道继续保持原有做事方式必定是死路一条,因此必须尝试新的做事方式。简单来说,动荡当中的公司更容易建立起新的习惯。

这对人生来说一样是成立的,所以 Target 花重金在数据挖掘上,就为了找出孕妇和新生婴儿的父母。在人生的转折点上,人更容易形成新习惯。由于孕妇和新生婴儿的父母急需购买婴儿用品的同时又对价格不敏感,所以一旦 Target 成功帮助他们培养在 Target 购买婴儿用品的习惯,之后他们就会乐意在 Target 购买任何东西。

书的第三部分讲述了习惯对社会的影响。作者尝试从习惯的角度分析美国的黑人运动和基督教传播。例如说,成功的传教应该是在维持教徒原有社交方式的前提下完成的。教徒只是把社交地点换到了教堂,只是鼓励角度在社交时谈论宗教。原有社交习惯所用的提示和奖励都不变,宗教被融入到例行事务当中去。

最后作者尝试探讨既然习惯属于无意识行为,那我们是否要对自己习惯的结果负法律责任。曾经有梦游杀妻而被判无罪的判例,理由是当事人当时无意识不能干预,那赌场刻意培养消费者上瘾导致消费者欠下大量赌债,这时候消费者又是否需要负责任呢?作者认为消费者是应该对自己的行为负责任的,因为习惯不是一个持续无意识不能干预的过程。既然你能遇见到习惯被触发后的后果,尽管习惯被触发后是无意识的过程,但你也有责任在触发前干预以保证习惯不被触发,否则的话你就要对习惯被触发后的结果负责任。

总的来说,我对这本书是非常满意的。虽然讲了那么多道理,但在如此多故事的穿插之下会让人觉得非常吸引。对于整本书来说,我觉得最有用的道理就是这一条:意志力不是技能,而更像是肌肉,反复使用就会疲劳。聪明人不是更多地使用意志力,而是更少地使用意志力。他们只在培养习惯时使用意志力,习惯一旦形成就不再需要使用意志力。如果你对这本书感兴趣,我推荐你去买一本来读一读。

P.S. 文章开头的「最近」二字是基于文章草稿阶段写的,那其实是 2013 年年底。

2014年3月16日星期日

如何捕获和分析 JavaScript Error

前端工程师都知道 JavaScript 有基本的异常处理能力。我们可以 throw new Error(),浏览器也会在我们调用 API 出错时抛出异常。但估计绝大多数前端工程师都没考虑过收集这些异常信息。反正只要 JavaScript 出错后刷新不复现,那用户就可以通过刷新解决问题,浏览器不会崩溃,当没有发生过好了。这种假设在 Single Page App 流行之前还是成立的。现在的 Single Page App 运行一段时间后状态复杂无比,用户可能进行了若干输入操作才来到这里的,说刷新就刷新啊?之前的操作岂不要完全重做?所以我们还是有必要捕获和分析这些异常信息的,然后我们就可以修改代码避免影响用户体验。

捕获异常的方式

我们自己写的 throw new Error() 想要捕获当然可以捕获,因为我们很清楚 throw 写在哪里了。但是调用浏览器 API 时发生的异常就不一定那么容易捕获了,有些 API 在标准里就写着会抛出异常,有些 API 只有个别浏览器因为实现差异或者有缺陷而抛出异常。对于前者我们还能通过 try-catch 捕获,对于后者我们必须监听全局的异常然后捕获。

try-catch

如果有些浏览器 API 是已知会抛出异常的,那我们就需要把调用放到 try-catch 里面,避免因为出错而导致整个程序进入非法状态。例如说 window.localStorage 就是这样的一个 API,在写入数据超过容量限制后就会抛出异常,在 Safari 的隐私浏览模式下也会如此。

try {
  localStorage.setItem('date', Date.now());
} catch (error) {
  reportError(error);
}

另一个常见的 try-catch 适用场景是回调。因为回调函数的代码是我们不可控的,代码质量如何,会不会调用其它会抛出异常的 API,我们一概不知道。为了不要因为回调出错而导致调用回调后的其它代码无法执行,所以把调用回到放到 try-catch 里面是必须的。

listeners.forEach(function(listener) {
  try {
    listener();
  } catch (error) {
    reportError(error);
  }
});

window.onerror

对于 try-catch 覆盖不到的地方,如果出现异常就只能通过 window.onerror 来捕获了。

window.onerror =
  function(errorMessage, scriptURI, lineNumber) {
    reportError({
      message: errorMessage,
      script: scriptURI,
      line: lineNumber
    });
}

注意不要耍小聪明使用 window.addEventListenerwindow.attachEvent 的形式去监听 window.onerror。很多浏览器只实现了 window.onerror,或者是只有 window.onerror 的实现是标准的。考虑到标准草案定义的也是 window.onerror,我们使用 window.onerror 就好了。

属性丢失

假设我们有一个 reportError 函数用来收集捕获到的异常,然后批量发送到服务器端存储以便查询分析,那么我们会想要收集哪些信息呢?比较有用的信息包括:错误类型(name)、错误消息(message)、脚本文件地址(script)、行号(line)、列号(column)、堆栈跟踪(stack)。如果一个异常是通过 try-catch 捕获到的,这些信息都在 Error 对象上(主流浏览器都支持),所以 reportError 也能收集到这些信息。但如果是通过 window.onerror 捕获到的,我们都知道这个事件函数只有 3 个参数,所以这 3 个参数以外的信息就丢失了。

序列化消息

如果 Error 对象是我们自己创建的话,那么 error.message 就是由我们控制的。基本上我们把什么放进 error.message 里面,window.onerror 的第一个参数(message)就会是什么。(浏览器其实会略作修改,例如加上 'Uncaught Error: ' 前缀。)因此我们可以把我们关注的属性序列化(例如 JSON.Stringify)后存放到 error.message 里面,然后在 window.onerror 读取出来反序列化就可以了。当然,这仅限于我们自己创建的 Error 对象。

第五个参数

浏览器厂商也知道大家在使用 window.onerror 时受到的限制,所以开始往 window.onerror 上面添加新的参数。考虑到只有行号没有列号好像不是很对称的样子,IE 首先把列号加上了,放在第四个参数。然而大家更关心的是能否拿到完整的堆栈,于是 Firefox 说不如把堆栈放在第五个参数吧。但 Chrome 说那还不如把整个 Error 对象放在第五个参数,大家想读取什么属性都可以了,包括自定义属性。结果由于 Chrome 动作比较快,在 Chrome 30 实现了新的 window.onerror 签名,导致标准草案也就跟着这样写了。

window.onerror = function(
  errorMessage,
  scriptURI,
  lineNumber,
  columnNumber,
  error
) {
  if (error) {
    reportError(error);
  } else {
    reportError({
      message: errorMessage,
      script: scriptURI,
      line: lineNumber,
      column: columnNumber
    });
  }
}

属性正规化

我们之前讨论到的 Error 对象属性,其名称都是基于 Chrome 命名方式的,然而不同浏览器对 Error 对象属性的命名方式各不相同,例如脚本文件地址在 Chrome 叫做 script 但在 Firefox 叫做 filename。因此,我们还需要一个专门的函数来对 Error 对象进行正规化处理,也就是把不同的属性名称都映射到统一的属性名称上。具体做法可以参考这篇文章。尽管浏览器实现会更新,但人手维护一份这样的映射表并不会太难。

类似的是堆栈跟踪(stack)的格式。这个属性以纯文本的形式保存一份异常在发生时的堆栈信息,由于各个浏览器使用的文本格式不一样,所以也需要人手维护一份正则表达,用于从纯文本中提取每一帧的函数名(identifier)、文件(script)、行号(line)和列号(column)。

安全限制

如果你也遇到过消息为 'Script error.' 的错误,你会明白我在说什么的,这其实是浏览器针对不同源(origin)脚本文件的限制。这个安全限制的理由是这样的:假设一家网银在用户登录后返回的 HTML 跟匿名用户看到的 HTML 不一样,一个第三方网站就能把这家网银的 URI 放到 script.src 属性里面。HTML 当然不可能被当做 JS 解析啦,所以浏览器会抛出异常,而这个第三方网站就能通过解析异常的位置来判断用户是否有登录。为此浏览器对于不同源脚本文件抛出的异常一律进行过滤,过滤得只剩下 'Script error.' 这样一条不变的消息,其它属性统统消失。

对于有一定规模的网站来说,脚本文件放在 CDN 上,不同源是很正常的。现在就算是自己做个小网站,常见框架如 jQuery 和 Backbone 都能直接引用公共 CDN 上的版本,加速用户下载。所以这个安全限制确实造成了一些麻烦,导致我们从 Chrome 和 Firefox 收集到的异常信息都是无用的 'Script error.'

CORS

想要绕过这个限制,只要保证脚本文件和页面本身同源即可。但把脚本文件放在不经 CDN 加速的服务器上,岂不降低用户下载速度?一个解决方案是,脚本文件继续放在 CDN 上,利用 XMLHttpRequest 通过 CORS 把内容下载回来,再创建 <script> 标签注入到页面当中。在页面当中内嵌的代码当然是同源的啦。

这说起来很简单,但实现起来却有很多细节问题。用一个简单的例子来说:

<script src="http://cdn.com/step1.js"></script>
<script>
  (function step2() {})();
</script>
<script src="http://cdn.com/step3.js"></script>

我们都知道这个 step1、step2、step3 如果存在依赖关系的话,则必须严格按照这个顺序执行,否则就可能出错。浏览器可以并行请求 step1 和 step3 的文件,但在执行时顺序是保证的。如果我们自己通过 XMLHttpRequest 获取 step1 和 step3 的文件内容,我们就需要自行保证其顺序正确性。此外不要忘记了 step2,在 step1 以非阻塞形式下载的时候 step2 就可以被执行了,所以我们还必须人为干预 step2 让它等待 step1 完成后再执行。

如果我们已经有一整套工具来生成网站上不同页面的 <script> 标签的话,我们就需要调整一下这套工具让它对 <script> 标签做出改动:

<script>
  scheduleRemoteScript('http://cdn.com/step1.js');
</script>
<script>
  scheduleInlineScript(function code() {
    (function step2() {})();
  });
</script>
<script>
  scheduleRemoteScript('http://cdn.com/step3.js');
</script>

我们需要实现 scheduleRemoteScriptscheduleInlineScript 这两个函数,并且保证它们在第一个引用外部脚本文件的 <script> 标签之前就被定义好,然后余下的 <script> 标签都会被改写成上面这种形式。注意原本立即执行的 step2 函数被放到了一个更大的 code 函数里面了。code 函数并不会被执行,它只是一个容器而已,这样使得原本 step2 的代码不需要转义就能保留下来,但又不会被立即执行。

接下来我们还需要实现一套完整的机制,保证这些由 scheduleRemoteScript 根据地址下载回来的文件内容和由 scheduleInlineScript 直接获取到的代码能够按照正确的顺序一个接一个地执行。详细的代码我就不在这里给出了,大家有兴趣可以自己去实现。

行号反查

通过 CORS 获取内容再把代码注入页面能够突破安全限制,但会引入一个新的问题,那就是行号冲突。原本通过 error.script 可以定位到唯一的脚本文件,再通过 error.line 可以定位到唯一的行号。现在由于都是页面内嵌的代码,多个 <script> 标签并不能通过 error.script 来区分,然而每一个 <script> 标签内部的行号都是从 1 算起的,结果就导致我们无法利用异常信息定位错误所在的源代码位置。

为了避免行号冲突,我们可以浪费一些行号,使得每一个 <script> 标签中有实际代码所使用的行号区间互相不重叠。举个例子来说,假设每个 <script> 标签中的实际代码都不超过 1000 行,那么我可以让第一个 <script> 标签中的代码占用第 1–1000 行,让第二个 <script> 标签中的代码占用第 1001–2000 行(前面插入 1000 行空行),第三个 <script> 标签种的代码占用第 2001–3000 行(前面插入 2000 行空行),以此类推。然后我们使用 data-* 属性记录这些信息,便于反查。

<script
  data-src="http://cdn.com/step1.js"
  data-line-start="1"
>
  // code for step 1
</script>
<script data-line-start="1001">
  // '\n' * 1000
  // code for step 2
</script>
<script
  data-src="http://cdn.com/step3.js"
  data-line-start="2001"
>
  // '\n' * 2000
  // code for step 3
</script>

经过这样处理后,如果一个错误的 error.line3005 的话,那意味着实际的 error.script 应该是 'http://cdn.com/step3.js',而实际的 error.line 则应该是 5。我们可以在之前提到的 reportError 函数里面完成这项行号反查工作。

当然,由于我们没办法保证每一个脚本文件只有 1000 行,也有可能有些脚本文件明显小于 1000 行,所以其实不需要固定分配 1000 行的区间给每一个 <script> 标签。我们可以根据实际脚本行数来分配区间,只要保证每一个 <script> 标签所使用的区间互不重叠就可以了。

crossorigin 属性

浏览器对于不同源的内容进行的安全限制当然不仅限于 <script> 标签。既然 XMLHttpRequest 可以通过 CORS 来突破这个限制,为什么直接通过标签引用的资源就不可以呢?这当然是可以的。

针对 <script> 标签引用不同源脚本文件的限制同样作用于 <img> 标签引用不同源图片文件。如果一个 <img> 标签是不同源的话,一旦在 <canvas> 绘图时用到了,该 <canvas> 将变为只写状态,保证网站不能通过 JavaScript 窃取未授权的不同源图片数据。后来 <img> 标签通过引入 crossorigin 属性解决了这个问题。如果使用 crossorigin="anonymous",则相当于匿名 CORS;如果使用 `crossorigin=“use-credentials”,则相当于带认证的 CORS。

既然 <img> 标签能这样做,为什么 <script> 标签就不能这样做?于是浏览器厂商就为 <script> 标签加入了同样的 crossorigin 属性用于解决上述安全限制问题。现在 Chrome 和 Firefox 对这个属性的支持是完全没有问题的。Safari 则会把 crossorigin="anonymous" 当做 crossorigin="use-credentials" 处理,结果是如果服务器只支持匿名 CORS 则 Safari 会当做认证失败。由于 CDN 服务器出于性能的考虑被设计为只能返回静态内容,不可能动态的根据请求返回认证 CORS 所需的 HTTP Header,Safari 相当于不能利用此特性来解决上述问题。

总结

JavaScript 异常处理看起来很简单,跟其它语言没什么区别,但真的要把异常都捕获了然后对属性做分析,其实还不是那么容易的事情。现在尽管有一些第三方服务提供捕获 JavaScript 异常的类 Google Analytics 服务,但如果要弄明白其中的细节和原理还是必须自己亲手做一次。

2014年3月11日星期二

Facebook 发布「流程」

时不时就会在面试过程中碰到有候选人问 Facebook 是否采用 Scrum 之类的敏捷方法,偶尔也会有中国的朋友问及 Facebook 上线流程。我通常会简单说几句,然后说「如果你真感兴趣的话,去搜索 Chuck Rossi 在 Velocity 2012 San Fancisco 演讲的视频」。无论从 Scrum 的角度来看,还是大多数中国公司的上线流程来看,Facebook 的发布流程都显得很不一样,但其实又非常合理,看完那个视频你就明白了。尽管里面提到的内部工具都没有在 Facebook 的 GitHub 上开源,但那些截图已经足够清晰说明其功能和用途了。

工具固然是重要的一方面,但我觉得更重要的是文化。我知道很多中国公司的上线流程都涉及各种签字,例如我在百度的时候上线就需要 RD、FE、QA、OP 等众多角色签字,有时候还需要对应经理甚至总监签字。相对这种上线流程来说,Facebook 的发布流程简单得很反流程,这也是我为标题中的「流程」二字加上引号的原因。考虑到我之后会专门写一篇文章讨论文化,所以在这里就不深入展开了。

至于工具,最重要的就是通过引入自动化来解答一些简单但涉及大量手工操作的问题。例如视频当中提到的,「一个 PHP 异常是由哪个 commit 引入的?」在没有工具的情况下,这只能手工 git bisect 来查找。万一这个异常不是稳定复现的话,那基本上就没办法定位到 commit 了。Facebook 的工具能自动把异常堆栈跟踪里面每一帧和 git blame 关联起来,再用异常发生频率的历史图谱跟 commit 合并时间做对比,很容易就能得到答案。

最后,如果你看完这个视频觉得还不满足的话,可以去看看 Jay Parikh 在 Velocity 2012 Santa ClaraGirish Patangay 在 Velocity 2012 London 的视频。