# JS执行机制

# 前言

# 一、变量提升:JavaScript代码是按顺序执行的吗?

JavaScript 的执行机制:先编译,再执行。

JS执行流程:

  • 编译阶段(变量提升) + 执行阶段

编译阶段:

  • 目标:生成执行上下文和可执行代码
  • 执行上下文:包含变量环境 Variable Environment,用来存储 var 变量
  • 可执行代码:字节码(或机器码)

小测验:你能按照 JavaScript 的执行流程,来分析最终输出结果吗?

showName()
var showName = function() {
    console.log(2)
}
function showName() {
    console.log(1)
}
1
2
3
4
5
6
7

# 二、调用栈:为什么JavaScript代码会出现栈溢出?

生成执行上下文的几种场景:

  1. 当 JavaScript 执行全局代码的时候,会编译全局代码并创建全局执行上下文,而且在整个页面的生存周期内,全局执行上下文只有一份。
  2. 当调用一个函数的时候,函数体内的代码会被编译,并创建函数执行上下文,一般情况下,函数执行结束之后,创建的函数执行上下文会被销毁。
  3. 当使用 eval 函数的时候,eval 的代码也会被编译,并创建eval执行上下文

执行上下文栈:JS引擎用来管理执行上下文

思考题: 这是一段递归代码,可以通过传入参数 n,让代码递归执行 n 次,也就意味着调用栈的深度能达到 n,当输入一个较大的数时,比如 50000,就会出现栈溢出的问题,那么你能优化下这段代码,以解决栈溢出的问题吗?

function runStack (n) {
  if (n === 0) return 100;
  return runStack( n- 2);
}
runStack(50000)
1
2
3
4
5

解法一:

function runStack (n) {
  if (n === 0) return 100;
  return setTimeout(function(){runStack( n- 2)},0);
}
runStack(50000)
1
2
3
4
5

解法二:

function runStack (n) {
  if (n === 0) return 100;
  return runStack.bind(null, n- 2); // 返回自身的一个版本
}
// 蹦床函数,避免递归
function trampoline(f) {
  while (f && f instanceof Function) {
    f = f();
  }
  return f;
}
trampoline(runStack(1000000))
1
2
3
4
5
6
7
8
9
10
11
12

# 三、块级作用域:var缺陷以及为什么要引入let和const?

正是由于 JavaScript 存在变量提升这种特性,从而导致了很多与直觉不符的代码,这也是 JavaScript 的一个重要设计缺陷

疑问:

  • 为什么在 JavaScript 中会存在变量提升,以及变量提升所带来的问题?
  • 如何通过块级作用域并配合 let 和 const 关键字来修复这种缺陷

# 作用域

通俗来说,作用域就是变量和函数可访问范围

ES6之前,ES的作用域只有两种:

  • 全局作用域:全局可访问,生命周期伴随页面的生命周期
  • 函数作用域:函数内部定义的变量或函数,只能在函数内访问。函数执行结束后,函数内部定义的变量会被销毁

其他语言则还支持块级作用域:一对大括号包裹的代码

  • 函数
  • 判断语句
  • 循环语句
  • 代码块{}

简单来讲,如果一种语言支持块级作用域,那么其代码块内部定义的变量在代码块外部是访问不到的,并且等该代码块中的代码执行完成之后,代码块中定义的变量会被销毁。

# 为什么会有变量提升?

JS在设计之初,为了简单,没有块级作用域,同时对函数作用域做了变量提升,结果是函数中的变量无论声明在哪里,在编译阶段都会被提升到执行上下文的变量环境中,所以这些变量在整个函数体内部的任何地方都能被访问到,这就是JS中的变量提升

# 变量提升带来的问题?

  1. 变量覆盖
var myname = "极客时间"
function showName(){
  console.log(myname);
  if(0){
   var myname = "极客邦"
  }
  console.log(myname);
}
showName()
1
2
3
4
5
6
7
8
9
  1. 变量污染(本应销毁的变量没有被销毁)
function foo(){
  for (var i = 0; i < 7; i++) {
  }
  console.log(i); 
}
foo()
1
2
3
4
5
6

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

ES6引入了 let 和 const关键字,从而使得 JavaScript 和其他语言一样拥有了块级作用域。

通过实际的例子来分析下,ES6 是如何通过块级作用域来解决上面的问题的。


function varTest() {
  var x = 1;
  if (true) {
    var x = 2;  // 同样的变量!
    console.log(x);  // 2
  }
  console.log(x);  // 2
}
1
2
3
4
5
6
7
8
9

上述代码最后通过 console.log(x) 输出的是 2,而对于相同逻辑的代码,其他语言最后一步输出的值应该是 1,因为在 if 块里面的声明不应该影响到块外面的变量

既然支持块级作用域和不支持块级作用域的代码执行逻辑是不一样的,那么接下来我们就来改造上面的代码,让其支持块级作用域。

这个改造过程其实很简单,只需要把 var 关键字替换为 let 关键字,改造后的代码如下:

function letTest() {
  let x = 1;
  if (true) {
    let x = 2;  // 不同的变量
    console.log(x);  // 2
  }
  console.log(x);  // 1
}
1
2
3
4
5
6
7
8

这种就非常符合我们的编程习惯了:作用域块内声明的变量不影响块外面的变量

# JavaScript是如何支持块级作用域的?

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

变量查找路径:当前执行上下文的词法环境 => 当前执行上下文的变量环境 => outer指向的执行上下文中查找

思考题: 你能通过分析词法环境,得出来最终的打印结果吗?

let myname= '极客时间'
{
  console.log(myname) 
  let myname= '极客邦'
}
1
2
3
4
5

# 四、作用域链和闭包:代码中出现相同的变量,JavaScript引擎是如何选择的?

# 作用域链

每个执行上下文的变量环境中,都包含了一个外部引用,用来指向外部的执行上下文,我们把这个外部引用叫做 outer

通过作用域查找变量的链条称为 作用域链

function bar() {
    console.log(myName)
}
function foo() {
    var myName = "极客邦"
    bar()
}
var myName = "极客时间"
foo()
1
2
3
4
5
6
7
8
9

上面的代码打印的是 "极客时间" ,也就是 bar 函数中的 myName 取值是全局作用域上的,而不是调用它的 foo 函数中的。

要回答这个问题,需要知道什么是 词法作用域 。这是因为在 JavaScript 执行过程中,其作用域链是由词法作用域决定的。

# 词法作用域

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

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

# 块级作用域中的变量查找

function bar() {
    var myName = "极客世界"
    let test1 = 100
    if (1) {
        let myName = "Chrome浏览器"
        console.log(test)
    }
}
function foo() {
    var myName = "极客邦"
    let test = 2
    {
        let test = 3
        bar()
    }
}
var myName = "极客时间"
let myAge = 10
let test = 1
foo() // 1
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

# 闭包

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())
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

闭包的产生

根据词法作用域的规则,内部函数 getName 和 setName 总是可以访问它们的外部函数 foo 中的变量,所以当 innerBar 对象返回给全局变量 bar 时,虽然 foo 函数已经执行结束,但是 getName 和 setName 函数依然可以使用 foo 函数中的变量 myName 和 test1。所以当 foo 函数执行完成之后,其整个调用栈的状态如下图所示:

闭包的定义

在JavaScript中,根据词法作用域的规则,内部函数总是可以访问外部函数的变量。

当通过调用一个外部函数返回一个内部函数后,即使该外部函数已经执行结束了,但是内部函数引用外部函数的变量依然保存在内存中,我们就把这些变量的集合称为闭包。

或者更精简的闭包定义:

内部函数引用外部函数的变量的集合

定义不重要,重要的是理解闭包。

在开发者工具中查看闭包

比如外部函数是 foo ,那么这些变量的集合就成为 foo 函数的闭包。可以在浏览器 console 中 debugger 查看闭包。

闭包是如何形成的?

在函数中返回内部函数(也可能是返回一个包含内部函数的对象)时,JS引擎会提前分析这个内部函数是否引用了外部作用域变量,用来判断是否要创建闭包,有引用的外部变量都不会被gc回收,因此形成闭包

闭包如何回收?

  • 如果引用闭包函数的是一个全局变量,那么闭包会一直存在直到页面关闭;
  • 如果引用闭包函数的是一个局部变量,那么等包含这个局部变量的函数销毁后,下次JavaScript引擎执行垃圾回收的时候,判断闭包这块内容如果已经不再被使用了,那么JavaScript引擎的垃圾回收器就会回收这块内存。

闭包的作用

  1. 保护私有变量
  2. 维持内部私有变量的状态

应用: 函数柯里化

# 五、从JavaScript执行上下文的视角讲清楚this

在对象内部的方法中使用对象内部的属性是一个非常普遍的需求。但是 JavaScript 的作用域机制并不支持这一点,基于这个需求,JavaScript 又搞出来另外一套 this 机制。

作用域链和 this 是两套不同的系统,它们之间基本没太多联系。

# JavaScript 中的 this 是什么

执行上下文中包含:

  • 变量环境
  • 词法环境
  • outer外部环境指针
  • this

全局执行上下文中的 this 指向 window对象

函数执行上下文中的 this 要根据调用的方式来确定指向

设置函数执行上下文的 this 值的几种方法:

  1. 通过函数的 call/apply/bind 方法设置
  2. 通过对象调用方法设置 (使用对象来调用其内部的一个方法,该方法的 this 是指向对象本身的。)
  3. 通过构造函数中设置(使用new)

this的设计缺陷

  1. 嵌套函数中的 this 不会从外层嵌套中继承
var myObj = {
  name : "极客时间", 
  showThis: function(){
    console.log(this)
    function bar(){console.log(this)}
    bar()
  }
}
myObj.showThis()
1
2
3
4
5
6
7
8
9

函数 bar 中的 this 指向的是全局 window 对象,而函数 showThis 中的 this 指向的是 myObj 对象

你可以通过一个小技巧来解决这个问题,比如在 showThis 函数中声明一个变量 self 用来保存 this,然后在 bar 函数中使用 self,代码如下所示:

var myObj = {
  name : "极客时间", 
  showThis: function(){
    console.log(this)
    var self = this
    function bar(){
      self.name = "极客邦"
    }
    bar()
  }
}
myObj.showThis()
console.log(myObj.name)
console.log(window.name)
1
2
3
4
5
6
7
8
9
10
11
12
13
14

执行这段代码,你可以看到它输出了我们想要的结果,最终 myObj 中的 name 属性值变成了“极客邦”。其实,这个方法的的本质是把 this 体系转换为了作用域的体系

其实,你也可以使用 ES6 中的箭头函数来解决这个问题,结合下面代码:

var myObj = {
  name : "极客时间", 
  showThis: function(){
    console.log(this)
    var bar = ()=>{
      this.name = "极客邦"
      console.log(this)
    }
    bar()
  }
}
myObj.showThis()
console.log(myObj.name)
console.log(window.name)
1
2
3
4
5
6
7
8
9
10
11
12
13
14

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

通过上面的讲解,你现在应该知道了 this 没有作用域的限制,这点和变量不一样,所以嵌套函数不会从调用它的函数中继承 this,这样会造成很多不符合直觉的代码。要解决这个问题,你可以有两种思路:

  • 第一种是把 this 保存为一个 self 变量,再利用变量的作用域机制传递给嵌套函数。
  • 第二种是继续使用 this,但是要把嵌套函数改为箭头函数,因为箭头函数没有自己的执行上下文,所以它会继承调用函数中的 this。
  1. 普通函数中的 this 默认指向全局对象 window

上面我们已经介绍过了,在默认情况下调用一个函数,其执行上下文中的 this 是默认指向全局对象 window 的。

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

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

this总结

首先,在使用 this 时,为了避坑,你要谨记以下三点:

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

最后,我们还提了一下箭头函数,因为箭头函数没有自己的执行上下文,所以箭头函数的 this 就是它外层函数的 this

箭头函数 引入目的:

  • 更简短的函数(各种简化的写法)
  • 不绑定this

特点:

  1. 不绑定this,所以箭头函数的 this 就是它外层函数的 this
  2. 箭头函数不能用作构造器,和 new一起用会抛出错误。
  3. 箭头函数没有prototype属性。

# JS垃圾回收

  • 什么是垃圾
    • 没有被引用的对象就是垃圾
  • 如何捡垃圾
    • 标记-清除算法(Mark-Sweep GC)
      • 1、标记阶段:从根集合出发,将所有活动对象及其子对象打上标记
      • 2、清除阶段:遍历堆,将非活动对象(未打上标记)的连接到空闲链表上
      • 优点:实现简单, 容易和其他算法组合
      • 缺点:碎片化, 会导致无数小分块散落在堆的各处
    • 引用计数(Reference Counting)
      • 引用计数,就是记录每个对象被引用的次数,每次新建对象、赋值引用和删除引用的同时更新计数器,如果计数器值为0则直接回收内存。
      • 优点:可即刻回收垃圾
      • 缺点:计数器的增减处理繁重、占用很多位 参考 (opens new window)

# 内存泄露

  • 1.闭包引起的内存泄漏
  • 2.没有清理的DOM元素引用
  • 3.没有清理的定时器/事件监听

# 知识点梳理和总结

# JS执行过程

(单线程)EventLoop -> (宏任务)执行script ->(编译原理) 先编译后执行-> 可执行代码(字节码)+ 执行上下文

# 变量提升

  • 变量提升现象
  • 执行过程:编译+执行
  • 编译时,会创建执行上下文(全局、函数、eval)和可执行代码
  • 执行上下文中包含变量环境,辅助变量提升
  • 变量提升是设计缺陷,为了简单,只设计了全局和函数作用域,没有设计块级作用域,因此带来的变量提升会造成变量覆盖、变量污染等问题
  • 因此,ES6加入了let和const来解决这个问题,本质是加入了块级作用域

# 块级作用域

  • 块级作用域的原理是:在执行上下文中的词法环境来管理的,词法环境也是一个栈结构,这样块级作用域内部的变量就不会被提升,进而影响到外部环境了
  • 有了块级作用域后,变量的查找路径变为:当前执行上下文的词法环境 => 当前执行上下文的变量环境 => outer指向的执行上下文的词法环境、变量环境
  • outer和作用域链有关,而js的作用域链是按照词法作用域的规则来的,也就是在代码声明的时候,outer就确定好了的
  • 由于词法作用域要求,内部函数需要能访问到外部函数的变量,因此外部函数的变量不能随便被GC,于是有了闭包

# 闭包

  • 闭包可以理解为内部函数引用外部函数变量的集合
  • 闭包的产生是在外部函数返回内部函数时,外部函数执行完毕,外部函数的执行上下文出栈,这时JS引擎会判断返回的这个内部函数是否有引用外部函数内的变量,如果有,则形成闭包,并加入到调用栈中
  • 闭包使用时,内部函数被调用,这时会首先在内部函数的执行上下文中查找,如果没有,则向闭包中查找变量
  • 闭包的释放,当引用内部函数的变量是全局变量时,和页面同生命周期;当是局部变量时,定义这个局部变量的函数若被销毁,则这个局部变量引用的闭包也会销毁。
  • 闭包的作用是保存私有变量和维护它的状态

# this

  • this存在于执行上下文中,是被设计用来在对象内部使用对象内部的属性的
  • 全局执行上下文中,this指向window
  • 函数执行上下文中,this默认指向这个函数,但我们也可以修改它的指向
  • 修改方法:apply/call/bind、new、在对象中调用
  • this的设计有缺陷,比如嵌套函数的this不会从外层嵌套中继承
  • 解决办法是:使用箭头函数、或者定义self(使用作用域链机制代替this)
  • 比如普通函数中的this默认指向全局对象window,解决办法是:使用严格模式

# 箭头函数

  • 箭头函数的引入是为了解决this不能继承的缺陷的,同时简化写法
  • 箭头函数的特点是它不绑定this、不能当做构造函数new对象,没有prototype属性(构造函数才有)

# 严格模式

  • 消除Javascript语法的一些不合理、不严谨之处,减少一些怪异行为;
  • 消除代码运行的一些不安全之处,保证代码运行的安全;
  • 提高编译器效率,增加运行速度;
  • 为未来新版本的Javascript做好铺垫。

# 参考资料

上次更新: 3/1/2022, 10:32:19 AM