阶段二:浏览器中JavaScript的执行机制

07|变量提升:JavaScript中的代码是按顺序执行的吗?

只有理解了JavaScript执行上下文,才能更好的理解JavaScript语言本身:变量提升、作用域、闭包等。

变量提升

变量提升指的是:JS代码在执行过程中,JS引擎会把变量的声明部分和函数的声明部分提升到代码开头的行为,变量提升后,会给变量设置默认值,这个值就是我们熟悉的undefined。

JavaScript的代码执行流程

JavaScript是先编译后执行,在编译阶段变量的声明和函数的声明提升到代码开头,被JS引擎放入到内存中去了。

编译阶段

输入一段代码,经过JS引擎编译后,会生成两部分内容:执行上下文可执行代码
执行上下文是JavaScript执行一段代码的运行环境:比如调用一个函数,就会进入这个函数的执行上下文,确定函数在执行期间的诸如this、变量、对象以及函数等。
要谨记在执行上下文中存在一个变量环境(Variable Enviroment)和词法环境的对象,变量环境对象中保存了变量提升的内容。

执行阶段

JS引擎执行可执行代码,按照顺序一行一行执行。执行过程中遇到一些变量或者函数就去变量变量环境中查找。

总结

JavaScript代码执行过程中,需要先做变量提升,这是因为代码执行前需要先编译,编译阶段JS引擎会将变量和函数的存放到变量环境中去,变量默认值为undefined,执行阶段,JS引擎会从变量环境中查找变量和函数,若在编译阶段,存在两个相同函数,会被第二个覆盖掉。
核心是要清楚JavaScript的执行机制:先编译后执行。

08|调用栈:为什么JavaScript代码会出现栈溢出?

学习完执行上下文,本章学习调用栈。
学习调用栈我们可以了解JavaScript引擎背后的工作原理、有调试JavaScript代码的能力。

调用栈是用来管理函数调用关系的一种数据结构–这个函数调用另外一个函数。

函数调用

通过几行代码来看函数调用

1
2
3
4
5
6
let a =1;
function add(){
let b=2;
return a + b
}
add()
  1. JS引擎为上面这段代码创建全局执行上下文,将全局变量和函数都保存在全局执行上下文的变量环境中。
  2. 执行到add,从全局执行上下文中取出add函数
  3. 对add这个函数创建,函数执行上下文和可执行代码
  4. 此刻,拥有两个执行上下文。
  5. 执行上下文的管理通过叫做栈的数据结构管理(后进先出原则)–想像一个盒子,将全局执行上下文当如盒底部、再放入函数执行上下文。

JS的调用栈

通常把管理执行上下文的栈成为执行上下文栈,又称调用栈,调用栈是引擎追踪函数执行的一个机制。
可以通过查看浏览器的call stack或者在函数中输出console.trace()来查看调用栈。

栈递归溢出问题

1
2
3
4
5
6
7
8
9
function runStack(n){
if(n===0) return 100;
return runStack(n-2)
}
// 修改后
function runStack(n){
if(n===0) return 100;
return setTimeout(function(){runStack(n-2)},0)
}

09|块级作用域:var缺陷以及为什么要引入let和const

  • ES6之前没有块级作用域
  • 变量提升使得变量容易在不被察觉的情况下被覆盖掉。
  • 变量提升使本应销毁的变量没有被销毁

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

  • ES6引入了let和const关键字,从而拥有了块级作用域。
    引入let后,一段代码的执行流程变为:
  • 编译并创建执行上下文:
  • 函数内部通过var声明的变量,在编译阶段都被存放到变量环境中去了。
  • 通过let声明的变量,在编译阶段被存放到词法环境中去了。
  • 在函数的作用域内部,let声明的变量并没有被放到词法环境中去。
  • 在词法环境内部,维护了一个小型栈结构,栈底是函数最外层的变量,进入一个作用域后,就会把该作用域内部的变量压到栈顶。

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

10 | 作用域和闭包:代码出现相同的变量,JavaScript引擎是如何选择的

理解作用域链是理解闭包的基础,简单总结下作用域链,然后通过作用域链来理解什么是闭包。

作用域链

其实在每个执行上下文变量环境中,都包含了一个外部引用,用来指向外部的执行上下文,我们把这个外部引用称为outer。
上句话明白之后,我们接着分析。
当一段代码中使用了一个变量,首先在"当前的执行上下文"的词法环境中查找该变量,若没有找到,继续在变量环境中去查找该变量。
如何它依旧没有找到,那么JavaScript引擎就会继续在outer所指向的执行上下文中查找。
我们就把这个查找变量过程的链条称为作用域链

到这里,还需要解决的一个问题是,foo函数中调用bar函数,为什么bar函数内部的外部引用执行的是全局执行上下文,而不是foo函数的执行上下文呢?

词法作用域

词法作用域是指作用域是由代码中函数声明的位置来决定的,所有词法作用域是静态的作用域,通过它能够预测代码在执行过程中如何查找标识符。
词法作用域是代码编译阶段就决定好的,和函数是怎么调用的没有关系,即之和函数声明的位置有关系。

闭包

了解了作用域链,接着我们就要来聊聊闭包了。
确切的说,了解了变量环境、词法环境和作用域链等概念,来理解闭包就容易多了。
先看段代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function foo() {
var myName = "内部变量"
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("外部传入")
bar.getName()
console.log(bar.getName())

分析上面代码,调用栈是这么进行的:
首先,全局执行上下文压入栈底,其中的变量环境为bar == undefined,词法环境为空,其作用域链包含的外部引用outer为空。
接着,执行foo函数的时候,形成了foo函数的执行上下文,它的变量环境为 myName=‘内部变量’,innerBar为函数,词法环境为test1为1,test2为2,其作用域链包含的外部outer(通过函数的定义位置)为全局作用域。
然后foo函数执行完毕之后,一般情况下我们分析内部的变量环境和词法环境就会清空,但是由于foo函数返回的innerBar还在使用者test1和myName这两个变量,所以虽然foo函数的执行上下文从栈顶弹出,但是用到的两个变量还是依旧保存在了内存中,而这两个变量除了setName和getName这两个方法可以访问,其他无论什么情况都访问不了,这个时候我们称:foo函数为闭包。

闭包定义

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

闭包是怎么回收的

之所以需要关注闭包的回收,是因为如果闭包使用不正确,会很容易造成内存泄漏。
通常,如果引用闭包的函数是一个全局变量,那么变量会一直存在页面直到页面关闭,但如果这个闭包以后不再使用的话,就会造成内存泄漏。
如果引用闭包的是一个局部变量,那么当函数销毁时,在下次JavaScript引擎执行垃圾回收时,会判断闭包这块内容是否已经不再使用,若不再使用,那额就回收这块内存。
最后,记住一个原则:如果该闭包一直使用,那么它可以作为全局变量而存在,如果使用频率不高且占用内存较大,那么尽量让它称为一个局部变量。

11 | this:从JavaScript执行上下文的视角学习this

作用域链和this是两套不同的系统,它们之间没有太多的联系。
在前文中,我们提到了执行上下文包含了:变量环境词法环境外部环境outer,这一节加上我们要分析的this,也就是四个部分。

this是和执行上下文进行绑定的,而我们已经知道了执行上下文包括全局执行上下文、函数执行上下文以及eval执行上下文,那么对应的this也只要这三种,撇去这个eval中的this,我们重点关注全局执行上下文中的this和函数执行上下文中的this。

全局执行上下文中的this,通过控制台打印的方式验证,我们知道这个this就是window。
然后,重点分析的就是函数中的this了,从下面最简单代码开始分析。

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

打印结果为this,也就是:“默认情况下调用一个函数,函数内部的执行上下文中的this也是指向Window对象”
那么,能不能设置函数内执行上下文中this的指向呢?That must be!

通过call、apply、bind方法设置

  • call apply bind因为继承自Funtion.prototype,所以函数、数组、对象的实例都可以使用这三个方法。
  • call方法如果没有参数、或者参数为null或者参数为undefined,那么this都是指向Window对象,如果有参数,支持传入两个参数,第一个参数为this的指向,后面的参数为this指向的那个对象所传入的参数,需要特别注意的是这个参数需要一个个的传入,即不能以数组形式传入。
  • apply方法与apply类似,唯一的区别是第二个传入的参数将以数组的形式传入。
  • bind方法是非立即执行的一个函数,即用于执行函数内部this的指向—执行时所在的区域,传递参数与call方法相同。

应用:找出一个数组const arr = [1,10,7]中最大的数:Math.max.call(null,…arr) or Math.max.apply(null,arr)

通过调用对象方法设置

1
2
3
4
5
6
7
let myObj = {
name:'liugezhou',
showThis:function(){
console.log(this)
}
}
myObj.showThis()

以上代码输出的为myObj对象,通过这个小例子,我们可以得出结论:
使用对象调用内部的一个方法,该方法的this是指向对象本身的。

通过构造函数中设置

1
2
3
4
function CreateObj(){
this.name='liugezhou'
}
let myObj = new CreateObj()

这几行代码的理解,可以让我们对new一个对象内部过程到底发生了什么,产生深刻的理解。
首先,new的时候呢,创建了一个临时 空对象,
然后,调用CreateObj的call方法,将临时对象作为参数传入
接着,执行CreateObj方法的时候,内部的this就是这个临时对象
最后返回这个临时对象,也就得到了我们的myObj。
¸

this的设计缺陷和应对方案

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

也就是说在一个对象中定义个方法,输出的this为这个对象,然后这个对象中的方法继续定义个方法,输出的this执行的就是window,因此在解决this执行的时候,可以在该对象内部的方法中定义一个变量that指向this,然后这个对象中方法的方法输出的that就是该对象。
当然,我们现在的做法一般是在该对象内部的方法中的方法使用ES6的箭头函数,这样就不用再去定义一个中间变量了。
也就告诉我们箭头函数不会创建自己的执行上下文,箭头函数中的this取决于他的外部函数。

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

这个原理我们已经清楚,这也是一个设计缺陷,在我们编写代码的时候,并不希望函数中的this是指向全局的,因此会容易造成误操作,,像我们前面学的,通过call方法来改变this指向。