Javascript的那些硬骨头:作用域、回调、闭包、异步……

终于到了神话破灭的时刻……

这注定是一篇“自取其辱”的博客,飞哥,你们眼中的大神,Duang,这次脸朝下摔地上了。

故事得从这个求助开始:e.returnValue 报错:未定义,“一起帮”现在人气还不够旺,碰到了我勉勉强强能够解决的问题,硬着头皮也得上啊!远程一看,问题不是e.returnValue没值,是e本身就没值。而更核心的问题是:这段代码,是被放在setTimeout()里面的。(这里插一句:很多问题,就得远程,求助人贴出来的代码,根本就没抓住重点。话说,很多时候,要是能抓住问题的核心,问题也已经解决了一大半了。)

把代码简单化一下,代码大致应该是这样的:

 1 <script>
 2     function showEvent(){
 3         setTimeout(
 4             function(){
 5                 alert('in setTimeout:'+ event);
 6             },100
 7         );
 8     }
 9 </script>
10 
11 <input type="submit" name="Submit" value="提交" onclick="showEvent()" />

点击提交按钮,就会看到:event 为 undefined。

凭着直觉,真的就是坑踩得太多的直觉,我飞快的解决了这个问题:在setTimeout()之前加一行代码,如下所示:

1     function showEvent(){
2         var e = event;    //把event赋值给e
3         setTimeout(
4             function(){
5                 alert('in setTimeout:'+ e);
6             },100
7         );
8     }

问题就解决了:

欧耶,\(^o^)/

但是,但是求助人要问:为什么呢

是啊,为什么呢?我敷衍他:三言两语说不清楚,等我写“总结”吧一起帮上每个求助搞定之后都可以写总结),结果这一弄啊,就是一周给混过去了……

花了这么多的心血,趁热打铁,干脆写篇博客,总结一下,运气好,园子里的同学还能给点指教呢!

 

++++++++++++++++++++

以下是试图维护偶像光环无力的自我辩护:

习惯了C#的优雅严谨,我承认:灰常灰常不喜欢JavaScript!所以JS一直是我的弱项,我的一贯原则是:能不用,就不用;就是要用,够用就行!“深入研究JavaScript”在我看来,纯粹就是找抽。我一直在等待JavaScript死掉的那一天,让我好结束苦逼的JavaScript开发工作……但微软不给力,看来是等不到那一天了。

以及无耻的推卸责任:

说句题外话,微软走到今天,犯的最大的错误:把开发者往外推。IE6不知道是多少开发人员的噩梦,各种不兼容,拥抱一个通用标准就那么难么?当初IE几乎是一统江湖啊!不管是CSS,还是JavaScript,要是IE能全面支持标准,哪有之前什么Firefox,现在什么Chrome的事?!包括.NET,现在才开源跨平台,早干什么去了?让Java这种古董级语言死灰复燃,全是自己作出来的。

++++++++++++++++++++

 

回到主题,这个问题,凭直觉,能想到的就这几方面的问题:

  • 作用域
  • 回调函数
  • 异步

我们一个一个的整起来吧。

说明一下,这篇博客的写作思路:紧紧围绕上面提出来的问题进行分析讲解。这比较适合像我这样的半吊子,很多概念有接触,但又理解不深,始终云里雾里的同学。借助这个具体问题的深入分析,把之前的夹生饭”掰细了蒸熟了!

都是自己的一些理解,欢迎JavaScript大神批评指正。

 

作用域

JavaScript变量的作用域分为两种:全局的,和局部的。

全局的,非常好理解,但同时,这一特性,可以说是JavaScript万恶之源。《JavaScript语言精粹》一书附录“糟粕”A.1首当其冲的就是“全局变量”。很多你不理解的“为什么呢”之流的问题(比如:函数声明理解执行、闭包、模拟名称空间,等等),都可以一直逆推到“避免全局污染”上面来。

有同学问过我,这么恶劣的一个语法特性,为什么会一直存在呢?这就得从JavaScript的发展历史说起了。JavaScript的发展,深刻的证明了雷军的那句话:风口上面,猪都飞得起来。

1995年5月,作为Netscape公司实习生的Brendan Eich只用了10来天的时间,就设计完成了JavaScript的第一版,最初的定位是一个“嵌入html网页功能简单、易于学习的”脚本语言。因为当时Java正如日中天,所以Netscape很“鸡贼”的取名为Javascript,而实际上,这玩意儿和Java半毛钱的关系也没有,而是一个粗制滥造的大杂烩…… 参考:http://javascript.ruanyifeng.com/introduction/history.html

我们可以想象,当作为一个简短的、内嵌于html页面的脚本语言,全局变量其实是一个非常方便的东西(尤其是JavaScript的局部变量同时还有很多问题)。然而,后来随着前端的不断发展,JavaScript代码量不断增加,模块化工程化的要求越来越高,全局变量“重名”的概率越来越大,大量滥用全局变量,最终变成了一场灾难。所以前端开发人员,想出了很多办法来解决这一问题。而非常不幸的是,这些hack方法,又进一步的加剧了JavaScript代码理解上的难度……

就前几天,一个网友告诉我“一起帮”上面的验证码失效了,而错误在我本地无法重现。远程到他电脑上一看,错误提示:找不到$。看他用的Chrome,马上问他:是不是装了插件?果然,卸载了插件就OK了。

这说明,即使今天,当web应用面向的是不特定人群时,我们仍然不能完全信任JavaScript,不应该把核心的功能交给JavaScript,因为客户端的情况,是你无法预知的。讲真,我真不知道那种整个页面都是JavaScript加载渲染的web应用,是如何保证其“健壮性”(甚至是“可用性”)的。

那我们今天这个问题,涉不涉及到全局变量?

看看我们使用的event变量,不是参数传递进来的局部变量。那就只能是全局变量,相当于window.event;而window.event,是存在版本兼容性问题的,大体上来说,只有IE支持(各种乱七八糟的细节,大家可以参考:e = e || window.event用法细节讨论

在我的Firefox上测试,event只能通过参数传递,所以代码应该改写为:

1     function showEvent(event){  //event作为参数传入
2         setTimeout(
3             function(){
4                 alert('in setTimeout:'+ event);
5             },100
6         );
7     }

相应的,html上事件绑定为:

<input type="submit" name="Submit" value="提交" onclick="showEvent(event)" />

这样一测试,( ⊙ o ⊙ )啊!event有值,再也不是undefined了。

如果就这样结尾,你会不会艹, ヽ(`Д´)ノ︵ ┻━┻ ┻━┻ (掀桌子)?

好吧,我们假装这个问题没有解决。因为即使到这里,我们还是不能解释:为什么通过参数传递(或者var e = event;再赋值)的event能一直存在,作为全局变量的event怎么就变成了undefined呢?

再多说两句,这也是细抠JavaScript就容易变“玄学”的又一个原因。JavaScript代码是在不同的宿主环境(浏览器)上编译执行的。而直到今天,各个浏览器都还没有严格的遵守ECMAScript规范,所以存在大量的兼容性问题,让人晕头转向不知所措……

我们还是继续吧,顺带复习/捋清很多JavaScript的基础概念。

再看局部变量,当event作为参数传入,它就类似于一个局部变量。局部变量也有很多坑爹的“特性”(是的,JavaScript到处都是“bug用久了就变特性”的例子),大致的:

  • 函数块内的变量可以“先使用后声明”,换成特性就是:变量声明提前
  • 没有“块级作用域”,典型的就是for循环里的i可以被用于循环体外(ES6引入了let解决这一问题)。而这个历史遗留问题,换成特性表述就是:词法作用域。我的理解:JavaScript的作用域不是基于花括号{},而是基于函数的;是一个函数定义一个作用域,而不是一个{}定义一个作用域。

所以我们现在遇到的这个问题,必须把函数也引入进来,继续分析。

 

函数

JavaScript号称“面向对象”,我觉得啊,还不如说它是“面向函数”。

函数在JavaScript中是一个非常特殊的存在。它又有一个特性:函数里面可以再嵌套函数,于是玄而又玄的“闭包”问题就产生了。关于闭包问题的文章,汗牛充栋,根据我之前“零基础课程”的反馈,我就简单的说几点,看能不能帮助大家。

首先,闭包产生的前提条件,是两个语法特征:

  • 函数里面还可以嵌套函数
  • 嵌套的函数可以调用外部函数中的变量

闭包本质上是一个“作用域”问题,或者说变量的生命周期问题。被C#和VisualStudio宠惯了,对于这个问题我们会觉得非常陌生。因为在VisualStudio里面写代码,如果一个变量不在作用域内,就不能使用,就使用不了智能提示,而且会立即报错。(这就是“强类型”语言的好处,唉~~JavaScript的槽点无处不在啊!)

而JavaScript这种所谓的“弱类型”“动态”语言,很容易就一团浆糊。

如果仅仅从概率上理解,做“名词解释”,我个人觉得,闭包就是这么回事了:(一个函数内部)嵌套的函数可以调用(嵌套它的)外部函数中的变量。

这样就完了?那衣物(naive)啊……

为什么我说JavaScript是面向函数的?因为在JavaScript中,函数也是一个变量。(个人觉得,理解到这一层就够了,深究下去“对象继承自函数,函数也继承自对象”会把你逼疯的……JavaScript,能用就行,能用就行!唉~~)

 

回调

函数是一个变量,你们就可以作为方法的参数,是不是?当函数作为参数进行传递,就产生了JavaScript另一个特性:回调。回调其实也不难理解,类似于C#中delegate,已经衍生出来的Aciton<T>,Func<T>等,函数作为方法参数嘛。问题在于,当回调和闭包同时出现时,问题就复杂了。

我们再看一遍我们的问题代码:

1         setTimeout(
2             //该匿名函数就被作为setTimeout的第一个参数了
3             function(){
4                 alert('in setTimeout:'+ event);  //event是哪里来的?
5             },100
6         );

回调表现得很清晰:整个function()匿名函数作为setTimeout()的第一个参数。再仔细看看,在该匿名函数中:alert('in setTimeout:'+ event); 咦,这个event是哪里来的?(说明:以下讨论都建立在非IE浏览器中运行,使用onclick="showEvent(event)",排除window.event的影响

凭直觉或者习惯,我会写成这样:

1         setTimeout(
2             function(event){ //把event作为参数传入
3                 alert('in setTimeout:'+ event);
4             },100
5         );

然而,在这里,这样写就会出问题:这样写event会是undefined。ʅ(‾◡◝)ʃ 为什么呢?

当我们在setTimeout()调用的匿名函数中声明参数event,匿名函数中的event就会“就近”的使用传入的参数event,但是这个参数event是没有赋值的(undefined)。

这又涉及到回调函数的参数传递问题。注意,不是回调函数作为参数被传递,是回调函数自己的参数问题。

setTimeout()函数是window自带的,其声明和实现我们(好吧,至少飞哥我)不知道。但我们查看其MDN文档,可以看到:

setTimeout()delay之后还可以带参数param1,param2,……,所以理论上(为什么是“理论上”?因为老版IE又不支持,艹)我们还可以这样:

1     function showEvent(event){
2         setTimeout(
3             function(event){ //把event作为参数传入
4                 alert('in setTimeout:'+ event);
5             }, 100, event  //event作为匿名回调函数的参数
6         );
7     }

根据上述setTimeout()函数的调用,大家能不能猜到setTimeout()的大致实现?我想应该是这样的:

1     function mockSetTimeout(callback, delay){
2         //JavaScript很有意思的一个特性:可以直接通过arguments取得传入的参数(实参)
3         callback(arguments[2], arguments[3]);
4     }
5     
6     mockSetTimeout(function(param1, param2){
7         alert(param1+" , "+param2);
8     },1, "hello","world");

好啦,不跑题太远了。

其实把event作为setTimeout()的参数传递是比较好理解的 ,这符合一般的编程语言的处理逻辑,参数得一层一层的传递:showEvent()把参数event传递给setTimeout(),setTimeout()再用参数把event传递匿名回调函数function(),因为event是局部变量啊。但是我们看一下我们的代码:

1 function showEvent(event){  //event作为参数传入
2         setTimeout(
3             function(){
4                 alert('in setTimeout:'+ event);
5             },100
6         );
7     }
8 
9 <input type="submit" name="Submit" value="提交" onclick="showEvent(event)" />

没有这种传递!

没有这种传递!

没有这种传递!

第4行代码中使用的event是直接地使用调用它的匿名函数function()之外的setTimeout()之外的showEvent()中的变量——这句话非常拗口,但我想习惯了C#之类语言的同学应该能明白我的意思:都特么的多少“级”(作用域)之外了,怎么这scope还能用?

其实,这就是JavaScript没有“块级作用域”,或者说只有“词法作用域”的体现。我看到过最经典最直白的解释:

你不要管JavaScript运行起来的时候是怎么样的,你就看它源代码书写起来是怎么样的就行了。

我觉得说得非常……嗯,非常简单,是不是绝对正确?唉!我也就不操这个心了。JavaScript里面太多诡异的地方,谁说得准呢?

所以,只要event出现在第4行,不管是函数的定义,还是函数的调用,只要包裹在第1行和第7行的函数之间,它就能使用第1行和第7行之间声明的变量。注意这里的“能使用”,准确的表述应该是:

当执行到第4行代码时,仍然能够获得event的值(不会是undefined),哪怕此时其外部函数showEvent()已运行完毕

这就是闭包的精髓

闭包的复杂性(容易把开发人员弄晕的地方)就体现在这里。

 

闭包

我自己写代码,总是尽量避免产生闭包,忒反人性了,一不留神就是bug,而且是非常难以发现的bug。

然而,很多时候你得调用别人的类库,稍不注意(甚至不用不行),闭包就来了。

写草稿的时候,想到setTimeout()这是一个函数调用,不是函数声明,脑子里又捣糨糊了,突然怀疑这是不是闭包?

结果查到这个:阮一峰关于 Javascript 中闭包的解读是否正确

里面的高赞答案显然认为setTimeout()里对外部变量的引用,就是一个闭包。

所以还是得记牢前面所说的JavaScript的“词法作用域”:JavaScript的变量作用域基于函数的声明,而不是函数的运行

好了,非IE浏览器下通过参数传递event的情形似乎已经OK了?但还有一个问题,使用window.event时,为什么在setTimeout()的回调函数里就undefined呢?

 

setTimeout()

我们首先看一看,这锅该不该setTimeout()背?因为setTimeout()是一种“特殊的”函数,它的回调函数要在一定时间后才执行。

为了验证这个问题,我自己写了一个“同步的”回调函数,如下所示:

    function showEvent(){  
        myFunc(
            function(){
                //仅适用于IE浏览器:event有值
                alert('in setTimeout:'+ event);
            }
        );
    }    
        
    function myFunc(callback){
        callback();
    }

耶!运行的结果,event的值是能取到的。

此外,在能够正常运行的代码中、分别alert通过参数传递event,和全部变量的window.event,如下所示:

1     function showEvent(event){  //event作为参数传入
2         alert('在setTime()之前的window.event:   ' + window.event);  //有值
3         setTimeout(
4             function(){
5                 alert('在setTime()中的window.event:   ' + window.event);  //undefined
6                 alert('in setTimeout:'+ event);  
7            },100
8         )
9     }

由此可见,对于IE浏览器,event失去值的过程发生在setTimeout()中。

那setTimeout()中究竟发生了些什么?我看了很多文章和书籍,感觉确实提高了不少,总结如下:

  • JavaScript是非阻塞(异步)的。比如,上述代码执行的顺序是:1-2-3-7-8-9-4-5……。JavaScript执行器碰到setTimeout()不会停留(阻塞),等上100毫秒,啥事不做,而是会直接执行后面的代码,直到100毫秒过后,再回头来执行setTimeout()里的回调函数。这比较好理解,因为我们经常调试,能发现这个现象。但接下来,
  • JavaScript是单线程的。这可能就会冲击有些同学的世界观了,单线程怎么能异步呢?这涉及到两个概念:JavaScript引擎线程和其他线程。简而言之,JavaScript引擎线程,负责进行JavaScript解释执行的线程,始终只有一个线程,浏览器无论什么时候都只有一个JS线程在运行JS程序;但浏览器的内核是多线程的,JS引擎线程碰到setTimeout(),就召唤其他线程,“,哥们,定时这活交给你了”,说完JS引擎继续干它自个的活去了(非阻塞)。那100毫秒过去了,其他线程怎么办?通知JS线程,停止执行手头上的代码,马上执行setTimeout()的回调函数?错!这里特别要注意:JS引擎线程不会停下手头的活儿(仍然是非阻塞),而是让setTimeout()的回调函数排队去,等着,等我把手头的活干完——这就是所谓的JavaScript的event loop机制。(详细的、规范的解释可以参考:以setTimeout来聊聊Event Loop

 知道了这些之后,不知道大家有没有什么启发。我能够想象出来(真的只能是“想象”啊,没找到实锤,如果有大神直到真相,欢迎赐教)的解释就是(仅对IE浏览器而言)

  1. onclick事件被触发,
  2. 事件相关的信息被存放进window.event对象,并开始执行事件回调函数
  3. 碰到setTimeout(),通知其他线程,setTimeout()中回调函数被略过
  4. 程序继续执行
  5. ……
  6. event事件执行完毕,window.event被清空(这点很关键,因为此时的window.event是全局的,它不能被setTimeout()一直占用着)
  7. ……
  8. 100毫秒以后(准确的说,和时间多少没关系,哪怕是0毫秒也一样,反正都得等event事件执行完毕),继续执行setTimeout()的回调函数

这时候,window.event当然就是undefined的啦!

 

写在最后

已经很久没有这么认真的写过技术博客了。草稿是上上周周末写完的,记得。昨天晚上和今天上午又改了一遍,真心累。

现在前端(JavaScript)很火,但个人觉得,JavaScript真的是先天不足,大型化的工程应用坑太多。就像前几年火得一塌糊涂的node.js,看上去很美,但真用起来你就知道厉害了。

很多同学都因为“简单一些”而入坑前端,其实我觉得前端一点都不简单(应该是简陋吧?)前端的复杂性在于JavaScript(以及CSS)各种奇葩“特性”,以及不胜其烦的兼容性。而且我很怀疑,一入门就学这些东西,会不会被“带偏”“带坏”?至少,通过JavaScript来理解“工程化”“模块化”,还有四不像的“面向对象”……反正我讲起来都特别累,真不知道刚入门的同学能不能听得懂。

说这些可能很多人不爱听,就这样吧,最后一句忠告:不要把自己局限在“前端”,后端也很精彩,而且比前端简单——至少是“清晰”多了。O(∩_∩)O~

 

+++++++++++++++++

 

好了,继续撸我的“一起帮”代码了。争取4月10日上线新版本,这个版本就应该定型了。写到现在,都整整一年了……

 

 

 

 

posted @ 2018-03-20 12:21  自由飞  阅读(2407)  评论(4编辑  收藏  举报