js基础系列(三)——作用域和闭包

296 阅读10分钟

1、词法作用域和动态作用域

作用域是指程序源代码中定义变量的区域。 作用域规定了如何查找变量,也就是确定当前执行代码对变量的访问权限。 JavaScript 采用词法作用域(lexical scoping),也就是静态作用域

因为 JavaScript 采用的是词法作用域,函数的作用域在函数定义的时候就决定了。 而与词法作用域相对的是动态作用域,函数的作用域是在函数调用的时候才决定的。

这里我们用一个《JavaScript权威指南》中的例子来理解一下js的作用域(词法/静态作用域):

var scope = "global scope";
function checkscope(){
    var scope = "local scope";
    function f(){
        return scope;
    }
    return f();
}
checkscope();

var scope = "global scope";
function checkscope(){
    var scope = "local scope";
    function f(){
        return scope;
    }
    return f;
}
checkscope()();

两段代码都会打印:local scope。

 原因也很简单,因为JavaScript采用的是词法作用域,函数的作用域基于函数创建的位置。

JavaScript 函数的执行用到了作用域链,这个作用域链是在函数定义的时候创建的。嵌套的函数 f() 定义在这个作用域链里,其中的变量 scope 一定是局部变量,不管何时何地执行函数 f(),这种绑定在执行 f() 时依然有效。

2、JavaScript的作用域和作用域链

作用域:

在JavaScript中,我们可以将作用域定义为一套规则,这套规则用来管理引擎如何在当前作用域以及嵌套的子作用域中根据标识符名称进行变量查找。 这里的标识符,指的是变量名或者函数名 。

JavaScript中只有全局作用域与函数作用域。

 作用域与执行上下文是完全不同的两个概念。

JavaScript代码的整个执行过程,分为两个阶段,代码编译阶段与代码执行阶段。编译阶段由编译器完成,将代码翻译成可执行代码,这个阶段作用域规则会确定。执行阶段由引擎完成,主要任务是执行可执行代码,执行上下文在这个阶段创建。(简单总结就是:作用域是在编译阶段确定的,而执行上下文是在执行阶段确定的。)


作用域链

函数在调用激活时,会开始创建对应的执行上下文,在执行上下文生成的过程中,变量对象,作用域链,以及this的值会分别被确定。


作用域链,是由当前环境与上层环境的一系列变量对象组成,它保证了当前执行环境对符合访问权限的变量和函数的有序访问。

var a = 20;

function test() {
    var b = a + 10;

    function innerTest() {
        var c = 10;
        return b + c;
    }

    return innerTest();
}

test();

在上面这段代码中,全局,函数test,函数innerTest的执行上下文先后创建。我们设定他们的变量对象分别为VO(global),VO(test), VO(innerTest)。而innerTest的作用域链,则同时包含了这三个变量对象,所以innerTest的执行上下文可如下表示。

innerTestEC = {
    VO: {...},  // 变量对象
    scopeChain: [VO(innerTest), VO(test), VO(global)], // 作用域链
}

我们可以直接用一个数组来表示作用域链,数组的第一项scopeChain[0]为作用域链的最前端,而数组的最后一项,为作用域链的最末端,所有的最末端都为全局变量对象

用一个图来表示作用域链,如下:


作用域链是由一系列变量对象组成,我们可以在这个单向通道中,查询变量对象中的标识符,这样就可以访问到上一层作用域中的变量了。

以上内容参考了一篇大神的文章,原文链接www.jianshu.com/p/9b9848747…

3、JavaScript的执行上下文

在上面讲作用域链的时候也提到了执行上下文,这里我们再来详细解释一下:

函数在调用激活时,会开始创建对应的执行上下文,在执行上下文生成的过程中,变量对象,作用域链,以及this的值会分别被确定。而在上下文执行阶段,则会进行变量赋值,函数引用和其他代码的执行工作。

在一个JavaScript程序中,必定会产生多个执行上下文,JavaScript引擎会以栈的方式来处理它们,这个栈称为函数调用栈(call stack)。栈底永远都是全局上下文,而栈顶就是当前正在执行的上下文。  

var color = 'pink';

function changeColor() {
  var anotherColor = 'green';

  function swapColors() {
    var tempColor = anotherColor;
    anotherColor = color;
    color = tempColor;
  }

  swapColors();
}

changeColor();

以这段代码为例,我们用图来表示一下函数调用栈执行的过程:

对于执行上下文,总的来说:
单线程,同步执行,只有栈顶的上下文处于执行中,其他上下文需要等待 

全局上下文只有唯一的一个,它在浏览器关闭时出栈

函数的执行上下文的个数没有限制。每次某个函数被调用,就会有个新的执行上下文为其创建,即使是调用的自身函数,也是如此。

4、this

this的指向,是在函数被调用的时候确定的。也就是执行上下文被创建时确定的。在函数执行过程中,this一旦被确定,就不可更改了。

this的指向很灵活,即使是同一个函数由于调用者的不同,this会指向不同的对象。

var a = 10;
var obj = {
  a: 20
}

function fn() {
  console.log(this.a);
}

fn(); // 10
fn.call(obj); // 20

在一个函数上下文中,this由调用者提供,由调用函数的方式来决定。如果调用者函数,被某一个对象所拥有,那么该函数在调用时,内部的this指向该对象。如果函数独立调用,那么该函数内部的this,则指向undefined。但是在非严格模式中,当this指向undefined时,它会被自动指向全局对象。

JavaScript内部提供了一种机制,让我们可以自行手动设置this的指向。它们就是call与apply。所有的函数都具有这两个方法。它们的第一个参数都为this将要指向的对象。

function fn() {
  console.log(this.a);
}
var obj = {
  a: 20
}

fn.call(obj);  //20

5、闭包

函数和对其周围状态(lexical environment,词法环境)的引用捆绑在一起构成闭包closure)。也就是说,闭包可以让你从内部函数访问外部函数作用域。在 JavaScript 中,每当函数被创建,就会在函数生成时生成闭包。(MDN)

来看个例子:

function makeFunc() {
    var name = "Mozilla";
    function displayName() {
        alert(name);
    }
    return displayName;
}

var myFunc = makeFunc();
myFunc();

JavaScript中的函数会形成了闭包。 闭包是由函数以及声明该函数的词法环境组合而成的。该环境包含了这个闭包创建时作用域内的任何局部变量。在本例子中,myFunc 是执行 makeFunc 时创建的 displayName 函数实例的引用。displayName 的实例维持了一个对它的词法环境(变量 name 存在于其中)的引用。因此,当 myFunc 被调用时,变量 name 仍然可用,其值 Mozilla 就被传递到alert中。

所以,通过闭包,我们可以在其他的执行上下文中,访问到函数的内部变量。

再看一个例子:

var fn = null;
function foo() {
  var a = 2;
  function innnerFoo() {
    console.log(a);
  }
  fn = innnerFoo; // 将 innnerFoo的引用,赋值给全局变量中的fn
}
function bar() {
  fn(); // 此处的保留的innerFoo的引用
}
foo();
bar(); // 2

在上面的例子中,foo()执行完毕之后,按照常理,其执行环境生命周期会结束,所占内存被垃圾收集器释放。但是通过fn = innerFoo,函数innerFoo的引用被保留了下来,复制给了全局变量fn。这个行为,导致了foo的变量对象,也被保留了下来。于是,函数fn在函数bar内部执行时,依然可以访问这个被保留下来的变量对象。所以此刻仍然能够访问到变量a的值。这样,我们就可以称foo为闭包。(闭包foo的作用域链如下图所示)


这里我给出自己的一点理解:闭包就是能够读取其他函数内部变量的函数。 由于在Javascript语言中,只有函数内部的子函数才能读取局部变量,因此可以把闭包简单理解成"定义在一个函数内部的函数"。 所以,在本质上,闭包就是将函数内部和函数外部连接起来的一座桥梁。(就是MDN中定义的:函数和其周围的词法环境引用捆绑在一起构成闭包)

闭包可以用在许多地方。它的最大用处有两个,一个是前面提到的可以读取函数内部的变量,另一个就是让这些变量的值始终保持在内存中(如上述的例2,通过闭包保留了foo的作用域)。

使用闭包的注意点  :

1)由于闭包会使得函数中的变量都被保存在内存中,内存消耗很大,所以不能滥用闭包,否则会造成网页的性能问题,在IE中可能导致内存泄露。解决方法是,在退出函数之前,将不使用的局部变量全部删除。

 2)闭包会在父函数外部,改变父函数内部变量的值。所以,如果你把父函数当作对象(object)使用,把闭包当作它的公用方法(Public Method),把内部变量当作它的私有属性(private value),这时一定要小心,不要随便改变父函数内部变量的值。

6、堆栈溢出和内存泄漏

堆栈溢出

什么是堆栈溢出?我们知道JS中的数据存储分为栈和堆,程序代码运行都需要一定的计算存储空间,就是栈了,栈遵循先进后出的原则,所以程序从栈底开始运行计算,程序内部函数的调用以及返回会不停的执行进栈和出栈的操作,栈内被所占的资源也在不断的对应变化,但是一旦你的调用即进栈操作过多,返回即出栈不够,这时候就会导致栈满了,再进栈的就会溢出来。举个例子:就好像你给你爸倒酒,你一边他一边喝,但是你倒的太快了,他喝不过来,最终导致酒溢出来洒在地上了,这时候你老爸就要揍你了。虽然JS堆栈溢出你不会被揍,但是它会报错然后罢工了(如下)。

Uncaught RangeError: Maximum call stack size exceeded

这个错误的意思就是:最大调用超过堆栈大小。因为程序在执行代码过程中,需要一定的计算空间即栈,一般大小为1M左右,当你每次调用程序内的函数时,就会占用一定的空间,当占用过多时,就会超过该程序所分配的栈的空间,就会报错了。

内存泄漏

内存泄漏是指程序被分配的栈内有一块内存既不能使用,也不能被回收,简单点说就是占着茅坑不拉屎(对不起,请原谅我的粗鄙之语)。

导致内存泄漏的原因一般有一下几种情况:

a)全局变量,如下:

function foo(){
    b=10;
    console.log(b);
}

b)闭包

这个我们在讲闭包的时候说过的,可以通过闭包保存函数的作用域不被回收,这就会带来内存泄漏问题。如果闭包不被调用,由于闭包会携带包含它的函数的作用域,因此会比其他函数占用更多的内存。过度使用闭包可能会导致内存占用过多。

c)dom引用,如下:

var elements = {
    button: document.getElementById('button'),
    image: document.getElementById('image')
};
function doStuff() {
    elements.image.src = 'http://example.com/image_name.png';
}
function removeImage() {
    document.body.removeChild(document.getElementById('image'));
}

在上述代码中我们在两个地方保存了对image元素的引用,当函数removeImage执行时尽管image元素被删除,但是全局变量elements中仍然存在对button元素的引用,内存回收时不会将该元素回收。

d)未被清除的定时器

结语

欢迎大家在评论区给出意见哟~~