0%

浏览器原理学习笔记-浏览器中 js 执行机制(下)

作用域

作用域指在程序中定义变量的区域,该位置决定了变量的生命周期。通俗理解,作用域就是变量与函数的可访问范围,即作用域控制着变量和函数的可见性和生命周期。

  • 全局作用域
  • 函数作用域

js之前支持上面两种作用域,因为作者设计该语言的时候只是用了最简单的方式。没有块级作用域,这就导致了变量提升这样神奇的设计。

变量提升所带来的问题

由于变量提升作用,使用 js 编写和其他语言相同逻辑的代码,都可能会导致不一样的执行结果。

1. 变量容易在不被察觉的情况下被覆盖

之前前面那篇文章有说到的调用栈执行上下文后声明的会覆盖掉前声明的。

image-20200602001927127

2. 本应销毁的变量没有被销毁

例如这样一段神奇的代码:

1
2
3
4
5
6
function foo () {
for (var i = 0;i<9;i++) {
}
console.log(i);
}
foo()

这个地方会因为变量提升的原因,在创建执行上下文阶段,变量 i 被提升了,所以 for 循环结束之后,变量 i 并没有被销毁。会打印出 7

ES6 是如何解决变量提升带来的缺陷

引入了let,const,从而使得 js 像其他语言一样也有了块级作用域。

关于 letconst 的用法,可以参考下面代码:

1
2
3
4
let x = 5
const y = 6
x = 7
y = 9

下面具体讲一下 ES6 是如何通过块级作用域解决上面问题:

1
2
3
4
5
6
7
8
function varTest () {
var x = 1;
if (true) {
var x= 2;
console.log(x); // 2
}
console.log(x); // 2
}

这段代码中,两个地方都定义了 x,第一个地方在函数块的顶部,第二个地方在 if 块的内部,由于 var 的作用范围是整个函数,所以编译阶段,会生成如下的执行上下文:

image-20200602003314573

从执行上下文的变量环境中可以看出,最终只生成了一个变量 x ,函数体内所有对 x 的赋值操作都会直接改变变量环境中的 x 值。

所以上述代码最后输出的结果是 2,而对于相同逻辑的代码,其他语言最后输出应该是1,因为 if 块里面声明不应该影响到块外面的变量。

改造过程其实很简单,把里面的 var 改成 let。输出结果就会一直了。

这是因为有了 let 关键字之后,就可以支持块级作用域了。所以在编译阶段,js 引擎并不会把 if 块中通过 let 声明的变量存放到变量环境中,这也就意味着在 if 块通过 let 声明的关键字,并不会提升到全函数可见。所以在 if 块之内打印出来的值是 2,跳出语块之后,打印出来的值就是 1 了。这种就非常符合我们的编程习惯了:作用域块内声明的变量不影响块外面的变量。

js是如何支持块级作用域

前面已经介绍过 js 是通过变量环境实现函数级的作用域的,es6 又是如何在函数级作用域的基础上,实现对块级作用域的支持的呢?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function foo () {
var a = 1;
let b = 2;
{
let b = 3;
var c = 4;
let d = 5;
console.log(a);
console.log(b);
}
console.log(b);
console.log(c);
console.log(d);
}

以上面这段代码作为例子,大概有这样的一些流程:

  1. 编译并创建执行上下文

    image-20200602092112365

通过上图可以得知:

  • 函数内部通过 var 声明的变量,编译阶段全都被存到变量环境里面了。
  • 通过 let 声明的变量,编译阶段会被放到词法环境
  • 函数作用域块内部,通过 let 声明的变量并没有被存到词法环境中
  1. 继续执行代码,这时变量环境中的 a 已经被设置了 1,词法环境中的 b 已经被设置成了 2,这时候函数的执行上下文就如图所示:

image-20200602092855471

从图中可以看出,当进入函数的作用域块时,作用域块中通过 let 声明的变量,会被存在放在一个单独的区域中,这个区域中的变量并不影响作用域块外面的变量,比如在作用域外面和内部同时声明了变量 b,当执行到作用域内部的时候,他们都是独立的存在。

在词法环境内部,维护了一个小型栈结构,栈底是函数最外层的变量,进入一个作用域块之后,就会把作用域块内部的变量压到栈顶,作用域执行完成之后,该作用域的信息就会从栈顶弹出来,这就是词法环境的结构。

再接下来,当执行到作用域块中的console.log(a)这行代码的时候,就需要在词法环境和变量环境中查找变量 a 的值了,查找方式为:沿着词法环境的栈顶向下查询,如果词法环境中的某个块查找到了,就直接返回给 js 引擎,如果没找到,就继续在变量环境中找。

这样一个变量查找就完成了,具体流程图如下:

image-20200602094648840

作用域块执行结束之后,内部定义的变量就会从词法环境的栈顶从弹出来,最终执行上下文如下图:

image-20200602094822065

块级作用域就是通过词法环境的栈结构来实现的,而变量提升是通过变量环境来实现,通过这两者的结合,JavaScript 引擎也就同时支持了变量提升和块级作用域了。

分析一个思考题目的结果:

1
2
3
4
5
let myname = 'xyx'
{
console.log(myname);
let myname = 'wd'
}

最终打印的结果为:Reference Error,原因是在块级作用域内,let 声明的变量被提升,但变量只是创建被提升,初始化没有被提升,在初始化之前使用变量,就会形成一个暂时性的死去。

作用域链和闭包

以下面代码为例子:

1
2
3
4
5
6
7
8
9
function bar () {
console.log(a)
}
function foo () {
var a = 'wd'
bar()
}
var a = 'xyx'
foo()

通过执行上下文来分析一下这里的代码执行流程。当这段代码执行到 bar 函数内部的时候,其调用栈的状态为:

image-20200602100114079

这里也许第一反应是按照调用栈的顺序去查找变量,最后执行出的打印结果会是 wd,实际上这里执行的结果是 xyx

作用域链

很多人对作用域链理解会费解,实际上当你理解了调用栈、执行上下文、词法环境、变量环境等概念。

其实在每个执行上下文的变量环境中,包含有一个外部引用,用来指向外部的执行上下文,我们把这个外部引用称之为 outer

当一段代码使用一个变量,js 引擎会首先在 “当前的执行上下文” 中查找变量。

比如上面那段代码,当前的 bar 里面没有,那么 js 引擎会继续在 outer 所指向的执行上下文中查找。

image-20200602100755124

而 bar 和 foo 函数的 outer 都是指向全局执行上下文的,这就意味着如果在 bar 函数或者 foo 函数中使用了外部变量,那么 js 引擎会去全局执行上下文中查找。查找的链条就叫作用域链

那么为啥是 foo 调用的 bar ,为啥 bar 的外部引用是全局执行上下文,而不是 foo 函数的执行上下文。于是这里就介绍一下词法作用域环境。

词法作用域

词法作用域就是指作用域是由代码中函数声明的位置来决定的,所以词法作用域是静态的作用域,通过它就能够预测代码在执行过程中如何查找标识符

具体如图:

image-20200602101938370

词法作用域是根据代码的位置来决定的。上面词法作用域的顺序为: foo -> bar ->main ->全局

这时再回过头看当时的问题:根据词法作用域,foo 和 bar 的上级作用域都是全局作用域,所以 foo 或 bar 函数使用了一个没定义的变量当然会先去全局作用域找。

词法作用域是在代码阶段就决定好的,和函数怎么调用的没有任何关系。

块级作用域中的变量查找

代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
function bar () {
var myName = 'wd'
let test1 = 100
if (1) {
let myName = 'xyx'
console.log(test)
}
}
function foo () {
var myName = 'wd1'
let test = 2
{
let test = 3
bar()
}
}
var myName = 'wd2'
let myAge = 18
let test = 1
foo()

ES6 是支持块级作用域的,当执行到代码块的时候,如果代码块中有 letconst 声明的变量,那么变量就会存放到该函数的词法环境中,上面那段代码执行到 bar 函数内部的 if 语句块的时候,其调用栈的情况为:

image-20200602104713114

现在是执行到 bar 函数的 if 语句块之内,需要打印出变量 test 的值,查找过程已经用序号标记出来了。

闭包

结合下面代码来理解闭包:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function foo () {
var myName = 'wd'
let test1 = 1;
const test2 = 2;
var innerBar = {
getName: function () {
console.log(test1)
return myName
},
setName: function (newName) {
myName = newName;
}
}
return innerBar
}
var bar = foo()
bar.setName('xyx')
bar.getName();
console.log(bar.getName())

当执行到 foo 函数内部的 return innerBar 这行代码时调用栈的情况,可以参考下图:

image-20200602105512644

从上面代码中可以看出,innerBar 是一个对象,包含了 getNamesetName 两个方法。这两个方法都是在 foo 函数内部定义的,并且这两个方法内部都使用了 myName 和 test1 两个变量。

根据词法作用域规则,内部函数 getName 和 setName 总是可以访问他们外部函数 foo 中的变量。所以当 innerBar 对象返回给全局变量 bar 时,虽然 foo 函数已经执行结束,但 getName 和 setName 函数仍然可以使用 foo 函数中的变量。

image-20200602110433087

foo 函数的整个调用栈状态如上图所示。

从上图可以看出,foo 函数执行完成之后,其执行上下文从栈顶弹出了,但由于返回的 setName 和 getName 方法使用了 foo 函数内部的变量 myName,test1,所以这两个变量依然保存在内存中。像极了 setName 和 getName 方法背的一个专属背包,无论在哪里调用这两个方法,他们都会背着这个 foo 函数的专属背包

之所以是专属背包,因为除了 setName 和 getName 函数之外,其他任何方法都是无法访问该背包的,我们就可把这个背包称为 foo 函数的闭包

好了,我们终于可以给闭包一个定义了。在 JavaScript 中,根据词法作用域的规则,内部函数总是可以访问其外部函数中声明的变量,当通过调用一个外部函数返回一个内部函数后,即使该外部函数已经执行结束了,但是内部函数引用外部函数的变量依然保存在内存中,我们就把这些变量的集合称为闭包。比如外部函数是 foo,那么这些变量的集合就称为 foo 函数的闭包。

当调用 setName 的时候,这个时候会修改掉闭包中的 myName 变量的值。

同样,调用 getName 的时候,返回的值也是位于闭包中的值。

可以通过开发者工具来查看闭包的情况,打开 Chrome 开发者工具,在 bar 函数的任意地方打上断点,然后刷新页面,可以看到如下的内容:

image-20200602112232043

Local 是当前函数 getName 的作用域,Closure(foo) 是指 foo 函数的闭包,最下面的 Global 就是指全局作用域,从 “Local -> Closure(foo) -> Global”就是一个完整的作用域链。

闭包是怎么回收的

如果引用闭包的函数是一个全局变量,那么闭包会一直存在直到页面关闭;但如果这个闭包以后不再使用的话,就会造成内存泄漏。

如果该闭包会一直使用,那么它可以作为全局变量而存在;但如果使用频率不高,而且占用内存又比较大的话,那就尽量让它成为一个局部变量。

思考:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
var bar = {
myName: 'yisin',
printName: function () {
console.log(myName)
}
}
function foo () {
let myName = 'zoodmong'
return bar.printName
}
let myName = 'wd';
let _printName = foo()
_printName()
bar.printName();

上面两次都会打印出 wd,因为:

  1. bar 不是一个函数,因此 bar 当中的 printName 其实是一个全局声明的函数,bar 当中的 myName 只是对象的一个属性,也和 printName 没有联系,如果要产生联系,需要使用 this 关键字,表示这里的 myName 是对象的一个属性,不然的话,printName 会通过词法作用域链去到其声明的环境,也就是全局,去找 myName。

  2. foo 函数返回的 printName 是全局声明的函数,因此和 foo 当中定义的变量也没有任何联系,这个时候 foo 函数返回 printName 并不会产生闭包。

js 中的 this 是什么

关于 this,还是先从执行上下文开始说起。前面提到过执行上下文中包含有变量环境、词法环境、外部环境。里面其实还有个 this,具体可以参考:

image-20200602115141291

每个执行上下文中都有一个 this。前面提过,有三种执行上下文——全局、函数、eval 执行上下文。所以 this 也存在于这三种情况中。

下面重点讲解一下全局执行上下文中的 this函数执行上下文中的 this

全局

全局执行上下文中的 this 是指向 window 对象的。这也是 this 和作用域链的唯一交点,作用域链的最底端包含了 window 对象,全局执行上下文中的 this 也是指向 window 对象。

函数

见下面这段代码:

1
2
3
4
function foo () {
console.log(this);
}
foo();

在 foo 函数内部打印出来的其实还是window对象,不过这里可以通过一些方法来修改这个 this 指向。

1. call方法设置

1
2
3
4
5
6
7
8
9
10
11
12
let bar = {
name: 'wd',
school: 'neuq'
}
function foo () {
this.name = 'yisin'
}
foo.call(bar)
// 这是输出会发现bar里面的name已经成了yisin
console.log(bar)
// 这里会提示该变量没有定义
console.log(name)

除了 call 方法,还可以用用 apply 和 bind 方法。

2. 通过对象调用方法设置

修改函数上下文的 this 指向,除了通过函数的 call 方法来实现外,还可以通过对对象调用的方式,比如下面这段代码:

1
2
3
4
5
6
7
var obj = {
name: 'geektime',
showThis: function () {
console.log(this)
}
}
obj.showThis()

在这段代码中,我们定义了一个 obj 对象,对象由一个 name 属性和一个 showThis 方法组成,然后再通过 obj 对象来调用 showThis 方法。执行这段代码,最终输出的 this 值是指向 obj 的。

因此我们可以得出这样的结论:使用对象来调用内部的一个方法,该方法的 this 是指向对象本身的。

js 引擎在执行 obj.showThis() 的时候,将其转换为了:

1
obj.showThis.call(this);

接下来稍微改变一下调用方式,把 showThis 赋值一个全局对象,然后再调用这个对象,代码如下图:

1
2
3
4
5
6
7
8
9
var obj = {
name: 'zoomdong',
showThis: function () {
this.name = 'yisin'
console.log(this)
}
}
var foo = obj.showThis
foo()

这段代码执行的时候,this 的指向就到了全局的 window 对象。

通过以上例子,可以得到这样两个结论:

  • 在全局环境中调用一个函数,函数内部的 this 指向的是全局变量 window
  • 通过一个对象来调用内部的一个方法,方法的执行上下文中的 this 指向对象本身。

3. 通过构造函数中设置

可以像这样设置构造函数中的 this

1
2
3
4
function createObj () {
this.name = 'zoomdong';
}
var obj = new createObj();

在这段代码中,我们使用了 new 创建了对象 obj,那么你知道这时候构造函数中的 this 指向了谁吗?

在执行 new createObj() 的时候,js 引擎做了如下四件事情:

  • 创建了一个空对象 tempObj
  • 调用 createObj.call 方法,并将 tempObj 作为 call 方法的参数,这样当 createObj 的执行上下文创建的时候,它的 this 指向了 tempObj 对象。
  • 然后执行 createObj 函数,此时 createObj 函数执行上下文中的 this 指向了 tempObj 对象;
  • 最后返回 tempObj 对象

为了直观理解,可以使用代码来演示一下:

1
2
3
var tempObj = {}
createObj.call(tempObj)
return tempObj

这样,我们就通过 new 关键字创建好了一个新对象,并且构造函数中的 this 其实就是新对象本身。

具体过程参考:https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Operators/new

this 函数中设计的一些缺陷

1. 嵌套函数中的 this 不会从外层中继承

这是个挺严重的设计错误:

1
2
3
4
5
6
7
8
9
10
11
var obj = {
name: 'yisin',
showThis: function () {
console.log(this);
function bar () {
console.log(this.name)
}
bar()
}
}
obj.showThis()

这段代码中 showThis 方法里面添加了一个 bar 方法,然后接着在 showThis 函数中调用了 bar 函数,执行这段代码的结果你会发现:函数 bar 中的 this 指向的是全局 window 对象,而函数 showThis 中的 this 指向的是 obj 对象。

这里可以使用一个中间变量 that 来解决这个过程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
var obj = {
name: 'yisin',
showThis: function () {
console.log(this);
var self = this;
function bar () {
self.name = 'zoomdong';
console.log(self)
}
bar();
}
}
obj.showThis()
console.log(obj.name)
console.log(window.name)

这样就可以输出到我们想要的结果了。其实这里 obj.name 的值就为 zoomdong。这个方法的本质其实就是把 this 体系转换为了作用域体系。

也可以使用箭头函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
var obj = {
name: 'yisin',
showThis: function () {
console.log(this);
var bar = () => {
this.name = 'zoomdong'
console.log(this)
}
bar()
}
}
obj.showThis()
console.log(obj.name)
console.log(window.name)

执行这段代码,你会发现它也输出了我们想要的结果,也就是箭头函数 bar 里面的 this 是指向 myObj 对象的。这是因为 ES6 中的箭头函数并不会创建其自身的执行上下文,所以箭头函数中的 this 取决于它的外部函数。

2.普通函数中的 this 默认指向全局对象 window

在默认情况下调用一个函数,其执行上下文中的 this 是默认指向全局对象 window 的。

不过这个设计也是一种缺陷,因为在实际工作中,我们并不希望函数执行上下文中的 this 默认指向全局对象,因为这样会打破数据的边界,造成一些误操作。如果要让函数执行上下文中的this 指向某个对象,最好的方式是通过 call 方法来显示调用。

这个问题可以通过设置 JavaScript 的“严格模式”来解决。在严格模式下,默认执行一个函数,其函数的执行上下文中的 this 值是 undefined,这就解决上面的问题了。总结

总结

  • 当函数作为对象的方法调用时,函数中的 this 就是该对象
  • 当函数被正常调用时,在严格模式下,this 值是 undefined,非严格模式下 this 指向的是全局对象 window
  • 嵌套函数中的 this 不会继承外层函数的 this