JS(浏览器)事件环 (宏、微任务)

3,001 阅读9分钟

文章已同步至【个人博客】,欢迎访问😃
文章地址:blog.fanjunyang.zone/archives/js…

在说 浏览器事件环 之前,先说几组概念:

堆(heap)和栈(stack)

堆中存的是引用数据类型,是动态分配的内存,大小不定也不会自动释放。

栈中存的是基本数据类型,会自动分配内存空间,自动释放;

  • 堆(heap):也可以叫堆内存;是一种队列优先,先进先出的数据结构;
  • 栈(stack):又名'堆栈',也是一种数据结构,不过它是按照先进后出原则存储数据的。

给张图片了解一下:

使用JS代码实现队列和栈的功能(就是用数组的增删方法):

  • 实现队列的方法(先进先出)
let arr = new Array();
arr.push(1);
arr.push(2);
arr.shift();
  • 实现栈的方法(先进后出)
let arr = new Array();
arr.push(1);
arr.push(2);
arr.pop();

线程和进程

首先,进程肯定要比线程大,一个程序至少要有一个进程,一个进程至少要有一个线程。
下面看一张浏览器的工作机制:

由此可见,浏览器就是多进程的,当一个网页崩溃时不会影响其他网页的正常运行。每个进程管理着浏览器不同的部分,主要分为以下几种:

  • 用户界面:包括地址栏、前进/后退按钮、书签菜单等
  • 浏览器引擎:在用户界面和呈现引擎之间传送指令
  • 呈现引擎,又称渲染引擎,在线程方面又称为UI线程,这是最为核心的部分,So也被称之为浏览器内核
  • GPU:用于提高网页浏览的体验
  • 插件:一个插件对应一个进程(第三方插件进程)

其中渲染引擎内部有三个线程是我们着重需要关注的

  • Networking:用于网络调用,比如HTTP请求
  • Javascript解释器:用于解析和执行Javascript代码
  • UI Backend

其中js线程和ui线程是互斥的,

当js执行的时候可能ui还在渲染,那么这时ui线程会把更改放到队列中 当js线程空闲下来 ui线程再继续渲染

除此之外还有一些其它的线程,这也是我们分发异步任务时用到的线程

  • 浏览器事件触发线程
  • 定时触发器线程
  • 异步HTTP请求线程

说一个老生常谈的问题:
JS是单线程的,任务是需要一个一个按顺序执行的,但是说的是 JS的主线程是单线程 的,他可以创建子线程,来帮他完成任务。

同步和异步

同步和异步关注的是消息通知机制

  • 同步在发出调用后,没有结果前是不返回的,一旦调用返回,就得到返回值。调用者会主动等待这个调用结果。
  • 异步是发出调用后,调用者不会立刻得到结果,而是被调用者通过状态或回调函数来处理这个调用。

任务队列:

  • 因为JavaScript是单线程的。就意味着所有任务都需要排队,前一个任务结束,后一个任务才能执行。前一个任务耗时很长,后一个任务也得一直等着。但是IO设备(比如ajax网络请求)很慢,CPU一直初一显得状态,这样就很不合理了。
  • 所以,其实主线程完全可以不管IO设备,挂起处于等待中的任务,先运行排在后面的任务。等到IO设备返回了结果,再回过头,把挂起的任务继续执行下去。于是有了同步任务和异步任务。

同步任务 是指在主线程上执行的任务,只有前一个任务执行完毕,下一个任务才能执行。
异步任务 是指不进入主线程,而是进入任务队列(task queue)的任务,只有主线程任务执行完毕,任务队列的任务才会进入主线程执行。

实现过程:
1.所有同步任务都在主线程上执行,形成一个执行栈;
2.只要异步任务有了运行结果,就在任务队列(task queue)(队列是一个先进先出的数据结构,而栈是一个先进后出的数据结构)之中放置一个事件;
3.一旦执行栈中的所有同步任务执行完毕,系统就会读取任务队列,又将队列中的事件放到stack中依次执行,就是执行异步任务中的回调函数。这个过程是循环不断的,这就是Event Loop(事件循环);

关于线程进程,同步异步,想了解更多请参考我的文章《进程与线程、同步与异步、阻塞与非阻塞、并发与并行》

宏任务和微任务

在上面的异步任务中又分为两种:宏任务微任务

常见的宏任务和微任务:

  • macro-task(宏任务,优先级低,先定义的先执行): ajax,setTimeout, setInterval, setImmediate, I/O,事件,postMessage,MessageChannel(用于消息通讯)

  • micro-task(微任务,优先级高,并且可以插队,不是先定义先执行):process.nextTick, 原生 Promise(有些实现的promise将then方法放到了宏任务中),Object.observe(已废弃), MutationObserver

Promise本身是同步的,Promise.then是异步的

宏任务微任务的区别:微任务是会被加入本轮循环的,而宏任务都是在次轮循环中被执行。简单就是说,微任务会比宏任务提前执行

简单的说就是:因为微任务的优先级较高,所以会先将微任务的异步任务取出来进行执行,当微任务的任务都执行完毕之后,会将宏任务中的任务取出来执行。

本轮循环是指什么呢?JS主线程会从任务队列中提取任务到执行栈中执行,每一次执行都可能会再产生一个新的任务,对于这些任务来说这次执行到下一次从任务队列中提取新的任务到执行栈之前就是这些新生任务的本轮。

浏览器的事件环(Event Loop)

给出一张网上很火的一张图:

从上图看出:
1.主线程运行的时候产生堆(heap)和栈(stack)
2.栈中的代码调用各种外部API,它们在"任务队列"中加入各种事件(例如:click,load,done)
3.只要栈中的代码执行完毕,主线程就会去读取"任务队列",将队列中的事件放到执行栈中依次执行。
4.主线程继续执行,当再调用外部API时又加入到任务队列中,等主线程执行完毕又会接着将任务队列中的事件放到主线程中。
5.上面整个过程是循环不断的。

例题(执行顺序):

JS代码本质上还是从上往下执行的

//例题1
console.log('script start');

setTimeout(function() {
  console.log('setTimeout');
}, 0);

Promise.resolve().then(function() {
  console.log('promise1');
}).then(function() {
  console.log('promise2');
});

console.log('script end');

输出结果顺序:script start --> script end --> promise1 --> promise2 --> setTimeout

1.先执行同步任务,输出script star script end
2.然后执行异步任务,先执行异步任务的微任务,输出 promise1
3.接着返回了一个Promise,然后又.then,还是微任务,接着执行,输出 promise2
4.最后执行异步任务中的宏任务,输出 setTimeout

//例题2
console.log(1);
setTimeout(function(){
    console.log(2);
    new Promise(function(resolve,reject){
        console.log(3);
        resolve();
    }).then(res=>{
        console.log(4);
    })
});
setTimeout(function(){
        console.log(5);
    })
console.log(6);

输出结果顺序:1 6 2 3 4 5

1.执行栈中同步任务先执行,先走console.log(1)console.log(6)
2.接着是遇到setTimeout将它们的回调函数放入MacroTask(宏任务队列);
3.然后将任务队列中的回调函数依次放入主执行栈中执行,console.log(2),接着console.log(3);是立即执行,console.log(4);是微任务放入MicroTask中先执行;
4.最后执行第二个setTimeout的回调函数console.log(5)

//例题3
setTimeout(() => {
    console.log('setTimeout1');
    Promise.resolve().then(data => {
        console.log('then3');
    });
},1000);
Promise.resolve().then(data => {
    console.log('then1');
});
Promise.resolve().then(data => {
    console.log('then2');
    setTimeout(() => {
        console.log('setTimeout2');
    },1000);
});
console.log(2);

输出结果顺序:2 then1 then2 setTimeout1 then3 setTimeout2

1.先执行栈中的内容,也就是同步代码,所以2被输出出来;
2.然后清空微任务,所以依次输出的是 then1 then2
3.因代码是从上到下执行的,所以1s后 setTimeout1 被执行输出;
4.接着再次清空微任务,then3被输出;
5.最后执行输出setTimeout2

下面例题就不一一分析了,可以自己尝试运行并分析一下

例题4
setTimeout(() => {
    console.log(2);
    Promise.resolve().then(() => {
        console.log(6);
    });
}, 0);
Promise.resolve(3).then((data) => {
    console.log(data);  	
    return data + 1;
}).then((data) => {
    console.log(data)		
    setTimeout(() => {
        console.log(data + 1)	
        return data + 1;
    }, 1000)
}).then((data) => {
    console.log(data);		
});

输出结果顺序:3 4 undefined 2 6 5

//例题5
setTimeout(() => {
    console.log('A');
}, 0);
var obj = {
    func: function () {
        setTimeout(function () {
            console.log('B')
        }, 0);
        return new Promise(function (resolve) {
            console.log('C');
            resolve();
        })
    }
};
obj.func().then(function () {
    console.log('D')
});
console.log('E'); 

输出结果顺序:C E D A B

扩充

这里内容不做过多解释

1. setTimeout,setImmediate谁先谁后?

  • 如果两者都在主模块中调用,那么执行先后取决于进程性能,也就是随机。
  • 如果两者都不在主模块调用(被一个异步操作包裹),那么setImmediate的回调永远先执行。

2. nextTick和promise.then谁快?

  • nextTick快,就是这么设计的

3. nextTick和其它的定时器嵌套

setImmediate(function(){
  console.log(1);
  process.nextTick(function(){
    console.log(4);
  })
})
process.nextTick(function(){
  console.log(2);
  setImmediate(function(){
    console.log(3);
  })
})


2 1 3 4

原因在于nextTick在node中的执行实际和浏览器中不完全一样,虽然它们在第一次进入事件环时都会先执行,但如果后续还有nextTick加入,node中只会在阶段转换时才会去执行,而浏览器中则是一有nextTick加入就会立即执行。

造成这样区别的原因在于,node中的事件环是有6种状态的,每种状态都是一个callbcak queue,只有当一个状态的callback queue中存放的回调都清空后才会执行nextTick

4. 定时器指定的回调函数一定会在指定的时间内执行吗?

不一定,先不说node中事件环的六种状态之间转化时的猫腻,光是浏览器中的事件环也可能因为本轮循环的执行时间过长,长的比定时器指定的事件还长从而导致定时器的回调触发被延误。


node11.x版本之后,node事件环就慢慢和浏览器的事件环一样了,这里就先不作过多解释


^_<