「学习笔记」深入理解JavaScript

306 阅读9分钟

执行上下文 EC

JS控制器转到可执行代码的时候就会进入到可执行上下文(EC)。EC是抽象的概念,需要和可执行代码(executable code)概念进行区分。活动的执行上下文会组成一个堆栈。堆栈的底部是全局上下文,顶部就是当前活动的执行上下文。进入上下文,上下文被推入堆栈。离开上下文,上下文弹出堆栈。

全局上下文

初始的上下文堆栈, 全局上下文不包含任何function代码。

# 执行上下文堆栈
ECStack = [
  globalContext,
]

函数代码

当进入function的时候,ECStack都会被推入新的元素(函数的执行上下文),但是不包括还没有执行的嵌套函数的上下文。return的时候或者异常没有catch的时候,ECStack顶部的执行上下文会被弹出。当所有代码执行完成后,ECStack中只会存在一个全局执行上下文。

function foo (trigger) {
  if (trigger) {
    return
  }
  // 递归调用
  foo(true);
}

foo()
# 第一次调用foo函数
ECStack = [
  <foo>functionContext,
  globalContext,
]
# 递归第二次调用foo函数
ECStack = [
  <foo>functionContext - recursion,
  <foo>functionContext,
  globalContext,
]
# 递归的函数return
ECStack.pop()
# foo函数执行完成后
ECStack.pop()
# 此是ECStack
ECStack = [
  globalContext,
]

调用上下文

调用上下文是eval函数调用时产生的上下文(calling context)。在汤姆大叔博客说,在firefox中,eval中实现了第二个参数,可以用于传递上下文,在指定的上下文中执行eval。但是我在Chrome中,实验了一下,Chrome没有实现。

eval('var x = 10');

(function foo() {
  eval('var y = 20');
})();
// 上述代码EC的变化过程

ECStack = [
  globalContext
];

ECStack = [
  evalContext,
  callingContext: globalContext
];

ECStack.pop();

ECStack = [
  callingContext: <foo> functionContext,
  globalContext
];

ECStack = [
  evalContext,
  callingContext: <foo> functionContext,
  globalContext
];

ECStack.pop();

ECStack.pop();

ECStack = [
  globalContext
];

变量对象/活动对象

变量对象VO, 是一个与执行上下文相关的特殊属性,它存储着上下文中以下内容:

  1. 变量
  2. 函数的声明(不包含函数表达式)
  3. 函数的形参
// VO,是执行上下文的属性
activeExecutionContext = {
  VO: {
  }
}

全局上下文的VO,可以通过VO的属性名称直接进行访问。全局对象自身就是VO。在其他上下文是无法直接访问VO对象的

var a = 10;
function foo(x) {
  var b = 20;
};
foo(30);
# 上下文堆栈和VO对象
ECStack = [
  # foo的上下文
  functionContext: {
    VO: {
      x: 30,
      b: 20,
    }
  }
  # 全局上下文
  globalContext: {
    VO: {
      a: 10
      foo: <reference to function>
    }
  }
]

全局上下文变量对象

全局对象是在进入任何上下文之前就已经创建的对象,全局对象只存在一份。全局对象在创建阶段,会将Math、String、Date、parseInt作为自身属性,进行初始化。其他的全局变量也会当作全局对象的属性。全局上下文的VO,就是全局对象自己 globalContext.VO === global

// 全局对象
global: {
  Math,
  Date,
  window: global // 引用自身
}

函数上下文的变量对象

函数执行上下文中VO无法直接访问。在函数的执行上下文中使用活动对象AO替代变量对象VO的功能

AO是在进入函数执行上下文时被创建的,通过arguments属性初始化,arguments属性的值Arguments对象。Arguments对象的length属性是实参的个数,callee是当前函数的引用。


AO = {
  arguments: Arguments
};

处理上下文代码的2个阶段

通过这节,我们可以了解到为什么在js中变量的值,默认是undefined。

执行上下文中代码分为两个阶段进行处理:

1. 进入执行上下文 2. 执行代码

阶段:进入执行上下文

在代码进入执行上下文时,还没有开始执行时,VO/AO就已经包含了下面的属性

  • 形参, 形参会作为AO对象的属性,如果传参了属性会被赋值,否则是undefined。
  • 函数声明,函数名和函数的内容作为变量对象的key和value(函数声明的优先级会高于变量声明,如果变量声明与函数声明有相同属性,函数声明会替代变量声明)
  • 变量声明,如果变量声明和形参和函数声明的属性相同,变量声明不会干扰。(变量的声明在形参和函数声明之后。)
function Foo (a, b) {
  var c = 10;
  var d = 10;
  function d() {}
  var e = function _e() {};
  (function x() {});
}

Foo(10);

此时的AO对象如下。AO对象不包含x函数,x函数不是函数声明,而是函数表达式。x不存在VO/AO中。未保存的函数表达式,只能在自己的执行上下文中被调用。由于函数声明优先,所以在进入执行上下文的阶段VO变量对象的d属性目前是函数,而不是数字。

// 进入执行上下文但是还没有执行的代码的活动对象
AO = {
  a: 10,
  b: undefined,
  c: undefined,
  d: <reference to FunctionDeclaration "d">,
  e: undefined
}

阶段:执行上下文中的代码

接下来进入代码执行阶段,此时AO/VO对象都已经有了属性,但是属性值还是undefined,代码执行阶段VO属性会被更新赋值。

// 代码执行阶段,AO活动对象的值被更新。
AO = {
  a: 10,
  b: undefined,
  c: 10,
  d: 10, // 代码在执行阶段,属性d会被赋值为10
  e: undefined
}

所以为什么js中变量的默认值是undefined的?因为在进入执行上下文的阶段,VO/AO对象的属性值就被赋予了undefined,所以js变量值默认是被赋予了undefined,而不是天生就是undefined。

所以下面的代码,即使永远走不到b的分支,b也会赋值为undefined,这是因为在进入执行上下文的阶段放入VO中被赋值为undefined。

if (true) {
  var a = 1;
} else {
  var b = 2;
}
 
alert(a); // 1
alert(b); // undefined

作用域链

在JS中允许嵌套函数,每一个函数都有自身的变量对象VO,由于函数无法直接访问VO,对于函数来说就是AO活动对象。而作用域链则是AO的列表。作用域链作用是用于变量的查询。

函数的执行上下文作用域链,在函数调用时创建,包含了VO/AO属性,以及[[scope]]属性。而作用域链等于VO + [[scope]]属性

ECStack = [
  functionContext: {
    VO: {},
    this: thisValue,
    [[scope]]: [...],
    Scope: [...] // 作用域链( VO + [[scope]])
  }
]

[[scope]]属性

[[scope]]是挡墙执行上下文的属性,[[scope]]属性包含所有父变量对象(VO)的层级链。[[scope]]在函数创建时被创建并存储,不可变。直到函数被销毁,也就是说作用域链在定义函数时就已经确定的,即使函数不被调用,它的的[[scope]]属性也已经被写入了。

function foo () {}

// foo函数的`[[scope]]`属性
// 包含了父变量对象,比如全局对象的VO
fooContext.[[scope]] = [
  globalContext.VO
]

函数激活

进入上下文,创建AO/VO对象后,AO/VO对象会添加Scope属性的第一位,这对于标示符解析很重要,因为变量名的查找是从最深处开始的,由于当前的AO/VO对象会放到第一位,所以局部的变量优先级在查找时会高于父作用域的变量。

Scope属性 = [当前执行上下文的AO/VO, [[scope]]]

下面是一个例子

var x = 10;
function foo() {
  var y = 20;
  function bar() {
    var z = 30;
  }
  bar();
}
foo();

上面函数的执行上下文堆栈如下


ECStack = [
  <bar>functionContext: {
    [[scope]]: [
      <foo>functionContext.VO
      globalContext.VO
    ],
    VO: {
      z: 30
    },
    Scope: [
      <bar>functionContext.VO,
      <foo>functionContext.VO,
      globalContext.VO
    ]
  },
  <foo>functionContext: {
    [[scope]]: [
      globalContext.VO
    ],
    VO: {
      y: 20,
      bar: <reference to function>
    }
    Scope: [
      <foo>functionContext.VO,
      globalContext.VO
    ]
  },
  globalContext: {
    VO: {
      x: 10
      foo: <reference to function>
    },
  },
]

eval与作用域链

代码eval的上下文与当前的调用上下文(calling context)拥有同样的作用域链

代码执行对作用域链的影响

在代码执行阶段with和catch会修改作用域链,它们会被添加到作用域的最前端

当遇到with或者catch时,Scope = [AO|VO, [[Scope]]] ==> [withObject|catchObject, AO|VO, [[Scope]]]

var foo = {x: 10, y: 20};
 
with (foo) {
  alert(x); // 10
  alert(y); // 20
}

被修改的作用域链

Scope = [foo, AO|VO, [[Scope]]]

var x = 10, y = 10, z = 10;
 
with ({x: 20, z: 20}) {
  // 这里修改的是with增加的x
  // 但是y修改的是外面的y
  var x = 30;
  var y = 30;
  
  // 这里访问的with增强的x和z
  alert(x); // 30
  alert(y); // 30
  alert(z); // 20
}

// 
alert(x); // 10
alert(y); // 30
alert(z); // 10

在with执行完成后,它的特定对象从作用域链中移除(已改变的变量x,z也从那个对象中移除),即作用域链的结构恢复到with得到加强以前的状态。所以x,z变为了10.

catch语句也会创建一个新属性的对象,属性名是异常参数名

try {
  ...
} catch (ex) {
  alert(ex);
}
var catchObject = {
  ex: <exception object>
};
 
Scope = catchObject + AO|VO + [[Scope]]

闭包

闭包是代码块和创建该代码块的上下文中数据的结合

函数的父级上下文数据保存在函数的内部属性[[scope]]中,由于js是使用静态词法作用域的语言,包含了父级上下文的数据的[[scope]]属性,在函数创建时就已经保存了,不管函数有没有调用。

因为作用域链,在js中所有函数都是闭包,因为它们在创建的时候,都保存了上层上下文的作用域链。

var x = 10;

function foo() {
  alert(x);
}
// foo就是一个闭包
foo: <FunctionObject> = {
  [[Call]]: <code block of foo>,
  [[Scope]]: [
    global: {
      x: 10 // foo的[[scope]],包含了父变量对象
    }
  ],
};

引用同一个[[scope]]

在同一个上下文中创建的闭包,共用一个[[scope]]属性,对[[scope]]的修改,会影响到其他闭包。也就是说同一个上下文创建的闭包,共享同一个父作用域。

var foo;
var bar;

function test() {
  var x = 1;
  foo = function () { return ++x; };
  bar = function () { return --x; };
  x = 2;
  // 3
  alert(firstClosure());
}
test();
alert(foo()); // 4
alert(bar()); // 3

一个经典的例子,下面的例子中打印都是3,因为k是同一个父作用域的k

var data = [];
for (var k = 0; k < 3; k++) {
  data[k] = function () {
    alert(k);
  };
}
data[0](); // 3
data[1](); // 3
data[2](); // 3

改造下, 打印的结果是0,1,2,因为IFEE返回的函数表达式打印的是_helper函数的活动对象的属性x。但是如果打印的是k,结果还是3。


var data = [];
for (var k = 0; k < 3; k++) {
  data[k] = (function _helper(x) {
    return function () {
      alert(x);
    };
  })(k);
}
data[0](); // 0
data[1](); // 1
data[2](); // 2

参考