一、历史回顾
1、浏览器进程和线程
1)现代浏览器为多进程,每一个标签页、扩展插件都会有自己的进程
2)线程由进程启动和管理
3)单个进程中任意线程出错,会挂掉该进程(线程出现死循环等)
4)由于进程运行需要操作系统分配对应的内存,所以进程产生的线程可以共享、读写该进程(内存)中的公共数据
5)进程和进程之间相互隔离,为了避免进程a挂掉后影响进程b的运行(进程之间数据通信使用IPC机制)
2、单进程浏览器时代
1)上面所有进程提供的功能都运行在单进程浏览器的一个进程里
2)插件提供网页视频、游戏等功能,所有插件在一起运行会造成极不稳定,一个插件挂了,浏览器挂了
3)线程占用引起的“进程占用”,当主线程中的js执行一个耗时较长的代码时(sleep)其他线程也会等待执行,这时页面会失去响应、卡顿,浏览器挂了
4)单进程直接在系统内存中运行,进程中的恶意代码/插件可以访问当前操作系统所有资源,极不安全,操作系统挂了
⚠️血压升高场景:大概在2008年以前,那时谷歌还在中国,但大部分用户使用的还是 winXP(32位操作系统,最大支持1.75G运行内存,一般电脑在256mb-512mb左右) + ie6以下版本的浏览器,操作系统运行内存及其宝贵,没有页签,我通常习惯打开一个新浏览器就会关掉之前的因为实在太卡了,而且浏览器打开多了说崩就崩,卡到直接冷重启,浏览器只是“浏览”,不能也不敢做复杂的交互和操作。
3、多进程浏览器(逐步发展起来的现代浏览器)
1、系统架构:
-浏览器主进程:Browser Process,提供浏览器界面交互、子进程管理、存储
-每个页签的渲染进程:将HTML、CSS、JS代码解析编译渲染给用户,
-网络进程:负责网络资源请求加载
-GPU进程:调用显存资源,负责3D CSS效果,canvas绘制等
-插件进程:负责插件的运行,本身运行非完全沙箱环境,管理插件子进程的进程,当插件挂了时保证不会影响其他进程
以上除了插件进程外都是完全在独立的沙盒环境运行,不能直接访问操作系统的所有资源(安全性问题解决,但也带来了一些负面影响,下文)
2、浏览器主进程、页面进程(js主进程)、所有插件进程、GPU进程等进程之间相互隔离,每个页签为独立的进程页签即使阻塞了也不影响其他页签和进程(稳定性、流畅性问题解决)
3、事物的两面性:
-js不能访问剪切板(clipboard.js用的document.execCommand API,该API是非标准的webAPI,是ie9提出的后来被陆续兼容,目前已废弃但未删除(mdn),删除后貌似没有替代方案);苹果生态比较会玩,设备和设备之间可以通过iCloud共享当前剪贴板,但只限于登录相同iCloud的苹果设备)
-不能直接读写硬盘(可以使用node的fs模块)
-更高的资源占用,例如每个页签的主进程都会搞一套js的运行环境(v8引擎、排版引擎Blink),页签越多,重复的运行环境越多
-架构更复杂和定制化了,随着时代的发展可能无法适应新的历史需求了(2016年谷歌提出“面向服务的架构”,将以上模块打散重构成独立的系统服务,服务间通过IPC通信,要把浏览器变成一个“便携式”的操作系统,zhuanlan.zhihu.com/p/150145602)
二、主进程的事件调度
1、目前沿用了一开始的单线程的设计,原因有:
-
提高了事件执行的效率
-
浏览器的本质:下载代码到本地内存,经过解析后以Dom呈现给用户,用户和浏览器界面的所有交互本质上都在操作dom,如果多线程则对Dom的操作会变的非常复杂。
-
理论上讲渲染进程用于执行js的只有js主线程这一个线程,webWorker是js主线程的一个子线程受控于js主线程且不能直接操作dom
2、消息队列和事件循环
- 单线程就意味着,所有任务需要排队,前一个任务结束,才会执行后一个任务。
-
并不是任务跑完就会结束线程,我们要让线程一直活着至少在用户切换出当前页签前可以持续跑下去
-
这样我们需要一个轮询(for(;;))来不断的处理当前线程中的任务
- 浏览器是要和用户交互的,比如一个点击事件,所以我们还需要接收处理其他线程发送过来的事件任务
-
以上实现方式还是有问题,当发生点击事件后先执行谁后执行谁?这是我们就要引入消息队列的设计了
-
一个简单的消息队列(如图)消息队列是一个先进先出的盏,可以存放要执行的任务(要添加任务的话,添加到队列的尾部;要取出任务的话,从队列头部去取。)
- 以上算是一个比较完备的设计了,但是现代浏览器是多线程架构所以渲染进程的js主线程上有些任务可能是其他进程发送过来的,通过IPC机制来将任务发给渲染进程的I/O线程然后排到消息队列后面
-
消息队列里的任务有很多类型,如:
-
输入事件(点击、移动)
-
微任务
-
WebSocket
-
setTimeout()
-
(more....)
-
具体可以参考Chromium的枚举源码:source.chromium.org/chromium/ch…
然而,并不是所有任务都是当时就产生运算结果(同步任务),有的任务会等待一段时间才会有结果,也就是我们常说的“异步”,js单线程的设计下如果直接等待异步任务返回结果则又会引起线程的占用,造成页面停滞卡顿,所以这时在消息队列中的“异步”任务会被挂起,继续执行后续的同步任务,等待有结果后又返回消息队列(如图)
这就是单线程处理异步任务而不造成长时间线程占用的原理
3、单线程消息队列设计的缺点和解决方案
如何处理优先级高的任务?
很多情况下无法直接写死定义一个任务排好去执行,可能这个任务是上一个任务的产出结果
不好的方案❌:
-
回调地狱:疯狂回调,嵌套层级很多的话代码的可读性和维护行将非常非常差,而且基于一个串行流程很容易写成“面向过程编程”
-
完全使用异步:这会导致渲染的及时性受影响;或者定义写死一个计时器,当前宏任务没有在定义的时间内完成怎么办?
-
这里并不是说回调和异步不好不能用,只是不能很好的解决优先级的问题
为了解决这个问题js主线程就使用宏任务-微任务的设计:
一个简单的🌰:
setTimeout(()=>{
console.log('task3')
},0)
new Promise((resolve, reject)=>{
console.log('task1')
resolve()
}).then(()=>{
console.log('task2-0')
console.log('task2-1')
console.log('task2-2')
})
console.log('task4')
console.log('task5')
// task1
// task4
// task5
// task2-012
// task3
当消息队列里执行完当前轮询列的宏任务,js主线程并不着急去执行下一个轮询,而是执行当前宏任务轮询中的微任务列
这里的微任务就是优先级比下一个轮询高的任务,所以通过宏任务-微任务机制在单线程里解决了优先级问题
async/await:
ES7 引入了 async/await,Promise的语法糖,提供了在不阻塞主线程的情况下使用同步代码实现异步访问资源的能力。
async/await的实现使用了“协程”的概念。(js生成器函数)
协程是比线程还要轻量的概念,一个线程可以有多个协程,但只能同时运行一个协程。运行协程A的时候要运行协程B,这时协程A将js主线程的控制权交给B,A暂停运行,B运行时也可以切换给C。
三、编译-执行
写好的代码-编译阶段-执行阶段
1、编译阶段
编译阶段会生成执行上下文:
1)可执行代码
2)变量环境
3)词法环境
1)变量提升🌰:
fun()
console.log(test)
var test = 123
function fun(){
console.log(test)
}
// 全是undefined
fun()
console.log(test)
// var test = 123
function fun(){
console.log(test)
}
// 第5行报错:test is not defined
fun()
console.log(test)
var test = 123
function fun(){
console.log(test)
let aaa = 456
}
console.log(aaa)
第5行输出undefined第2行输出undefined第8行报错:aaa is not defined
-
js运行时,在作用域内使用了从未声明的变量,会报错
-
一个变量在声明前使用它,不会报错但值是undefined
-
函数定义前使用它能正常执行
变量提升后的代码:
var test = undefind
function fun(){
console.log(test)
let aaa = 456
}
fun(); // 第三行打印test当前的值 undefind
console.log(test) // 打印test当前的值 undefind
console.log(aaa) // 作用域内没有变量aaa 报错aaa is not defined
可执行上下文中存在变量环境对象(Viriable Environment)
ViriableEnvironment:{
test: undefined,
fun:()=>{
console.log(test)// 打印undefined
let aaa = 456 // 严格来讲这行代码应该出现在函数fun()的可执行上下文的Viriable Environment中
}
}
2)执行部分代码
以上代码的可执行代码相当于以下代码,执行代码所有需要的环境对象都会去变量环境对象里去找
fun()
console.log(test)
test = 123
console.log(aaa)
3)词法环境
代码模拟无限套娃。。。
// 全局词法环境
GlobalEnvironment = {
outer: null, //全局环境的外部环境引用为null
//全局对象环境记录
GlobalEnvironmentRecord: {
//全局this绑定指向全局对象
[[GlobalThisValue]]: ObjectEnvironmentRecord[[BindingObject]],
//声明式环境记录,除了全局函数和var,其他声明都绑定在这里
DeclarativeEnvironmentRecord:{
// let,const 声明的变量会放到这里
},
//对象式环境记录,绑定对象为全局对象
ObjectEnvironmentRecord: {
test: 123,
fun:<< funciotn >>,
},
}
}
// 函数fun的词法环境
funFunctionEnvironment = {
outer: GlobalEnvironment,//外部词法环境引用指向全局环境
FunctionEnvironmentRecord: {// 函数环境记录:用于函数作用域
[[ThisValue]]: GlobalEnvironment,//this绑定指向全局环境
aaa:456
}
}
所以在全局环境中打印aaa会报错,
-
变量环境存储了可执行代码的相关使用变量
-
词法环境规定了某个变量的作用域链
2、执行阶段
将可执行代码从上倒下执行
fun()
console.log(test)
test = 123
console.log(aaa)
ViriableEnvironment:{
test: undefined,
fun:()=>{
console.log(test)// 打印undefined
let aaa = 456 // 严格来讲这行代码应该出现在函数fun()的可执行上下文的Viriable Environment中
}
}
-
执行第一行代码fun()时:
-
去变量环境中找test(对应词法环境的GlobalEnvironment.GlobalEnvironmentRecord.ObjectEnvironmentRecord.test),找到了 是undefined,打印出来;
-
在词法环境funFunctionEnvironment的函数环境记录FunctionEnvironmentRecord中添加属性aaa值为456
-
执行第二行代码:
-
去变量环境中找test(对应词法环境的GlobalEnvironment.GlobalEnvironmentRecord.ObjectEnvironmentRecord.test),找到了 是undefined,打印出来;
-
执行第三行代码
-
在词法环境全局GlobalEnvironment的全局对象环境记录的对象式环境记录ObjectEnvironmentRecord中将test的值改为123
-
变量环境中test的值改为123
-
执行第四行代码
-
去变量环境中找aaa,(对应词法环境的GlobalEnvironment.GlobalEnvironmentRecord.ObjectEnvironmentRecord.aaa),找不到,报错
思考题:为什么不用var了?