浏览器原理入门(2)
JavaScript执行机制
只有理解了JavaScript的执行上下文,才能更好的理解JavaScript语言本身。
变量提升
var myName = undefined;
function showName(){
console.og('函数执行')
}
showName();
console.log(myName);
myName = 'Bob';
所谓的变量提升,是指在JavaScript代码执行过程中,JavaScript引擎把变量的声明部分和函数的声明部分提升到开头的“行为”。变量被提升后,会给变量设置默认值,这个默认值就是我们熟悉的undefined。
实际上变量和函数声明在代码里的位置是不会改变的,而是在编译阶段被JavaScript引擎放入内存中。
代码中出现相同的变量或者函数
function a(){
alert(20)
}
alert(a)
a();
a=3;
alert(a)
a=6;
a();
调用栈
那些情况下代码才算是“一段”代码,才会在执行之前就进行编译并创建执行上下文,一般来说有如下三种情况:
- 当JavaScript执行全局代码的时候,会编译全局代码并创建全局执行上下文,而且在整个页面的生存周期内,全局执行上下文只有一份。
- 当调用一个函数的时候,函数体内的代码会被编译,并创建函数执行上下文,一般情况下,函数执行完成之后,创建的函数执行上下文会被销毁。
- 当使用eval函数的时候,eval的代码也会被编译,并创建执行上下文。
调用栈就是用来管理函数调用关系的一种数据结构。
var a = 2;
function add(a,b){
return a+b;
}
function addAll(b,c){
var d = 10;
var result = add(b,c);
return a+result+d;
}
addAll(3,6)
第一步,创建全局上下文,并将其压入栈底。


调用addAll函数

执行完add函数

执行完addAll函数
块级作用域
正由于JavaScript存在变量提升这种特性,从而导致了很多与直觉不符的代码,这也是JavaScript的一个重要设计缺陷,ECMAScript6(一下简称ES6)已经通过引入块级作用域并配合let、const关键字,来避开了这种设计缺陷,但是由于JavaScript需要保持向下兼容,所以变量提升在相当一段时间内还会继续存在。
作用域
作用域是指在程序中定义变量的区域,该位置决定了变量的生命周期。通俗理解,作用域就是函数的可访问范围,即作用域控制着变量和函数的可见性和生命周期。
在ES6之前,ES的作用域只有两种:全局作用域和函数作用域。
变量提升带来的问题:
- 变量容易在不被察觉的情况下被覆盖
- 本应销毁的变量没有被销毁
function foo(){
for(var i = 0;i<7;i++>){}
console.log(i)
}
foo();
javascript是如何支持块级作用域的
function foo(){
var a = 1;
var 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();
- 编译并创建执行上下文
- 继续执行文件
- 变量查找过程
- 最终执行上下文
let a = 2;
console.log(a)
{
console.log(a)
function a(){console.log(1)}
}
console.log(a)
作用域和闭包
词法作用域是代码阶段就决定好的,和函数怎么调用的没有关系。
function bar(){
console.log(myName)
}
function foo(){
var myName = 'Bob';
bar();
}
var myName ='COC';
foo();
其实在每个执行上下文的变量环境中,都包含了一个外部引用,用来指向外部的执行上下文。把这个外部引用称为outer。当一段代码使用了一个变量时。JavaScript引擎首先还在“当前的执行上下文“中查找该变量,比如上面那段代码中在查找myName变量时,如果在当前的变量环境中查找没有查找到。那么JavaScript引擎会继续在outer指向的执行上下文中查找。
闭包
function foo(){
var myName = 'bob'
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('onPiece');
bar.getName();
console.log(bar.getName())
根据词法作用域的规则,内部函数getName和setName总是可以访问它们的外部函数foo中的变量,所以当innerBar对象返回给全局变量bar时,虽然foo函数已经执行结束,但是getName和setName函数依然可以使用foo函数中的变量myName和test1。
foo函数执行完成之后,其执行上下文栈从栈顶弹出,但是由于返回的setName和getName方法中使用了foo函数内部的变量myName和test1,所以这两个变量依然保存在内存中,像极了setName和getName方法背的一个专属背包,无论在哪里调用了setName和getName方法,它们都会背着这个foo函数的专属背包。这个背包被称为foo函数的闭包。
当调用bar.getName的时候,右边Scope项就体现了作用域链的情况:Local就是当前getName函数的作用域,Closure(foo)是指函数foo的闭包,最下面的Global就是指全局作用域,从Local->Closure(foo)->Global就是一个完整的作用域链。
闭包:在JavaScript中,根据词法作用域的规则,内部函数总是可以访问其外部函数中声明的变量,当通过调用一个外部函数返回一个内部函数后,即使该外部函数已经执行结束了,但是内部函数引用外部函数的变量依然保存在内存中,我们把这些变量的集合称为闭包。比如外部函数是foo,那么这些变量的集合就称为foo函数的闭包。
通常,如果引用闭包的函数是一个全局变量,那么闭包就会一直存在直到页面关闭;但是如果这个闭包以后不再使用的话,就会造成内存泄露。尽量让它成为一个局部变量。
this

全局执行上下文中的this指向window对象。
函数执行上下文中的this:
- 通过函数的call、apply、bind方法设置
- 通过对象调用方法设置
- 通过构造函数中设置new
function CreateObj(){
this.name = 'bob';
}
var myObj = new CreateObj();
⾸先创建了⼀个空对象tempObj;接着调⽤CreateObj.call⽅法,并将tempObj作为call⽅法的参数,这样当CreateObj的执⾏上下⽂创建时,它的this就指向了tempObj对象;然后执⾏CreateObj函数,此时的CreateObj函数执⾏上下⽂中的this指向了tempObj对象;最后返回tempObj对象。
this的设计缺陷以及应对方案
嵌套函数中的this不会从外层函数中继承
var myObj = {
name: 'bob',
showThis:function(){
console.log(this)
function bar(){console.log(this)}
bar();
}
}
myObj.showThis();
函数bar中的this指向的是全局window对象,⽽函数showThis中的this指向的是myObj对象。箭头函数解决普通函数中的this默认指向全局对象window在严格模式下,默认执⾏⼀个函数,其函数的执⾏上下⽂中的this值是undefined。
栈空间和堆空间

JavaScript是一种弱类型的动态语言。
JavaScript中的数据类型一共有8中,它们分别是:

原始数据类型是存储在栈空间中的,引用类型的数据是存储在堆空间中的。
垃圾回收
JavaScript引擎会通过向下移动ESP来销毁该函数保存在栈中的执⾏上下⽂。要回收堆中的垃圾数据,就需要⽤到JavaScript中的垃圾回收器了。代际假说和分代收集 代际假说有以下两个特点:第⼀个是⼤部分对象在内存中存在的时间很短,简单来说,就是很多对象⼀经分配内存,很快就变得不可访问;第⼆个是不死的对象,会活得更久。 V8中会把堆分为新⽣代和⽼⽣代两个区域,新⽣代中存放的是⽣存时间短的对象,⽼⽣代中存放的⽣存时间久的对象。副垃圾回收器,主要负责新⽣代的垃圾回收。主垃圾回收器,主要负责⽼⽣代的垃圾回收。垃圾回收器的⼯作流程
- 第⼀步是标记空间中活动对象和⾮活动对象。所谓活动对象就是还在使⽤的对象,⾮活动对象就是可以进⾏垃圾回收的对象。
- 第⼆步是回收⾮活动对象所占据的内存。其实就是在所有的标记完成之后,统⼀清理内存中所有被标记为可回收的对象。
- 第三步是做内存整理。⼀般来说,频繁回收对象后,内存中就会存在⼤量不连续空间,我们把这些不连续的内存空间称为内存碎⽚。当内存中出现了⼤量的内存碎⽚之后,如果需要分配较⼤连续内存的时候,就有可能出现内存不⾜的情况。所以最后⼀步需要整理这些内存碎⽚,但这步其实是可选的,因为有的垃圾回收器不会产⽣内存碎⽚,⽐如接下来我们要介绍的副垃圾回收器
副垃圾回收器
Scavenge算法:

角色反转的操作还能让新生代中的这两块区域无限重复使用下去,经过两次垃圾回收依然还存活的对象,会被移动到老生区中。
主垃圾回收器
采用标记-清除(Mark-Sweep)的算法进行清除操作。

一旦执行垃圾回收算法,都需要将正在执行的JavaScript脚本暂停下来,待垃圾回收完毕后再恢复脚本执行,我们把这种行为叫做全停顿(Stop-The-World)。

增量标记(Increment Marking)算法。
编译器和解释器
编译型语⾔在程序执⾏之前,需要经过编译器的编译过程,并且编译之后会直接保留机器能读懂的⼆进制⽂件,这样每次运⾏程序时,都可以直接运⾏该⼆进制⽂件,⽽不需要再次重新编译了。⽐如C/C++、GO等都是编译型语⾔。⽽由解释型语⾔编写的程序,在每次运⾏时都需要通过解释器对程序进⾏动态解释和执⾏。⽐如Python、JavaScript等都属于解释型语⾔。
- 在编译型语⾔的编译过程中,编译器⾸先会依次对源代码进⾏词法分析、语法分析,⽣成抽象语法树(AST),然后是优化代码,最后再⽣成处理器能够理解的机器码。如果编译成功,将会⽣成⼀个可执⾏的⽂件。但如果编译过程发⽣了语法或者其他的错误,那么编译器就会抛出异常,最后的⼆进制⽂件也不会⽣成成功。
- 在解释型语⾔的解释过程中,同样解释器也会对源代码进⾏词法分析、语法分析,并⽣成抽象语法树(AST),不过它会再基于抽象语法树⽣成字节码,最后再根据字节码来执⾏程序、输出结果
V8是如何执行一段JavaScript代码的
- 将源代码转换为抽象语法树,并⽣成执⾏上下⽂
AST:AST是⾮常重要的⼀种数据结构,在很多项⽬中有着⼴泛的应⽤。其中最著名的⼀个项⽬是Babel。Babel是⼀个被⼴泛使⽤的代码转码器,可以将ES6代码转为ES5代码,这意味着你可以现在就⽤ES6编写程序,⽽不⽤担⼼现有环境是否⽀持ES6。Babel的⼯作原理就是先将ES6源码转换为AST,然后再将ES6语法的AST转换为ES5语法的AST,最后利⽤ES5的AST⽣成JavaScript源代码。
- 第一阶段是分词(tokenize),又称为词法分词
- 第二阶段是解析(parse),又称为语法分析
- 生成字节码 解释器Ignition就登场了,它会根据AST⽣成字节码,并解释执⾏字节码。字节码就是介于AST和机器码之间的⼀种代码。但是与特定类型的机器码⽆关,字节码需要通过解释器将其转换为机器码后才能执⾏。
- 执行代码
⽣成字节码之后,接下来就要进⼊执⾏阶段了。通常,如果有⼀段第⼀次执⾏的字节码,解释器Ignition会逐条解释执⾏。到了这⾥,相信你已经发现了,解释器Ignition除了负责⽣成字节码之外,它还有另外⼀个作⽤,就是解释执⾏字节码。在Ignition执⾏字节码的过程中,如果发现有热点代码(HotSpot),⽐如⼀段代码被重复执⾏多次,这种就称为热点代码,那么后台的编译器TurboFan就会把该段热点的字节码编译为⾼效的机器码,然后当再次执⾏这段被优化的代码时,只需要执⾏编译后的机器码就可以了,这样就⼤⼤提升了代码的执⾏效率。
字节码配合解释器和编译器是目前一段时间很火的技术即时编辑(JIT)
消息队列和事件循环
要想在线程运行过程中,接受并执行新的任务,就需要采用事件循环机制。

消息队列是⼀种数据结构,可以存放要执⾏的任务。它符合队列“先进先出”的特点,也就是说要添加任务的话,添加到队列的尾部;要取出任务的话,从队列头部去取。消息队列:输⼊事件(⿏标滚动、点击、移动)、微任务、⽂件读写、WebSocket、JavaScript定时器等等。除此之外,消息队列中还包含了很多与⻚⾯相关的事件,如JavaScript执⾏、解析DOM、样式计算、布局计算、CSS动画等
页面使用单线程的缺点
⻚⾯线程所有执⾏的任务都来⾃于消息队列。消息队列是“先进先出”的属性,也就是说放⼊队列中的任务,需要等待前⾯的任务被执⾏完,才会被执⾏。
如何优先处理高优先级的任务
如果DOM发⽣变化,采⽤同步通知的⽅式,会影响当前任务的执⾏效率;如果采⽤异步⽅式,⼜会影响到监控的实时性。通常我们把消息队列中的任务称为宏任务,每个宏任务中都包含了⼀个微任务队列,在执⾏宏任务的过程中,如果DOM有变化,那么就会将该变化添加到微任务列表中,这样就不会影响到宏任务的继续执⾏,因此也就解决了执⾏效率的问题。
单个任务执行时长过久的问题

如果在执⾏动画过程中,其中有个JavaScript任务因执⾏时间过久,占⽤了动画单帧的时间,这样会给⽤⼾制造了卡顿的感觉,这当然是极不好的⽤⼾体验。针对这种情况,JavaScript可以通过回调功能来规避这种问题,也就是让要执⾏的JavaScript任务滞后执⾏
宏任务
- 渲染事件(如解析DOM、计算布局、绘制);
- ⽤⼾交互事件(如⿏标点击、滚动⻚⾯、放⼤缩⼩等);
- JavaScript脚本执⾏事件;
- ⽹络请求完成、⽂件读写完成事件
微任务
微任务就是⼀个需要异步执⾏的函数,执⾏时机是在主函数执⾏结束之后、当前宏任务结束之前。当JavaScript执⾏⼀段脚本的时候,V8会为其创建⼀个全局执⾏上下⽂,在创建全局执⾏上下⽂的同时,V8引擎也会在内部创建⼀个微任务队列。MutationObserver、Promise。
Promise
异步编程模型

封装异步代码,让流程处理变得线性。

Promise:消灭了嵌套调用和多次错误处理
为什么要引入微任务?
由于promise采⽤.then延时绑定回调机制,⽽newPromise时⼜需要直接执⾏promise中的⽅法,即发⽣了先执⾏⽅法后添加回调的过程,此时需等待then⽅法绑定两个回调后才能继续执⾏⽅法回调,便可将回调添加到当前js调⽤栈中执⾏结束后的任务队列中,由于宏任务较多容易堵塞,则采⽤了微任务。
Promise是如何实现回调函数返回值穿透的?
⾸先Promise的执⾏结果保存在promise的data变量中,然后是.then⽅法返回值为使⽤resolved或rejected回调⽅法新建的⼀个promise对象,即例如成功则返回newPromise(resolved),将前⼀个promise的data值赋给新建的promise。
Promise出错后,是怎么通过“冒泡”传递给最后那个捕获
promise内部有resolved和rejected变量保存成功和失败的回调,进⼊.then(resolved,rejected)时会判断rejected参数是否为函数,若是函数,错误时使⽤rejected处理错误;若不是,则错误时直接throw错误,⼀直传递到最后的捕获,若最后没有被捕获,则会报错。可通过监听unhandledrejection事件捕获未处理的promise错误