手撕作用域与上下文

| 字数 3156
  1. 1. 作用域
  2. 2. 上下文(即我们常说的 this)
  3. 3. 执行环境(执行上下文/执行上下文环境)
  4. 4. 补充
    1. 4.1. 执行环境与作用域的关系
    2. 4.2. 如何在 callback 中绑定this
  1. 1. 作用域
  2. 2. 上下文(即我们常说的 this)
  3. 3. 执行环境(执行上下文/执行上下文环境)
  4. 4. 补充
    1. 4.1. 执行环境与作用域的关系
    2. 4.2. 如何在 callback 中绑定this

 先来抛出结论:

  1. 作用域与上下文肯定不是一回事
  2. 作用域是由 function 进行声明的而非代码块({ })。
  3. 除了全局作用域,函数只要被声明(创建了),它就有了独立的作用域。
  4. 我们常说的上下文指的是 this,这里其实对 this 更准确的说法应该被称为函数上下文(function context)
  5. 各大网文与部分书籍中所讲解的上下文,其实是执行环境(execution context)有的地方也称为执行上下文/执行上下文环境。这个执行环境不仅确定了 this (即我们常说的上下文对象),还确定了将各个作用域联系起来的作用域链
  6. 执行环境并不是我们常说的上下文,而是用来确定它的指向。
  7. 本文中将采用高程3的说法 — 执行环境。

作用域

在 Javascript 中,作用域是由 function 声明的,而不是代码块。声明的作用域创建于代码块,但不终于代码块(其他语言终于代码块)。查看以下代码:

1
2
3
4
if (window) {
 var x = '123';
}
alert(x);

在其它语言中,x 终结于大括号关闭处,alert 弹出 undefined。

但是这里还是会出现 123,这是因为Javascript 中并没有块级作用域的概念。这样看起来很简单,但是其中还是有一些细微的差别。如下:

  1. 变量声明的作用域开始于声明开始的地方,结束于所在函数的结尾。
  2. 函数可以在其作用域范围内被提前引用(被提升),但变量不行。
  3. 对于作用域声明,全局作用域就像一个包含页面所有代码的超大型函数。

函数提升的详细原因参照下述的 三、执行环境 中的内容

来看下面代码:

对于这段代码,执行调用 outer() 时,outer 函数中按照从上到下的顺序执行代码,当进入第2行时(在 outer 中,变量 a 声明前),inner 已经在作用域(scope)中,此时作用域中有 outer () 与 inner()。之后执行第 2 行,现在 a 也在 scope 中。当进入第 4 行时(在 outer 中,inner() 与 a 之后),由于函数声明提前这里第 3 行相当于已经提前执行,所以越过第 3 行,直接执行第 4 行。后面过程以此类推。

上下文(即我们常说的 this)

在开始上下文之前我们需要明白我们研究的 this 是从哪里来的。其实这个问题很简单,在我们调用函数的时候关注点总是在可以看到的函数参数上面,而没有注意到俩个隐式(implicit)参数—— arguments 与 this。arguments 参数是传递给函数的一个所有参数的集合,它本质不是数组但是有 length 属性,所以我们更喜欢叫它类数组。this 参数引用了与该函数调用进行隐式关联的一个对象(这里需要注意,上下文是一个对象!),被称为函数上下文(function context)

不同的方法进行函数调用决定了函数上下文的不同。总结如下:

  1. 作为普通函数进行调用时,其上下文是全局对象(window)。
  2. 作为方法调用的时候,其上下文是拥有该方法的对象。
  3. 作为构造器进行调用时,其上下文是一个新分配的对象。
  4. 通过函数的 apply/call 方法进行调用时,上下文可以设置成任意值。

对于上述的 1、2 点我们来看以下代码:

1-4 行代码,都是作为普通函数调用,上下文为window。6-12 行代码,都是做为方法调用,上下文是拥有该方法的对象(ninja1、ninja2)。对于第
3 点我们来看下列代码:

这时的上下文指新创建的 ninja ,通过第 8 行代码测试,我们发现 skulk 方法返回的是构造器对象本身。对于第 4 点我们来看下列代码:

通过 apply&call 我们可以分别将上下文从 juggle 切换至 ninja1&ninja2

对于 apply&call 我们很常用,所以扩展也很多。下面列出一个自定义 forEach 函数:

这个例子,列出了如何在回调中指定上下文,而在这里的9-12行我们也验证了如上所说的,this (上下文)是一个对象。因为在第 3 行代码处,我们传入的 List[i] 的类型是 string,但是经过 call 将其指定成上下文对象后在第 10 行我们比较时发现 this === heroList[index] 结果为 false,第 11 行验证了上下文确实是一个对象。这里是一个 String 对象具体信息就不再深究了,有兴趣的同学可以打印 this 出来看看。

执行环境(执行上下文/执行上下文环境)

  这里再次重新声明,执行环境并不是我们常说的上下文,我们常说的上下文指的是 this(函数上下文)。该执行环境确定了作用域链(scope chain)与 this 。来一段高程三中对于执行环境的介绍如下:

  1. 执行环境(execution context,为简单起见,有时也称为“环境”)是Javascript中最重要的一个概念。执行环境定义了变量和函数有权访问的其他数据,决定了它们各自的行为。每个执行环境都有一个与之关联的变量对象(variable object),环境中定义的所有变量和函数都保存在这个对象中。虽然我们编写的代码无法访问这个对象,但解析器在处理数据时会在后台使用它。
  2. 全局执行环境是最外围的一个执行环境。根据ECMAScript 实现所在宿主环境不同,表示执行环境的对象也不一样。在Web浏览器中,全局执行环境被认为是 window 对象,因此所有全局变量和函数都是作为 window 对象的属性和方法创建的。某个执行环境中的所有代码执行完毕后,该环境被销毁,保存在其中的所有变量和函数定义也随之销毁(全局执行环境直到应用程序退出——例如关闭网页或浏览器——时才会被销毁)。
  3. 每个函数都有自己的执行环境。当执行环境流入一个函数时,函数的环境就会被推入一个环境栈中。而在函数执行之后,栈将其环境弹出,把控制权返回给之前的执行环境。ECMAScript 程序中的执行流正在由这个方便的机制控制着。

简洁明了,这里对于纯文字看着可能比较苦涩。来个图片以及例子,大家可以结合着消化。下面先给出 js 引擎执行函数时的进出栈图。

根据这幅图,大家结合下面代码与 executionContextAction.gif 可以很清楚的理解js线程工作的方式。代码片段:
  

1
2
3
4
5
6
7
8
(function foo(i) {
if (i === 3) {
return;
}
else {
foo(++i);
}
}(0));

这个代码对应引擎处理函数的过程如下:

executionContextAction.gif

  
在搞清楚了引擎工作的方式后,我们还得知道执行环境在建立的时候发生的详细过程。建立阶段以及代码执行阶段的详细分析如下

确切地说,执行环境是在函数被调用时,但是在函数体被真正执行以前所创建的。函数被调用时,就处于第一个阶段——建立阶段。这个时刻,引擎会检查函数中的参数,声明的变量以及内部函数,然后基于这些信息建立执行环境中。在这个阶段,variableObject 对象,作用域链,以及 this 所指向的对象都会被确定。

具体过程如下:

  1. 找到当前上下文中的调用函数的代码
  2. 在执行被调用的函数体中的代码以前(编译阶段),开始创建执行环境(执行上下文/执行上下文环境)
  3. 进入第一个阶段-建立阶段:
  • 建立variableObject对象:

    • 建立arguments对象,检查当前上下文中的参数,建立该对象下的属性以及属性值

    • 确定参数变量,若有重名,以已有的变量为准。用已有的变量去覆盖参数变量

    • 检查当前执行环境中的函数声明:

      1. 每找到一个函数声明,就在variableObject下面用函数名建立一个属性,属性值就是指向该函数在内存中的地址的一个引用。
      2. 如果上述函数名已经存在于variableObject下,那么对应的属性值会被新的引用所覆盖。
    • 检查当前执行环境中的变量声明:

      1. 每找到一个变量的声明,就在variableObject下,用变量名建立一个属性,属性值为undefined。
      2. 如果该变量名已经存在于variableObject属性中,直接跳过(防止指向函数的属性的值被变量属性覆盖为undefined),原属性值不会被修改。
  • 初始化作用域链

  • 确定上下文 — this(确定指向)
  1. 代码执行阶段: 执行函数体中的代码,一行一行地运行代码,给variableObject中的变量属性赋值。

来个例子来模拟引擎的执行过程。如下:

在调用 testEC(11) 时,真正执行 testEC(11) 之前,建立以下阶段:

由此可见,在建立阶段,除了arguments,函数的声明,以及参数被赋予了具体的属性值,其它的变量属性默认的都是undefined。一旦上述建立阶段结束,引擎就会进入代码执行阶段。如下:

那么通过上面例子中的三张图片我们能否发现在函数作用域中关于提升的一些秘密呢?答案是肯定的!在 testEC-prev.png 中 c 函数 是被提升的!因为在建立阶段c: pointer to function c (),而 b 与 a 均是 undefined,由图上显而易见。

补充

执行环境与作用域的关系

  • 在执行环境中首先我们将参数、变量等都存在 VO
    (变量对象中),这是确确实实存在的,只能供内部使用。
  • 执行环境只在函数被调用时创建
  • 执行函数时 VO -> AO(活动对象) 这时我们就可以使用了
  • 一个作用域下可以没有执行环境(未被调用);可以有1个;还可以有若干个(存在闭包)

如何在 callback 中绑定this

在 div 节点事件函数内部,有一个局部的 callback 方法,callback 被作为普通函数调用时,callback 内部的 this 指向了 window,但我们往往想让它指向该 div 节点。如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<!DOCTYPE html>
<html>
<body>
<div id="test">I am a div!</div>
<script>
window.id = 'window';
document.getElementById('test').onclick = function () {
alert(this.id); // test
var callback = function () {
alert(this.id); // window
};
callback();
};
</script>
</body>
</html>

这种情况下我们需要一个变量保存 div 节点的引用:

1
2
3
4
5
6
7
document.getElementById('test').onclick = function () {
var that = this; // 保存 div 节点引用
var callback = function () {
alert(that.id); // test
};
callback();
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
<!DOCTYPE html>
<html>
<head>
<meta charset=utf-8 />
<title>Events in JavaScript: Removing event listeners</title>
</head>
<body>
<button id="element">Click Me</button>
<script src="https://code.jquery.com/jquery-1.10.2.js"></script>
</body>
</html>

<script>
//var element = document.getElementById('element');
var element = $('#element');

var user = {
firstname: 'Bob',
greeting: function(){
alert('My name is ' + this.firstname);
}
};

function bind (obj, name) {
return obj[name].apply(obj);
}
// Attach user.greeting as a callback
//element.addEventListener('click', bind(user, 'greeting'));
element.on('click', user.greeting.bind(user))
</script>

这个例子是告诉我们无论原生js还是jquery在使用事件函数调用执行方法时函数上下文(this)默认指当前获取的dom元素(jq对象)所以当我们希望当前函数上下文指向user时我们应该改变函数上下文即改变this的指向。改变的方法有三种apply/call/bind。

参考资料: