参考极客时间《浏览器工作原理与实践》 https://www.yuque.com/handshell/kygzse

1、JS是解释型语言/编译型语言?

https://segmentfault.com/a/1190000013126460

答: JavaScript 是一个解释型语言

  • 编译型语言在程序执行之前,需要经过编译器的编译过程,并且编译之后会直接保留机器能读懂的二进制文件,这样每次运行程序时,都可以直接运行该二进制文件,而不需要再次重新编译了。比如 C/C++、GO 等都是编译型语言。
  • 解释型语言编写的程序,在每次运行时都需要通过解释器对程序进行动态解释和执行。比如 Python、JavaScript 等都属于解释型语言。

image.png
image.png
这二者的执行流程,大致可阐述为如下:

  1. 在编译型语言的编译过程中,编译器首先会依次对源代码进行词法分析、语法分析,生成抽象语法树(AST),然后是优化代码,最后再生成处理器能够理解的机器码。如果编译成功,将会生成一个可执行的文件。但如果编译过程发生了语法或者其他的错误,那么编译器就会抛出异常,最后的二进制文件也不会生成成功。
  2. 在解释型语言的解释过程中,同样解释器也会对源代码进行词法分析、语法分析,并生成抽象语法树(AST),不过它会再基于抽象语法树生成字节码,最后再根据字节码来执行程序、输出结果。

2、V8代码执行过程

https://www.bilibili.com/video/BV1c44y1y7sx

image.png
主要流程 : 初始化基础环境; 解析源码生成 AST 和作用域; 依据 AST 和作用域生成字节码; 解释执行字节码; 监听热点代码; 优化热点代码为二进制的机器代码; 反优化生成的二进制机器代码。

  1. V8 提供的调试工具D8 https://time.geekbang.org/column/article/219418
  2. d8 --print-ast test.js // 查看ast
  3. d8 --print-scopes test.js // 查看作用域
  4. d8 --print-bytecode test.js // 查看字节码
  5. d8 --trace-opt test.js // 查看被编译器优化的代码
  6. d8 --trace-deopt test.js //查看被反优化的代码

1. 生成抽象语法树(AST)和执行上下文

AST:https://resources.jointjs.com/demos/javascript-ast

通常,生成 AST 需要经过两个阶段。先分词,再解析

第一阶段是分词(tokenize),又称为词法分析,其作用是将一行行的源码拆解成一个个 token。所谓token,指的是语法上不可能再分的、最小的单个字符或字符串。
image.png
第二阶段是解析(parse),又称为语法分析,其作用是将上一步生成的 token 数据,根据语法规则转为 AST。如果源码符合语法规则,这一步就会顺利完成。但如果源码存在语法错误,这一步就会终止,并抛出一个“语法错误”。

有了 AST 后,V8 就会生成该段代码的执行上下文

2. 生成字节码

接下来解释器 Ignition会根据 AST 生成字节码,并解释执行字节码。

字节码就是介于 AST 和机器码之间的一种代码。但是与特定类型的机器码无关,字节码需要通过解释器将其转换为机器码后才能执行。

image.png
字节码和机器码占用空间对比
从图中可以看出,机器码所占用的空间远远超过了字节码,所以使用字节码可以减少系统的内存使用。

3. 执行代码

通常,如果有一段第一次执行的字节码,解释器 Ignition 会逐条解释执行。在执行字节码的过程中,如果发现有热点代码(HotSpot),比如一段代码被重复执行多次,这种就称为热点代码,那么后台的编译器 TurboFan 就会把该段热点的字节码编译为高效的机器码,然后当再次执行这段被优化的代码时,只需要执行编译后的机器码就可以了,这样就大大提升了代码的执行效率。

这其实就是即时编译(JIT)技术


不过,和静态语言不同的是,JavaScript 是一种非常灵活的动态语言,对象的结构和属性 是可以在运行时任意修改的,而经过优化编译器优化过的代码只能针对某种固定的结构,一旦在执行过程中,对象的结构被动态修改了,那么优化之后的代码势必会变成无效的代码, 这时候优化编译器就需要执行反优化操作,经过反优化的代码,下次执行时就会回退到解释器解释执行。

image.png

3、变量提升

所谓的变量提升,是指在 JavaScript 代码执行过程中,JavaScript 引擎把变量的声明部分和函数的声明部分提升到代码开头的“行为”。变量被提升后,会给变量设置默认值,这个默认值就是我们熟悉的 undefined
⭐️JavaScript执行机制 - 图7
⭐️JavaScript执行机制 - 图8

同名变量和函数的两点处理原则
1:如果是同名的函数,JavaScript编译阶段会选择最后声明的那个。
2:如果变量和函数同名,那么在编译阶段,变量的声明会被忽略。

代码的执行流程

实际上变量和函数声明在代码里的位置是不会改变的,而且是在编译阶段被 JavaScript 引擎放入内存中。
一段 JavaScript 代码在执行之前需要被 JavaScript 引擎编译,编译完成之后,才会进入执行阶段。image.png

1. 编译阶段

  1. showName()
  2. console.log(myname)
  3. var myname = '极客时间'
  4. function showName() {
  5. console.log('showName被调用');
  6. }
  7. // showName被调用
  8. // undefined

image.png
JavaScript 执行流程细化图

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

  • 执行上下文是 JavaScript 执行一段代码时的运行环境,比如调用一个函数,就会进入这个函数的执行上下文,确定该函数在执行期间用到的诸如 this、变量、对象以及函数等。
  • 执行上下文中存在一个变量环境的对象(Viriable Environment),该对象中保存了变量提升的内容,比如上面代码中的变量 myname 和函数 showName,都保存在该对象中。

简单地把变量环境对象看成是如下结构:

  1. VariableEnvironment:
  2. myname -> undefined,
  3. showName ->function : {console.log(myname)

我们可以一行一行来所解析的代码:

  • 第 1 行和第 2 行,由于这两行代码不是声明操作,所以 JavaScript 引擎不会做任何处理;
  • 第 3 行,由于这行是经过 var 声明的,因此 JavaScript 引擎将在环境对象中创建一个名为 myname 的属性,并使用 undefined 对其初始化
  • 第 4 行,JavaScript 引擎发现了一个通过 function 定义的函数,所以它将函数定义存储到堆 (HEAP)中,并在环境对象中创建一个 showName 的属性,然后将该属性值指向堆中函数的位置。

这样就生成了变量环境对象接下来 JavaScript 引擎会把声明以外的代码编译为字节码。

编辑阶段V8不会处理表达式,函数表达式和 立即调用函数表达式(IIFE)都是表达式

2. 执行阶段

  1. //模拟执行的字节码
  2. showName()
  3. console.log(myname)
  4. myname = '极客时间'

JavaScript 引擎开始执行“可执行代码”,按照顺序一行一行地执行。下面我们就来一行一行分析下这个执行过程:

  • 当执行到 showName 函数时,JavaScript 引擎便开始在变量环境对象中查找该函数,由于变量环境对象中存在该函数的引用,所以 JavaScript 引擎便开始执行该函数,并输出“函数 showName 被执行”结果。
  • 接下来打印“myname”信息,JavaScript 引擎继续在变量环境对象中查找该对象,由于变量环境存在 myname 变量,并且其值为 undefined,所以这时候就输出 undefined。
  • 接下来执行第 3 行,把“极客时间”赋给 myname 变量,赋值后变量环境中的 myname 属性值改变为“极客时间”,变量环境如下所示: ```javascript VariableEnvironment: myname -> “ 极客时间 “, showName ->function : {console.log(myname)
  1. **总结**
  2. - 在**编译阶段**,变量和函数会被存放到**变量环境**中,变量的默认值会被设置为undefined
  3. - 在代码**执行阶段**,JavaScript引擎会从变量环境中去查找自定义的变量和函数。
  4. > 函数声明和变量声明类似,V8 在编译阶段,都会对其执行变量提升的操作,将它们提升到作用域中,在执行阶段,如果使用了某个变量,就可以直接去作用域中去查找。
  5. > 不过 V8 对于提升函数和提升变量的策略是不同的,如果提升了一个变量,那么 V8 在将变量提升到作用域中时,还会为其设置默认值 undefined,如果是函数声明,那么 V8 会在内存中创建该函数对象,并提升整个函数对象。
  6. <a name="NpDb4"></a>
  7. # 4、调用栈
  8. 当一段代码被执行时,JavaScript 引擎先会对其进行**编译**,并**创建执行上下文**。一般说来,有这么三种情况:
  9. - JavaScript 执行全局代码的时候,会编译全局代码并创建**全局执行上下文**,而且在整个页面的生存周期内,全局执行上下文只有一份。
  10. - 当调用一个函数的时候,函数体内的代码会被编译,并创建**函数执行上下文**,一般情况下,函数执行结束之后,创建的函数执行上下文会被销毁。
  11. - 当使用 eval 函数的时候,eval 的代码也会被编译,并**创建执行上下文**。
  12. JavaScript 引擎会将执行上下文压入栈中,通常把这种用来管理执行上下文的栈称为**执行上下文栈**,又称**调用栈**。<br />![image.png](https://cdn.nlark.com/yuque/0/2021/png/338495/1626707872915-60f24d3c-6f87-4763-b503-342604f6c484.png#clientId=u88b8c89d-0934-4&crop=0&crop=0&crop=1&crop=1&from=paste&height=323&id=OPDhk&margin=%5Bobject%20Object%5D&name=image.png&originHeight=843&originWidth=1142&originalType=url&ratio=1&rotation=0&showTitle=false&size=276018&status=done&style=none&taskId=ub4c5d8bf-be3c-4ab3-801a-f966e98ad08&title=&width=437)<br />执行 add 函数时的调用栈
  13. 1. 如何利用浏览器查看调用栈的信息<br />![image.png](https://cdn.nlark.com/yuque/0/2021/png/338495/1626707915415-a7d556dc-a9d8-49bb-94ea-878adabc6864.png#clientId=u88b8c89d-0934-4&crop=0&crop=0&crop=1&crop=1&from=paste&height=308&id=xHdoi&margin=%5Bobject%20Object%5D&name=image.png&originHeight=636&originWidth=1142&originalType=url&ratio=1&rotation=0&showTitle=false&size=227108&status=done&style=none&taskId=u0a3111e9-fa26-49c4-bb5c-7c981899036&title=&width=553)
  14. 右边的“call stack”下面显示出来了函数的调用关系:栈的最底部是 anonymous,也就是**全局的函数入口**;中间是 addAll 函数;顶部是 add 函数。
  15. 还可以使用 `console.trace()`来输出当前的函数调用关系<br />![image.png](https://cdn.nlark.com/yuque/0/2021/png/338495/1626707965568-70d9bee9-402c-46f7-aa08-ec692a5e80d8.png#clientId=u88b8c89d-0934-4&crop=0&crop=0&crop=1&crop=1&from=paste&height=416&id=sGSpM&margin=%5Bobject%20Object%5D&name=image.png&originHeight=832&originWidth=1142&originalType=url&ratio=1&rotation=0&showTitle=false&size=225969&status=done&style=none&taskId=u94ef1bb4-9982-4980-b87c-d5f8cd23cf1&title=&width=571)
  16. **调用栈是有大小的**,当入栈的执行上下文超过一定数目,JavaScript引擎就会报错,这种错误叫做**栈溢出**。特别是写递归代码的时候,就很容易出现栈溢出的情况。
  17. <a name="mOFFs"></a>
  18. # 5、块级作用域
  19. 作用域是指在程序中定义变量的区域,该位置决定了变量的生命周期。<br />通俗地理解,作用域就是变量与函数的可访问范围,**即作用域控制着变量和函数的可见性和生命周期**。
  20. ES6 之前,ES 的作用域只有两种:**全局作用域**和**函数作用域**。
  21. - **全局作用域**中的对象在代码中的任何地方都能访问,其生命周期伴随着页面的生命周期。
  22. - **函数作用域**就是在函数内部定义的变量或者函数,并且定义的变量或者函数只能在函数内部被访问。函数执行结束之后,函数内部定义的变量会被销毁。
  23. 块级作用域就是使用一对大括号包裹的一段代码,比如函数、判断语句、循环语句,甚至单独的一个{}都可以被看作是一个块级作用域。
  24. <a name="dcu4f"></a>
  25. ## 变量提升所带来的问题
  26. 由于JavaScript存在变量提升这种特性,从而导致了很多与直觉不符的代码,这也是JavaScript的一个重要设计缺陷。
  27. ```javascript
  28. var myname = " 极客时间 "
  29. function showName(){
  30. console.log(myname);
  31. if(0){
  32. var myname = " 极客邦 "
  33. }
  34. console.log(myname);
  35. }
  36. showName()
  37. //打印出来的是 undefined undefined
  38. JavaScript会优先从当前的执行上下文中查找变量,由于变量提升,
  39. 当前的执行上下文中就包含了变量myname,而值是undefined,
  40. 所以获取到的myname的值就是undefined。
  1. function foo(){
  2. for (var i = 0; i < 7; i++) {
  3. }
  4. console.log(i);
  5. }
  6. foo()
  7. //最后打印出来的是 7
  8. 同样也是由变量提升而导致的,在创建执行上下文阶段,变量 i 就已经被提升了,
  9. 所以当 for 循环结束之后,变量 i 并没有被销毁
  1. function varTest() {
  2. var x = 1;
  3. if (true) {
  4. var x = 2; // 同样的变量!
  5. console.log(x); // 2
  6. }
  7. console.log(x); // 2
  8. }
  9. 从执行上下文的变量环境中可以看出,最终只生成了一个变量 x
  10. 函数体内所有对 x 的赋值操作都会直接改变变量环境中的 x 值。

image.png

词法环境

为了解决这些问题,ES6 引入了 let 和 const 关键字,从而使JavaScript也能像其他语言一样拥有了块级作用域。

当进入函数的作用域块时,作用域块中通过 let 声明的变量,会被存放在词法环境的一个单独的区域中,这个区域中的变量并不影响作用域块外面的变量,比如在作用域外面声明了变量 b,在该作用域块内部也声明了变量 b,当执行到作用域内部时,它们都是独立的存在。
⭐️JavaScript执行机制 - 图12
其实,在词法环境内部,维护了一个小型栈结构,栈底是函数最外层的变量,进入一个作用域块后,就会把该作用域块内部的变量压到栈顶;当作用域执行完成之后,该作用域的信息就会从栈顶弹出,这就是词法环境的结构。需要注意下,我这里所讲的变量是指通过 let 或者 const 声明的变量。
⭐️JavaScript执行机制 - 图13
再接下来,当执行到作用域块中的console.log(a)这行代码时,就需要在词法环境和变量环境中查找变量 a 的值了,具体查找方式是:沿着词法环境的栈顶向下查询,如果在词法环境中的某个块中查找到了,就直接返回给 JavaScript 引擎,如果没有查找到,那么继续在变量环境中查找
image.png
块级作用域就是通过词法环境的栈结构来实现的,而变量提升是通过变量环境来实现,通过这两者的结合,JavaScript引擎也就同时支持了变量提升和块级作用域了。

6、作用域链和闭包

作用域链

作用域链就是将一个个作用域串起来,实现变量查找的路径。
其实在每个执行上下文的变量环境中,都包含了一个外部引用,用来指向外部的执行上下文,我们把这个外部引用称为outer

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

当一段代码使用了一个变量时,JavaScript 引擎首先会在“当前的执行上下文”中查找该变量。如果在当前的变量环境中没有查找到,那么 JavaScript 引擎会继续在 outer 所指向的执行上下文中查找。
image.png
带有外部引用的调用栈示意图

在JavaScript执行过程中,其作用域链是由词法作用域决定的。

词法作用域

词法作用域就是指查找作用域的顺序是按照函数定义时的位置来决定的 ,所以词法作用域是静态的作用域,通过它就能够预测代码在执行过程中如何查找标识符。
image.png
从图中可以看出,词法作用域就是根据代码的位置来决定的,其中 main 函数包含了 bar 函数,bar 函数中包含了 foo 函数,因为 JavaScript 作用域链是由词法作用域决定的,所以整个词法作用域链的顺序是:
foo 函数作用域—>bar 函数作用域—>main 函数作用域—> 全局作用域。
词法作用域是代码阶段就决定好的,和函数是怎么调用的没有关系

块级作用域中的变量查找

image.png块级作用域中是如何查找变量的

现在是执行到 bar 函数的 if 语块之内,需要打印出来变量 test,那么就需要查找到 test 变量的值,其查找过程我已经在上图中使用序号 1、2、3、4、5 标记出来了。
首先是在 bar 函数的执行上下文中查找,但因为 bar 函数的执行上下文中没有定义 test 变量,所以根据词法作用域的规则,下一步就在 bar 函数的外部作用域中查找,也就是全局作用域。

闭包

JavaScript 闭包相关的三个重要特性:

  • JavaScript函数内部可以定义新的函数
  • 内部函数可以访问父函数中定义的变量
  • 函数可以作为另一个函数的返回值。
  1. function foo() {
  2. var myName = " 极客时间 "
  3. let test1 = 1
  4. const test2 = 2
  5. var innerBar = {
  6. getName:function(){
  7. console.log(test1) //闭包
  8. return myName // 闭包
  9. },
  10. setName:function(newName){
  11. myName = newName
  12. }
  13. }
  14. return innerBar
  15. }
  16. var bar = foo()
  17. bar.setName(" 极客邦 ")
  18. bar.getName()
  19. console.log(bar.getName())

根据词法作用域的规则,内部函数 getName 和 setName 总是可以访问它们的外部函数 foo 中的变量,所以当 innerBar 对象返回给全局变量 bar 时,虽然 foo 函数已经执行结束,但是 getName 和 setName 函数依然可以使用 foo 函数中的变量 myName 和 test1。所以当 foo 函数执行完成之后,其整个调用栈的状态如下图所示:
image.png
从上图可以看出,foo 函数执行完成之后,其执行上下文从栈顶弹出了,但是由于返回的 setName 和 getName 方法中使用了 foo 函数内部的变量 myName 和 test1,所以这两个变量依然保存在内存中。这像极了 setName 和 getName 方法背的一个专属背包,无论在哪里调用了 setName 和 getName 方法,它们都会背着这个 foo 函数的专属背包。之所以是专属背包,是因为除了 setName 和 getName 函数之外,其他任何地方都是无法访问该背包的,我们就可以把这个背包称为 foo 函数的闭包

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

那这些闭包是如何使用的呢?当执行到 bar.setName 方法中的myName = “极客邦”这句代码时,JavaScript 引擎会沿着“当前执行上下文–>foo 函数闭包–> 全局执行上下文”的顺序来查找 myName 变量,你可以参考下面的调用栈状态图:
image.png

你也可以通过“开发者工具”来看看闭包的情况,打开 Chrome 的“开发者工具”,在 bar 函数任意地方打上断点,然后刷新页面,可以看到如下内容:
image.png
开发者工具中的闭包展示

从图中可以看出来,当调用 bar.getName 的时候,右边 Scope 项就体现出了作用域链的情况:Local 就是当前的 getName 函数的作用域,Closure(foo) 是指 foo 函数的闭包,最下面的 Global 就是指全局作用域:
“Local–>Closure(foo)–>Global”就是一个完整的作用域链

惰性解析是指解析器在解析的过程中,如果遇到函数声明,那么会跳过函数内部的代码,并不会为其生成 AST 和字节码。 由于闭包会引用当前函数作用域之外的变 量,所以当 V8 预解析器解析一个函数的时候,还需要判断该函数的内部函数是否引用了当前函数内部声明的变量,如果引用了,那么需要将该变量存放到堆中,即便当前函数执行结束之后, 也不会释放该变量。

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

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

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

7、执行上下文和this

全局作用域和全局执行上下文的关系,其实你可以把作用域看成是一个抽象的概念,比如在 ES6 中,同一个全局执行上下文中,都能存在多个作用域, 你可以看下面这段代码

  1. var x = 5
  2. {
  3. let y = 2
  4. const z = 3
  5. }

当 V8 调用了一个函数时,就会进入函数的执行上下文,这时候全局执行上下文和当前的函 数执行上下文就形成了一个栈结构。

  1. var x = 1
  2. function show_x(){
  3. console.log(x)
  4. }
  5. function bar(){
  6. show_x()
  7. }
  8. bar()

image.png

this

this 是和执行上下文绑定的,也就是说每个执行上下文中都有一个 this。

image.png
执行上下文主要分为三种——全局执行上下文、函数执行上下文和 eval 执行上下文,所以对应的 this 也只有这三种——全局执行上下文中的 this、函数中的 this 和 eval 中的 this。


全局执行上下文中的 this

以在控制台中输入console.log(this)来打印出来全局执行上下文中的 this,最终输出的是 window 对象。所以你可以得出这样一个结论:全局执行上下文中的 this 是指向 window 对象的。这也是 this 和作用域链的唯一交点,作用域链的最底端包含了 window 对象,全局执行上下文中的 this 也是指向 window 对象。

函数执行上下文中的 this

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

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

1. 通过函数的 call 方法设置

  1. let bar = {
  2. myName : " 极客邦 ",
  3. test1 : 1
  4. }
  5. function foo(){
  6. this.myName = " 极客时间 "
  7. }
  8. foo.call(bar)
  9. console.log(bar)//极客时间
  10. console.log(myName)//未定义

使用 call、apply、bind 方式调用函数,会改变 this 的值,指向传入的第一个参数

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

  1. var myObj = {
  2. name : " 极客时间 ",
  3. showThis: function(){
  4. console.log(this)
  5. }
  6. }
  7. myObj.showThis() //打印的就是myObj 对象
  • 在全局环境中调用一个函数,函数内部的this指向的是全局变量window。
  • 通过一个对象来调用其内部的一个方法,该方法的执行上下文中的this指向对象本身

3. 通过构造函数中设置

  1. function CreateObj(){
  2. this.name = " 极客时间 "
  3. }
  4. var myObj = new CreateObj()

当执行 new CreateObj() 的时候,JavaScript 引擎做了如下四件事:

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

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

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

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

this 的设计缺陷以及应对方案

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

  1. var myObj = {
  2. name : " 极客时间 ",
  3. showThis: function(){
  4. console.log(this)
  5. function bar(){console.log(this)}
  6. bar()
  7. }
  8. }
  9. myObj.showThis()
  10. 你会发现函数 bar 中的 this 指向的是全局 window 对象,
  11. 而函数 showThis 中的 this 指向的是 myObj 对象。
  12. 这就是 JavaScript 中非常容易让人迷惑的地方之一,也是很多问题的源头。

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

  1. var myObj = {
  2. name : " 极客时间 ",
  3. showThis: function(){
  4. console.log(this)
  5. var self = this
  6. function bar(){
  7. self.name = " 极客邦 "
  8. }
  9. bar()
  10. }
  11. }
  12. myObj.showThis()
  13. console.log(myObj.name)
  14. console.log(window.name)
  15. //也可以使用 ES6 中的箭头函数来解决这个问题
  16. var myObj = {
  17. name : " 极客时间 ",
  18. showThis: function(){
  19. console.log(this)
  20. var bar = ()=>{
  21. this.name = " 极客邦 "
  22. console.log(this)
  23. }
  24. bar()
  25. }
  26. }
  27. myObj.showThis()
  28. console.log(myObj.name)
  29. console.log(window.name)

因为 ES6 中的箭头函数并不会创建其自身的执行上下文,所以箭头函数中的 this 取决于它的外部函数。

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

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

2. 普通函数中的 this 默认指向全局对象 window
在默认情况下调用一个函数,其执行上下文中的 this 是默认指向全局对象 window 的。
不过这个设计也是一种缺陷,因为在实际工作中,我们并不希望函数执行上下文中的 this 默认指向全局对象,因为这样会打破数据的边界,造成一些误操作。如果要让函数执行上下文中的 this 指向某个对象,最好的方式是通过 call 方法来显示调用。
这个问题可以通过设置 JavaScript 的“严格模式”来解决。在严格模式下,默认执行一个函数,其函数的执行上下文中的 this 值是 undefined,这就解决上面的问题了。

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

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

    8、内存空间

    JavaScript的数据类型

    JavaScript中的数据类型一种有8种,它们分别是:
    ⭐️JavaScript执行机制 - 图23

我们把前面的7种数据类型称为原始类型,把最后一个对象类型称为引用类型,之所以把它们区分为两种不同的类型,是因为它们在内存中存放的位置不一样。

栈空间和堆空间

在 JavaScript 的执行过程中, 主要有三种类型内存空间,分别是代码空间、栈空间堆空间
image.png
栈空间就是调用栈,是用来存储执行上下文的。
当执行一段代码时,需要先编译,并创建执行上下文,然后再按照顺序执行代码

  1. function foo(){
  2. var a = " 极客时间 "
  3. var b = a
  4. var c = {name:" 极客时间 "}
  5. var d = c
  6. }
  7. foo()

image.png
执行到第 3 行时的调用栈状态图

当执行到第 3 行时,变量 a 和变量 b 的值都被保存在执行上下文中,而执行上下文又被压入到栈中,所以你也可以认为变量 a 和变量 b 的值都是存放在栈中的。

接下来继续执行第 4 行代码,由于 JavaScript 引擎判断右边的值是一个引用类型,这时候处理的情况就不一样了,JavaScript 引擎并不是直接将该对象存放到变量环境中,而是将它分配到执行上下文栈里面,分配后该对象会有一个在“堆”中的地址,然后再将该数据的地址写进 c 的变量值,最终分配好内存的示意图如下所示:
image.png
对象类型是“堆”来存储

原始类型的数据值都是直接保存在“栈”中的,引用类型的值是存放在“堆”中的
原始类型的赋值会完整复制变量值,而引用类型的赋值是复制引用地址

为什么一定要分“堆”和“栈”两个存储空间呢? 是因为 JavaScript 引擎需要用栈来维护程序执行期间上下文的状态,如果栈空间大了话,所有的数据都存放在栈空间里面,那么会影响到上下文切换的效率,进而又影响到整个程序的执行效率。

所以通常情况下,栈空间都不会设置太大,主要用来存放一些原始类型的小数据。而引用类型的数据占用的空间都比较大,所以这一类数据会被存放到堆中,堆空间很大,能存放很多大的数据,不过缺点是分配内存和回收内存都会占用一定的时间。

从内存模型的角度来分析闭包

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

当 foo 函数的执行上下文销毁时,由于 foo 函数产生了闭包,所以变量 myNametest1并没有被销毁,而是保存在内存中,那么应该如何解释这个现象呢?

  1. 当 JavaScript 引擎执行到 foo 函数时,首先会编译,并创建一个空执行上下文。
  2. 在编译过程中,遇到内部函数 setName,JavaScript 引擎还要对内部函数做一次快速的词法扫描,发现该内部函数引用了 foo 函数中的 myName 变量,由于是内部函数引用了外部函数的变量,所以 JavaScript 引擎判断这是一个闭包,于是在堆空间创建换一个closure(foo)的对象(这是一个内部对象,JavaScript 是无法访问的),用来保存 myName 变量。
  3. 接着继续扫描到 getName 方法时,发现该函数内部还引用变量 test1,于是 JavaScript 引擎又将 test1 添加到closure(foo)对象中。这时候堆中的closure(foo)对象中就包含了 myName 和 test1 两个变量了。
  4. 由于 test2 并没有被内部函数引用,所以 test2 依然保存在调用栈中。

通过上面的分析,我们可以画出执行到 foo 函数中return innerBar语句时的调用栈状态,如下图所示:
image.png
闭包的产生过程

从上图你可以清晰地看出,当执行到 foo 函数时,闭包就产生了;当 foo 函数执行结束之后,返回的 getNamesetName方法都引用clourse(foo)对象,所以即使 foo 函数退出了,clourse(foo)依然被其内部的 getNamesetName方法引用。所以在下次调用bar.setName或者bar.getName时,创建的执行上下文中就包含了clourse(foo)

总的来说,产生闭包的核心有两步:

  • 第一步是需要预扫描内部函数;
  • 第二步是把内部函数引用的外部变量保存到堆中

9、垃圾回收

ESP

  1. function foo(){
  2. var a = 1
  3. var b = {name:"极客邦"}
  4. function showName(){
  5. var c = 2
  6. var d = {name:"极客时间"}
  7. }
  8. showName()
  9. }
  10. foo()

记录当前执行状态的指针(称为ESP)
⭐️JavaScript执行机制 - 图28
从图中可以看出,当showName函数执行结束之后,ESP向下移动到foo函数的执行上下文中,上面showName的执行上下文虽然保存在栈内存中,但是已经是无效内存了。比如当foo函数再次调用另外一个函数时,这块内容会被直接覆盖掉,用来存放另外一个函数的执行上下文。

所以说,当一个函数执行结束之后,JavaScript引擎会通过向下移动ESP来销毁该函数保存在栈中的执行上下文

当上面那段代码的foo函数执行结束之后,ESP应该是指向全局执行上下文的,那这样的话,showName函数和foo函数的执行上下文就处于无效状态了,不过保存在堆中的两个对象依然占用着空间,如下图所示:
⭐️JavaScript执行机制 - 图29

从图中可以看出,1003和1050这两块内存依然被占用。要回收堆中的垃圾数据,就需要用到JavaScript中的垃圾回收器了

GC Root

image.png

  1. window.test = new Object()
  2. window.test.a = new Uint16Array(100)
  3. window.test.a = new Object()

a 属性之前是指向堆中数组对象的,现在已经指向了另外一个空对象,那么 此时堆中的数组对象就成为了垃圾数据,因为我们无法从一个根对象遍历到这个 Array 对象。

通过 GC Root 标记空间中活动对象和非活动对象。

目前 V8 采用的可访问性(reachability)算法来判断堆中的对象是否是活动对象。具体地讲,这个算法是将一些 GC Root 作为初始存活的对象的集合,从 GC Roots 对象出发,遍历 GC Root 中的所有对象:

  • 在浏览器环境中,GC Root 有很多,通常包括了以下几种 (但是不止于这几种): 通过 GC Root 遍历到的对象,我们就认为该对象是可访问的(reachable),那么必须保证这些对象应该在内存中保留,我们也称可访问的对象为活动对象;
  • 通过 GC Roots 没有遍历到的对象,则是不可访问的(unreachable),那么这些不可访问的对象就可能被回收,我们称不可访问的对象为非活动对象。

    在浏览器环境中,GC Root 有很多,通常包括了以下几种 (但是不止于这几种):

  • 全局的 window 对象(位于每个 iframe 中);

  • 文档 DOM 树,由可以通过遍历文档到达的所有原生 DOM 节点组成;
  • 存放栈上变量。

回收机制

在V8中会把堆分为新生代老生代两个区域,新生代中存放的是生存时间短的对象,老生代中存放的生存时间久的对象。新生区通常只支持1~8M的容量,而老生区支持的容量就大很多了。

对于这两块区域,V8分别使用两个不同的垃圾回收器,以便更高效地实施垃圾回收。

  • 副垃圾回收器,主要负责新生代的垃圾回收。
  • 主垃圾回收器,主要负责老生代的垃圾回收。

不论什么类型的垃圾回收器,它们都有一套共同的执行流程:

  • 第一步是标记空间中活动对象和非活动对象。所谓活动对象就是还在使用的对象,非活动对象就是可以进行垃圾回收的对象。
  • 第二步是回收非活动对象所占据的内存。其实就是在所有的标记完成之后,统一清理内存中所有被标记为可回收的对象。
  • 第三步是做内存整理。一般来说,频繁回收对象后,内存中就会存在大量不连续空间,我们把这些不连续的内存空间称为内存碎片。当内存中出现了大量的内存碎片之后,如果需要分配较大连续内存的时候,就有可能出现内存不足的情况。所以最后一步需要整理这些内存碎片,但这步其实是可选的,因为有的垃圾回收器不会产生内存碎片,比如接下来我们要介绍的副垃圾回收器。

我们就按照这个流程来分析新生代垃圾回收器(副垃圾回收器)和老生代垃圾回收器(主垃圾回收器)是如何处理垃圾回收的。

副垃圾回收器

副垃圾回收器主要负责新生区的垃圾回收。而通常情况下,大多数小的对象都会被分配到新生区,所以说这个区域虽然不大,但是垃圾回收还是比较频繁的。

新生代中用Scavenge算法来处理。所谓Scavenge算法,是把新生代空间对半划分为两个区域,一半是对象区域,一半是空闲区域,如下图所示:
⭐️JavaScript执行机制 - 图31
新生区要划分为对象区域和空闲区域

新加入的对象都会存放到对象区域,当对象区域快被写满时,就需要执行一次垃圾清理操作。

在垃圾回收过程中,首先要对对象区域中的垃圾做标记;标记完成之后,就进入垃圾清理阶段,副垃圾回收器会把这些存活的对象复制到空闲区域中,同时它还会把这些对象有序地排列起来,所以这个复制过程,也就相当于完成了内存整理操作,复制后空闲区域就没有内存碎片了。

完成复制后,对象区域与空闲区域进行角色翻转,也就是原来的对象区域变成空闲区域,原来的空闲区域变成了对象区域。这样就完成了垃圾对象的回收操作,同时这种角色翻转的操作还能让新生代中的这两块区域无限重复使用下去

由于新生代中采用的 Scavenge 算法,所以每次执行清理操作时,都需要将存活的对象从对象区域复制到空闲区域。但复制操作需要时间成本,如果新生区空间设置得太大了,那么每次清理的时间就会过久,所以为了执行效率,一般新生区的空间会被设置得比较小

也正是因为新生区的空间不大,所以很容易被存活的对象装满整个区域。为了解决这个问题,JavaScript引擎采用了对象晋升策略,也就是经过两次垃圾回收依然还存活的对象,会被移动到老生区中

主垃圾回收器

主垃圾回收器主要负责老生区中的垃圾回收。除了新生区中晋升的对象,一些大的对象会直接被分配到老生区。因此老生区中的对象有两个特点,一个是对象占用空间大,另一个是对象存活时间长。

由于老生区的对象比较大,若要在老生区中使用Scavenge算法进行垃圾回收,复制这些大的对象将会花费比较多的时间,从而导致回收执行效率不高,同时还会浪费一半的空间。因而,主垃圾回收器是采用标记-清除(Mark-Sweep)的算法进行垃圾回收的。下面我们来看看该算法是如何工作的。

首先是标记过程阶段。标记阶段就是从一组根元素开始,递归遍历这组根元素,在这个遍历过程中,能到达的元素称为活动对象,没有到达的元素就可以判断为垃圾数据

比如最开始的那段代码,当showName函数执行退出之后,这段代码的调用栈和堆空间如下图所示:
⭐️JavaScript执行机制 - 图32
标记过程
从上图你可以大致看到垃圾数据的标记过程,当showName函数执行结束之后,ESP向下移动,指向了foo函数的执行上下文,这时候如果遍历调用栈,是不会找到引用1003地址的变量,也就意味着1003这块数据为垃圾数据,被标记为红色。由于1050这块数据被变量b引用了,所以这块数据会被标记为活动对象。这就是大致的标记过程。

接下来就是垃圾的清除过程。它和副垃圾回收器的垃圾清除过程完全不同,你可以理解这个过程是清除掉红色标记数据的过程,可参考下图大致理解下其清除过程:
⭐️JavaScript执行机制 - 图33

上面的标记过程和清除过程就是标记-清除算法,不过对一块内存多次执行标记-清除算法后,会产生大量不连续的内存碎片。而碎片过多会导致大对象无法分配到足够的连续内存,于是又产生了另外一种算法——标记-整理(Mark-Compact),这个标记过程仍然与标记-清除算法里的是一样的,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。你可以参考下图:

⭐️JavaScript执行机制 - 图34

全停顿

现在你知道了V8是使用副垃圾回收器和主垃圾回收器处理垃圾回收的,不过由于JavaScript是运行在主线程之上的,一旦执行垃圾回收算法,都需要将正在执行的JavaScript脚本暂停下来,待垃圾回收完毕后再恢复脚本执行。我们把这种行为叫做全停顿(Stop-The-World)

比如堆中的数据有1.5GB,V8实现一次完整的垃圾回收需要1秒以上的时间,这也是由于垃圾回收而引起JavaScript线程暂停执行的时间,若是这样的时间花销,那么应用的性能和响应能力都会直线下降。主垃圾回收器执行一次完整的垃圾回收流程如下图所示:
⭐️JavaScript执行机制 - 图35

在V8新生代的垃圾回收中,因其空间较小,且存活对象较少,所以全停顿的影响不大,但老生代就不一样了。如果在执行垃圾回收的过程中,占用主线程时间过久,就像上面图片展示的那样,花费了200毫秒,在这200毫秒内,主线程是不能做其他事情的。比如页面正在执行一个JavaScript动画,因为垃圾回收器在工作,就会导致这个动画在这200毫秒内无法执行的,这将会造成页面的卡顿现象。

为了降低老生代的垃圾回收而造成的卡顿,V8将标记过程分为一个个的子标记过程,同时让垃圾回收标记和JavaScript应用逻辑交替进行,直到标记阶段完成,我们把这个算法称为增量标记(Incremental Marking)算法。如下图所示:
⭐️JavaScript执行机制 - 图36

V8 最开始的垃圾回收器有两个特点,第一个是垃圾回收在主线程上执行,第二个特点是一次执行一个完整的垃圾回收流程。 由于这两个原因,很容易造成主线程卡顿,所以 V8 采用了很多优化执行效率的方案。

  • 第一个方案是并行回收,在执行一个完整的垃圾回收过程中,垃圾回收器会使用多个辅助线 程来并行执行垃圾回收。
  • 第二个方案是增量式垃圾回收,垃圾回收器将标记工作分解为更小的块,并且穿插在主线程 不同的任务之间执行。采用增量垃圾回收时,垃圾回收器没有必要一次执行完整的垃圾回收 过程,每次执行的只是整个垃圾回收过程中的一小部分工作。
  • 第三个方案是并发回收,回收线程在执行 JavaScript 的过程,辅助线程能够在后台完成的 执行垃圾回收的操作。 主垃圾回收器就综合采用了所有的方案,副垃圾回收器也采用了部分方案。