浅析作用域链–JS基础核心之一

时间:2020-12-20 06:28:13

JS中的作用域,大家都知道的,分为全局作用域和局部作用域,没有块级作用域,听起来其实很简单的,可是作用域是否能够有深入的了解,对于JS代码逻辑的编写成功率,BUG的解决能力,以及是否能写出更优秀的代码,都有很重要的影响的,如果想要写出更优雅更高效的逻辑代码,那么就要深入的了解一下作用域的问题了,确切的说,是要更深入的了解一下,怎么更有效更巧妙的利用作用域。

全局和局部作用域

这个我觉得吧,只要学习过编程语言的,就会对这些有简单的了解的。比如在JS语言中,属于window对象的属性和方法,是可以被我们自定义的函数或者方法的局部作用域访问的,而我们自定义的函数和对象内部的属性和方法,却只能在内部使用。这里,window对象就是在全局作用域中,而我们自定义的函数或者对象内部,就是局部作用域。

  • var num = 1;
  • function changeNum(){
  • var str = "zhang";
  • num = 2;
  • }
  • console.log(num);       //1
  • console.log(typeof str);//undefined
  • changeNum();
  • console.log(num);       //2
  • console.log(typeof str);//undefined

上述代码中,之所以要使用typeof str,是因为对于没有定义的变量,浏览器会抛出错误,并且阻塞浏览器继续执行后续代码的。

注:如果确定要定义为局部变量,那么千万不要忘记使用 var 操作符哦。

局部作用域的位置一般是在函数或者对象内部,为了叙述方便,接下来就只以函数的局部作用域来进行分析说明。

在函数中使用var操作符定义一个变量,那么当这个函数执行完毕之后,这个变量也会被销毁(也有的情况下不会,比如闭包,后面会说明),而全局变量会一直存在。所以在我们写代码时,尽量少的使用全局变量,滥用全局变量,简直就是一个会令人恶心的习惯,因为它会带来很多不必要的麻烦。

  • 1:变量过多,命名麻烦
  • 2:局部变量,忘记使用var定义,修改了全局变量,这样的错误对于代码的维护简直是噩梦
  • 3:全局变量会在页面卸载前一直存在,损耗不必要的内存。

暂时就想到这些,反正就是尽量少用就对了。。。。

作用域链

引自Javascript高级程序设计(第三版)(P73):当代码在一个环境中执行时,会创建变量对象的的一个作用域链(scope chain)。作用域链的用途,是保证对执行环境有权访问的所有变量和函数的有序访问。作用域链的前端,始终都是当前执行的代码所在环境的变量对象。如果这个环境是一个函数,则将其活动对象作为变量对象。

每一个函数都有自己的执行环境,当执行流进一个函数时,函数环境就会被推入一个环境栈中,而在函数执行之后,栈将其环境弹出,把控制权返回给之前的执行环境,这个栈也就是作用域链。

上面写了那么多,在我看起来可以用下面的简单代码来表达:

  • var a = 1;
  • //全局作用域,只能访问全局变量,也就是a变量
  • function A(){
  • var b = 2;
  • //A函数的局部作用域,可以访问到a,b变量,但是访问不到c变量
  • function B(){
  • //B函数局部作用域,可以访问到a,b,c变量
  • var c = 3;
  • }
  • }

很明显的,貌似作用域方面,也没有什么好说的。可是,有时候,我们却不得不去访问一些局部作用域内部的东西,比如两个模块函数,使用了相同的数据,这里我们也只能把这些相同的数据放入全局变量,使得两个函数模块,都可以调用这些数据。

但是想想,如果这样的需求很多,那么不久需要很多很多的全局变量,而滥用全局变量的不好之处,前面也说了,所以这并不是一种好的写法。

减少全局变量

减少全局变量的方法,其实也很多,比如把一些相同类型的全局变量存入一个对象,那么就可以把这些类型的N多个全局变量,变成一个全局的对象,之后按照对象访问即可。

当然,我觉得吧,最简单,又好用的,还是在一个函数内部,继续定义函数,就像之前在函数A内部,定义了函数B,这样我们只需要一个函数A的执行,就可以完成一整个逻辑。内部的调用,都只能算是局部变量的调用,在全局只添加了一个函数A

比如:

  • function A(){
  • var arr = [];
  • function a(){};
  • function b(){};
  • return;
  • }

这样,我们本来需要三个全局变量的问题,就变成了只需要一个。当然,如何减少全局变量的方法是有很多种的,这里不做讨论。

这里,我们就讨论一种我们最常见的方法,也算是很常用的一种代码书写方法吧,它叫:闭包。

减少全局变量方法–闭包

说到闭包,我们首先来看一个最最简单的例子,也是最最基础的例子:为多个相同的元素,绑定事件,在点击每一个元素时,提示被点击元素的排列位置。

  • <div id = "test">
  • <p>栏目1</p>
  • <p>栏目2</p>
  • <p>栏目3</p>
  • <p>栏目4</p>
  • </div>

这样的结构

  • function bindClick(){
  • var allP = document.getElementById("test").getElementsByTagName("p"),
  • i=0,
  • len = allP.length;
  • for( ;i<len;i++){
  • allP[i].onclick = function(){
  • alert("you click the "+i+" P tag!");
  • //you click the 4 P tag!
  • }
  • }
  • }
  • bindClick();
  • //运行函数,绑定点击事件

这样的JS处理,看起来没有问题,可是在测试的时候,不管我们点击哪一个p标签,我们获取到的结果都是相同的,tell me why?说白了,这就是作用域到导致的一个问题。

下面来分析一下原因。首先呢,我们先把上述的JS代码给分解一下,让我们看起来更容易理解。

  • function bindClick(){
  • var allP = document.getElementById("test").getElementsByTagName("p"),
  • i=0,
  • len = allP.length;
  • for( ;i<len;i++){
  • allP[i].onclick = AlertP;
  • }
  • function AlertP(){
  • alert("you click the "+i+" P tag!");
  • }
  • }
  • bindClick();
  • //运行函数,绑定点击事件

这里应该没有什么问题吧,前面使用一个匿名函数作为click事件的回调函数,这里使用的一个非匿名函数,作为回调,完全相同的效果。也可以做下测试哦。

理解上面的说法了,那么就可以很简单的理解,为什么我们之前的代码,会得到一个相同的结果了。首先看一下for循环中,这里我们只是对每一个匹配的元素添加了一个click的回调函数,并且回调函数都是AlertP函数。这里当为每一个元素添加成功click之后,i的值,就变成了匹配元素的个数,也就是i=len,而当我们触发这个事件时,也就是当我们点击相应的元素时,我们期待的是,提示出我们点击的元素是排列在第几个,这个时候,click事件触发,执行回调函数AlertP,但是当执行到这里的时候,发现alert方法中,有一个变量是未知的,并且在AlertP的局部作用域中,也没有查找到相应的变量,那么按照作用域链的查找方式,就会向父级作用域去查找,这里的父级作用域中,确实是有变量i的,而i的值,却是经过for循环之后的值,i=len。所以也就出现了我们最初看到的效果。

了解了这里的原因,那么解决方法也就很简单了,控制这个作用域的问题呗,说白了,也就一个方法,那就是在回调函数中,用一个局部变量,来记录这个i的值,这样当再局部作用域中使用到i变量时,就会使用优先使用局部变量中的i变量的值。不会再去查找全局变量了。

所以呢,理解了这两段文字,那么如果我把代码写成下面的样式:

  • function bindClick(){
  • var allP = document.getElementById("test").getElementsByTagName("p"),
  • i=0,
  • len = allP.length;
  • for( ;i<len;i++){
  • allP[i].onclick = AlertP;
  • }
  • }
  • function AlertP(){
  • alert("you click the "+i+" P tag!");
  • }
  • bindClick();
  • //运行函数,绑定点击事件

分析一下,如果这段代码这样写,那么结果会是如何呢?

说到了这里,大概也能理解一下闭包的概念了,按照之前我们说的作用域链的说法,当一个函数运行时,该函数就会被推入作用域链的前端,当函数执行结束,这个函数就会被推出作用域链,并且销毁函数内部的局部变化和方法。

但是这里呢,当bindClick运行结束后,依然可以通过click事件访问到bindClick函数内部的i变量,说明bindClick函数内部的i变量,在bindClick结束后,并没有被销毁,这也就是闭包了。

2014.10.19-PS:发现上面的这段代码,是有问题的,这样的写法,在运行时,i的值会一直是undefined,因为这个时候,i是在AlertP内部和全局作用域中查找,而这两个作用域中,并没有i的定义,正确的写法,在文章的后面有说明,所以现在想不到当时为什么会这么写了。。汗一个~~
PS:闭包,说白了也就是在函数执行结束,作用域链将函数弹出之后,函数内部的一些变量或者方法,还可以通过其他的方法引用。

OK,回到正题,这里既然知道了需要一个局部变量的i值,可以解决这个问题,那么方法也就很简单了,按我们之前说的,变量按照可访问性的话,只分为全局变量和局部变量,那么这里的就很简单了,使用一个函数,构造一个局部变量即可。

方法1:使得绑定click事件的目标对象和变量i都变成局部变量。这里可以直接把这两者作为形参,传递给另外的一个函数即可。

  • function bindClick(){
  • var allP = document.getElementById("test").getElementsByTagName("p"),
  • i=0,
  • len = allP.length;
  • for( ;i<len;i++){
  • AlertP(allP[i],i);
  • }
  • function AlertP(obj,i){
  • obj.onclick = function(){
  • alert("you click the "+i+" P tag!");
  • }
  • }
  • }
  • bindClick();

这里,objiAlertP函数内部,就是局部变量了。click事件的回调函数,虽然依旧没有变量i的值,但是其父作用域AlertP的内部,却是有的,所以能正常的显示了,这里AlertP我放在了bindClick的内部,只是因为这样可以减少必要的全局函数,放到全局也不影响的。

这里是添加了一个函数进行绑定,如果我不想添加函数呢,当然也可以实现了,这里就要说到自执行函数了。说到自执行函数,不知道大家有什么理解,曾经有段事件,我实在是理解不到那种写法,为何叫做自执行函数,这里也顺便带一笔了。

有没有人,在刚开始接触到JS时,会这样绑定事件:obj.onclick = callback();

然后出错了却一直找不到错误在哪里,后来才之后,当一个函数名添加了括号之后,就是函数执行了,那么也就明白了,上面的写法,其实就是把callback函数执行后的返回结果作为了objclick事件的回调函数了。

而函数名的话,也就是一个function函数的引用吧,根据函数名查找到对应的function处理模块,所以这里很容易的也就想到了,自执行函数也就是直接在一个匿名函数的后面添加一对小括号,那么这个匿名函数就会自己执行了。所以也就是自执行函数了。

比如我们在页面加载之后,想要立即提示用户,页面加载完毕,我们习惯于这么写:

  • function loadSuccess(){
  • alert("page onload success!");
  • }
  • loadSuccess();

这是我们常用的方法,这里首先定义个函数,并把函数名命名为loadSuccess,之后调用这个函数。很常用很简单。

这里我们通常也可以使用自执行函数来完成这个提示,你就可以这样写:

  • (function(){
  • alert("page onload success!");
  • })();

完成相同的功能,这里必须把这个匿名函数放在小括号内部,不然浏览器会报错的。

原因呢,也是JS中的常识之一,那就是function A(){}这样的定义函数的方法,会在浏览器进行预编译的时候进行解析,而var A = function(){}这样的定义函数的方法,则是当JS解析到该行代码时,才会被解析。

这里呢,如果在上面的自执行函数中,不添加第一个小括号,浏览器就会在预编译时,对该部分进行解析,但是这个时候,因为没有对这部分function进行命名,浏览器在预编译时就会报错,而导致无法进行下去了。

使用下面这段函数,就可以证明,是在预编译的时候,报错的而导致无法执行的

  • alert("123");
  • function(){
  • alert("page onload success!");
  • }();

当然啦,加括号本就不是必须的,比如我们使用表达式定义函数时,var A = function(){}这种写法,就不是在预编译的时候进行的,所以,如果我们的自执行函数会把返回值定义到另外一个变量,是可以省略掉小括号的。

比如:

  • alert("123");
  • var a = function(){
  • alert("page onload success!");
  • }();

这样写也会连续有两个alert执行,完成我们之前说的功能,也不会报错,只是这时,自执行函数是没有返回值的,所以最后的a变量,是undefined。不过呢,为了统一起见,也为了看着方便,所以还是对各种写法的自执行函数的写法,都添加上小括号吧。

至于为什么,添加了小括号()(),这样写,就可以,那就是因为,这样的写法就变成一个表达式了。。。。

可以这么证明一下:

  • (function A(){
  • alert("page onload success!");
  • });
  • A();

只是这样的写法,和表达式定义函数就类似了,而且还会有一个问题就是,A函数,只有在这个括号内部使用。在外部使用,需要先把这个表达式进行赋值才行,如果赋值,那不就是成了使用赋值表达式定义函数了。

说的远了点,回来继续:到这里也大概了解了自执行函数的执行方法了吧。那使用自执行函数的方法,进行事件的绑定,大概也能猜到它的原理了吧。obj.onclick = callback();。如果我把callback函数的返回值,定义成一个函数,那当click事件触发时,不就是触发了这个返回的函数了。

所以呢,我们可以这样写:

  • function bindClick(){
  • var allP = document.getElementById("test").getElementsByTagName("p"),
  • i=0,
  • len = allP.length;
  • for( ;i<len;i++){
  • allP[i].onclick = AlertP(i);
  • }
  • }
  • function AlertP(i){
  • return function(){
  • alert("you click the "+i+" P tag!");
  • }
  • }
  • bindClick();

没有什么问题吧?应该很容易理解到吧。

可是这样的写法呢,添加了一个函数变量,如果不添加呢。。。OK的,把后面的函数直接替换过去就行了。。。。

  • function bindClick(){
  • var allP = document.getElementById("test").getElementsByTagName("p"),
  • i=0,
  • len = allP.length;
  • for( ;i<len;i++){
  • allP[i].onclick = function (i){
  • return function(){
  • alert("you click the "+i+" P tag!");
  • }
  • }(i);
  • }
  • }
  • bindClick();

这样看起来,对比之前的写法,应该就能很明显的了解到,为什么这么写,能得到我们想要的结果了吧。

OK,这也是闭包的最简单的应用了,其他的闭包写法也有,只是就原理方面来说,和上面这种是相同的原理,所以这里就不一一列举了,用到闭包的地方其实很多(比如惰性载入函数,单例模式中的对象定义等),如果您能理解到这最简单闭包的原理,那么其他用到闭包的地方,见到了,也就能理解了。或者说,想要使用的时候,也就能想到应该怎么用了吧。

之前的文章中,也有一篇文章中的代码,主要就是使用的闭包的思想,可以参考:jQuery源码学习(二)–proxy

备注

计时器在一些动态页面,做一些动画效果时,是不可或缺的一个元素,它和alert方法相同,都是属于window对象的方法。使用计时器时,是有少许差别的,这里就以setTimeout为例简单说明:

看例子:代码中中的两个setTimeout执行后的结果分别是什么?

  • var a = 1;
  • function B(){
  • var a = 2;
  • setTimeout("C()",1000);
  • setTimeout(C,2000);
  • function C(){
  • alert("a="+a);
  • }
  • }
  • function C(){
  • alert("a="+a);
  • }
  • B();

测试一下也就知道了,分别为12,因为setTimeout是把后面执行的方法,第一种写法,只会查找全局变量中,是否有A函数,而第二种写法,会优先查找当前作用域中是否有A函数,如果局部没有的话,则顺序查找到全局作用域中。

有一种情况,是说,计时器内部调用的函数的this指向,是指向window的,这里可以说有错,也可以说没错,看一个例子:假设给id=test的一个元素绑定一个click事件。查看其中的this的值。

  • document.getElementById("test").onclick = function(){
  • alert(this);            //指向触发该事件的元素对象
  • setTimeout("A()",1000); //这里调用指向window
  • }
  • function A(){
  • alert(this);
  • }

这里就不考虑在IE8-的浏览器了。

按照最初写的两个计时器的例子,在写出如下的代码:

  • document.getElementById("test").onclick = function() {
  • alert(this);             //指向触发该事件的元素对象
  • setTimeout(A,1000);      ////这里依然指向window
  • function A(){
  • alert(this);
  • }
  • };
  • function A(){
  • alert(this);
  • }

为什么?不是按理说,这里应该是调用的内部的A方法吗?为什么this却是指向的window

有一个不确定的想法是:当调用了计时器时,会把当前作用域中的方法,内部的this指向window对象了。而且仅仅是修改了方法内部的this指向,如果有私有变量的取值,依然按照原函数所在的位置,根据作用域,进行取值。

可以这么证明一下:

  • var a = 1;
  • document.getElementById("test").onclick = function() {
  • alert(this);
  • var a = 123;
  • setTimeout(A,1000);
  • function A(){
  • alert("a="+a);
  • alert(this);
  • }
  • }
  • function A(){
  • alert("a="+a);
  • alert(this);
  • }

this的指向是和上面一个实例相同的,而alert中的a变量的取值,却是优先获取局部作用域中的值。

当然啦,这里如果把计时器中的调用方法,更换一下,那结果就不相同了哦。

  • var a = 1;
  • document.getElementById("test").onclick = function() {
  • alert(this);
  • var a = 123;
  • setTimeout("A()",1000);
  • function A(){
  • alert("a="+a);
  • alert(this);
  • }
  • }
  • function A(){
  • alert("a="+a);
  • alert(this);
  • }

这里,有兴趣的可以试试吧,说到这里,也发现,虽然使用计时器会强制把调用函数的内部的this指向改变成指向window的,但是对于作用域链的影响却只有写法不同带来的影响。即:setTimeout("A()",1000);setTimeout(A,1000);的不同。当然对于第二种写法,我们可以使用callapply强行改变A内部this的指向,不过这些跟本文的内容,貌似没有什么关系,就不多说了。

其实,按照我本来的想法,这里该写一下计时器(setTimeout,setInterval)和call,apply这几个和作用域链的关系,但是写到这里,又感觉他们的并没有什么关系,所以关于作用域链,就到这里。

OK了,如果您有什么新的想法,或者认识,或者发现文中的错误,请指教,非常感谢!