作用域

上下文栈

JavaScript 是一种单线程编程语言,这意味着它只有一个 Call Stack 。因此,它一次仅能做一件事。

Call Stack 是一个数据结构,它基本上记录了我们在程序中的所处的位置。如果我们进入一个函数,我们把它放在堆栈的顶部。如果我们从一个函数中返回,我们弹出堆栈的顶部。这是所有的堆栈可以做的东西。

我们来看一个例子。看看下面的代码:

function multiply(x, y) {
    return x * y;
}
function printSquare(x) {
    var s = multiply(x, x);
    console.log(s);
}
printSquare(5);

当引擎开始执行这个代码时,Call Stack 将会变成空的。之后,执行的步骤如下: image

Call Stack 的每个入口被称为 Stack Frame(栈帧)。

这正是在抛出异常时如何构建 stack trace 的方法 - 这基本上是在异常发生时的 Call Stack 的状态。看看下面的代码:

function foo() {
    throw new Error('SessionStack will help you resolve crashes :)');
}
function bar() {
    foo();
}
function start() {
    bar();
}
start();

如果这是在 Chrome 中执行的(假设这个代码在一个名为 foo.js 的文件中),那么会产生下面的 stack trace: image

image

执行上下文

每个函数执行的时候都会进行以下操作

  1. 内存创建
  • 提升优先度
  • 创建作用域链
  • 设置上下文 this
  1. 代码执行

提升优先度

在JavaScript中,一个作用域(scope)中的名称(name)有以下四种:

优先度:

  1. 语言自身定义(Language-defined): 所有的作用域默认都会包含this和arguments。
  2. 函数形参(Formal parameters): 函数有名字的形参会进入到函数体的作用域中。
  3. 函数声明(Function decalrations): 通过function foo() {}的形式
  4. 变量声明(Variable declarations): 通过var foo;的形式。

函数声明和变量声明总是被JavaScript解释器隐式地提升(hoist)到包含他们的作用域的最顶端。很明显的,语言自身定义和函数形参已经处于作用域顶端。这就像下面的代码:

function foo() {
    bar();
    var x = 1;
}

实际上被解释成像下面那样:

function foo() {
    var x;
    bar();
    x = 1;
}

结果是不管声明是否被执行都没有影响。下面的两段代码是等价的:

function foo() {
    if (false) {
        var x = 1;
    }
    return;
    var y = 1;
}
function foo() {
    var x, y;
    if (false) {
        x = 1;
    }
    return;
    y = 1;
}

注意到声明的赋值部分并没有被提升(hoist)。只有声明的名称被提升了。这和函数声明不同,函数声明中,整个函数体也都会被提升。但是请记住,声明一个函数一般来说有两种方式。考虑下面的JavaScript代码:

function test() {
    foo(); // TypeError "foo is not a function"
    bar(); // "this will run!"
    var foo = function () { // 函数表达式被赋值给变量'foo'
        alert("this won't run!");
    }
    function bar() { // 名为'bar'的函数声明
        alert("this will run!");
    }
}
test();

在这里,只有函数声明的方式会连函数体一起提升,而函数表达式中只会提升名称,函数体只有在执行到赋值语句时才会被赋值。

以上就包括了所有关于提升(hoisting)的基础,看起来并没有那么复杂或是令人困惑对吧。但是,这是JavaScript,在某些特殊情况下,总会有那么一点复杂。

注意情况

覆盖

如果我们在作用域中重复地声明同名函数,则会由后者覆盖前者:

sayHello();

function sayHello () {
    function hello () {
        console.log('Hello!');
    }

    hello();

    function hello () {
        console.log('Hey!');
    }
}

// Hey!

函数表达式

而 JavaScript 中提供了两种函数的创建方式,函数声明(Function Declaration)与函数表达式(Function Expression);函数声明即是以 function 关键字开始,跟随者函数名与函数体。而函数表达式则是先声明函数名,然后赋值匿名函数给它;典型的函数表达式如下所示:

var sayHello = function() {
  console.log('Hello!');
};

sayHello();

// Hello!

函数表达式遵循变量提升的规则,函数体并不会被提升至作用域头部:

sayHello();

function sayHello () {
    function hello () {
        console.log('Hello!');
    }

    hello();

    var hello = function () {
        console.log('Hey!');
    }
}

// Hello!

避免全局变量

在 ES6 中可以利用 let 关键字来声明本地变量,好的 JavaScript 代码就是没有定义全局变量的。在 JavaScript 中,我们有时候会无意间创建出全局变量,即如果我们在使用某个变量之前忘了进行声明操作,那么该变量会被自动认为是全局变量,譬如:

function sayHello(){
  hello = "Hello World";
  return hello;
}
sayHello();
console.log(hello);

在上述代码中因为我们在使用 sayHello 函数的时候并没有声明 hello 变量,因此其会创建作为某个全局变量。如果我们想要避免这种偶然创建全局变量的错误,可以通过强制使用 strict mode 来禁止创建全局变量。

const与let

我们先来看一个例子:

var a = 1
let b = 1
const c = 1
console.log(window.b) // undefined
console.log(window. c) // undefined

function test(){
  console.log(a)
  let a
}
test()

首先在全局作用域下使用 let 和 const 声明变量,变量并不会被挂载到 window 上,这一点就和 var 声明有了区别。

再者当我们在声明 a 之前如果使用了 a,就会出现报错的情况 image 你可能会认为这里也出现了提升的情况,但是因为某些原因导致不能访问。

首先报错的原因是因为存在暂时性死区,我们不能在声明前就使用变量,这也是 let 和 const 优于 var 的一点。然后这里你认为的提升和 var 的提升是有区别的,虽然变量在编译的环节中被告知在这块作用域中可以访问,但是访问是受限制的。

称为暂时死区

作用域链

进入执行上下文

当进入执行上下文时,这时候还没有执行代码 变量对象会包括:

  1. 函数的所有形参 (如果是函数上下文)
  • 由名称和对应值组成的一个变量对象的属性被创建
  • 没有实参,属性值设为 undefined
  1. 函数声明
  • 由名称和对应值(函数对象(function-object))组成一个变量对象的属性被创建
  • 如果变量对象已经存在相同名称的属性,则完全替换这个属性
  1. 变量声明
  • 由名称和对应值(undefined)组成一个变量对象的属性被创建;
  • 如果变量名称跟已经声明的形式参数或函数相同,则变量声明不会干扰已经存在的这类属性 举个例子:
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"
}

到这里变量对象的创建过程就介绍完了,让我们简洁的总结我们上述所说:

  1. 全局上下文的变量对象初始化是全局对象
  2. 函数上下文的变量对象初始化只包括 Arguments 对象
  3. 在进入执行上下文时会给变量对象添加形参、函数声明、变量声明等初始的属性值
  4. 在代码执行阶段,会再次修改变量对象的属性值

作用域链创建

函数的作用域在函数定义的时候就决定了。 这是因为函数有一个内部属性 [[scope]],当函数创建的时候,就会保存所有父变量对象到其中,你可以理解 [[scope]] 就是所有父变量对象的层级链,但是注意:[[scope]] 并不代表完整的作用域链! 举个例子:

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

函数创建时,各自的[[scope]]为:

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

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

函数激活

当函数激活时,进入函数上下文,创建 VO/AO 后,就会将活动对象添加到作用链的前端。 这时候执行上下文的作用域链,我们命名为 Scope:

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

至此,作用域链创建完毕。

捋一捋

以下面的例子为例,结合着之前讲的变量对象和执行上下文栈,我们来总结一下函数执行上下文中作用域链和变量对象的创建过程:

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

执行过程如下: 1.checkscope 函数被创建,保存作用域链到 内部属性[[scope]]

checkscope.[[scope]] = [
    globalContext.VO
];

2.执行 checkscope 函数,创建 checkscope 函数执行上下文,checkscope 函数执行上下文被压入执行上下文栈

ECStack = [
    checkscopeContext,
    globalContext
];

3.checkscope 函数并不立刻执行,开始做准备工作,第一步:复制函数[[scope]]属性创建作用域链

checkscopeContext = {
    Scope: checkscope.[[scope]],
}

4.第二步:用 arguments 创建活动对象,随后初始化活动对象,加入形参、函数声明、变量声明

checkscopeContext = {
    AO: {
        arguments: {
            length: 0
        },
        scope2: undefined
    },
    Scope: checkscope.[[scope]],
}

5.第三步:将活动对象压入 checkscope 作用域链顶端

checkscopeContext = {
    AO: {
        arguments: {
            length: 0
        },
        scope2: undefined
    },
    Scope: [AO, [[Scope]]]
}

6.准备工作做完,开始执行函数,随着函数的执行,修改 AO 的属性值

checkscopeContext = {
    AO: {
        arguments: {
            length: 0
        },
        scope2: 'local scope'
    },
    Scope: [AO, [[Scope]]]
}

7.查找到 scope2 的值,返回后函数执行完毕,函数上下文从执行上下文栈中弹出

ECStack = [
    globalContext
];

外部呈现

image

闭包

什么闭包

MDN 对闭包的定义为:

闭包是指那些能够访问自由变量的函数。

什么是自由变量

自由变量是指在函数中使用的,但既不是函数参数也不是函数的局部变量的变量

宏观到微观看待作用域链

结合上文的 作用域链 image

变量、函数提升

程序运行前会进行一次预编译。把变量、函数提升。可以理解把var,function放到程序的顶部。但是其实没有移动,只是放在内存中。然后才开始运用程序。 注意: 覆盖

  • 同名var变量声或者声明函数会被后面覆盖
  • 函数声明能覆盖变量声明,但是变量声明不能覆盖函数声明
  • 函数表达式遵循变量提升的规则,函数体并不会被提升至作用域头部
  • let 和const也会暂时性死区,只有声明后才能使用

上下文

每当运行到函数会先放进callStack 调用栈,栈是以先进先出的原则。一个一个执行。当函数执行的时候引擎会为他创造一个上下文,但是没有执行 上下文会有活动变量AO 会包括

  • 函数的形成
  • 函数声明
  • 变量声明
AO = {
    arguments: {
        0: 1,
        length: 1
    },
    a: 1,
    b: undefined,
    c: reference to function c(){},
    d: undefined
}

函数执行

会有scope内部属性,会保存父级AO,而且是个递归,一直到全局的scope为止。 然后函数一行一行运行修改当前的AO.

那么就会从宏观上看到子函数能访问到父函数,因为子函数scope里面有父函数的scope,通过scope链能访问到父函数的AO,但是父函数的scope没有子函数scope,所有没法访问

this