【JS基础】深入理解 JavaScript(上)|青训营笔记(内附相关知识点面经串联)

109 阅读12分钟

01 JS的基本概念

浏览器进程

一个页面的打开包括四个进程,而多个页面的时候浏览器进程、网络进程、GPU 进程等,这些都是通用的进程

每个标签页的都是独立的渲染进程,进程之间的资源( CPU、内存等)和行为( UI、逻辑等)互不共享,所以即便某个标签页崩溃了也不会影响其他标签页

而在每个标签页的渲染进程中有如下线程,各线程主要功能如图所示:

GUI渲染线程和JS引擎线程是互斥的【浏览器】Chrome浏览器的执行机制(内附相关知识点面经串联)

webWork就是应对JS的任务实在太重,造成阻塞了,JS线程就会向浏览器申请开一个子线程(子线程是浏览器开的,完全受主线程控制,而且不能操作DOM)这样里面不管如何翻天覆地都不会影响JS引擎主线程, 只待计算出结果后,将结果通信给主线程即可,perfect!

而且注意下,JS引擎是单线程的,这一点的本质仍然未改变,Worker可以理解是浏览器给JS引擎开的外挂,专门用来解决那些大量计算问题

当我们键入URI时,浏览器各进程之间的配合

数据类型

JS有以下八种数据类型: yuque_diagram (1).jpg

原始值和引用值的定义方式很类似,都是创建一个变量然后赋值

但是在赋值后,能对这个变量做什么就很不同了

  1. 存储位置不同:原始值存储在栈内存中,引用值存储在堆内存中。
  2. 传递方式不同:原始值通过值传递的方式进行传递,引用值通过引用传递的方式进行传递。
  3. 比较方式不同:原始值通过值的比较进行比较,引用值通过引用的比较进行比较。
  4. 原始值不可变:原始值在被创建后无法修改,只能重新赋值。而引用值可以修改其属性和方法。

面试八股之:JS 的数据类型有哪些?数据类型的区别是什么?如何确定数据类型?

作用域

作用:让变量的名字只在某个范围内起作用,目的是提高程序的可靠性,更重要的是减少命名冲突

ES6之前

  • 全局作用域
  • 局部作用域(函数作用域)

全局作用域

概念:整个script标签 或者是一个单独的js文件都是全局作用域

特性:全局作用域中的对象在代码中任何位置都能访问,其生命周期伴随着页面的生命周期

函数作用域

概念:在函数内部定义的变量或者函数就是函数作用域

特点:定义的变量或者函数只能在函数内部被访问。函数执行结束之后,函数内部定义的变量会被销毁。

ES6之后

  • 全局作用域
  • 局部作用域
  • 块级作用域

块级作用域

概念:块级作用域就是使用一对大括号包裹的一段代码,比如函数、判断语句、循环语句,甚至单独的一个{}都可以被看作是一个块级作用域。

特点:代码块内部定义的变量在代码块外部是访问不到的,并且等该代码块中的代码执行完成之后,代码块中定义的变量会被销毁

//if块
if(1){}

//while块
while(1){}

//函数块
function foo(){}
 
//for循环块
for(let i = 0; i<100; i++){}

//单独一个块
{}

var let const

var和let声明的不是不同类型的变量,只是指出变量在相关作用域如何存在

注意:如果在函数内部没有声明 直接赋值的也是全局变量

var——ES5变量声明方式

  1. 在变量未赋值时,变量undefined
  2. 作用域——var的作用域为方法作用域

只要在方法内定义了,整个方法内定义变量前后的代码都可以使用,定义前使用var声明的变量会变量提升,不会报错,但输出为undefined

let——ES6变量声明方式

  1. 在声明变量前直接使用会报错
  2. 作用域——let的作用域为块作用域
  3. let不能重复声明变量,会报错;var可以,但是会覆盖

const——ES6变量声明方式

  1. const为常量声明方式,且声明时必须初始化赋值,在后面的代码中不能再修改该常量的值
  2. const实际上保证的并不是变量的值不能改,而是变量为引用数据类型时,指向的那个内存地址不得改动

面试八股之: let、const、var的区别?暂时性死区?

02 JS是如何执行的

一个例子

首先我们知道JavaScript是单线程,也就是说按照从上往下的顺序执行的,如下这段代码:

showName()
console.log(myname)
var myname = 'fyyy'
function showName() {
    console.log('函数showName被执行');
}

按照顺序执行的话,showName()函数和myname变量都没有定义,所以执行都会报错

但实际结果是没有报错,所以我们知道了函数或者变量可以在定义之前使用 (因为有变量提升)

但如果删掉第三行代码,使用没有定义的变量或者函数就会执行报错

showName()
console.log(myname);
function showName() {
    console.log('函数showName被执行');
}

所以我们可以得出一下结论:

  1. 在执行过程中,若使用了未声明的变量,那么 JavaScript 执行会报错。(删掉第三行代码)
  2. 在一个变量定义之前使用它,不会出错,但是该变量的值会为 undefined,而不是定义时的值。
  3. 在一个函数定义之前使用它,不会出错,且函数能正确执行。

第一个结论比较好理解,而第二三个结论就似乎和我们说js是按照顺序执行的前提条件矛盾

(小声bb:不是说顺序执行吗,为什么不报错?),没报错的原因就是变量提升

变量提升

在JavaScript中变量分为声明赋值两步操作

var myname    //声明部分
myname = 'fyyyy'  //赋值部分
//等价于var myname = 'fyyyy'

函数也分为声明和赋值部分

//函数声明:完整的函数声明,不涉及到赋值操作
function foo(){
  console.log('foo')
}
//函数表达式:先声明变量 bar再把函数赋值给bar,也可以写成箭头函数
var bar = function(){
  console.log('bar')
}
var bar2 = ()=>{
    console.log('bar2');
}

在了解完声明和赋值之后,所谓的变量提升就是在JavaScript代码执行过程中,把变量和函数的声明部分提升到代码开头的行为,变量被提升后会先默认赋值为undefined

//我们看到的形式
showName()
console.log(myname)
var myname = 'fyyy'
function showName() {
    console.log('函数showName被执行');
}
/*
* 实际变量提升后
*/
//变量myname提升到开头,同时赋值为undefined,函数也提升到开头
var myname = undefined
function showName() {
    console.log('showName被调用');
}
myname = 'fyyyy'
showName()
console.log(myname)

从概念的字面意义上来看,“变量提升”意味着变量和函数的声明会在物理层面移动到代码的最前面,但实际声明的位置在代码中并不会改变

变量提升是在编译阶段被 JavaScript引擎放入内存中,也就是说一段JavaScript代码不会直接进入执行阶段 而是先进入编译阶段,再进入执行阶段

image.png

JavaScript 执行流程图

JavaScript代码的执行流程

1.编译阶段

输入的一段JavaScript代码,在编译阶段经过编译后,会生成两部分内容:执行上下文可执行代码

上面的例子,我的理解就是

//执行上下文部分
var myname = undefined
function showName() {
    console.log('showName被调用');
}
//可执行代码
myname = 'fyyyy'
showName()
console.log(myname)

什么是执行上下文

执行上下文是JavaScript 执行一段代码时的运行环境或准备工作(说白了执行js就可以看成是要做一道菜,创建执行上下文就是要知道自己需要哪些食材嘛,而炒菜的具体的步骤,就是可执行代码嘛)

比如调用一个函数时(菜:土豆烧排骨),确定该函数在执行期间用到的this、变量、对象以及函数等(食材:土豆,排骨)

(哪些情况下代码才算是“一段”代码,才会在执行之前就进行编译并创建执行上下文呢?见下文调用栈部分)

执行上下文又包含变量环境词法环境

可以简单记为变量环境放var变量,词法环境放let、const定义的变量和函数

变量环境可以简单理解为,在执行上下文中存在一个变量环境对象(Viriable Environment),这个对象里面保存了变量提升的内容,词法环境同理

结合一段代码来看如何生成变量环境对象和词法环境对象的:

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

这样就生成了变量环境对象和词法环境

可执行代码

接下来 JavaScript 引擎会把声明以外的代码编译为字节码,字节码可以类比如下的模拟代码:

showName()
console.log(myname)
myname = 'fyyyy'

2 执行阶段

JavaScript 引擎开始执行“可执行代码”,按照顺序一行一行地执行

/*
* 可执行代码
*/
showName()
console.log(myname)
var myname = 'fyyyy'// 去掉var声明部分,保留赋值语句
function showName()
{ 
    console.log('函数showName被执行');
 }
  • 首先showName(),JavaScript 引擎会在变量环境中查找这个函数,可以看到VariableEnvironment中存在showName的引用,所以JavaScript 引擎就开始执行这个函数并输出“函数 showName 被执行”结果。
  • 接下来执行console.log(myname),JavaScript 引擎会在变量环境中查找这个变量,由于变量环境中存在这个变量,并且其值为 undefined,所以这时候就输出 undefined
  • 接下来执行第 3 行,把“fyyyy”赋给 myname 变量,赋值后变量环境中的 myname 属性值改变为“fyyyy”

变量提升带来的问题

变量被覆盖掉

var myname = "xxxxx"
function showName(){
  console.log(myname);
  if(0){
   var myname = "yyyyy"
  }
  console.log(myname);
}
showName()

通过调用栈分析,调用栈最顶端是showName的执行上下文,而showName又因为if块中的myname变量提升,所以执行上面这段代码,打印出来的是 undefined

应销毁的变量没有被销毁

function foo(){
  for (var i = 0; i < 7; i++) {
  }
  console.log(i); 
}
foo()

i 的值并未被销毁,最后打印出来的是 7。这同样也是由变量提升而导致的,在创建执行上下文阶段,变量 i 就已经被提升了,所以当 for 循环结束之后,变量 i 并没有被销毁。

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

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

那么let和const是如何和块级作用域结合的呢?看同样的两段代码

//var 的作用范围是整个函数
function varTest() {
  var x = 1;
  if (true) {
    var x = 2;  // 同样的变量!所以只生成了一个变量x
    console.log(x);  // 2
  }
  console.log(x);  // 2
}
//改造后支持的块级作用域
function letTest() {
  let x = 1;
  if (true) {
    let x = 2;  // 不同的变量
    console.log(x);  // 2
  }
  console.log(x);  // 1
}

因为 let 关键字是支持块级作用域的,所以在编译阶段,JavaScript 引擎并不会把 if 块中通过 let 声明的变量存放到letTest函数的执行上下文变量环境中,这也就意味着在 if 块中通过 let 声明的关键字,并不会提升到全函数可见。所以在 if 块之内打印出来的值是 2,跳出语块之后,打印出来的值就是 1 了

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

调用栈

在上文我们知道了执行上下文是JavaScript 执行一段代码时的运行环境或准备工作 而哪些情况下代码才算是“一段”代码,才会在执行之前就进行编译并创建执行上下文呢?一般来说有三种情况

  • 全局执行上下文:代码开始执行时就会创建,将他压执行栈的栈底每个生命周期内只有一份
  • 函数执行上下文:当执行一个函数时,这个函数内的代码被编译,生成变量环境、词法环境等,当函数执行结束的时候该执行环境从栈顶弹出
  • Eval执行上下文:用的比较少

什么是调用栈

JavaScript 中有很多函数,经常会出现在一个函数中调用另外一个函数的情况,调用栈就是用来管理函数调用关系的一种数据结构

这段代码的调用栈如下图所示: image.png 在执行左边这段代码之前

  • JavaScript 引擎会为上面这段代码创建全局执行上下文,包含了声明的函数和变量(如右图的全局执行上下文内容所示)
  • 执行上下文准备好之后,便开始执行全局代码,当执行到 func 这儿时,JavaScript 判断这是一个函数调用,那么又将执行以下操作:
    • 首先,从全局执行上下文中,取出 func 函数代码。
    • 其次,对 func 函数的这段代码进行编译,并创建该函数的执行上下文和可执行代码。(如右图执行栈中的func执行上下文所示)
    • 最后,执行代码,输出结果。

就这样,当执行到 func 函数的时候,我们就有了两个执行上下文了——全局执行上下文和 func 函数的执行上下文,执行上下文通过调用栈这种数据结构来管理。

什么是栈溢出

而调用栈是有大小的,当入栈的执行上下文超过一定数目,JavaScript 引擎就会报错,我们把这种错误叫做栈溢出。

简单来说:栈溢出就是一直往里面压,没有被弹出,没有被垃圾回收,一直压到最大容量,就会造成栈溢出(写递归或者死循环)

参考文章:

字节前端初阶训练营

理解 JavaScript 中的执行上下文和执行栈