一次搞定闭包和this

闭包和this,是两个相当高频的考点,然而你有没有想过,实际上他们两个都跟同一个知识点相关?

有请我们的这篇文章的主角,执行上下文

执行上下文

执行上下文是什么

可以简单理解执行上下文是js代码执行的环境,当js执行一段可执行代码时,会创建对应的执行上下文。他的组成如下

1
2
3
4
5
6
executionContextObj = {
this: 对的就是你关注的那个this,
VO:变量对象,
scopeChain: 作用域链,跟闭包相关
}

由于JS是单线程的,一次只能发生一件事情,其他事情会放在指定上下文栈中排队。js解释器在初始化执行代码时,会创建一个全局执行上下文到栈中,接着随着每次函数的调用都会创建并压入一个新的执行上下文栈。函数执行后,该执行上下文被弹出。

五个关键点:

  1. 单线程
  2. 同步执行
  3. 一个全局上下文
  4. 无限制函数上下文
  5. 每次函数调用创建新的上下文,包括调用自己
执行上下文建立的步奏

创建阶段

  1. 初始化作用域链
  2. 创建变量对象
    1. 创建arguments
    2. 扫描函数声明
    3. 扫描变量声明
  3. 求this

执行阶段

  1. 初始化变量和函数的引用
  2. 执行代码

this

在函数执行时,this 总是指向调用该函数的对象。要判断 this 的指向,其实就是判断 this 所在的函数属于谁。

指向调用对象
1
2
3
4
5
6
7
8
9
10
function foo() {
console.log( this.a );
}
var obj = {
a: 2,
foo: foo
};
obj.foo(); // 2
指向全局对象
1
2
3
4
5
6
7
function foo() {
console.log( this.a );
}
var a = 2;
foo(); // 2

注意

1
2
3
4
5
6
7
//接上
var bar = foo
a = 3
bar() // 3不是2

通过这个例子可以更加了解this是函数调用时才确定的

再绕一点

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function foo() {
console.log( this.a );
}
function doFoo(fn) {
this.a = 4
fn();
}
var obj = {
a: 2,
foo: foo
};
var a =3
doFoo( obj.foo ); // 4

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function foo() {
this.a = 1
console.log( this.a );
}
function doFoo(fn) {
this.a = 4
fn();
}
var obj = {
a: 2,
foo: foo
};
var a =3
doFoo( obj.foo ); // 1

这是为什么呢?是因为优先读取foo中设置的a,类似作用域的原理吗?

通过打印foo和doFoo的this,可以知道,他们的this都是指向window的,他们的操作会修改window中的a的值。并不是优先读取foo中设置的a

因此如果把代码改成

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
function foo() {
setTimeout(() => this.a = 1,0)
console.log( this.a );
}
function doFoo(fn) {
this.a = 4
fn();
}
var obj = {
a: 2,
foo: foo
};
var a =3
doFoo( obj.foo ); // 4
setTimeout(obj.foo,0) // 1

上面的代码结果可以证实我们的猜测。

用new构造就指向新对象
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
a = 4
function A() {
this.a = 3
this.callA = function() {
console.log(this.a)
}
}
A() // 返回undefined, A().callA会报错。callA被保存在window上
var a = new A()
a.callA() // 3,callA在new A返回的对象里
apply/call/bind

大家应该都很熟悉,令this指向传递的第一个参数,如果第一个参数为null,undefined或是不传,则指向全局变量

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
31
32
33
34
35
36
37
38
39
a = 3
function foo() {
console.log( this.a );
}
var obj = {
a: 2
};
foo.call( obj ); // 2
foo.call( null ); // 3
foo.call( undefined ); // 3
foo.call( ); // 3
var obj2 = {
a: 5,
foo
}
obj2.foo.call() // 3,不是5!
//bind返回一个新的函数
function foo(something) {
console.log( this.a, something );
return this.a + something;
}
var obj =
a: 2
};
var bar = foo.bind( obj );
var b = bar( 3 ); // 2 3
console.log( b ); // 5

箭头函数

箭头函数比较特殊,没有自己的this,它使用封闭执行上下文(函数或是global)的 this 值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
var x=11;
var obj={
x:22,
say:()=>{
console.log(this.x); //this指向window
}
}
obj.say();// 11
obj.say.call({x:13}) // 11
x = 14
obj.say() // 14
//对比一下
var obj2={
x:22,
say() {
console.log(this.x); //this指向obj2
}
}
obj2.say();// 22
obj2.say.call({x:13}) // 13
事件监听函数

指向被绑定的dom元素

1
2
3
4
5
6
7
8
document.body.addEventListener('click',function(){
console.log(this)
}
)
// 点击网页
// <body>...</body>
HTML

HTML标签的属性中是可能写JS的,这种情况下this指代该HTML元素。

1
2
3
4
<div id="foo" onclick="console.log(this);"></div>
<script type="text/javascript">
document.getElementById("foo").click(); //logs <div id="foo"...
</script>

变量对象

变量对象是与执行上下文相关的数据作用域,存储了上下文中定义的变量和函数声明。

变量对象式一个抽象的概念,在不同的上下文中,表示不同的对象

全局执行上下文的变量对象

全局执行上下文中,变量对象就是全局对象。
在顶层js代码中,this指向全局对象,全局变量会作为该对象的属性来被查询。在浏览器中,window就是全局对象。

1
2
3
var a = 1
console.log(window.a) // 1
console.log(this.a) // 1
函数执行上下文的变量对象

函数上下文中,变量对象VO就是活动对象AO。

初始化时,带有arguments属性。
函数代码分成两个阶段执行

  1. 进入执行上下文时
    此时变量对象包括

    1. 形参
    2. 函数声明,会替换已有变量对象
    3. 变量声明,不会替换形参和函数
  2. 函数执行

根据代码修改变量对象的值

举个例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function test (a,c) {
console.log(a, b, c, d) // 5 undefined [Function: c] undefined
var b = 3;
a = 4
function c () {
}
var d = function () {
}
console.log(a, b, c, d) // 4 3 [Function: c] [Function: d]
var c = 5
console.log(a, b, c, d) // 4 3 5 [Function: d]
}
test(5,6)

来分析一下过程

1.创建执行上下文时

VO = {
arguments: {0:5},
a: 5,
b: undefined,
c: [Function], //函数C覆盖了参数c,但是变量声明c无法覆盖函数c的声明
d: undefined, // 函数表达式没有提升,在执行到对应语句之前为undefined
}

  1. 执行代码时

通过最后的console可以发现,函数声明可以被覆盖

作用域链

先了解一下作用域

作用域

变量与函数的可访问范围,控制着变量及函数的可见性与生命周期。分为全局作用域和局部作用域。

全局作用域:

在代码中任何地方都能访问到的对象拥有全局作用域,有以下几种:

  1. 在最外层定义的变量;

  2. 全局对象的属性

  3. 任何地方隐式定义的变量(未定义直接赋值的变量),在任何地方隐式定义的变量都会定义在全局作用域中,即不通过 var 声明直接赋值的变量。

局部作用域:

JavaScript的作用域是通过函数来定义的,在一个函数中定义的变量只对这个函数内部可见,称为函数(局部)作用域

作用域链

作用域链是一个对象列表,用以检索上下文代码中出现的标识符。
标识符可以理解为变量名称,参数,函数声明。

函数在定义的时候会把父级的变量对象AO/VO的集合保存在内部属性[[scope]]中,该集合称为作用域链。
自由变量指的是不在函数内部声明的变量。
当函数需要访问自由变量时,会顺着作用域链来查找数据。子对象会一级一级的向上查找父对象的变量,父对象的变量对子对象是可见的,反之不成立。
作用域链就是在所有内部环境中查找变量的链式表。

可以直接的说,JS采用了词法作用域(静态作用域),JS的函数运行在他们被定义的作用域中,而不是他们被执行的作用域。可以举一个例子说明:

1
2
3
4
5
6
7
8
9
10
11
var s = 3
function a () {
console.log(s)
}
function b () {
var s = 6
a()
}
b() // 3,不是6

如果js采用动态作用域,打印出来的应该是6而不是3,这个例子说明了js是静态作用域。

函数作用域链的伪代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function foo() {
function bar() {
...
}
}
foo.[[scope]] = [
globalContext.VO
];
bar.[[scope]] = [
fooContext.AO,
globalContext.VO
];

函数在运行激活的时候,会先复制[[scope]]属性创建作用域链,然后创建变量对象VO,然后将其加入到作用域链。

1
2
3
4
5
executionContextObj: {
VO:{},
scopeChain: [VO, [[scope]]]
}

闭包

闭包是什么

闭包按照mdn的定义是可以访问自由变量的函数。自由变量前面提到过,指的是不在函数内部声明的变量。

闭包的形式
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function a() {
var num = 1
function b() {
console.log(num++)
}
return b
}
var c1 = a()
c1() // '1'
c1() // '2'
var c2 = a()
c2() // '1'
c2() // '2'
闭包的过程

写的不是很严谨。可能省略了一些过程

  1. 运行函数a
    1. 创建函数a的VO,包括变量num和函数b
    2. 定义函数b的时候,会保存a的变量对象VO和全局变量对象到[[scope]]中
    3. 返回函数b,保存到c1
  2. 运行c1
    1. 创建c1的作用域链,该作用域链保存了a的变量对象VO
    2. 创建c1的VO
    3. 运行c1,这是发现需要访问变量num,在当前VO中不存在,于是通过作用域链进行访问,找到了保存在a的VO中的num,对它进行操作,num的值被设置成2
  3. 再次运行c1,重复第二步的操作,num的值设置成3
一些问题

通过上面的运行结果,我们可以观察到,c2所访问num变量跟c1访问的num变量不是同一个变量。我们可以修改一下代码,来确认自己的猜想

1
2
3
4
5
6
7
8
9
10
11
12
13
function a() {
var x = {y : 4}
function b() {
return x
}
return b
}
var c1 = a()
var c2 = a()
c1 === c2() // false

因此我们可以确定,闭包所访问的变量,是每次运行父函数都重新创建,互相独立的。
注意,同一个函数中创建的自由变量是可以在不同的闭包共享的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function a() {
var x = 0
function b() {
console.log(x++)
}
function c() {
console.log(x++)
}
return {
b,
c
}
}
var r = a()
r.b() // 0
r.c() // 1

补充一个查看作用域链和闭包的技巧
打开chrome控制台

1
2
3
4
5
6
7
8
console.dir(r.b)
f b() {
[[Scopes]]: [
{x:0},
{type: 'global', name: '', object: Window}
]
}

最后

最后,我们再来总结一下执行上下文的过程,加深下印象

1
2
3
4
5
6
7
var scope = "global scope";
function checkscope(a){
var scope2 = 'local scope';
}
checkscope(5);
创建全局上下文执行栈

创建全局变量globalContext.VO.

创建checkscope函数

将全局变量VO保存为作用域链,设置到函数的内部属性[[scope]]

1
2
3
checkscope.[[scope]] = [
globalContext.VO
];
执行checkscope函数

创建函数执行上下文,将checkscope函数执行上下文压入执行上下文栈

1
2
3
4
ECStack = [
checkscopeContext,
globalContext
];
函数执行上下文创建阶段

第一步是复制[[scope]],创建作用域链

1
2
3
4
checkscopeContext = {
Scope: checkscope.[[scope]],
}

第二步是创建活动对象AO

1
2
3
4
5
6
7
8
9
10
11
checkscopeContext = {
AO: {
arguments: {
0: 5
length: 1
},
a: 5
scope2: undefined
},
Scope: checkscope.[[scope]],
}

第三步是将活动对象AO放入作用域链顶端

1
2
3
4
5
6
7
8
9
10
11
12
checkscopeContext = {
AO: {
arguments: {
0: 5
length: 1
},
a: 5
scope2: undefined
},
Scope: [AO, checkscope.[[scope]]],
}

第四步,求出this,上下文创建阶段结束

这里的this等于window

进入函数执行阶段

随着函数执行,修改AO的值

1
2
3
4
5
6
7
8
AO: {
arguments: {
0: 5
length: 1
},
a: 5
scope2: 'local scope'
},
函数执行完毕

函数上下文从执行上下文栈弹出

1
2
3
ECStack = [
globalContext
];

文章写的比较长,涉及的范围也比较广,可能有不少的错误,希望大家可以指正。

本文章为前端进阶系列的一部分,
欢迎关注和star本博客或是关注我的github

参考

  1. 深入理解ES6箭头函数中的this
  2. 你不知道的JS上卷
  3. JavaScript深入之执行上下文栈
  4. 理解JavaScript的作用域链
  5. JavaScript深入之变量对象
  6. 深入理解JavaScript系列(12):变量对象(Variable Object)
  7. 了解JavaScript的执行上下文
hpoenixf wechat
扫码获取最新博客推送
支持作者