Skip to main content
  1. Posts/

JS基础之作用域

·4 分钟

什么是作用域 #

作用域就是代码中定义变量的区域。

作用域规定了当前执行代码对变量的访问权限。

Javascript采用的是静态作用域。

静态作用域与动态作用域 #

静态作用域就是在函数定义的时候,它的作用域就被决定了。

动态作用域是函数在被调用的时候,作用域才被定义。

var value = 1;

function foo() {
    console.log(value);
}

function bar() {
    var value = 2;
    foo();
}

bar(); // 1

上面的例子执行了bar()函数,函数里又调用了foo()函数,既然js是静态作用域,那么就在它定义的地方去找valuefoo()函数内部没有定义value,就去上一层找,在全局定义了一个变量value = 1

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",因为是静态作用域,不管f()是在哪执行的,找的都是f()函数外部的那个声明变量。

但这两段代码还是有明显的不一样,那就是f()函数的执行上下文(执行环境)不同。

执行上下文 #

一般会认为,Javascript代码是顺序执行的,但如果遇到下面这种情况:

function foo() {
    console.log('foo1')
}
foo() // 'foo2'
function foo() {
    console.log('foo2')
}
foo() // 'foo2'

我们定义了两次foo(),但两次执行结果都是一样的,为什么?

因为JS引擎并不是一行一行对代码分析和执行的,而是一段一段地分析执行。上面的例子实际上触发了函数提升

那么这个一段要怎么去划分呢?这就是下面需要讲到的内容了。

执行上下文栈 #

JS的可执行代码类型有三种:全局代码、函数代码、eval代码。

如果执行到一个函数的时候,就会进行一个准备工作,这个准备工作就可以被认为是执行上下文。

但是代码中不可能只有一个函数,为了管理这些函数的执行上下文,JS引擎创建了执行上下文栈。

我们可以定义一个数组来模拟执行上下文栈的行为

ESCtack = []

首先遇到的肯定是全局代码,就将全局执行上下文压入到执行上下文栈中ECStack = [globalContext];,全局执行上下文直到整个应用结束了才会被弹出。

然后我们再回到这一节一开始的那个例子,它定义在全局,也就是那么多行代码作为一段代码globalContext一起压入了执行上下文栈中。

而重复声明foo函数,导致后面的函数覆盖了前面的,所以执行结果是一样的。为什么会覆盖呢?就得继续学习**变量对象**才能理解了。这里先按下不表。

我们来看另一段代码:

function fun3() {
    console.log('fun3')
}

function fun2() {
    fun3();
}

function fun1() {
    fun2();
}

fun1();

现在需要执行fun1()函数,这时会创建一个执行上下文并压入栈中,当要执行的时候发现函数了还调用了另一个函数fun2(),又要创建一个执行上下文压入栈中,但里面又调用了fun3(),再创建一个执行上下文压入栈中。没有其它函数代码了就开始按照后进先出的顺序执行函数,每执行一个就从栈中弹出。这段过程的伪代码可以写成这样:

// 伪代码

ECStack.push(<fun1> functionContext);

// fun1中竟然调用了fun2,还要创建fun2的执行上下文
ECStack.push(<fun2> functionContext);

// 擦,fun2还调用了fun3!
ECStack.push(<fun3> functionContext);

// fun3执行完毕
ECStack.pop();

// fun2执行完毕
ECStack.pop();

// fun1执行完毕
ECStack.pop();

// javascript接着执行下面的代码,但是ECStack底层永远有个globalContext

参考上面的代码,我们给上一节最后的那两段代码写一个执行上下文栈的流程:

ECStack.push(<checkscope> functionContext);
ECStack.push(<f> functionContext);
// f 执行完毕
ECStack.pop();
// checkscope 执行完毕
ECStack.pop();
ECStack.push(<checkscope> functionContext);
// checkscope 执行完毕
ECStack.pop();
ECStack.push(<f> functionContext);
// f 执行完毕
ECStack.pop();

变量对象 #

每个执行上下文里都有个变量对象(variable object),执行上下文中定义的所有变量和函数都保存在这个对象中,但我们无法访问这个对象。

上一节有提到JS的可执行代码有三种,它们都有执行上下文,而不同执行上下文的变量对象也会有点不同。

全局上下文 #

全局上下文的变量对象就是全局对象,也就是window对象。

所有对象都是Object构造函数的实例,window对象也不例外:

console.log(this instanceof Object); // true

全局对象预定义了一堆函数和属性,可以直接调用,也可以通过thiswindow调用:

// 都能生效
console.log(Math.random());
console.log(this.Math.random());
console.log(window.Math.random());

全局对象是全局变量的宿主

var a = 1;
console.log(window.a); // 1
console.log(this.a); // 1

this.window.b = 2;
console.log(this.b); // 2

函数上下文 #

在函数上下文中,变量对象变成了活动对象(activation object, AO),只有在进入一个执行上下文中时,这个执行上下文中的变量对象才被激活,而活动对象上的属性时可以被访问的。

执行流程 #

上文提到,JS代码在执行前会有个准备工作,也就是生成执行上下文,然后进入执行上下文,最后再执行。

进入执行上下文 #

这时代码并没有执行,变量对象包括:

  1. 函数的所有形参(Arguments对象)
    • 通过函数的arguments属性初始化,传入的所有值都会变成它的数组元素
    • 由名称和对应值组成的一个变量对象的属性被创建
    • 没有实参,属性值设为undefined
  2. 函数声明
    • 由名称和对应值(函数对象(function-object))组成一个变量对象的属性被创建
    • 如果变量对象已经存在相同名称的属性,则完全替换这个属性(函数提升)
  3. 变量声明
    • 由名称和对应值(undefined)组成一个变量对象的属性被创建
    • 如果变量名称跟已经声明的形式参数或函数相同,则变量声明不会干扰已经存在的这类属性

是不是看到函数提升了?前面那个例子还记得吗?声明了两次foo函数,后面的替换了前面的导致两次执行的结果是一样的。

回到正题,再举个例子:

function foo(a) {
  var b = 2;
  function c() {}
  var d = function() {};

  b = 3;

}

foo(1);

进入执行上下文时的AO是这样的:

AO = {
    arguments: {
        0: 1,
        length: 1
    },
    a: 1,
    b: undefined,
    c: reference to function c(){},
    d: undefined
}

代码执行 #

执行后AO的变化:

AO = {
    arguments: {
        0: 1,
        length: 1
    },
    a: 1,
    b: 3,
    c: reference to function c(){},
    d: reference to FunctionExpression "d"
}

其实变量对象和活动对象是同一个对象,只是处于执行上下文的不同生命周期,在没有进入执行上下文之前是无法访问的,进入之后可以访问,执行之后属性就根据代码完成了赋值。

思考题 #

function foo() {
    console.log(a);
    a = 1;
}

foo(); // 报错

function bar() {
    a = 1;
    console.log(a);
}
bar(); // 1

第一个函数会直接报错,因为并没有使用var对变量进行声明,所以不会被存放在AO当中。

而第二个函数虽然也没声明,但在打印变量前,已经将变量a赋值给了全局对象。

我们稍微改一个第一个函数:

function foo() {
    console.log(a);
    var a = 1;
}
foo(); // undefined

没有报错,但输出的是undefined,因为声明后会被加入到AO中,这就是为什么var会造成变量提升,但只是声明提升了,并没有赋值。 那如果改成这样呢:

function foo() {
    console.log(a);
}
a = 1;

foo(); // 1

输出的是1,这是因为a是全局对象,在进入函数的执行上下文的时候就已经声明并赋值完了。

再来一个例子:

console.log(foo);

function foo(){
    console.log("foo");
}

var foo = 1;

最终输出的是foo()函数,而不是1,因为进入执行上下文中时会先处理函数声明,然后才是变量声明,又如前面对变量声明对定义所说如果变量名称跟已经声明的形式参数或函数相同,则变量声明不会干扰已经存在的这类属性

作用域链 #

其实在执行上下文中不仅会有变量对象,还有作用域链和this,这一小节就来介绍一下作用域链。

前文提到,在查找变量时,首先会在当前执行上下文查找,如果没有就词法层面上的父级上下文里找,一直找到全局对象。

这样由多个执行上下文变量对象构成的链表叫做作用域链。

创建流程 #

一开始我们就有提到,JS使用的是静态作用域,当函数被定义的时候,作用域就被决定了。

因为函数内部有一个[[scope]]属性,当函数被创建时,会自动把父级变量对象保存在里面。

比如有这么一个函数:

function foo() {
    function bar() {
        
    }
}

函数里面还有个函数,它们各自的[[scope]]为:

foo.[[scope]] = [
    globalContext.VO
]

bar.[[scope]] = [
    fooContext.AO,
    globalContext.VO
]

[[scope]]属性并不是完整的作用域链。

当函数被激活之后,进入到执行上下文中创建VO/AO,将活动对象添加到作用域链的前端,这才是完整的作用域链:

Scope = [AO].concat([[scope]])

最后我们结合前几节的内容,加上执行上下文栈和变量对象来模拟一下它们的创建流程:

var scope = "global scope";
function checkscope(){
    var scope2 = 'local scope';
    return scope2;
}
checkscope();
  1. 函数被创建,保存父执行上下文变量对象到[[scope]]属性:
    checkscope.[[scope]] = [
        globalContext.VO
    ]
    
  2. 准备执行checkscope函数,创建执行上下文,将它压入到执行上下文栈中:
    ECStack = [
        checkscopeContext,
        globalContext
    ]
    
  3. 开始做准备工作,复制[[scope]]属性,开始创建作用域链
    checkscopeContext = {
        Scope: checkscope.[[scope]]
    }
    
  4. arguments创建活动对象,将其初始化,加入形参、函数声明、变量声明
    checkscopeContext = {
        AO: {
            arguments: {
                length: 0
            },
            scope2: undefined // 变量声明
        },
        Scope: checkscope.[[scope]]
    }
    
  5. 将活动对象压入作用域顶端
    checkscopeContext = {
        AO: {
            arguments: {
                length: 0
            },
            scope2: undefined // 变量声明
        },
        Scope: [AO, checkscope.[[scope]]]
    }
    
  6. 准备工作完毕,开始执行函数,改变AO属性值
    checkscopeContext = {
        AO: {
            arguments: {
                length: 0
            },
            scope2: 'local scope' // 变量声明
        },
        Scope: [AO, checkscope.[[scope]]]
    }
    
  7. 函数执行完毕,函数执行上下文从执行上下文栈中弹出
    ECStack = [
        globalContext
    ]
    

闭包 #

闭包是可以访问自由变量的函数。 啥是自由变量呢?就是指在函数中被使用,但既不是函数参数又不是函数的局部变量。

又有句话说,所有JS函数都是闭包,就比如:

var a = 1
function foo() {
    console.log(a)
}
foo()

foo()访问了全局对象里的变量a,再看看上面的定义,可不就是个闭包吗?

理论上确实如此,但实践中的闭包却不太一样。

从实践角度对闭包的定义是:

  1. 代码中使用了自由变量;
  2. 即使创建它的上下文已经销毁了,它依然存在。

我们结合上面作用域相关的知识来理解第二点:

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

var foo = checkscope();
foo();

参考前几节用为代码来谢谢这段代码的执行流程:

// 保存checkscope的父级执行上下文变量对象
checkscope.[[scope]] = [global.VO]
// checkscope执行上下文创建
ECStack = [checkscopeContext, globalContext]
// 进入checkscope执行上下文,先复制一份刚刚创建的不完整的作用域链
checkscopeContext = {
    Scope: checkscope.[[scope]]
}
// 然后初始化AO
checkscopeContext = {
    AO: {
        arguments: {
            length: 0
        },
        scope: undefined,
        f: reference to function f(){} 
    },
    Scope: checkscope.[[scope]]
}
// 把AO压入到作用域链顶端
checkscopeContext = {
    AO: {
        arguments: {
            length: 0
        },
        scope: undefined,
        f: reference to FunctionExpression "f"
    },
    Scope: [AO, checkscope.[[scope]]]
}
// 执行checkscope,改变AO
checkscopeContext = {
    AO: {
        arguments: {
            length: 0
        },
        scope: local scope,
        f: reference to function f(){}
    },
    Scope: [AO, checkscope.[[scope]]]
}
// 执行完毕,弹出执行上下文栈
ECStack = [globalContext]
// 准备执行f,保存f的父级执行上下文变量对象
f.[[scope]] = [checkscope.AO, global.VO]
// f的执行上下文压入执行上下文栈中
ECStack = [fContext, globalContext]
// 进入checkscope执行上下文,复制一份刚刚创建的不完整的作用域链
fContext = {
    Scope: [f.[[scope]]]
}
// 初始化AO,并压入作用域链顶端
fContext = {
    AO: {
        arguments: {
            length: 0
        },
    }
    Scope: [AO, f.[[scope]]]
}
// 后面就是执行出栈,和上面大同小异,就省略了

写了这么多,其实就是想要看看f()的作用域链里包含了些啥,f.[[scope]]里面存放了checkscope函数的AO,所以f()执行的时候要返回的scope的值会在作用域链里的checkscope.AO中找到,这就是为什么checkscopeContext已经销毁了,但依然能访问到里面的变量对象。这就是所谓的闭包!