浏览器工作原理笔记 - 浏览器中的 JavaScript

104 阅读12分钟

「本文已参与低调务实优秀中国好青年前端社群的写作活动」

变量提升

声明和赋值

var myname = 'cellinlab';

可以将上面代码看成两部分组成:

var myname; // 声明部分
myname = 'cellinlab'; // 赋值部分

对于函数来说:

function foo () {
  console.log('foo');
}

var bar = function () {
  console.log('bar');
}

函数 foo() 是一个完整的函数声明,没有涉及赋值操作;第二个函数,先声明了变量 bar,再把 function () {} 赋值给 bar。可以理解为:

变量提升

变量提升,是指在 JavaScript 代码执行的过程中,JavaScript 引擎将变量的声明部分和函数的声明部分提升到代码的顶部的“行为”。变量被提升后,会给变量设置一个默认值,默认值为 undefined

从字面上看,“变量提升”意味着变量和函数的声明会在物理层面移动到代码的前面。但是,实际上,并不是这样的。实际上变量和函数的声明在代码中的位置是不变的,而是在编译阶段被 JavaScript 引擎放入内存中

一段 JavaScript 代码在执行前需要被 JavaScript 引擎编译,编译完之后,才会进入执行阶段。

代码执行流程 之 编译阶段

showName();
console.log(myname);
var myname = 'cellinlab';
function showName() {
  console.log('showName called');
}

上面代码可以被拆成两部分来看,声明部分:

var myname = undefined;
function showName() {
  console.log('showName called');
}

执行部分的代码:

showName();
console.log(myname);
myname = 'cellinlab';

可以看出,输入一段代码,经过编译后,会生成两部分内容:执行上下文可执行代码

执行上下文是 JavaScript 执行一段代码时的运行环境,如调用一个函数,就会进入这个函数的执行上下文,以确定该函数在执行期间用到的诸如 this、变量、对象以及函数等。

或者说,在执行上下文中存在一个变量环境的对象(Variable Environment),该对象中保存了变量提升的内容。

可以简单理解是这个样子的:

VariableEnvironment:
  myname -> undefined
  showName -> function () {
    console.log('showName called');
  }

变量环境对象生成的过程:

showName();
console.log(myname);
var myname = 'cellinlab';
function showName() {
  console.log('showName called');
}
  1. 在 line 1 和 line 2 ,由于这两行代码不是声明操作,所以 JavaScript 引擎不会做任何处理;
  2. 在 line 3 中,使用了 var 声明,因此 JavaScript 引擎将在环境对象中创建一个名为 myname 的属性,并将其初始化为 undefined
  3. 在 line 4 中,JavaScript 引擎发现一个通过 function 定义的函数,所以将函数定义存储到堆(Heap)中,并将函数的引用存储到环境对象中的 showName 属性中;

接下来,JavasScript 引擎会把声明以外的代码编译为字节码:

showName();
console.log(myname);
myname = 'cellinlab';

代码执行流程 之 执行阶段

JavasScript 引擎按顺序逐行执行“可执行代码”:

  1. 当遇到 showName 函数时,JavaScript 引擎便开始在变量环境对象中查找该函数,由于变量环境对象中存在该函数的引用,所以 JavaScript 引擎开始执行该函数,输出 showName called
  2. 接下来,输出 myname 的值,JavaScript 引擎在变量环境对象中查找该属性,找到 myname 且其值为 undefined,所以 JavaScript 引擎输出 undefined
  3. 接下来,将 'cellinlab' 赋值给 myname
VariableEnvironment:
  myname -> 'cellinlab'
  showName -> function () {
    console.log('showName called');
  }

同名变量和函数的处理

function showName() {
  console.log('cell');
}
showName();
function showName() {
  console.log('cellinlab');
}
showName();
  • 编译阶段,遇到第一个 showName 函数,引擎会将该函数存到变量环境对象中。当遇到第二个 showName 函数时,会继续存放,但是发现已经存在一个同名函数,此时,新来的函数会将之前的函数覆盖掉,变量环境对象中的 showName 函数体的内容被更新为新的函数体。
  • 执行阶段,从变量环境对象中查找函数,找到同名函数,执行新的函数体,输出 cellinlab

所以,如果一段代码中定义了两个同名函数,那么,最后生效的是晚点定义的函数。

调用栈

调用栈就是用来管理函数调用关系的一种数据结构。

函数调用

函数调用就是运行一个函数,具体方法就是使用函数名后加括号:

var a = 2;
function add () {
  var b = 10;
  return a + b;
}
add();

在执行到函数 add() 之前,JavaScript 引擎会为代码创建全局执行上下文,包含声明的函数和变量。

代码中的全局变量和函数都保存在全局上下文的变量环境中。

执行上下文准备好之后,便开始执行全局代码,当执行到 add 时,JavaScript 引擎识别出这是个函数调用,会进行:

  1. 全局执行上下文 中,取出 add 函数代码;
  2. add 函数代码进行编译,并创建该函数的执行上下文可执行代码
  3. 执行代码,输出结果。

在执行 JavaScript 时,可能存在多个执行上下文,JavaScript 引擎通过来管理执行上下文。

JavaScript 的调用栈

在执行上下文创建好后,JavaScript 引擎会将执行上下文压入栈中,通常将用来管理执行上下文的栈称执行上下文栈,也叫调用栈

var a = 2;
function add (b, c) {
  return b + c;
}
function addAll(b, c) {
  var d = 10;
  result = add(b, c);
  return a + result + d;
}
addAll(3, 6);

上面代码执行流程:

  1. 创建全局上下文,将其压入栈底;
    • 变量 a、函数 addaddAll 都保存到了全局上下文的变量环境对象中
    • 全局上下文压入到调用栈后,JavaScript 引擎开始执行全局代码
  2. 调用 addAll 函数,当调用该函数时,JavaScript 引擎会编译该函数,并为其创建一个执行上下文,最后还将该函数的执行上下文压入栈:
    • addAll 函数的执行上下文创建好之后,便进入函数代码的执行阶段
  3. 当执行到 add 函数调用语句时,同样会为其创建执行上下文,并将其压入调用栈
    • add 函数返回时,该函数的执行上下文就会从栈顶弹出,并将 result 的值设置为 add 函数的返回值,也就是 9
    • 紧接着 addAll 执行最后一个相加操作后并返回,addAll 的执行上下文也会弹出
    • 最后,调用栈中只剩下全局上下文,程序执行完毕

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

块作用域

作用域

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

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

  • 全局作用域:其中的内容在代码中的任何地方都能访问,其生命周期与页面的生命周期相同,只要页面存在,其内容就存在;
  • 函数作用域:在函数内部定义的变量或函数,并且定义的变量或者函数只能在函数内部被访问。函数执行结束后,函数内部定义的变量也会被销毁。

块级作用域是一对大括号包裹的一段代码,如函数、判断语句、循环语句,甚至单独的一个 {} 都可以被看做是一个块级作用域。对于支持块作用域的语言,代码块内部定义的变量在代码块外部是访问不到的,并且等该代码块中的代码执行完之后,代码块中定义的变量会被销毁。

因为,在 ES6 之前,是不支持块级作用域的。没了块级作用域,再把作用域内部的变量统一提升无疑是最快速、最简单的设计,但是,这导致了函数中的变量无论是在哪里声明的,在编译阶段都会被提取到执行上下文的变量环境中,所以,这些变量在整个函数体内部的任何地方都是能被访问的。

变量提升带来的问题

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

    var myname = 'cellinlab';
    function showName () {
      console.log(myname);
      if (0) {
        var myname = 'cell';
      }
      console.log(myname);
    }
    showName(); 
    // undefined
    // undefined
    // undefined
    
    • 执行上下文和调用栈的状态
    • showName 函数的执行上下文创建后,JavaScript 引擎便开始执行 showName 函数内部的代码
    • 调用栈中有两个 myname 变量:一个在全局上下文中,其值是 cellinlab;另一个在 showName 函数的执行上下文中,其值是 undefined
    • 在函数执行过程中,JavaScript 会优先从当前的执行上下文中查找变量,由于变量提升,当前的执行上下文中就包含了变量 myname 值是 undefined,故 输出 undefined
  2. 本应销毁的变量没有被销毁

    function foo () {
      for (var i = 0; i < 7; i++) {
      }
      console.log(i);
    }
    foo(); // 7
    
    • 在创建执行上下文是,i 被提升,当 for 循环结束时,i 并没有被销毁。

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

ES6 引入了 letconst 关键字,从此 JavaScript 也有了 块作用域。

function varTest () {
  var x = 1;
  if (ture) {
    var x = 2;
    console.log(x); // 2
  }
  console.log(x); // 2
}
varTest();

使用 let :

function letTest () {
  let x = 1;
  if (ture) {
    let x = 2;
    console.log(x); // 2
  }
  console.log(x); // 1
}

let 支持块级作用域,在编译阶段,JavaScript 引擎并不会把 if 块中的变量提升,使其在全函数可见范围内。

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

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);
}
foo();
// 1 3 2 undefined undefined
  1. 编译并创建执行上下文
    • 函数内部通过 var 声明的变量,在编译阶段全都被存放到变量环境里面
    • 通过 let 声明的变量,在编译阶段会被存放到 词法环境
    • 在函数的作用域内部,通过 let 声明的变量并没有被存放到词法环境中
  2. 继续执行代码,当执行到代码块里面时:
    • 当进入函数的作用域块时,作用域块中通过 let 声明的变量,会被存放在词法环境的一个单独的区域中,该区域中的变量不会影响块作用域外的变量
    • 实质上,在词法环境内部,维护了一个小型栈结构,栈底是函数最外层的变量,进入一个作用域块后,就会把该作用域块内部的变量压到栈顶,并在执行完该作用域后弹出
    • 在读取值时,会现在词法环境找,找不到再去变量环境中找
    • 等作用域块执行结束后,其内部定义的变量就会从词法环境的栈顶弹出,最终执行上下文如下

作用域和闭包

function bar () {
  console.log(myName);
}
function foo () {
  var myName = 'cell';
  bar();
}
var myName = 'cellinlab';
foo();
// cellinlab

作用域链

在每个执行上下文的变量环境中,都包含一个外部引用,用来指向外部的执行上下文,可以称为 outer

当一段代码使用了一个变量时,JavaScript 引擎首先会在 “当前的执行上下文” 中查找该变量。如果当前的环境变量中没有找到,会继续在 outer 所指向的执行上下文中查找。

bar 函数 和 foo 函数的 outer 都是指向全局上下文的,也就意味着如果在 bar 函数或者 foo 函数中使用了外部变量,那么 JavaScript 引擎回去全局执行上下文中查找。把这个查找的路径称为 作用域链

不过,有个疑问,foo 函数中调用的 bar 函数,为什么 bar 函数的外部引用是全局上下文,而不是 foo 函数的执行上下文?这需要了解词法作用域,JavaScript 执行过程中,其作用域链是由词法作用域决定的。

词法作用域

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

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

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

闭包

function foo () {
  var myName = 'cell';
  let test1 = 1;
  const test2 = 2;
  var innerBar = {
    getName: function () {
      console.log(test1);
      return myName;
    },
    setName: function (name) {
      myName = name;
    }
  };
  return innerBar;
}
var bar = foo();
bar.setName('cellinlab');
console.log(bar.getName());
// 1
// cellinlab

根据词法作用域的规则,内部函数 getNamesetName 总是可以访问它们的外部函数 foo 中的变量。当 innerBar 对象返回给全局变量 bar 时,虽然 foo 函数已经执行结束,但是 getNamesetName 函数依然可以使用 foo 函数中的变量。在函数执行完后,其执行上下文弹出了,但是由于返回的方法中使用了 foo 中的变量 myNametest1,所以这两个变量依然存在于内存中,这就是闭包。

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

闭包回收

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

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

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

this

JavaScript 中的 this 是什么

this 是和执行上下文绑定的,即每个执行上下文中都有一个 this。执行上下文主要有三种:全局执行上下文、函数执行上下文和 eval 执行上下文,那对应的 this 就有三种。

全局执行上下文中的 this

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

函数执行上下文中的 this

function foo() {
  console.log(this);
}
foo();
// window

默认情况下调用一个函数,其执行上下文中的 this 是指向 window 对象的。可以通过一些方法来设置函数执行上下文中的 this 值:

  1. 通过函数的 call 方法设置(还有 bindapply)
let bar = {
  myName: 'cell',
  test1: 1,
};
function foo () {
  this.myName = 'cellinlab';
}
foo.call(bar);
console.log(bar.myName); // cellinlab
  1. 通过对象调用方法设置
var myObj = {
  name: 'cellinlab',
  showName: function () {
    console.log(this.name);
  }
}
myObj.showName(); // cellinlab
  • 使用对象来调用其内部的一个方法,该方法的 this 指向对象本身
var name = 'default';
var myObj = {
  name: 'cellinlab',
  showName: function () {
    console.log(this.name);
    this.name = 'cell';
    console.log(this.name);
  }
};
var foo = myObj.showName;
foo(); 
// default
// cell
  • 在全局环境中调用一个函数,函数内部的 this 指向对象本身
  1. 通过构造函数设置
function CreateObj () {
  this.name = 'cellinlab';
}
var myObj = new CreateObj();
console.log(myObj.name); // cellinlab
  • 当执行 new CreateObj() 时,JavaScript 引擎会:
    1. 创建一个空对象 tempObj
    2. 调用 CreateObj.call,并将 tempObj 作为 call 的参数,这样当 CreateObj 的执行上下文创建时,它的 this 指向的就是 tempObj
    3. 执行 CreateObj 函数,此时的 CreateObj 函数上下文中的 this 指向了 tempObj 对象;
    4. 返回 tempObj 对象
  • 可以理解为:
var tempObj = {};
CreateObj.call(tempObj);
var myObj = tempObj;

this 的设计缺陷及应对方案

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

    var name = 'default';
    var myObj = {
      name: 'cellinlab',
      showName: function () {
        console.log(this.name);
        function foo () {
          console.log(this.name);
        };
        foo();
      }
    };
    myObj.showName();
    // cellinlab
    // default
    
    • foo 中的 this 指向的是全局 window 对象,而函数 showName 中的 this 指向的是 myObj 对象
    • 可以通过在 showName 中声明式地引用 this 来解决这个问题,这种方案的本质是将 this 体系转换为了作用域的体系
    var name = 'default';
    var myObj = {
      name: 'cellinlab',
      showName: function () {
        console.log(this.name);
        const self = this;
        function foo () {
          console.log(self.name);
        };
        foo();
      }
    };
    myObj.showName();
    // cellinlab
    // cellinlab
    
    • 也可以使用箭头函数解决这个问题,因为箭头函数不会创建自身的执行上下文,所以箭头函数中的 this 取决于它的外部函数
    var name = 'default';
    var myObj = {
      name: 'cellinlab',
      showName: function () {
        console.log(this.name);
        const self = this;
        const foo = () => {
          console.log(self.name);
        };
        foo();
      }
    };
    myObj.showName();
    // cellinlab
    // cellinlab
    
  2. 普通函数中的 this 默认指向全局对象 window

如果要让函数执行上下文中的 this 指向某个对象,最好的方法是使用 call 来做显示绑定。

也可以通过 严格模式 来解决,严格模式下,默认执行一个函数,其函数的执行上下文中的 thisundefined