Javascript 执行机制

635 阅读9分钟

执行流程

先编译,再执行。

JS代码执行流程

  • 编译阶段:进行变量提升,变量与函数会被存放到变量环境中,变量的默认值被设为 undefined.若存在两个相同的函数,最终存放在变量环境中的是后面那个。如果函数带有参数,编译过程中,参数会通过参数列表保存在变量环境中。
  • 执行阶段:JS 引擎会从变量环境中去查找自定义的变量和函数。

哪些情况下代码在执行之前会编译并创建执行上下文?

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

调用栈

  • 栈溢出是如何产生的?
    当调用一个函数时,会给他创建一个执行上下文 push 到栈中,执行完毕从栈中 pop。若函数内部又调用了其他函数,内部又调用其他函数...,不断将执行上下文往栈中 push 却没有 pop,超过一定数量就会导致栈溢出报错。

    没有终止条件的递归会一直创建新函数的执行上下文压入栈中,超过栈容量的最大先之后就会报错;

    可以通过把递归改造成其他形式、加入定时器拆分任务等方法来解决。

    调用栈是 JavaScript 引擎追踪函数执行的一个机制,当一次有多个函数被调用时,通过调用栈就能追踪到哪个函数正在被执行和各个函数间的调用关系。

如何用好调用栈?

  • 利用浏览器查看调用栈信息
    函数调用关系
  • 加入 console.trace() 输出当前函数调用关系
    使用 trace 打印调用栈信息

JS 中 let,const,{} 如何实现块级作用域?

ES6 之前的作用域

  • 全局作用域中的对象在代码中的任何地方都能访问,其生命周期伴随着页面的生命周期。
  • 函数作用域就是在函数内部定义的变量或者函数只能在函数内部被访问。函数执行结束之后,函数内部定义的变量被销毁。

变量提升带来的问题

  1. 变量容易被覆盖
chat* myname = "geek time";
void showName() {
  printf("%s \n", myname);  // 'geek time'
  if(0){
    chat* myname = "Hei ha";
  }
}
int main(){
  showName();
  return 0;
}

最终打印为 'geek time'

var myname = "geek time";
function showName() {
  console.log(myname);
  if (0) {
    var myname = "Hei ha";
  }
}
showName();

最终打印为 undefined

  1. 本应销毁的变量没有销毁
function foo() {
  for (var i = 0; i < 7; i++) {}
  console.log(i);
}
foo(); //7

输出为 7,变量 i 在 foo 循环结束后并没有被销毁,说明在创建执行上下文阶段,变量 i 就已经被提升了。

在其他语言中,for,if,while,{},函数块等内部变量执行完后就会被销毁。

ES6 如何解决变量提升带来的问题?

通过 var 声明的变量,在编译阶段被放进变量环境,而通过 let,const 声明的被放进词法环境(Lexical Environment);

let声明变量-1
每个块级作用域内的 let,const 声明又被放进词法环境的一个单独区域中。
let声明变量-2
当作用域块执行结束后,内部定义的变量就会从词法环境的栈顶弹出,从而实现和其他语言一样的变量销毁。
let声明变量-3

作用域链与闭包

作用域链

  • 下面代码输出什么?
function bar() {
  console.log(myName);
}
function foo() {
  var myName = "极客邦";
  bar();
}
var myName = "极客时间";
foo();

执行bar时的调用栈
按上面调用栈顺序来分析,那么结果应该是极客邦; 实际答案是极客时间
带有外部引用的调用栈
每个执行上下文的环境中都包含了一个外部引用,用来指向外部执行的上下文,上图中的 outer。

当一段代码使用一个变量时,JS 引擎首先在“当前执行上下文(bar)”中查找该变量,若没有,则在 outer 所指向的执行上下文中查找,这个查找链条就是作用域链

  • 问题:那么 foo 中调用的 bar,为什么 bar 的外部引用是全局执行上下文而不是 foo 函数的执行上下文?

因为在 JS 执行过程中,作用域链是由词法作用域决定的。

词法作用域

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

词法作用域
上图中,整个词法作用域链的顺序是: foo 函数作用域 -> bar 函数作用域 -> main 函数作用域 -> 全局作用域。

词法作用域是代码阶段就决定好的,和函数怎么调用没有关系。 再看上面的问题,就知道打印的结果为什么是“极客时间”了。 如果换成下面的:

function foo() {
  var myName = "极客邦";
  function bar() {
    console.log(myName);
  }
  return bar();
}
var myName = "极客时间";
foo();

此时打印的就是“极客邦”了。

块级作用域中的变量查找

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)

块级作用域的变量查找

闭包

function foo() {
  var myName = "极客时间";
  let test1 = 1;
  const test2 = 2;
  var innerBar = {
    getName() {
      console.log(test1);
      return myName;
    },
    setName(newName) {
      myName = newName;
    }
  };
  return innerBar;
}
var bar = foo();
bar.setName("极客邦");
bar.getName();
console.log(bar.getName());

执行到 return innerBar 时的调用栈

根据词法作用域的规则易得,内部函数getNamesetName可以访问 foo 中的 myName 和 test1。所以,当 foo 执行完后,这两个变量成为 foo 闭包的专属变量,除了 setName 和 getName 其他任何地方都无法访问 foo 闭包中的变量。调用栈的状态如下:
闭包的产生过程-1
闭包的产生过程

通过上图可以看出,当执行到 foo 时,闭包就产生了,foo 结束后,getName 与 setName 都引用了clourse(foo) 对象,所以即使 foo 函数结束了,clourse(foo)依然被其内部的 getName 和 setName 引用,调用这两个方法时,创建的执行上下文就包含了 clourse(foo)

  • 站在内存模型角度分析代码的执行流程
  1. 执行 foo 函数,编译、创建执行上下文。
  2. 编译过程中,遇到 setName,发现其中使用了外部函数的变量myName,于是生成一个闭包环境来存放 myName 变量。
  3. 接着扫描,又遇到 getName 发现函数内部有使用了外部变量,JS 引擎又将 test1 存放到闭包中。
  4. test2 没有被函数内部引用,所以依然保存在执行栈中。
  • 产生闭包的核心两步:
  1. 预扫描内部函数
  2. 把内部函数引用的外部变量保存到堆中

闭包如何使用?

当执行 bar.setName() 方法中的 myName = 'xxx' 时,JS 引擎会沿着“当前执行上下文 -> foo 函数闭包 -> 全局执行上下文”的属性来查找,如下:

执行bar.setName时调用栈状态

Chrome 开发者工具中在 innerBar 的函数中打断点,刷新页面也可查看闭包状态。
开发者工具中闭包展示

通过Scope即可查看作用域链的情况。

闭包如何回收?

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

如果引用闭包的函数是局部变量,等函数销毁后,下次 JS 引擎执行垃圾回收时,判断闭包如果已不再被使用,就会回收这块内存。

综上所述,若闭包一直使用,则作为全局变量,否则为局部变量。

this

this 是和执行上下文绑定的,执行上下文有全局、函数、eval 执行上下文,故对应的 this 也有这三种。

执行上下文中的 this

全局执行上下文中的 this

window

函数执行上下文中的 this

  1. 通过 call,bind,apply 设置
let bar = {
  myName: 'x'
}
function foo() {
  this.myName = 'xxx'
}
foo.call(bar)
  1. 通过对象调用方法设置
var myObj = {
  name: 'x',
  showThis() {
    console.log(this)
  }
}
myObj.showThis()  // 等同于 myObj.showThis.call(myObj)
var foo = myObj.shiwThis
foo() // window

在全局环境中调用一个函数,函数内部的 this 指向的是全局变量 window。 通过一个对象来调用其内部的一个方法,该方法的执行上下文中的 this 指向对象本身。
3. 通过构造函数中设置 new 运算符

this 的设计缺陷

  1. 嵌套函数中的 this 不会从外层继承 this 没有作用域限制,所以嵌套函数不会从调用它的函数中继承。
var myObj = {
  name: 'jk',
  showThis() {
    console.log(this) // myObj
    function bar() {
      console.log(this)
    }
    bar() // window
  }
}

解决办法:1. 外层绑定 this 2. 箭头函数
2. 普通函数中的 this 默认指向全局对象 window 严格模式下,默认执行一个函数,这个函数执行上下文中的 this 是 undefined

some question

  1. 第一题
showName();
var showName = function() {
  console.log(2);
};
function showName() {
  console.log(1);
}

输出 1,第一个 showName 带 var 经过变量提升后被赋值为 undefined,变量 showName 会被下面同名函数覆盖,再次执行 showName 就为 2,具体过程如下

// 编译
var showName = undefined;
function showName() {
  console.log(1);
}
// 执行
showName(); // 1
showName = function() {
  console.log(2);
};
showName(); // 2
  1. 第二题
let myname = "geek time";
{
  console.log(myname);
  let myname = "Hei ha";
}

最终的打印结果不是 undefined.
而是:Cannot access 'myname' before initialization 原因:在块级作用域内,let 变量只是创建被提升,初始化并没有被提升,在初始化之前使用变量,会形成一个暂时性死区。

  • var 的创建和初始化被提升,赋值不会被提升。
  • let 的创建被提升,初始化和赋值不会被提升。
  • function 的创建、初始化和赋值均会被提升。
  • 暂时性死区:
    执行函数时才有进行编译,抽象语法树(AST)在进入函数阶段就生成了,并且函数内部作用域已经明确了,所以进入块级作用域不会有编译过程,只不过通过 let 或者 const 声明的变量会在进入块级作用域时才被创建,但是在该变量没有赋值之前,引用该变量 JavaScript 引擎会抛出错误---这就是“暂时性死区”

参考资料

浏览器工作原理与实践